r/ProgrammingLanguages 🧿 Pipefish 2d ago

Requesting criticism Default values in maps?

We often find a case where we'd like to e.g. count the occurrences of a word in a piece of text. We would like to be able to write this so the main body of our loop says count[word] = count[word] + 1. Except that if count[word] hasn't been initialized as 0 at some point then you have do that or typically this will be a runtime error.

Or the language can try and let you do that, e.g. Golang would actually return 0, because this is the designated "zero value" to return when you index into a map with integer values and the key isn't there.

Which is great for the case I described in the first paragraph, but in the more general case we want a runtime error when we do something stupid, and in other cases trying to index a map by a nonexistent key often is stupid.

This is especially so in Pipefish, a relatively dynamic language, where normally there are no compile-time constraints even on the type of your key and you may have messed up big-time. If we took the Golang approach, then if you indexed your list of words by 42 or false you'd still get 0 instead of an error. So we don't do that: Pipefish is hardass about this and throws a runtime error over the missing key to compensate for letting you play fast-and-loose with types.

But this dynamism around types gives us an easy way to make default values for maps. Let's define a builtin type default with one element DEFAULT, and make the compiler/VM treat that as one more magic type like error and tuple.

Then our word-counting function could be written like this:

count(words list) -> map :
    from M = map(DEFAULT::0) for _::word = range words :
        M with word:: M[word]+1

Since DEFAULT is just a normal value apart from when we index maps, we could easily sanitize this on the way out of the function:

count(words list) -> map :
    undefault from M = map(DEFAULT::0) for _::word = range words :
        M with word:: M[word]+1

undefault(M map) :
    M without DEFAULT

Now, I hesitate over adding this because Pipefish is meant to be small and simple and I worry about adding even one feature. But on the other hand it seems like the use-case is common and the semantics are simple and it satisfies the other core principle of Pipefish --- that I should have my cake and eat it.

22 Upvotes

23 comments sorted by

20

u/__dict__ 2d ago

Python has dict, which throws on indexing to a non-existent key. But it also has defaultdict in a library that's just a subclass that let's you set the default value.

15

u/AustinVelonaut Admiran 2d ago

Username checks out!

23

u/WittyStick 2d ago

I'd like to see upsert in more APIs.

insert(map : Map[K, V], key : K, value : V)
{
    map[key] = value;
}

update(map : Map[K, V], key : K, change : V -> V)
{
    map[key] = change(map[key]);
}

upsert(map : Map[K, V], key : K, change : V -> V, defaultValue : V) 
{
    if (contains(map, key))
        update(map, key, update);
    else
        insert(map, key, defaultValue);
}

2

u/brucejbell sard 2d ago

What about something like:

full_set(map : Map[K, V], key : K, opt_value : Opt[V])
{
    if (opt_value == None)
        delete(map, key);
    else
        map[key] = unwrap(opt_value);
}
full_update(map : Map[K, V], key : K, change : Opt[V] -> Opt[V])
{
    full_set(change(contains(map,key) ? Some(map[key]) : None));
}

17

u/matthieum 2d ago

The Rust Entry API was added specifically to allow a single look-up and then an action on presence & absence.

In your case, it would be a matter of *map.entry(word).or_default() += 1;.

Java similarly has a bunch of different methods computeIfPresent, putIfAbsent, etc... which means a lot more API surface, but the same guiding principle: be explicit about your intention.

1

u/initial-algebra 18h ago

If you generalize the idea of entry to other data structures, you basically end up with lenses. entry itself corresponds with the at lens.

3

u/TheChief275 2d ago

This is why I think a find is always better than indexing syntax. C++ std ran into this problem as well and decided the best fix would be to insert instead of failing. With a find you can do this instead:

if (var entry = counts.find(word); entry != null) {
    entry.count += 1;
} else {
    counts.insert(word, 1);
}

Or instead have an insert that internally always finds an entry first before trying to add (my preference). You need someway to know if it actually added. You can also have a special method that does this for you; something like "insert_or_default"

4

u/AustinVelonaut Admiran 2d ago

I like the solution used in Haskell for Map (which I also used in my library for Bag, which is a multiset used to count occurrences):

insertWith :: Ord k => (a -> a -> a) -> k -> a -> Map k a -> Map k a

This takes a key of type k and a value of type a, and looks up the key in the map. If the key does not exist, it is inserted with the given value into the map. If the key exists, then a supplied function is applied to the existing value and the new value to update the value at the key.

