In the last episode I asked you to think about how you might implement mutable state using a process. Today we'll look at doing it ourselves, as well as checking out Agent from the Standard Library that exists for this very reason. Let's get started.
We'll start a new project:
mix new stateful_processes
cd stateful_processes
We'd like to build a process that represents a counter. We can increment it or decrement it by sending it messages. This means it has to maintain an internal state that we can change. Elixir's an immutable language, so how do we manage this? Let's start out with some tests:
defmodule StatefulProcessesTest do
use ExUnit.Case
test "starting the counter" do
{:ok, pid} = Counter.start(0)
assert is_pid(pid)
end
end
We'd like to start a counter, passing it an initial value. Let's make sure that works:
vim lib/counter.ex
defmodule Counter do
def start(initial_value) do
{:ok, spawn(Counter, :loop, [initial_value])}
end
def loop(value) do
:ok
end
end
Let's build out a means to get the value from this process, following the same pattern we used in the last episode:
defmodule StatefulProcessesTest do
# ...
test "getting the value" do
{:ok, pid} = Counter.start(0)
assert {:ok, 0} = Counter.get_value(pid)
end
We'll receive a message in our loop and return the value we know about, and write a function that sends a message and receives the value back.
defmodule Counter do
# ...
def loop(value) do
receive do
# In our loop we'll expect to be told who a message is from, a ref that is
# unique to their request, and a term that tells us what to do - in this
# case, get the value of the counter.
#
# We'll send the value back with the ref so they know it's the appropriate
# response, and then we'll tail-call with the existing value
{from, ref, :get_value} ->
send(from, {:ok, ref, value})
loop(value)
end
end
# Then we just make the nice function to interact with our process loop
def get_value(pid) do
ref = make_ref()
send(pid, {self(), ref, :get_value})
receive do
{:ok, ^ref, val} -> {:ok, val}
end
end
end
With this, the tests pass. From here, we can add two new messages to our receive
loop - :increment
and :decrement
. Let's write a test for increment:
test "incrementing the value" do
{:ok, pid} = Counter.start(0)
:ok = Counter.increment(pid)
assert {:ok, 1} = Counter.get_value(pid)
end
This fails because there is no such function, of course. Let's add the function and the corresponding receive case:
defmodule Counter do
# ...
def loop(value) do
receive do
# ...
:increment ->
loop(value + 1)
end
end
# ...
def increment(pid) do
send(pid, :increment)
:ok
end
end
Now this recursive function calls back into itself with a modified argument, in
the event of an :increment
message in the mailbox. This is how we can use
processes to manage mutable state. If you run the tests, you'll find they pass.
From here, it's easy to see how to implement decrement:
test "decrementing the value" do
{:ok, pid} = Counter.start(0)
:ok = Counter.decrement(pid)
assert {:ok, -1} = Counter.get_value(pid)
end
defmodule Counter do
# ...
def loop(value) do
receive do
# ...
:decrement ->
loop(value - 1)
end
end
# ...
def decrement(pid) do
send(pid, :decrement)
:ok
end
end
And the tests pass. So this is how you can implement mutable state with processes. Of course this is something that people frequently do, so we'll discuss GenServer next week.
For now, though, let's look at something else the standard library provides us: Agent. According to the documentation
Agents are a simple abstraction around state.
That is, they provide an easy way to wrap some state up in a process and
interact with it. Let's look at how we could use an Agent
in place of our
hand-rolled process to build this counter:
defmodule Counter do
def start(initial_value) do
# Starting an agent just requires giving it a function that returns the
# agent's initial value.
Agent.start(fn -> initial_value end)
end
def get_value(pid) do
# To get the value, we pass a function that receives the agent's state and
# returns whatever we want - in our case, we want to wrap the value in an
# `:ok` 2-tuple.
Agent.get(pid, fn(x) -> {:ok, x} end)
end
def increment(pid) do
# To increment, we send a function that tells the agent what to do with its
# state.
Agent.update(pid, fn(x) -> x + 1 end)
end
def decrement(pid) do
# To decrement, we send a function that tells the agent what to do with its
# state.
Agent.update(pid, fn(x) -> x - 1 end)
end
end
This is a bit nicer to read, clearly, but it works just as well as our hand-rolled process. I wanted to go through managing mutable state with processes by hand initially so that you could see that there's no magic here - just a nice API on top of essentially what we were doing before. It's not exactly the same, because it's build on top of OTP - we'll get into that a bit more next week.
In today's episode, we saw how to manage mutable state with processes.
First, by rolling our own process. Then, by using the Agent
module from the
standard library, which exists for this exact purpose - introducing an actor
that owns some mutable state.
I hope you enjoyed it. See you soon!