I am with Louis and Paul so far I think. I won't repeat their comments but I think I can extend the issue by pointing out that this breaks refactoring for the inner contents of `for`. Previously, if you have:
``` for lesson <- section["lessons"], reduce: 0 do counter -> # complex multi-line-thing using the lesson and counter end ``` I can refactor this into: ``` for lesson <- section["lessons"], reduce: 0 do counter -> complex_operation(lesson, counter) end def complex_thing(lesson, counter) do # complex multi-line-thing using the lesson and counter end ``` And everything just works, as is normal in Elixir code. The proposed changes would (as far as I can see) break this and that feels very unexpected and foreign. I sympathize with the problem space, but so far it's a -1 for me on this particular proposed improvement. - Ben On Thursday, December 16, 2021 at 10:02:49 AM UTC-5 José Valim wrote: > Note: This proposal contains images and rich text that may not display > correctly in your email. If so, you can read this proposal in a gist > <https://gist.github.com/josevalim/5c6735a4b90acc1bafdafec09acabe4f>. > > There is prior art in languages like Common Lisp, Haskell, and even in C# > with LINQ on having very powerful comprehensions as part of the language. > While Elixir comprehensions are already very expressive, allowing you to > map, filter, reduce, and collect over multiple enumerables at the same > time, it is still not capable of expressing other constructs, such as > map_reduce. > > The challenge here is how to continue adding more expressive power to > comprehensions without making the API feel massive. That's why, 7 years > after v1.0, only two new options have been added to comprehensions, :uniq > and :reduce, to a total of 3 (:into, :uniq, and :reduce). > Imperative loops > > I have been on the record a couple times saying that, while many 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" > <https://github.com/josevalim/nested-data-structure-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, the most concise and clear solution is the Python > one, which I reproduce here: > > section_counter = 1lesson_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 > <https://github.com/josevalim/nested-data-structure-traversal/blob/master/elixir/map_reduce.exs>. > > I am pasting an image below which highlights certain aspects: > > [image: Screenshot 2021-12-13 at 10 02 48] > <https://user-images.githubusercontent.com/9582/145821890-6557ea21-e61f-4813-8c54-53c4ea1a9438.png> > > - > > 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 only reason I brought it up > is so we can confidently discard it from the discussion from now on. And > also because there is zero chance of the language suddenly becoming > whitespace sensitive. > > There is also zero chance of us introducing reassignment and making > mutability first class in Elixir too. The reason for this is because we all > agree that, the majority of the time, lack of reassignment and lack of > mutability are features that make our code more readable and understandable > in the long term. The snippet above is one of the few examples where we are > on the wrong end of the trade-offs. > > Therefore, how can we move forward? > 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). > > As mentioned in the introduction, we can express map, filter, reduce, and > collect inside comprehensions. But how can we represent map_reduce in a > clear and concise way? > The :map_reduce option > > Since we have :reduce in comprehensions, we could introduce :map_reduce. > The solution above would look like this: > > {sections, _acc} = > for section <- sections, map_reduce: {1, 1} do > {section_counter, lesson_counter} -> > lesson_counter = if section["reset_lesson_position"], do: 1, else: > lesson_counter > > {lessons, lesson_counter} = > for lesson <- section["lessons"], map_reduce: lesson_counter do > lesson_counter -> > {Map.put(lesson, "position", lesson_counter), lesson_counter + 1} > end > > section = > section > |> Map.put("lessons", lessons) > |> Map.put("position", section_counter) > > {section, {section_counter + 1, lesson_counter}} > end > > While there is a bit less noise compared to the original solution, the > reduction of noise mostly happened by the removal of modules names and a > few tokens, such as fn, (, and ). In terms of implementation, there is > still a lot of book keeping required to manage the variables. Can we do > better? > Introducing :let > > Our goal is to declare variables that are automatically looped within the > comprehension. So let's introduce a new option that does exactly that: > :let. :let expects one or a tuple of variables that will be reused across > the comprehension. At the end, :let returns a tuple with the > comprehension elements and the let variables. > > Here is how the solution would look like: > > section_counter = 1lesson_counter = 1 > {sections, _} = > for section <- sections, > let: {section_counter, lesson_counter} do > lesson_counter = if section["reset_lesson_position"], do: 1, else: > lesson_counter > > {lessons, lesson_counter} = > for lesson <- section["lessons"], let: lesson_counter do > lesson = Map.put(lesson, "position", lesson_counter) > lesson_counter = lesson_counter + 1 > lesson > end > > section = > section > |> Map.put("lessons", lessons) > |> Map.put("position", section_counter) > > section_counter = section_counter + 1 > section > end > > The :let option automatically takes care of passing the variables across > the comprehension, considerably cutting down the noise, without introducing > any mutability into the language. At the end, for+:let returns the result > of the comprehension plus the :let variables wrapped in a tuple. > Extensions > > Here are some extensions to the proposal above. Not all of them might be > available on the initial implementation. > Let initialization > > You can also initialize the variables within let for convenience: > > {sections, _} = > for section <- sections, > let: {section_counter = 1, lesson_counter = 1} do > > This should be available in the initial implementation. > :reduce vs :let > > With :let, :reduce becomes somewhat redundant. For example, > Enum.group_by/2 could be written as: > > for {k, v} <- Enum.reverse(list), reduce: %{} do > acc -> Map.update(acc, k, [v], &[v | &1])end > > with :let: > > {_, acc} = > for {k, v} <- Enum.reverse(list), let: acc = %{} do > acc = Map.update(acc, k, [v], &[v | &1]) > end > > The difference, however, is that :let returns the collection, while > :reduce does not. While the Elixir compiler could be smart enough to > optimize away building the collection in the :let case if we don't use > it, we may want to keep both :let and :reduce options for clarity. If > this is the case, I propose to align the syntaxes such that :reduce uses > the same semantics as :let. The only difference is the return type: > > for {k, v} <- Enum.reverse(list), reduce: acc = %{} do > acc = Map.update(acc, k, [v], &[v | &1])end > > This can be done in a backwards compatible fashion. > after > > When you look at our solution to the problem using let, we had to > introduce temporary variables in order to update our let variables: > > {lessons, lesson_counter} = > for lesson <- section["lessons"], let: lesson_counter do > lesson = Map.put(lesson, "position", lesson_counter) > lesson_counter = lesson_counter + 1 > lesson > end > > One extension is to add after to the comprehensions, which are computed > after the result is returned: > > {lessons, lesson_counter} = > for lesson <- section["lessons"], let: lesson_counter do > Map.put(lesson, "position", lesson_counter) > after > lesson_counter = lesson_counter + 1 > end > > This does not need to be part of the initial implementation. > Summary > > Feedback on the proposal and extensions is welcome! > -- You received this message because you are subscribed to the Google Groups "elixir-lang-core" group. To unsubscribe from this group and stop receiving emails from it, send an email to elixir-lang-core+unsubscr...@googlegroups.com. To view this discussion on the web visit https://groups.google.com/d/msgid/elixir-lang-core/22703d4b-60cb-4b0e-83d2-4a122f9147afn%40googlegroups.com.