Elixir: Як виглядає ООП у функціональному мовою?

останнім часом почастішали статті та обговорення на тему прощання з ООП і пошуки сенсу, який Алан Кей спочатку вкладав у це поняття.

Кілька висловлювань Кея для тих, хто пропустив
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 state of-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 and great growable systems is much more to design how its modules communicate rather than what their properties and internal 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 дозволяє вам застосовувати ООП там, де воно дійсно працює, — на верхньому рівні проектування системи. І при цьому не ускладнювати нижні рівні системи абстракціями, надуманими і контрпродуктивними тезами на кшталт «Все є об'єктом».
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.