I’ve always been fascinated by how Elixir’s Stream module handles lazy
operations by storing the operation data in a struct. 🤯

It’s so simple but so brilliant!

You can see that if you dig into the source code:

defmodule Stream do
 # ...
 defstruct enum: nil, funs: [], accs: [], done: nil
 # ...
end

It defines a struct with funs (among other things) to store the functions to
apply lazily.

And I know Stream is not the only module to do that. Ecto.Multi also stores
operations
in a struct, and I’m sure there are others.

Lazy Math

So, I wanted to see what it’d be like to write a simple implementation of a lazy
math evaluator.

Unsurprisingly, Elixir makes it easy to do this.

Let’s take a look:

defmodule LazyMath do
  defstruct initial: 0, ops: []

  def new(initial), do: %LazyMath{initial: initial}
end

We first define a LazyMath module with struct definition that has the
initial value set to 0 and an empty list of ops (operations).

We also add a new/1 function that will be a helper to initialize our struct.

Now let’s add some operations: add/2, subtract/2, multiply/2 and
divide/2:

  def add(math = %{ops: ops}, number) do
    %LazyMath{math | ops: [{:add, number} | ops]}
  end

  def subtract(math = %{ops: ops}, number) do
    %LazyMath{math | ops: [{:sub, number} | ops]}
  end

  def multiply(math = %{ops: ops}, number) do
    %LazyMath{math | ops: [{:mult, number} | ops]}
  end

  def divide(math = %{ops: ops}, number) do
    %LazyMath{math | ops: [{:div, number} | ops]}
  end

As you can see, they all look very similar (and we could probably refactor a
common private function). But what’s interesting is that we’re storing a
representation of the operation instead of performing the operation.

So, when we want to add a number, we prepend an {:add, number} tuple to the
list of existing ops, and return the updated %LazyMath{} struct.

  def add(math = %{ops: ops}, number) do
    # store `{:add, number}` instead of adding `number` to existing total
    %LazyMath{math | ops: [{:add, number} | ops]}
  end

The rest of the operations work the exact same way (though we store different
tuples).

Now, let’s see how we can evaluate all of the operations:

  def evaluate(%LazyMath{initial: init, ops: ops}) do
    ops
    |> Enum.reverse()
    |> Enum.reduce(init, fn
      {:add, number}, acc_total -> acc_total + number
      {:sub, number}, acc_total -> acc_total - number
      {:mult, number}, acc_total -> acc_total * number
      {:div, number}, acc_total -> div(acc_total, number)
    end)
  end

Our evaluate/1 function takes an existing %LazyMath{} struct, pattern
matching the initial value and the operations.

We then reverse the list of operations, so we can apply them in the correct
order — remember we were prepending new operations before.

Finally, we Enum.reduce/3 over the list of operations (now in order),
passing the initial value, and then we pattern match on the operation tuple to
perform the actual operation on the accumulated total.

Here’s the full module:

defmodule LazyMath do
  defstruct initial: 0, ops: []

  def new(initial), do: %LazyMath{initial: initial}

  def add(math = %{ops: ops}, number) do
    %LazyMath{math | ops: [{:add, number} | ops]}
  end

  def subtract(math = %{ops: ops}, number) do
    %LazyMath{math | ops: [{:sub, number} | ops]}
  end

  def multiply(math = %{ops: ops}, number) do
    %LazyMath{math | ops: [{:mult, number} | ops]}
  end

  def divide(math = %{ops: ops}, number) do
    %LazyMath{math | ops: [{:div, number} | ops]}
  end

  def evaluate(%LazyMath{initial: init, ops: ops}) do
    ops
    |> Enum.reverse()
    |> Enum.reduce(init, fn
      {:add, number}, acc_total -> acc_total + number
      {:sub, number}, acc_total -> acc_total - number
      {:mult, number}, acc_total -> acc_total * number
      {:div, number}, acc_total -> div(acc_total, number)
    end)
  end
end

Let’s test how lazy we are:

result =
  LazyMath.new(0)
  |> LazyMath.add(5)
  |> LazyMath.subtract(2)
  |> LazyMath.multiply(2)
  |> LazyMath.divide(3)
# => %LazyMath{initial: 0, ops: [div: 3, mult: 2, sub: 2, add: 5]}

As you can see, our result hasn’t evaluated any math operations yet. Instead,
it stored the initial value along with the list of operations. 🥳

Finally, we can evaluate the result:

result |> LazyMath.evaluate()
# => 2

Pretty cool, right?

Read More