Why are reverse/1 and at/1 in Enum?

Calling Enum.reverse/1 and Enum.at/1 doesn’t make sense on a Map, as Maps’ key/value pairs are not ordered. For new adopters, it could be even harmful as one could call Enum.at/1 on a map and it would work, until it doesn’t.

Why having reverse/1 and at/1 in Enum (instead of having these functions in List)? (and if any good reason, why is then insert_at/3 & co not in Enum?).

2 Likes

A map is still enumerable, thats why :slight_smile:

Its just not a stable (guaranteed) order.

If it’s acceptable to have at/1 in Enum while the order is not stable for a map, and so, return an arbitrary element which could lead to bugs if used on maps, then why not having insert_at/3 and delete_at/2 in Enum as well?

Good point, it does seem like some of these functions don’t make sense for maps, especially the at/1.

I guess the reason maps implement enum protocol is to allow you to use functions like map, filter, without having to go to another module to search for them.

One more thing that seems to be consistent is that once a Enum function is applied, the subsequent map becomes a keyword list:

map = %{a:1, b: 2}
iex> Enum.reverse(map)
[a: 1, b: 2]

iex> Enum.map(map, fn el -> el end)
[b: 2, a: 1]

So maybe under the hood the structure used to store the map is ordered, but map operations cannot guarantee order?

Maps which size is <= 32 are ordered but if they grow larger, the backing data structure is a hash array mapped trie.

1 Like

This is not necessarily true on more recent OTP versions (OTP26 changed the order for atom keys for instance), and one should never rely on this either way since it is an implementation detail.

4 Likes

Honestly this begs the question why functions like Enum.at/1 don’t raise an error when used with maps?

The Enumerable protocol is for the most part just implementing reduce, where higher level apis are built on top of the protocol implementation. So the map implementation of Enumerable doesn‘t even know it‘s used to handle Enum.at specifically and Enum.at doesn‘t differentiate between different implementations.

1 Like

Why isn’t Enum.at and Enum.reverse in List instead?

They could be there as well, but those functions make sense for any enumerable as well. A collection, which can be iterated can be used to take a certain nth item as well as have items be returned in reverse order of the iteration. The abstraction doesn‘t care how the underlying datatype is made to be ordered for iteration in the first place.

4 Likes

Then why insert_at and delete_at is not in Enum? (sorry for going in circles hehe)

at/1 can be written as a way of enumeration but things like insert_at and delete_at does not fit under those. For example, how do I insert_at into a Range, which is also enumerable?

One possible alternative here would be to introduce an “Indexing” protocol, that provides at, insert_at, and delete_at. We could implement this for tuples and lists. The downside here is that lists and tuples have completely different performance characteristics for these operations. And someone could correctly argue that a list should not implement “Indexing”, because it is linear.

That’s to say we could slice this in very different ways, depending on which properties you want to group together. We chose in Elixir to focus particularly in Enumerable but it only guarantees enumeration, not order. For example, I can do this as well:

iex(1)> stream = Stream.repeatedly(fn -> :rand.uniform() end)
#Function<53.57225826/2 in Stream.repeatedly/1>
iex(2)> Enum.at(stream, 3)
0.07121644203444732
iex(3)> Enum.at(stream, 3)
0.9778670290254113

Which provides fewer guarantees than maps: the same map would always return the same value for Enum.at(map, 3), but here we got different values for the same stream.

TL;DR: Enumerable provides the property of enumeration. If we augmented it to include insert_at/delete_at, then data types that are enumerable today would no longer match the description of Enumerable.

10 Likes

Even if e.g. at/1 and reverse/1 can be written as a way of enumeration, wouldn’t it have still been better to include those into List?

Couldn’t we put a rule/convention that any function on an enumerable that we expect to depend on the order, should be better moved to List?

Should we group functions together according to their implementation/interfaces or according to their concrete use cases for the end user?

The end result is that a developer can call at/1 and reverse/1 on a map and will not be alerted of an error in the code, where in (nearly) every case it is an error.

The developer should know the difference between a list and a map. They’re different data structures with different properties.

2 Likes

There are several other data structures where at and reverse make sense, such as file readers, streams, etc. Putting them in List would also deny several use cases.

4 Likes

Ty for your answer!:slight_smile:

Enum.insert_at(1..4, 2, 0)
[1, 2, 0, 3, 4]

Other functions also transform the range into a list:

Enum.reverse(1..5)
[5, 4, 3, 2, 1]
1 Like

That’s a good point. The extra concern now is that people would have even more expectations that this returns a collection of the same type as the first argument, no? Some already expect this to be the case for Enum.map and such style of functions would expect even more?

