Декларативный стиль кода в 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» — один из способов побудить разработчиков писать в декларативном стиле. При императивном подходе сначала задаются вопросы, а потом принимаются решения; при декларативном — даются команды и ожидается их выполнение.

© 2020 / Россия Любые мысли и вопросы пишите на elixir@wunsh.ru.