Can we dump []=

Can we dump []=
Photo by Sagnik Acharya / Unsplash

One thing a colleague reminds me of from time to time is less syntax is good. But less syntax shouldn't come at the cost of visual parsing conciseness. Let's look at some specific use cases of [] and []= and see if we even need them.

people[1] = {name: 'second person'}.
people do> [index, person |
  people[index] = person {name: name + ' modified'}].

The Smalltalk-80 approach is to use the messages at: and at:put: instead of [] and []=. The unified approach to doing things is great but after decades of writing those selectors I can definitively say I do not like it.

It's also not that common to have to write them. But let's see if we can eliminate both cases. The first easy solution is to copy the array or map and modify it in one step. That is a 'clean' solution but not always the right solution when writing optimised code where modifying in place makes the most sense.

new_people := {...people, {name: 'second person'}}.

What if we wanted to modify it in place because we're doing some sort of bulk processing; or multi-threaded processing where every N slots belongs to a different worker thread.

First we can use the focus operator to change an existing object - so why not an existing list or map too?

people {1: {name: 'second person'}}.
people do> [person: &Person | person {name: name + ' modified'}].

The trick here is to make sure we're not getting a copy-on-write person in the iterator; so we have to specifically state that's what we want. A short hand for that could be to move the & reference indicator to the name so the type is inferred:

people do> [&person | person {name: name + ' modified'}].

I don't hate that. It's a quick way of identifying the meaning. The first line where we append something to the array is not great though - we're better off starting with a List instead of an Array in that case:

people add: {name: 'second person'}.
people do> [&person | person {name: name + ' modified'}].

Are there other use cases where we need to explicitly pick from the array or map? yes for sure, but we can create combined iterators:

people1 and: people2 do> [person1, person2 | ...].

The other place we're using [] is in subtyping as a short hand to get a subtype of a generic type, eg:

// long hand:
people: (List of: Person).

// short hand:
people: List[Person].

What do we do if we remove [] as a part of our syntax so that we don't need to wrap the type declaration in () ? . We could use a binary-operator to achieve the same result. May be something like this:

people: List / Person.

Unfortunately we're already using / to mean Either; so this would be a generic List or a Person. Likewise + is used to make tagged unions and , is already syntax to make an Array.

We could get wacky and use \ instead but then it gets confusing as to the meaning of / and \. We cannot use > or < either because they are magnitudal operators. Not that Type < Type makes much sense for magnitudes but let's not add extra confusing if we don't need to. Yes, I'm looking at you C++ List<Person>.

We could keep using of: but change it to the binary operator of>

people: List of> Person.

That's a little ugly. We could instead add container types as a message on all types. List and Map and Array could be sent to a type to construct it in to a container type:

people: Person List.

It reverses the grammar but that might not be a bad thing. The grammar is now more legible. It is a change, the kind of change that hurts my head a little. I will need to sit on that idea for a bit to decide if it's right for me or not.

How do we do a Map in that fashion? We need a Key and Element types:

our-map: (String, Integer) Map.

That's not ... great. It's also not terrible. Map declarations should probably stand out a little more. Another approach would be to literally make a List as our type declaration:

people: {Person}.
our-map: {String, Integer}.

This is technically a {Array of: Class length: 1 | Person} and {List of: Class length: 2 | String, Integer}. Having a longer form that is more explicit is a good thing too.

This is such a simple solution I'm surprised I hadn't thought of it before. When you need something fancier you can simply (send some: messages); otherwise the majority of cases are covered by this approach.

The next thing we need to look at is slicing. Right now we have [start:stop] (not-inclusive) and [start::length], as well as [start:], [:stop], and [::length]. But if we're dropping [] then we need to find another way to do this too.

First off we have the Range object (or Interval in Smalltalk-80). Our obvious answer is to not have a short-hand syntax at all and use messages. Borrowing the slice() function from Javascript we can get something like this:

{1, 2, 3, 4, 5} slice: 2 length: 2. // {3, 4}
{1, 2, 3, 4, 5} slice: 2  until: 3. // {3}

We can also utilise the destination type to specify length and the API to specify start offset. A copy or a slice won't attempt to access more than it is allowed to.

(Array length: 2 | {1, 2, 3, 4, 5} slice: 2). // {2, 3}
(Array length: 3 | {1, 2, 3, 4, 5} copy: 3).  // {3, 4, 5} 

This allows us to drive the operation either from a longer API or from the type we're putting it into. I used the type-cast syntax to be lazy with my example here.

The compiler will need to convert the Array in to an Array Type for you. That's not the end of the world and not at all unreasonable. But with that we can now declare that [] and []= are no longer part of STZ.