Декларативный стиль кода в Elixir
Принцип «Tell, Don’t Ask» — часто обсуждаемая тема в сообществах объектно-ориентированных языков. Его цель — побудить к использованию инкапсуляции, то есть предписанию объекту определённых действий вместо принятия решений на его состояния. С целью избежать плохого кода, как, например, здесь, нужно сделать так, чтобы вызывающая функция в явном виде давала команды независимо от состояния объекта.
Принцип «Tell, Don’t Ask» в Elixir
Является ли Elixir объектно-ориентированным языком? С точки зрения парадигмы, Elixir — язык функциональный: об этом говорят иммутабельность, сопоставление с образцом, функции, имеющие входные и выходные параметры, предназначенные для отправки сообщений «объектам». Что же означает принцип «Tell, Don’t Ask»?
Чтобы выяснить это, проведём некоторые смысловые параллели. Объект в ООП — это сущность, наделённая поведением (методы) и данными (состояние). В ФП основным объектом выступает функция, состояние которой хранится в различных структурах данных (в Elixir это Maps или Structs). Согласно указанному принципу, необходимо избежать того, чтобы вызывающая функция принимала решения на основе информации, полученной на основе имеющихся данных.
Рассмотрим нехарактерный для Elixir код и попробуем выяснить, что в нём можно исправить.
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
явно что-то не так. Очевидна излишняя функциональная зависимость от типа player и структуры %Game.Player{}
. И почему функция Game.Player.generate_id/0
объявлена публично? Похоже, в функции Game.Lobby.add_player/2
всё внимание стоит обратить только на её структуру (последние две строки тела функции).
Вместо того, чтобы заставлять функцию Game.Lobby.add_player/2
создавать игрока, генерировать id и т. п., поручим модулю Game.Player
следующие действия:
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
Таким образом, за процесс создания игрока и генерацию структуры вместо функции Game.Lobby.add_player/2
теперь отвечает модуль Game.Player
Пишем декларативно
Переместив логику создания игрока из Game.Lobby.add_player/2
в Game.Player.new/1
, можно осуществить необходимое действие путём вызова единственной функции на основе входящих данных. Это и есть то поведение, необходимое для создания %Game.Player{}
.
Особенно важно это учитывать при использовании пайп-оператора, который отлично подходит для преобразования данных.
Принцип «Tell, don’t ask» — один из способов побудить разработчиков писать в декларативном стиле. При императивном подходе сначала задаются вопросы, а потом принимаются решения; при декларативном — даются команды и ожидается их выполнение.