josevalim

josevalim

Creator of Elixir

Local accumulators for cleaner comprehensions

Hi everyone,

This is a proposal for introducing local accumulators to Elixir. This is another attempt of solving the comprehension problem, which we first discussed two years ago. This proposal is divided in four parts:

  1. Problem statement

  2. Comprehensions

  3. Local accumulators (if you read the previous proposal, start from here!)

  4. Revisiting the problem

  5. Implementation considerations

Problem statement

I have been on the record a couple times saying that, while some problems are more cleanly solved with recursion, there is a category of problems that are much more elegant with imperative loops. One of those problems have been described in the “nested-data-structures-traversal” repository, with solutions available in many different languages. Please read the problem statement in said repository, as I will assume from now on that you are familiar with it.

Personally speaking, I find imperative solutions clearer and with considerably less boilerplate. The Python solution is the most concise one, which I reproduce here:

section_counter = 1
lesson_counter = 1

for section in sections:
    if section["reset_lesson_position"]:
        lesson_counter = 1

    section["position"] = section_counter
    section_counter += 1

    for lesson in section["lessons"]:
        lesson["position"] = lesson_counter
        lesson_counter += 1

There are many things that make this solution clear:

  • Reassignment
  • Mutability
  • Sensitive whitespace

Let’s compare it with the Elixir solution I wrote and personally prefer. I am pasting an image below which highlights certain aspects:

Screenshot 2021-12-13 at 10 02 48

  • Lack of reassignment: in Elixir, we can’t reassign variables, we can only rebind them. The difference is, when you do var = some_value inside a if, for, etc, the value won’t “leak” to the outer scope. This implies two things in the snippet above:

    1. We need to use Enum.map_reduce/3 and pass the state in and out (highlighted in red)
    2. When resetting the lesson counter, we need both sides of the conditional (hihhlighted in yellow)
  • Lack of mutability: even though we set the lesson counter inside the inner map_reduce, we still need to update the lesson inside the session (highlighted in green)

  • Lack of sensitive whitespace: we have two additional lines with end in them (highlighted in blue)

As you can see, do-end blocks add very litte noise to the final solution compared to sensitive whitespace. In fact, the reason why I brought it up is to make it clear they are not the source of verbosity, so we can confidentaly discard it from the discussion from now on. And also because there is zero chance of the language suddenly becoming whitespace sensitive. :slight_smile:

However, there is still a gap to mind. So how can we move forward? Before we start on that, let’s learn what we can already do with comprehensions.

Comprehensions

Comprehensions in Elixir have always been a syntax sugar to more complex data-structure traversals. Do you want to have the cartesian product between all points in x and y? You could write this:

Enum.flat_map(x, fn i ->
  Enum.map(y, fn j -> {i, j} end)
end)

Or with a comprehension:

for i <- x, j <- y, do: {i, j}

Or maybe you want to brute force your way into finding Pythagorean Triples?

Enum.flat_map(1..20, fn a ->
  Enum.flat_map(1..20, fn b ->
    1..20
    |> Enum.filter(fn c -> a*a + b*b == c*c end)
    |> Enum.map(fn c -> {a, b, c} end)
  end)
end)

Or with a comprehension:

for a <- 1..20,
    b <- 1..20,
    c <- 1..20,
    a*a + b*b == c*c,
    do: {a, b, c}

There is no question the comprehensions are more concise and clearer, once you understand their basic syntax elements (which are, at this point, common to many languages). They support further options, such as :into, :uniq and :reduce.

Comprehensions start falling apart when you need to return additional values.

Let’s go back to our initial example. Imagine that you want to traverse a list of numbers, multiple each element in it by two while returning the sum of the original list at the same time.

In most non-functional programming languages, you might achieve this task like this:

sum = 0
list = []

for(element of [1, 2, 3]) {
  list.append(element * 2)
  sum += element
}

list /* [2, 4, 6] */
sum /* 6 */

