“Tell, Don’t Ask” is a well-covered topic within object-oriented programming communities. Its goal? Encourage encapsulation by having the caller tell an object to do something instead of checking on state and acting upon it. Almost at odds with the control couple code smell, our goal is to have the caller issue explicit commands without concerning itself with object state.
“Tell, Don’t Ask” in Elixir
Is Elixir object-oriented? From a paradigm perspective, Elixir is a functional language when looking at aspects like immutability, pattern-matching, and functions with inputs and outputs, focused on the sending of messages to “objects” directly. How does “Tell, Don’t Ask” translate?
Thinking about the goal, let’s do some mental mapping. In OOP, objects are a blueprint with information containing behavior (methods) and data (state). In FP, we have functions organized within modules, with state being captured in various values (e.g. Elixir’s Maps or Structs). We want to avoid having the caller (a function) dictate paths based on information present in our data.
Let’s write out some non-idiomatic Elixir and see what we can improve.
defmodule Game.Lobby do
def add_player(%{game: game} = lobby, player) do
new_player = cond do
is_binary(player) ->
%Game.Player{name: player, id: Game.Player.generate_id}
is_map(player) ->
%Game.Player{} |> Map.merge(player)
true ->
%Game.Player{}
end
%{lobby |
game: %{game | players: game.players ++ [new_player]}}
end
end
defmodule Game.Player do
defstruct id: 0, name: "New player", active: true
def generate_id do
UUID.uuid4()
end
end
Game.Lobby.add_player/2
doesn’t feel right. There’s a significant amount of
feature envy as it cares about the various shapes of player
and how to
construct a %Game.Player{}
. Also, why is Game.Player.generate_id/0
public?
It seems all Game.Lobby.add_player/2
should care about is managing its own
structure (the final two lines of the function).
Instead of having Game.Lobby.add_player/2
care about constructing players,
generating ids, and so on, let’s tell Game.Player
to handle that instead:
defmodule Game.Lobby do
def add_player(%{game: game} = lobby, player) do
%{lobby |
game: %{game | players: game.players ++ [Game.Player.new(player)]}}
end
end
defmodule Game.Player do
defstruct id: 0, name: "New player", active: true
def new(name) when is_binary(name), do: new(%{name: name, id: generate_id})
def new(a) when is_map(a), do: %__MODULE__{} |> Map.merge(a)
def new(_), do: %__MODULE__{}
defp generate_id, do: UUID.uuid4()
end
Here, we move player generation to the Game.Player
module, where it can
determine how best to generate the struct instead of Game.Lobby.add_player/2
.
Write declarative (not imperative) code
By moving player creation logic from Game.Lobby.add_player/2
to
Game.Player.new/1
, we were able to call a single function to take the
appropriate action based on data. It is important to note that the data it’s
acting upon specifically is behavior to construct a %Game.Player{}
.
This becomes more important when using the pipe operator, which shines as a way to transform data.
“Tell, don’t ask” is a way to encourage developers to write declarative code instead of imperative code. Imperative code asks questions before making decisions; declarative code issues a command and expects it to be done correctly.