Pull to refresh

Elixir: Как выглядит ООП в функциональном языке?

Reading time 6 min
Views 22K
В последнее время участились статьи и обсуждения на тему прощания с ООП и поиски смысла, который Алан Кэй изначально вкладывал в это понятие.

Несколько высказываний Кэя для тех, кто пропустил
I made up the term “object-oriented”, and I can tell you I didn't have C++ in mind

OOP to me means only messaging, local retention and protection and hiding of state-process, and extreme late-binding of all things.

I’m sorry that I long ago coined the term “objects” for this topic because it gets many people to focus on the lesser idea. The big idea is “messaging”.

The key in making great and growable systems is much more to design how its modules communicate rather than what their internal properties and behaviors should be.

Late binding allows ideas learned late in project development to be reformulated into the project with exponentially less effort than traditional early binding systems (C, C++, Java, etc.)

I’m not against types, but I don’t know of any type systems that aren’t a complete pain, so I still like dynamic typing.

В связи с этими обсуждениями, часто всплывает мысль о том, что Erlang/Elixir очень хорошо удовлетворяют критериям, которые Кэй предъявлял к понятию «объектно-ориентированный». Но далеко не все знакомы с этими языками, поэтому возникает непонимание как функциональные языки могут быть более объектно-ориентированными, чем популярные C++, Java, C#.

В этой статье я хочу на простом примере с exercism.io показать как выглядит ООП на Elixir.

Описание задачи
Напишите небольшую программу, который хранит имена школьников, сгруппированные по номеру класса, в котором они учатся.

В конце концов, вы должны быть в состоянии:

  • Добавить имя школьника в класс
  • Получить список всех школьников, обучающихся в классе
  • Получить отсортированный список всех учащихся во всех классах. Классы должны быть отсортированы по возрастанию (1, 2, 3 и т.д.), а имена школьников — по алфавиту.


Начнём с тестов, чтобы посмотреть как будет выглядеть код, вызывающий наши функции. Взглянем на тесты, которые Exercism подготовил для Ruby, в котором ООП дошло до того, что даже операторы — это чьи-то методы.

И напишем аналогичные тесты для Elixir-версии этой программы:
Code.load_file("school.exs")
ExUnit.start

defmodule SchoolTest do
  use ExUnit.Case, async: true
  import School, only: [add_student: 3, students_by_grade: 1, students_by_grade: 2]

  test "get students in a non existant grade" do
    school = School.new
    assert [] == school |> students_by_grade(5)
  end

  test "add student" do
    school = School.new
    school |> add_student("Aimee", 2)

    assert ["Aimee"] == school |> students_by_grade(2)
  end

  test "add students to different grades" do
    school = School.new
    school |> add_student("Aimee", 3)
    school |> add_student("Beemee", 7)

    assert ["Aimee"] == school |> students_by_grade(3)
    assert ["Beemee"] == school |> students_by_grade(7)
  end

  test "grade with multiple students" do
    school = School.new
    grade = 6
    students = ~w(Aimee Beemee Ceemee)
    students |> Enum.each(fn(student) -> school |> add_student(student, grade) end)
    
    assert students == school |> students_by_grade(grade)
  end

  test "grade with multiple students sorts correctly" do
    school = School.new
    grade = 6
    students = ~w(Beemee Aimee Ceemee)
    students |> Enum.each(fn(student) -> school |> add_student(student, grade) end)
    
    assert Enum.sort(students) == school |> students_by_grade(grade)
  end

  test "empty students by grade" do
    school = School.new
    assert [] == school |> students_by_grade
  end

  test "students_by_grade with one grade" do
    school = School.new
    grade = 6
    students = ~w(Beemee Aimee Ceemee)
    students |> Enum.each(fn(student) -> school |> add_student(student, grade) end)
    
    assert [[grade: 6, students: Enum.sort(students)]] == school |> students_by_grade
  end

  test "students_by_grade with different grades" do
    school = School.new
    everyone |> Enum.each(fn([grade: grade, students: students]) ->
      students |> Enum.each(fn(student) -> school |> add_student(student, grade) end)
    end)

    assert everyone_sorted == school |> students_by_grade
  end

  defp everyone do
    [
      [ grade: 3, students: ~w(Deemee Eeemee) ],
      [ grade: 1, students: ~w(Effmee Geemee) ],
      [ grade: 2, students: ~w(Aimee Beemee Ceemee) ]
    ]
  end

  defp everyone_sorted do
    [
      [ grade: 1, students: ~w(Effmee Geemee) ],
      [ grade: 2, students: ~w(Aimee Beemee Ceemee) ],
      [ grade: 3, students: ~w(Deemee Eeemee) ]
    ]
  end
end

Если не вдаваться в тонкости написания тестов, то нас больше всего интересуют получившиеся «методы» для работы с «классом» School:

    school = School.new
    school |> add_student("Aimee", 2) # => :ok
    school |> students_by_grade(2) # => ["Aimee"]
    school |> students_by_grade # => [[grade: 2, students: ["Aimee"]]]

Конечно, на самом деле, тут нет ни класса, ни методов, но об этом чуть позже. А пока обратите внимание, насколько это похоже на работу с экземпляром класса в рамках привычных реализаций ООП. Только вместо точки или стрелочки -> используется pipe-оператор |>.