This is quite different from how we have been doing things so far. In the example above, the for loop is changing the values of list and sum directly, which is then reflected in those variables once the loop is over.

Unfortunately, there is no mechanism to write this using for-comprehensions in Elixir. We need to fallback to map_reduce algorithms, going back to the problems described at the top of this post.

While you can use the :reduce in for-comprehensions to solve this problem, it has similar trade-offs to the Enum.map_reduce/3 solution presented here.

Local accumulators

This proposal offers to introduce local accumulators to Elixir. This aims to improve the proposed solution by introducing local reassignment to the language. As mentioned earlier, variables defined or rebound inside an inner scope do not affect the outer scope in Elixir:

value = 123

if true do
  value = 456
end

value #=> 123

With local accumulators, we can instead write:

@@value = 123

if true do
  @@value = 456
end

@@value #=> 456

With local accumualtors we could solve the problem above like this:

@@sum = 0

list =
  for element <- [1, 2, 3] do
    @@sum = element + @@sum
    element * 2
  end

list #=> [2, 4, 6]
@@sum #=> 6

Local accumulators are local because can only be reassigned within their scope. You can mutate them inside if/unless, for comprehensions, cond, receive, case… but you can’t pass them to other functions, not even anonymous functions. Therefore, you cannot write this:

@@sum = 0

Enum.map([1, 2, 3], fn x ->
  @@sum = x + @@sum
end)

In fact, the code above would fail to compile. Once we enter an anonymous function, any local accumulator becomes read-only. Passing a local accumulator to another function will only pass its current value.

In other words, the local accumulators are fully local and they never escape the current function, so their behaviour can never be observed outside of the function. Furthermore, Elixir will take care of compiling local accumulators to purely functional code!

Revisiting the problem

With all of this said, how we can solve the initial problem with local accumulators:

@@section_counter = 0
@@lesson_counter = 0

for section <- sections do
  if section["reset_lesson_position"] do
    @@lesson_counter = 0
  end

  @@section_counter = @@section_counter + 1

  lessons =
    for lesson <- section["lessons"] do
      @@lesson_counter = @@lesson_counter + 1
      Map.put(lesson, "position", @@lesson_counter)
    end

  section
  |> Map.put("lessons", lessons)
  |> Map.put("position", @@section_counter)
end

By adding reassignment to the language in the form of local accumulators, we can considerably reduce the amount of noise. We do this while still preserving the immutability semantics of data-structures and of all function callers. The only modification I have done to the solution is to start counting from zero, so we can store the incremented values in sessions and lessons.

I hope this provides some food for thought on this long-running discussion. Personally speaking, I find this solution gives the guarantees we expect from a functional language while providing a safe and clear alternative for those coming from imperative backgrounds.

Implementation considerations

This proposal uses the @@var notation to make it clear where local accumulators are used. @ is already used for module attributes, and there are similarties to them:

  1. Module attributes are often used to accumulate values

  2. When passing a module attribute or a local accumulators to a function, we simply pass the current value

However, they also have one large difference: module attributes are set using the @var :value notation, local accumulators use @@var = :value. When a module attribute is used inside a pattern, it is replaced by its contents. Local accumulators are reassigned.

There is also the possibility of confusion between them. Luckily, @@doc "foo" can raise a compilation error, which is a benefit of them having different write syntaxes. Furthermore, unused module attributes already warn and unused local accumulators shall warn too.

We should also consider the AST for local accumulators. @@var is already valid syntax today, and translates to {:@, _, [{:@, _, [{:var, _, nil}]}]}. Therefore, we could implement local accumulators today by pattern matching on the AST inside the implementation of module attributes. However, this has some downsides:

  • It will be easier to find and document if @ and @@ are distinct operators

  • @@ will most likely need to be a special form, as it has special mearning by the compiler, and module attributes are not special forms today

