В последнее время участились статьи и обсуждения на тему прощания с ООП и поиски смысла, который Алан Кэй изначально вкладывал в это понятие.
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, в котором ООП дошло до того, что даже операторы — это чьи-то методы.
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 позволяет вам применять ООП там, где оно действительно работает, — на верхнем уровне проектирования системы. И при этом не усложнять нижние уровни системы надуманными абстракциями и контрпродуктивными тезисами типа «Всё есть объект».
Автор: Source