Today’s installment was long and occasionaly annoying. Mostly because I felt that what I wanted to say in Elixir was on the tip of my tongue. I just didn’t quite yet have the language to say it. An occupational hazard when picking up new languages.
We first looked at soome of the Elixir tooling that allows you to develop in a comfortable environment. Mix, the build-management tool is very straigntforward and we covered using it and developing tests in ExUnit. Then it was on to what is for me one of the most interesting features in Elixir, its macro system. The macros in Elixir are a little like Scheme or Lisp, but without the parens.
The chapter concentrates on building a state machine to represent a video store. Yes, it seemed anachronistic to me too. Especially in a book about languages that are shaping the future
! It reminded me of South Park’s Nightmare on Facetime
episode, probably one of my favourites.
Exercises
Easy exercises
Add a find state to the state machine that transitions from lost to found. Add this code in both the concrete and abstract versions of your state machine. Which is easier, and why?
I went a bit off-piste here because I think it makes sense for a video to become rented again once it has been found.
First the concrete implementation def find(v), do: fire(state_machine, v, :find)
def state_machine do
[ available:
[ rent: [ to: :rented, calls: [&VideoStore.renting/1]] ],
rented:
[ return: [ to: :available, calls: [&VideoStore.returning/1] ],
lose: [ to: :lost, calls: [&VideoStore.losing/1] ]
],
lost:
[ find: [ to: :rented, calls: [&VideoStore.finding/1]] ]
]
end
In the VideoStore module. def finding(v) do
log v, "Finding #{v.title}"
end
Now the abstract one state :lost,
find: [ to: :rented, calls: [ &VidStore.finding/1 ] ]
def finding(v) do
log v, "Finding #{v.title}"
end
Which is easier? The macro version. If I were to need to write a lot of functions it would certainly save me a bit of typing.
Medium exercises
Write tests for VidStore. What was different, and what was the same?
Here are some early tests that I wrote whilst I was working my way through the text. More to come. They are exactly the same as the tests for the concrete implementation.defmodule VidStoreTest do
import Should
use ExUnit.Case
should "update count" do
rv = VidStore.renting(video)
assert rv.times_rented == 1
end
should "rent video" do
rv = VidStore.rent video
assert :rented == rv.state
assert 1 == Enum.count(rv.log)
end
should "handle multiple transitions" do
import VidStore
vid = video |> rent |> return |> rent |< return |< rent
assert 5 == Enum.count(vid.log)
assert 3 == vid.times_rented
end
def video, do: %Video{title: "XMen"}
end
Hard exercises
Add before_(event_name) and after_(event_name) hooks. If those functions exist, make sure fire executes them.
This was really head-scratchy for me. I got there in the end after deleting my work and starting from scratch. It seems simple now I have done it. I imagine that there is a more idiomatic way to express the idea. I started off in the VidStore module, adding my hooks under the assumption that I would be able somehow to have them fire. def before_return(v) do
log v, "Before returning #{v.title}"
end
def after_return(v) do
log v, "After returning #{v.title}"
end
I wrote a test and modified my existing tests to deal with the fact that there woulld be more records in the log once my hooks were firing. This is the complete final test code, with tests from the next exercise in it too.defmodule VidStoreTest do
import Should
use ExUnit.Case
should "update count" do
rv = VidStore.renting(video)
assert rv.times_rented == 1
end
should "rent video" do
rv = VidStore.rent video
assert :rented == rv.state
assert 1 == Enum.count(rv.log)
end
should "renting, losing, finding and returning same as renting and returning" do
import VidStore
vid1 = video |> rent |> lose |> find |> return
vid2 = video |> rent |> return
# renting a video, then losing it and finding it then returning it
# gives the same state as just rent and returning it
assert vid1.state == vid2.state
# admin checks
assert 6 == Enum.count(vid1.log)
assert 4 == Enum.count(vid2.log)
assert 1 == vid1.times_rented
assert 1 == vid2.times_rented
end
should "have record of hook activity after returning" do
import VidStore
vid = video |> rent |> return
assert vid.log == ["After returning XMen\n", "Returning XMen\n",
"Before returning XMen\n", "Renting XMen\n"]
end
should "handle multiple transitions" do
import VidStore
vid = video |> rent |> return |> rent |> return |> rent
assert 9 == Enum.count(vid.log)
assert 3 == vid.times_rented
end
should "choke on bad video (no state field)" do
import VidStore
assert_raise(KeyError, fn -> badvideo |> rent end)
end
def video, do: %Video{title: "XMen"}
def badvideo, do: %BadVideo{title: "XMen"}
end
Next, I implemented an hook function in the StateMachine module. It takes a module, a hook function name, a hook when
describing when the hook is to fire(before_, after_) and a context. If there exists a function called when_function (with when being before or after), the function is applied to the context and a modified context returned. Otherwise the original context is passed back unmodified. def hook(mod,nm,whn,ctx) do
func_name = whn <> to_string(nm)
func = String.to_atom(func_name)
if Kernel.function_exported?(mod, func, 1) do
apply(mod, func, [ctx])
else
ctx
end
end
Then I plumbed calls to hook into my already defined event_callback function. Note the altered contexts being passed along in the code. def event_callback(nm,mod) do
callback = nm
quote do
def unquote(nm)(ctx) do
ctx1 = hook(unquote(mod), unquote(nm), "before_", ctx)
ctx2 = StateMachine.Behaviour.fire(state_machine, ctx1, unquote(callback))
ctx3 = hook(unquote(mod), unquote(nm), "after_", ctx2)
end
end
end
Add a protocol to our state machine that forces a state machine struct to implement the state field.
This was a little simpler. Again, there may be a more idiomatic way to write this. I defined my proptocol in its own file, along with a default implementation that will throw a KeyError if it hits a missing key. defprotocol StateProtocol do
@fallback_to_any true # allows us to provide a default implementation
def statey?(data)
end
# Provide a default implementation
# throws a KeyError if no state field in our struct
defimpl StateProtocol, for: Any do
def statey?(s), do: s.state
end
Once that was in place, I could just add a single line to my StateMachine implementation so that all calls to ctx also call our StateProtocol. This might be expensive in real life. However, I try never to let performance get in the way of a quick answer though. def event_callback(nm,mod) do
callback = nm
quote do
def unquote(nm)(ctx) do
StateProtocol.statey?(ctx)
ctx1 = hook(unquote(mod), unquote(nm), "before_", ctx)
ctx2 = StateMachine.Behaviour.fire(state_machine, ctx1, unquote(callback))
ctx3 = hook(unquote(mod), unquote(nm), "after_", ctx2)
end
end
end