Also, is there a use case for those on anything other than a list?

1 Like

Another way to answer your question is The Robustness Principle.

Putting at and reverse in Enum allows them to be as liberal as possible by accepting any Enum. And, if want to take advantage of the methods in Enum you need only implement the Enum protocol for your data type. This allows your custom data type to feel “ergonomic” and “familiar” to the average Elixir dev, kind of neat, no?

Re: at not making sense for Maps. I disagree.

I think the issue here is that the docs for Maps state that their ordering is “arbitrary,” without an explanation of what that means. From what I can tell, the ordering is determined by erts_internal:map_next (zhttps://github.com/erlang/otp/blob/OTP-27.0.1/lib/stdlib/src/maps.erl#L537), and I don’t know Erlang so it will be a minute before I can explain it. But, arbitrary doesn’t mean “random.” Nor does mean “possibly different between invocations.” So what does “arbitrary” mean to communicate here? I can see two possibilities: the definition for map_next could change between erlang versions. This would mean that between an upgrade one map could return different results for Enum.at(m, n) for some n. Secondly, the use of “arbitrary” may be to convey that small changes to map (such as adding a new key) may have large, unexpected, changes to the ordering. E.g., Given a map m and a number n with n < Map.size(m) when Enum.at(m, n) # returns x you may expect that

m2 = Map.put(m, :new_key, :some_value)
Enum.at(m2, n) # returns x

Which may not be the case. The ordering, as they say, is arbitrary, so there is no promise of consistent ordering among “similar” maps.

Re: why isn’t insert_at or delete_at in Enum?

Your example illustrates that some types, which are Enumerable, can’t be maintained from these two “write” operations. E.g., you provided an example of an insert_at on a Range which produces a List. That list, can’t be expressed as a Range. Similarly for your delete_at example. I think this reason enough is to exclude these methods from Enum, though my opinion is subject to change on that. The bigger issue is that, as Jose said, not all types which are Enumerable are “obvious” for how an insert/delete should occur. For example, we can find a way to make a Map enumerable (barring the fact that to you it feels unintuitve at the moment), but what would the expected outcome of a Enum.insert_at(map, 2, {:key, :value}) even be? Is it the same or different from Enum.insert_at(map, 3, {:key, :value})? And how do you make sure _that_ choice is consistent with the behavior ofMap.to_list? if you insert something at index n` it had better be at that same index when you convert it to a list, no?

Edit: I hate to go on beating a dead horse, but I also want to address this comment in your initial post,

Maps key/value pairs are not ordered.

There is some trickery afoot with language when we say something is ordered. Enumerable doesn’t mean ordered, it means orderable. More specifically it means “this is a thing that contains a bunch of other things and we have a function that can assign each of those things to a natural number.” So yes, maps are not ordered, but enums don’t need to be ordered, they need to be orderable.

Edit 2: I’m not sure that my post helped clear any of your confusions. I hope it did. Or at least I hope it convinced you that the language design choice on the part of Elixir is sane in this case. But if it didn’t, note that this also exists for other languages as well, see ElementAt for IEnumerable in C#. Dictionaries in .NET (aka maps) are IEnumerable. Or HashMaps in Rust, which implement the IntoIterator trait. In fact, the docs for the .iter() method in Rust which converts the HashMap into an Iterator uses the same phrasing (“arbitrary order”) as Elixir.

Edit 3: I realized this morning that Enum.drop(range, n) also returns a list, so ignore that part of the rest of this post. I don’t know if I can think of a good reason for delete_at to not exist in Enum. it would essentially just be Enum.with_index() |> Enum.reject(fn {_, i} -> i != n end) |> Enum.map(&(elem(&1,0))

1 Like

You’re not wrong pedantically speaking, but contrasting “arbitrary” and “random” in this instance seems to be recognizing a difference without a distinction.

Enum.at/3 doesn’t really make sense for maps not because maps aren’t necessarily random, but because they aren’t guaranteed to be deterministically ordered. Using Enum.at/3 with a map might work and may do so repeatedly: but it is so because of third party implementation details which, as you point out, can change over time with version changes or may be subject to internal dynamics which aren’t immediately apparent in simple cases (like new key insertion, value updates, etc).

Using Enum.at/3 with maps because of an apparent stability in a given version of Erlang would, in my opinion, be a hack at best… something to work around an immediate problem, but I certainly wouldn’t expect map ordering to be a durable or reliable over time.

In the end, maps may be arbitrary and not random, but in regular programming I’m going to treat them as though they are random because I’m given no better guarantees than that.

To be clear, they are deterministically ordered within the same node/instance. :slight_smile:

2 Likes