Hello
I’m learning Elixir by following this course by Dave Thomas, here: The Coding Gnome
Which is great btw.
However, I’m a little puzzled by a couple of aspects to which I’l like feedback from experienced Elixir programmers.
In this course, we program a “hangman” game.
From a high level perspective, the consuming client can initialize a new_game()
, and update the internal state as a side effect by calling consecutive make_move(String.t)
until the game is finished.
iex(1)> game = Hangman.new_game()
%Hangman.Impl.Game{
game_state: :initializing,
letters: ["d", "r", "y", "e", "r"],
turns_left: 7,
used: #MapSet<[]>
}
iex(2)> Hangman.make_move(game, "d")
{%Hangman.Impl.Game{
game_state: :good_guess,
letters: ["d", "r", "y", "e", "r"],
turns_left: 5,
used: #MapSet<["d"]>
}, %{game_state: :good_guess, letters: [], turns_left: 5, used: ["d"]}}
In order to handle the changing of the internal state, we create many small methods functions and make extensive use of pattern matching.
Here is a little (truncated) example to illustrate:
def make_move(game, guess) do
accept_guess(game, guess, MapSet.member?(game.used, guess))
end
defp accept_guess(game, _guess, _already_used = true) do
%{ game | game_state: :already_used }
end
The first thing I notice that slightly bugs me, and this technique is used many times, is that the caller make_move
handles the game logic (here by calling MapSet.member?(game.used, guess)
to feed accept_guess
’s third parameter. As a result, accept_guess
becomes a “dumb” function.
I would tend to do the opposite so I would like to know if this is a common idiom in Elixir. And if so, what is the rationale behind inverting the logic this way.
One of the reasons given by the author is that programming this way reduces branching. Instead, we only add functions and use pattern matching: no if statements!
However, and this is my second question, I notice that the code becomes less readable or understandable to me. Because, as a result, the managing of state is then spread out to many functions and it’s hard to see at a glance what’s going on, what the real logic is, from top down.
Putting it into other words, the flow of the code becomes as follows:
Step 1: create a `new_game`
Step 2: take a turn and change the internal state with `make_move(String.t)`
Step 2 is represented by:
- `make_move` variant 1
- `make_move` variant 2
- `accept_guess` variant 1, called by `make_move`, sometimes
- `accept_guess` variant 2, called by `make_move`, sometimes
- `score_guess` variant 1, called by `accept_guess`, sometimes
- `score_guess` variant 2, called by `accept_guess`, sometimes
- `score_guess` variant 3, called by `accept_guess`, sometimes
I tried to reduce those many steps, and condense them into one function instead. As such:
defp update(game, guess) do
duplicate_move? = MapSet.member?(game.used, guess)
good_guess? = Enum.member?(game.letters, guess)
new_used = MapSet.put(game.used, guess)
{new_state, new_turns_left} =
if(duplicate_move?) do
{:already_used, game.turns_left}
else
if good_guess? do
{:good_guess, game.turns_left - 1}
else
{:bad_guess, game.turns_left - 1}
end
end
%{game | game_state: new_state, turns_left: new_turns_left, used: new_used}
end
With this API, the calling code becomes more straightforward, resembling this (truncated to simplify the discussion)
def make_move(game, guess) do
update(game, guess)
end
And the changing of the internal state (via update()
) can be observed from one place in the code.
Although update
is more complicated, I feel that it’s more readable and that the main logic can be understood at a glance.
Anyhow, I’d like to have your input on that: do you spread the changing of state to many functions or try to isolate it into one function as I did here and why?
If you do the latter, do you have any tips on how to handle this more “complicated” function better? I thought about introducing some kind of truth table looking code, as such:
defp update(game, guess) do
duplicate_move? = MapSet.member?(game.used, guess)
good_guess? = Enum.member?(game.letters, guess)
new_used = MapSet.put(game.used, guess)
{new_state, new_turns_left} =
case {duplicate_move?, good_guess?} do
{true, _} -> {:already_used, game.turns_left}
{false, true} -> {:good_guess, game.turns_left - 1}
{false, false} -> {:bad_guess, game.turns_left - 1}
end
%{game | game_state: new_state, turns_left: new_turns_left, used: new_used}
end
Do you think it’s readable? Do you think of any way to express this code better?
Thanks for any input, I hope I’m clear enough ^^