Хотя сразу признаюсь, для того, чтобы вызвать одну функцию, pipe-оператор обычно не используют, да и сами функции размещаются в модулях, а не в классах. Поэтому более идиоматической записью для Elixir будет:

    school = School.new
    School.add_student(school, "Aimee", 2) # => :ok
    School.students_by_grade(school, 2) # => ["Aimee"]
    School.students_by_grade(school) # => [[grade: 2, students: ["Aimee"]]]

Однако суть от смены синтаксиса не меняется! Идентификатор «объекта» просто передаётся в качестве первого аргумента во все функции. Но ведь в функциональных языках нет объектов. Давайте разберёмся, что же тут происходит на самом деле…

Дело в том, что все программы, что на Erlang, что на Elixir, строятся на базе OTP, де-факто это часть стандартной библиотеки языка, обеспечивающая ту самую отказоустойчивость и масштабируемость, которой славится Erlang. OTP включает в себя очень богатый и мощный арсенал модулей и поведений (это типа абстрактных классов). Но сегодня мы поговорим только об одном, но очень часто используемом поведении — GenServer. Оно позволяет превратить обычный модуль в своеобразный генератор акторов (легковесные процессы виртуальной машины Erlang).

Каждый процесс-актор имеет своё изолированное состояние, которое можно изменять или получать информацию из него, исключительно посредством отправки ему сообщений. Если сообщения поступают конкурентно, то они обрабатываются в порядке очереди, исключая даже теоретическую возможность получить race condition на состоянии, хранимом в процессе. Таким образом каждый процесс ведёт себя с одной стороны подобно серверу — отсюда и название GenServer, а с другой стороны подобно объекту — согласно описанию Кэя.

Он так же, как объект, имеет состояние и предоставляет возможности работы с этим состоянием при помощи обработки сообщений колбеками handle_call (c возвратом ответа) и handle_cast (без ответа). То самое позднее связывание, о котором постоянно говорит Алан. А за отправку сообщений чаще всего отвечают т.н. API-функции, размещенные в том же модуле, но вызываемые из других процессов.

Процессы полностью изолированы друг от друга вплоть до того, что падение одного процесса не повлияет ни на какой другой, если вы явно не пропишете как оно должно влиять (т.н. стратегия перезапуска).

Впрочем, хватит слов. Давайте посмотрим, как это выглядит в коде:

defmodule School do
  use GenServer

  # API

  @doc """
  Start School process.
  """
  def new do
    {:ok, pid} = GenServer.start_link(__MODULE__, %{})
    pid
  end

  @doc """
  Add a student to a particular grade in school.
  """
  def add_student(pid, name, grade) do
    GenServer.cast(pid, {:add, name, grade})
  end

  @doc """
  Return the names of the students in a particular grade.
  """
  def students_by_grade(pid, grade) do
    GenServer.call(pid, {:students_by_grade, grade})
  end

  @doc """
  Return the names of the all students separated by grade.
  """
  def students_by_grade(pid) do
    GenServer.call(pid, :all_students)
  end

  # Callbacks

  def handle_cast({:add, name, grade}, state) do
    state = Map.update(state, grade, [name], &([name|&1]))
    {:noreply, state}
  end

  def handle_call({:students_by_grade, grade}, _from, state) do
    students = Map.get(state, grade, []) |> Enum.sort
    {:reply, students, state}
  end

  def handle_call(:all_students, _from, state) do
    all_students = state
      |> Map.keys
      |> Enum.map(fn(grade) ->
        [grade: grade, students: get_students_by_grade(state, grade)]
      end)

    {:reply, all_students, state}
  end

  # Private functions

  defp get_students_by_grade(state, grade) do
    Map.get(state, grade, []) |> Enum.sort
  end
end

Как правило, модуль, реализующий поведение GenServer, делится на 3 части:

  • API — функции для взаимодействия с процессом извне, они вызывают функции модуля GenServer для посылки сообщений, старта/остановки процесса и т.д. А также скрывают от вызывающего кода детали реализации
  • Callbacks — функции, реализующие поведение GenServer: обработка сообщений и т.п.
  • Private functions — вспомогательные функции, которые используются внутри модуля

При старте процесса мы получаем его идентификатор — pid, который можно потом передавать в качестве первого аргумента API-функциям. Обычно процессы стартуются функцией start_link, это соглашение позволяет удобно описывать целые деревья процессов, которые запускаются одной командой, но тут (для упрощения аналогий) я назвал её new.

Если у вас в системе есть какой-то system-wide процесс, для которого достаточно одного экземпляра, то можно дать ему имя. В этом случае вы можете обойтись без передачи pid в API-функции, т.к. они смогут отправлять сообщения процессу по имени.

На верхнем уровне абстракции практически любое приложение на Elixir состоит исключительно из подобных процессов, которые обмениваются друг с другом сообщениями.

P.S. Таким образом, Elixir позволяет вам применять ООП там, где оно действительно работает, — на верхнем уровне проектирования системы. И при этом не усложнять нижние уровни системы надуманными абстракциями и контрпродуктивными тезисами типа «Всё есть объект».
Tags:
Hubs:
+28
Comments 410
Comments Comments 410

Articles