aseigo
Stupid pet tricks: functions as state
Had a little task today, and the quick solution felt a bit like a stupid pet trick … thought I’d share it here as something a little different from the continual stream of questions ![]()
The task was this: we needed all the pair-wise combinations from a set of entries, and these need to (for performance reasons) be batched up. So we have data like this:
[1..10_000]
… which gets turned into batches like this using Enum.chunk_every/2:
[[1..500], [501..1000], [1001..1500], .. etc]
We then want to do some computation with the next value (e.g. 1) against the rest of the values in its batch (e.g. [2..500]) and then subsequently against each further batch (e.g. [501..1000], then [1001..1500], etc). Pair-wise combinations. Fun.
The computation is done async and the batches are generated on request by a GenServerfor consumption by workers. So we need to keep track of where we are in the batching, so we need to keep state. Yuck! State! amiright?
Instead of keeping the chunked data around in a state term, I instead opted to keep a function there instead which captured that data:
batches = Enum.chunk_by(sequence, batch_size)
state = fn -> next_batch(batches) end
It is then used like this from a message handler in the GenServer:
def next_job(state) do
case state.() do
{:done, _} = done -> done
{{subject, batch}, next} -> {create_job(subject, batch), next}
end
end
What is that next_batch call in initial state term, you ask? (Ok, you probably didn’t .. but, then again, maybe you did since you have read this far!)
defp next_batch([[current| rest] | batches]) do
next_batch(current, rest, batches, [])
end
defp next_batch(current, [], [], []) do
done_tuple()
end
defp next_batch(_current, [], [], [[next | rest] | batches]) do
{{next, rest}, fn -> next_batch(next, rest, batches, []) end}
end
defp next_batch(_current, [next|rest], [], acc) do
{{next, rest}, fn -> next_batch(next, rest, acc, []) end}
end
defp next_batch(current, rest, [next_batch | batches], acc) do
{{current, next_batch},
fn -> next_batch(current, rest, batches, [next_batch | acc]) end}
end
defp done_tuple(), do: {:done, fn -> done_tuple() end}
Generators! Ignoring the moderately ugly function headers, the useful bit is that the functions return a tuple containing the result of the calculation (the next batch) as well as an anonymous function that contains the next call to next_pair_job that can be used to get the next job … this allows the “detail” of how next_batch/4 works to be entirely opaque to the calling code.
It calls the function (which is its state!) until it gets a :done tuple. It doesn’t matter how many times it is called as the :done tuple has a function which, when called, itself returns the same :done tuple. As this is used in a distributed application where we can not know the order or number of calls in advance, that’s a necessary attribute to have, and the above manages that elegantly. ![]()
It was just a nice way to encapsulate the actual iteration through the batches so it could be “hidden” from the GenServer using it without cluttering up its own message handlers. Performance was just fine, so the code cleanliness this approach offered was considered to offset the overhead.
During code review it came up as an out-of-the-ordinary approach (though certainly not novel), so thought I’d share it here. Yay, stupid pet tricks! ![]()
Most Liked
peerreynders
It seems the real culprit for containing state isn’t mentioned: closure.
Returning state via a function closure, for example, is at the core of implementing trampolines in JavaScript in order to implement stackless recursion.
And then there is this (2003):
- Objects are merely a poor man’s closures
- Closures are a poor man’s object
michalmuskala
It’s not entirely true in Elixir because of one thing - you can’t mutate data in the closure. It’s entirely true in runtimes with mutable data.
michalmuskala
There’s one disadvantage of the described pattern - the state is opaque. This will become problematic in some debugging situations - the function is basically opaque and you can’t “look into” it with default logging to figure out what’s going on (unless you use some tricks like :erlang.fun_info(fun, :env) to get the data bound in the closure).
I’d propose a slightly different, but I believe similarly convenient mechanism of a “continuation token”:
def next_job(token) do
case Batcher.next(token) do # instead of state.()
{:done, token} -> token
{{subject, batch}, token} -> {create_job(subject, batch), token}
end
end
def next({next, rest, batches, acc}) do
next_batch(next, rest, batches, acc)
end
defp next_batch(_current, [], [], []) do
done_tuple()
end
defp next_batch(_current, [], [], [[next | rest] | batches]) do
{{next, rest}, {next, rest, batches, []} # instead of fn -> next_batch(next, rest, batches, []) end}
end
# and similar
This provides similar benefits of encapsulating the continuation state, but makes it more debuggable. Similar approach is used in things like :ets.select/1 for batched traversal of the ets table.
Popular in Discussions
Other popular topics
Categories:
Sub Categories:
Forums
Popular Tags
- #ecto
- #liveview
- #troubleshooting
- #learning-elixir
- #deployment
- #library
- #erlang
- #testing
- #genserver
- #mix
- #absinthe
- #remote-other
- #otp
- #plug
- #how-to-question
- #macros
- #postgres
- #channels
- #elixirconf
- #exunit
- #discussion
- #code-sync
- #javascript
- #podcasts
- #onsite
- #dialyzer
- #docker
- #authentication
- #umbrella
- #full-time-contract
- #podcasts-by-brainlid
- #ecto-query
- #elixir-ls
- #phoenix_html
- #iex
- #blog-post
- #graphql
- #genstage
- #ai
- #websockets
- #supervisor
- #advent-of-code
- #elixirconf-us
- #distillery
- #processes
- #forms
- #api
- #metaprogramming
- #security
- #performance








