View Source Version 2: update

The code and tests for this version – version 2 – can be found in implementation_v1_update_test.exs.

Update-capable lens code has the same "shape" as get-capable code. For example, here's V2.update vs. V1.get_all. update takes an update function and uses it as the final "descender":

def update(container, lens, update_fn) do      def get_all(container, lens) do
                            ^^^^^^^^^             
  lens.(container, update_fn)                    lens.(container, & &1)
                   ^^^^^^^^^                                      ^^^^
end                                            end

(A put operation is the same, except that it codes up a constant-returning update function:

def put(container, lens, constant) do
  lens.(container, fn _ -> constant end)
end

I won't mention put any more.)

It's worth emphasizing what happens in a call like:

iex> lens = Lens.key(:a) |> Lens.key(:b) |> Lens.key(:c)
iex> container = %{a: %{b: %{c: 1}}}
iex> update_fn = & &1 * 1111
iex> Deeply.update(container, lens, update_fn)
%{a: %{b: %{c: 1111}}}

The code descends all the way to the inner map %{c: 1}. It calls this function:

Map.update!(%{c: 1}, :c, update_fn)

... which produces %{c: 1111}. Having descended as far as it can, it begins to "retreat" back to the original container. That means that the lens for key(:b) will have pointers to two pieces of data:

%{b: %{c: 1}}  # embedded within the original container
%{c: 1111}     # a value returned from the lower lens

It must do this:

Map.put(%{b: %{c: 1}}, :b, %{c: 1111})

... to make a new map %{b: %{c: 1111}}. And, retreating further up the pipeline, we get this:

Map.put(%{a: %{b: %{c: 1}}}, :a, %{b: %{c: 1111}})

Every step of retreat "back up to" the original container allocates a new map. That's just the way it is in a language without mutability. (Such languages can make optimizations to share structure between original and updated versions of structures, so long as no user code can tell. I don't know if the Erlang virtual machine's optimizations for maps would help with this example.)

With that background, here's the definition of V2.key:

def key(key) do
  fn container, descender ->
    updated =
      Map.get(container, key)
      |> descender.()

    Map.put(container, key, updated)
  end
end

In the leaf (%{c: 1}) case, that code has this effect:

    updated =
      Map.get(%{c: 1}, :c)
      |> (&1 * 1111).()
    Map.put(%{c: 1}, :c, updated)

In the next level up, the code should look like this:

    updated =
      Map.get(%{b: {c: 1}}, :b)
      |> leaf_descender.()
    Map.put(%{b: %{c: 1}}, :b, updated)

However, there's a V2.seq in between the key(:c) leaf lens and the preceding key(:b) lens. What must that look like?

The V1 version of seq gets wrapped values and has to unwrap them. But this V2 version gets a sub-container that's had a repacement done. It doesn't have to do anything but return that value to the previous lens, which will put it into the enclosing container. So that's easy:

# `get_all` version                                     # `update` version
def seq(outer_lens, inner_lens) do                      def seq(outer_lens, inner_lens) do
  fn outer_container, inner_descender ->                  fn outer_container, inner_descender ->
    outer_descender =                                       outer_descender =
      fn inner_container ->                                   fn inner_container ->
        inner_lens.(inner_container, inner_descender)           inner_lens.(inner_container, inner_descender)
      end                                                     end

    gotten =                                                updated =
    ^^^^^^                                                  ^^^^^^^
      outer_lens.(outer_container, outer_descender)           outer_lens.(outer_container, outer_descender)

    Enum.concat(gotten)                                     updated
    ^^^^^^^^^^^^^^^^^^^                                     ^^^^^^^
  end                                                     end
end                                                     end

When it comes to ordinary lenses, the changes are equally trivial. Here's at:

# `get_all` version                # `update` version
def at(index) do                   def at(index) do
  fn container, descender ->        fn container, descender ->
    gotten =                          updated =
      Enum.at(container, index)         Enum.at(container, index)
      |> descender.()                   |> descender.()
    [gotten]                          List.replace_at(container, index, updated)
    ^^^^^^^^                          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  end                               end
end                               end

all doesn't have to change at all:

def all do                          def all do
  fn container, descender ->          fn container, descender ->
    for item <- container,              for item <- container,
        do: descender.(item)                do: descender.(item)
  end                                 end
end                                 end

Next is to combine the V1 and V2 lenses into a template that works for both getting and updating.