Therefore, introducing @@ as a separate token and its own AST node is a backwards incompatible change to the language. We must assess the impact of such change (for example, by emitting warnings in the tokenizer before the feature is introduced and by parsing all packages in Hex for occurrences of @@).

Alternatives syntaxes

One possible alternative is to use an operator to assign to local accumulators, such as:

session_counter <~ session_counter + 1

Unfortunately the operator introduces a few issues:

  1. There are different ways we can assign/bind to a variable in Elixir, such as the = operator, case clauses, on the left side of <- in with clauses, etc

  2. Patterns may mix both “regular” variables and local accumulators in them and the operator does not allow us to express which is which

These reasons indicate local accumulators should be a property of the variable name.

1043 10964 245

Most Liked

LostKobrakai

LostKobrakai

This to me makes this proposal a no-go. Elixir in its early days had this behaviour with normal variables and it was eventually removed to align with the idea of “everything is an expression” and using the return of the if/else. Having the return value of if/else be its result is imo an (/one of many) example of what makes elixir code easier to understand and follow than other languages, which do not enforce that explicitness.

The fact that we now have to prefix variables with @@ imo doesn’t really change the fact that the whole snippet is now harder to understand. It might not look like it here, but imagine there being 20 more unrelated lines scattered between the lines shown here.

I do like the efforts of trying to improve the lesson counting example, something which indeed benefits from improvement, but I’m not a fan of adding new features, which can easily cause code to be made worse in other places – other places which I expect to be larger in quantity than the improved ones.

I’ve answered many peoples questions around how “if/else” works in elixir, where they tried to go the assign within the block route and found it not working. By now I explained that this is not how things go and that they’d need to change their approach. Going forward I’d need to explain that things don’t work like that 'ish and that they could use this local accumulators thing, which would work the way they’ve been coding before, but is not really the thing they want to use, because it can easily make things harder to understand in many places, …. My expectation would be that the requirement to change is the larger effort than just prefixing a bunch of variables with @@, so the latter will be what at least a bunch of beginners will be using.

Instead of “the improvement when needed” it might become the default approach taken for them.

One other issue I see with the proposals around this topic (including previous proposals) is the focus on for. I am a big fan of for and the conciseness it can provide. I also rarely use it – even though I’d love to do it more – because it’s so easy to introduce filtering of values, where filtering was not intended, but an error needed if a pattern couldn’t be matched for an element. In production code I’d rather be cautious and fail to process something instead of having elements be silently ignored on accident.

I’d be fine with a solution only updating how for works. It’s limiting, but a tradeoff to be made just like the tradeoff between nested Enum usage vs. a single for. But I’m not a fan of extending the changes to if/else, …, causing larger scale effects for all their use-cases, but excluding Enum APIs, where people will still run into “problem statement”.

josevalim

josevalim

Creator of Elixir

Because there is no monopoly or unified way in how we teach people the language, nor in how people learn. We can’t assume Enum will be taught and, even if it is, different people will take different amount of times to grasp it. Is it really worth saying: you will be unable to solve certain problems unless you fully grasp this concept, while elsewhere it is considerably easier to tackle it?

Look, I completely understand those who don’t like the solution, consider it may bloat the language, or don’t like the proposed syntax. But I was honestly hoping more empathy towards the problem statement. Telling people “oh, you can use map_reduce” to me is somewhat equivalent to the famous joke:

A monad is just a monoid in the category of endofunctors, what’s the problem?

It is not even about learning. There is one assumption and one personal preference here:

  1. I assume that something like local accumulators will provide several users a better on-boarding ramp
  2. I personally prefer the Python (and other imperative language) solutions to this problem

And this is not about Python. Pick any imperative language and their solution will be clearer. Python just happens to be the most concise one. As I said in the other thread, give any Elixir developer both the Python and the Elixir solution, and I believe the majority will most likely understand what is happening on the Python one in less time, because of the amount of boilerplate in the Elixir one.