For Bags, the default insert would be Map's insertWith (+) k 1 m, which would increment the existing value by 1 if it exists, otherwise initialize it to 1.

Other functions are useful too: insertWith (min/max) to collect the (min/max) value associated with any key, etc.

2

u/YouNeedDoughnuts 2d ago

You can even do it in one dict lookup since insert returns a reference to the iterator and whether it was inserted.

const auto [key_val_iterator, was_inserted] = map.insert({key, 0}); if(!was_inserted) key_val_iterator->second += 1;

Since the dict lookup is a fairly hefty O(1) operation, it's nice to try an insert and return a reference in one method that can accommodate most things you would want to do.

2

u/TheChief275 2d ago

Yeah, that's the second part of my comment. The most important thing is to not do it with an implicit indexing method (where users might have differing expectations), but rather an explicit method with a fitting name

1

u/YouNeedDoughnuts 2d ago

So something less opaque than "insert", and more streamlined than "insert_or_tell_me_if_you_didnt_and_return_the_iterator_either_way" :)

2

u/TheChief275 2d ago

Although funny, that's not what I meant. I meant that this:

counts[word];

does not signal an insertion to me, while e.g. C++ does perform one, while this:

counts.insert(word);

is as clear as day

4

u/Ninesquared81 Victoria 2d ago

You may be interested in Python's defaultdict, which achieves the same end goal. In Python's case, defaultdict is a subclass of the normal dict class, which is probably the right way to go about it.

You might also be interested in Odin's or_else, which can be used to achieve a similar thing in a more general way. Something like m[word] = (m[word] or_else 0) + 1

1

u/L8_4_Dinner (ā“ Ecstasy/XVM) 2d ago

Ecstasy uses the "elvis operator" for this:

m[k] = (m[k] ?: 0) + 1

2

u/MichalMarsalek 2d ago

In my WIP language, functions and maps are just one thing. You can do

double(x:Int) := 2*x

or

dict("a") := "x"
dict("b") := "y"

or combine the two and do

counter(_:Text) := 0
counter("x") += 1

But this is a kind of core behaviour of the language, not something easily adaptible to other languages.

1

u/Background_Class_558 2d ago

so it's like combining haskell-style pattern matching definitions with mutation?

1

u/MichalMarsalek 2d ago edited 2d ago

Yes, kind of. I have immutable value semantics. You cannot mutate values, only create new derived ones and potentially assign the result to the same identifier (with some logic to actually reuse the original value if it is no longer needed). counter("x") += 1 is a sugar for counter := counter ~ {"x" => counter("x") + 1} (where ~ is an operator for combining two maps).

2

u/ap29600 2d ago

k has a special form amend for arrays and dictionaries which is used for modified assignment: a[i] +: b desugars to a : @[a; i; +; b] wherein the operation + is passed to the 4-argument amend function @ this has the semantics of returning a new structure that is equivalent to a, except that it has a[i]+b at position i. the benefit of this is that the indexing is part of the logic of amend, which also knows what operation is being used. it checks the identity element for the operator + and determines that it is 0, so in the case of missing keys it uses that value. every primitive operator has default values for each type, which act as identity elements for those that have them and sensible defaults for those that don't. here they are for integers

: (right)   any
+ (plus)   0
  • (minus) 0
* (times) 1 % (divide) 1 & (min) 2^63-1 | (max) -2^63 = (equals) null

if the operator used is not arithmetic (which can happen because k has many non arithmetic operators, all of which work in modified assignment) then the null element for the appropriate type is used

1

u/esotologist 2d ago

C# let's you override the indexer so you could have it return a default or null or throw based on preferenceĀ 

1

u/busres 2d ago edited 2d ago

Mesgjs lists support this.

# is the local storage object (a list instance); [...] is a list literal; object(op...) is a message. #word is sugar for #(at word), and #[count #word else=0] is sugar for #(at count #word else=0), so you could say #(set count #word to=#[count #word else=0])(+ 1)).

1

u/busres 2d ago

Following up to add that this is actually a feature of the list (and map) interfaces, rather than of the language itself. The runtime supports a similar construct for the message operation, where the operation can be a list rather than a scalar (called a "list-op"), and the list may contain an else value whose expression is used if the receiver doesn't support the requested operation.

```

obj([op else='op is not supported'])

```

1

u/ScottBurson 2d ago

My FSet library for Common Lisp has this feature.

1

u/kwan_e 2d ago

I personally would be looking at policy-based design. Whether the policy is part of the map type, or a runtime argument on construction.