There is a meme that is applicable here. Some of you may have seen a slide about design patterns in OO and FP languages:

Yet we have this here:

FP Imperative
map loop
reduce loop
flat_map loop
flat_map_reduce loop
scan loop
count_by Oh my, loop
count_until loop+break
take_while loop+break
split_while loop+break
drop_while loop+break
reduce_while loop+break

And it goes on and on.

Look, I love the Enum module. It provides a great shared vocabulary. But we need to acknowledge it is a sizeable step in our learning curve. Once again, I don’t think it means we need to accept this, but I do think the status quo can be improved considerably.

josevalim

josevalim

Creator of Elixir

Thanks everyone for the insights and feedback. I will unpin the thread and leave it open for those who want to continue discussing it. It is clear that, if we were to move this forward, we should at least have another proposal that considers different syntax options. Perhaps in a year or two. :slight_smile:

Where Next?

Popular in Proposals Top

josevalim
I am resubmitting the proposal from earlier today with more context and more focus on the important parts. Some concerns and praises stay...
452 18493 123
New
josevalim
Hi everyone, This is a proposal for introducing local accumulators to Elixir. This is another attempt of solving the comprehension probl...
1043 10963 245
New
josevalim
Hi everyone, We are considering deprecating 'charlists' in Elixir in favor of ~c"charlist". In many languages, 'foobar' is equivalent to...
New
josevalim
One of the major differences between running your application as a release and as a Mix project is the differences in configuration. Mix ...
382 18427 108
New
josevalim
NOTE: this is a focused thread, so we appreciate if everybody stayed on topic. Feel free to comment anything in regards to calendar forma...
New
josevalim
Hi everyone, One of the features added to Elixir early on to help integration with Erlang code was the idea of overridable function defi...
New
josevalim
Hi everyone, Erlang/OTP 21 comes with two new guards contributed by @michalmuskala: map_get/2 and is_map_key/2. Now we need to discuss h...
New
josevalim
Hi everyone, as you may be aware, we are researching a type system for Elixir. As preparation for my upcoming ElixirConf US talk, I woul...
New
josevalim
UPDATE: This proposal has been retracted. Read the new proposal here: Local accumulators for cleaner comprehensions Hi everyone, This i...
New
michalmuskala
TL;DR: The Elixir Core team is announcing a call for proposals to extend support for time zones in Elixir’s standard library. The reason...
New

Other popular topics Top

9mm
I am constructing a JSON object (map) and I need to conditionally set a field. I’m trying to write proper elixir-way code… and I’m at a l...
New
shahryarjb
Hello, I have map which I want to convert it to string like this: the map: %{last_name: "tavakkoli", name: "shahryar"} the string I ne...
New
chrismccord
Phoenix 1.4.0 released Phoenix 1.4 is out! This release ships with exciting new features, most notably with HTTP2 support, improved deve...
688 30877 112
New
stefanchrobot
What’s the safe way to decode a JSON string into a struct? I want to avoid calling String.to_atom. Jason.decode can give me a map with st...
New
jason.o
In the code below, if the create action is not set to accept “extra_key” as an input, it errors out with a message shown above. Is there ...
New
KronicDeth
Elixir plugin for JetBrain’s IntelliJ Platform (including Rubymine) This is a plugin that adds support for Elixir to JetBrains IntelliJ...
289 36128 110
New
jononomo
For some reason my phoenix channels are working for me in my local dev environment, but as soon as I deploy via Docker, I get a 403 error...
New
svb
Hi! Currently I want to submit a form by pressing the Enter key. However, since my input field is of type “textarea” this is just adds a...
New
sergio
Kind of like when jquery came out, it was super necessary. Existing drag and drop libraries have a bunch of baggage to support old browse...
New
lanycrost
Hi everyone! I need implement if…else if…else condition from my elixir code, and anymore of this control flow structures not work proper...
New

We're in Beta

About us Mission Statement