Объектная ориентация в Ruby и Elixir

Говоря об основных языках программирования, мы часто помещаем их в два основных блока: объектно-ориентированное программирование и функциональное программирование. Но эти парадигмы программирования – не масло и не вода. В этой статье я буду смешивать их, чтобы продемонстрировать технику в Elixir.

 

В мире Erlang и Elixir было небольшое обсуждение по этой темы. Недавно было заявлено, что Elixir является «наиболее объектно-ориентированным языком». Авди Гримм выполнил упражнения из «Функционального Программирования Брайана Марика» для Объектно-Ориентированного Программиста, изначально написанные на Clojure. Есть даже веселая библиотека и молниеносная беседа от Wojtek Mach для готовой ориентации объектов в Elixir.

 

Эти ресурсы проливают свет на ООП в Elixir, но они не демонстрируют строительные блоки, составляющие рабочую модель. В этой статье мы создадим объектную систему в Elixir с нуля и как можно проще. Для этого весь код Elixir будет основан на примерах Ruby. Цель состоит в том, чтобы выделить некоторые из основных понятий Elixir и сравнить их с основными понятиями Ruby. (Предполагается некоторое знакомство с Elixir.)

 

Хотя мы создадим рабочую модель, она не предназначена для того, чтобы быть идеальной объектной системой, и я не поддерживаю этот стиль программирования. На самом деле, сообщество Erlang опирается на совершенно разные модели. Это всего лишь упражнение в изучении передачи сообщений и управления состоянием в Elixir. Вероятно, есть несколько способов добиться того же эффекта, и я хотел бы услышать о других методах в комментариях.

 

Основные Понятия в Объектной Ориентации

Объектно-ориентированное и функциональное программирование сосуществовало с момента появления современных компьютеров. Функциональное программирование возникло из математики и, естественно, было задумано первым. Объектная ориентация появилась вскоре после Simula, целью которой было сделать управление более простым.

 

Большая часть литературы по объектной ориентации перефразирует те же основные концепции программирования: состояние, свойство и полиморфизм. В Ruby это означает переменные экземпляра, методы и типизацию утили. Эти зачатки могут быть распространены на другие концепции ОО, такие как инкапсуляция и принципы SOLID.

 

Но современные объектно-ориентированные языки неоправданно ограничивают. Как будто они скучают по лесу за деревьями. Великие техники в объектной ориентации могут быть написаны в функциональном стиле, если вы захотите их, при этом сохраняя чистоту и безопасный параллелизм, чего мы обычно хотим.

 

Основной Объект

Объект соединяет состояние и свойство. Он инкапсулирует состояние внутри и раскрывает свойство извне. Инкапсуляция важна, потому что она является эффективным средством организации и контроля государства. Свойство – это средство, с помощью которого мы меняем состояние.

 

Рассмотрим следующий пример машины на Ruby. У автомобиля есть позиция, х, и способность двигаться вперед. Когда он едет, он увеличивает x на 1.

 

# ruby
class Car
  DEFAULT_ATTRS = {
    x: 0
  }

  def initialize(attrs = {})
    @attrs = DEFAULT_ATTRS.merge(attrs)
  end

  def drive
    old_x = @attrs[:x]
    @attrs[:x] += 1

    puts "[#{self.class.name}] [#{@attrs[:color]}] X .. #{old_x} -> #{@attrs[:x]}"
  end
end

 

Обратите внимание, что в приведенном выше классе, когда вызывается #drive, он печатает имя класса, его атрибут цвета и изменение состояния x.

 

Переменная @attrs является инкапсулированным состоянием. Метод #drive – это свойство. Что прекрасно в этом классе, так это то, что после его создания мы можем просто вызвать метод #drive, и нам не нужно думать о внутреннем состоянии x. Давайте сделаем это сейчас.

 

# ruby
car
= Car.new({color: "Red"})
car
.drive #=> [Car] [Red] X .. 0 -> 1
car
.drive #=> [Car] [Red] X .. 1 -> 2

 

Мы дважды вызываем #drive, и внутреннее состояние x изменяется. Инкапсуляция проста и мощна.

 

Мы можем написать этот «класс» и этот «объект» в Elixir. Основное отличие состоит в том, как мы инкапсулируем состояние и вызываем свойство. Вместо того, чтобы быть первоклассным гражданином, как это делается в Ruby, мы используем рекурсию для представления состояния. Вместо вызова методов, которые мы делаем в Ruby, мы передаем сообщение.

 

# elixir
defmodule
Car do
 
@default_state %{
    type
: "Car",
    x
: 0
 
}

 
def new(state \\ %{}) do
    spawn_link fn
->
     
Map.merge(@default_state, state) |> run
   
end
 
end

  defp run
(state) do
    receive
do
     
:drive ->
        new_x
= state.x + 1
        new_state
= Map.put(state, :x, new_x)

        IO
.puts "[#{state.type}] [#{state.color}] X .. #{state.x} -> #{new_x}"

        run
(new_state)
   
end
 
end
end

 

Вот как мы будем использовать это:

 

# elixir
car
= Car.new(%{color: "Red"})
send
(car, :drive) #=> [Car] [Red] X .. 0 -> 1
send
(car, :drive) #=> [Car] [Red] X .. 1 -> 2

 

Давайте разберем этот Elixir код.

 

Функция new/1 называется “new”, чтобы отразить метод Ruby .new, но это можно назвать как угодно. Эта функция создает новый процесс Elixir, используя spawn_link/1. Мы можем считать spawn_link/1 эквивалентом специального .new метода Ruby. Создав новый процесс, мы создали область для инкапсуляции состояния.

 

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

 

Внутри вновь созданного процесса атрибуты по умолчанию объединяются с атрибутами, передаваемыми в качестве аргумента. Затем вызывается рекурсивная функция run/1.

 

# elixir
Map.merge(@default_state, state) |> run

 

Функция run/1 является ядром нашего «объекта», и состояние рекурсивно передается в run/1 снова и снова, инкапсулируя состояние в качестве аргумента функции. Когда мы хотим обновить состояние, мы вызываем функцию run/1 с новым состоянием.

 

Давайте внимательнее посмотрим на функцию run/1.

 

# elixir
defp run
(state) do
  receive
do
   
:drive ->
      new_x
= state.x + 1
      new_state
= Map.put(state, :x, new_x)

      IO
.puts "[#{state.type}] [#{state.color}] X .. #{state.x} -> #{new_x}"

      run
(new_state)
 
end
end

 

Одним из ключевых компонентов для того, чтобы заставить это работать, является вызов функции приема. Когда вызывается метод receive, он блокирует текущий процесс и ждет, пока процесс получит сообщение. Помните, что этот код выполняется в новом процессе самостоятельно. Когда сообщение передается процессу, оно разблокируется и запускает код, объявленный в исходном блоке.

 

Этот исходный блок вычисляет новое состояние, увеличивая x в новую переменную, обновляя x в карте, которая представляет это состояние, а затем рекурсивно вызывая функцию run/1. После рекурсивного вызова run/1 процесс снова блокируется при получении. Он продолжает делать это бесконечно, пока функция run/1 не решит больше не вызывать себя. Когда мы больше не выполняем рекурсию, процесс умирает, и государство собирает мусор. (Этот нерекурсивный случай не представлен в этом коде.)

 

Давайте еще раз посмотрим, как выглядит «экземпляр» и передача сообщений. Встроенная функция, send/2 используется для передачи сообщения: drive в процесс дважды.

 

# elixir
car
= Car.new(%{color: "Red"})
send
(car, :drive) #=> [Car] [Red] X .. 0 -> 1
send
(car, :drive) #=> [Car] [Red] X .. 1 -> 2

 

В приведенном выше коде вызов Car.new/1 порождает процесс и возвращает идентификатор процесса или «pid». Затем он отправляет сообщение этому pid с помощью send/2. send/2 по сути аналогичен вызову метода в Ruby. Создатель термина «объектная ориентация» Алан Кей, похоже, разочарован тем, что передача сообщений была смещена вызовом метода. Основное различие между передачей сообщений и вызовом метода заключается в том, что передача сообщений является асинхронной – подробнее об этом позже.

 

Это наш основной объект. Мы инкапсулировали состояние и предоставили поведение, которое меняет состояние. Версия Ruby скрывает состояние в переменной экземпляра, а версия Elixir делает состояние явным в качестве аргумента рекурсивной функции. Версия Ruby вызывает методы, версия Elixir передает сообщения.

 

Наследование

Помимо состояния и поведения, наследование является еще одним основным принципом объектно-ориентированного программирования. Наследование позволяет нам расширять типы (классы) новым состоянием и свойством.

 

Наследование является первоклассным гражданином в Ruby, что позволяет легко разделить состояние и поведение на подтипы. Следующий код должен быть ощутим для всех Rubyists. Этот код создает новый тип Truck в качестве подтипа Car и добавляет метод #offroad, доступный только для грузовиков.

 

# ruby
class Truck < Car
 
def offroad
    puts
"Going offroad."
 
end
end

 

Поскольку мы унаследовали от класса Car, мы можем вызывать методы #drive и #offroad в экземпляре класса Truck.

 

# ruby
truck
= Truck.new({color: "Blue"})
truck
.drive #=> [Truck] [Blue] X .. 0 -> 1
truck
.offroad #=> Going offroad.

 

Это версия Ruby. Elixir не имеет классов. Наследование в Elixir не первоклассный гражданин. Это потребует дополнительных настроек и церемоний.

 

Во-первых, как мы представляем типы и подтипы без классов? Внимательный читатель заметил бы, что после определения модуля Car в Elixir, одним из значений по умолчанию было поле с именем type со значением «Car». В Elixir классы и типы могут быть представлены как простые данные, как двоичные файлы (строки). Концепция использования данных для представления типов, а не конкретных классов, как в Ruby, широко распространена в функциональном программировании – например, записи и теговые кортежи.

 

Для моделирования унаследованных типов в Elixir мы будем использовать данные для представления типа Car и подтипа Truck. Чтобы имитировать наследование поведения (методов), которое подтип наследует от родительского типа, мы будем поддерживать экземпляр Car, которому мы делегируем сообщение.

 

# elixir
defmodule
Truck do
 
def new(state \\ %{}) do
    spawn_link fn
->
      typed_state
= Map.merge(%{type: "Truck"}, state)
      parent
= Car.new(typed_state)

     
Map.merge(%{parent: parent}, typed_state) |> run
   
end
 
end

 
def run(state) do
    receive
do
     
:offroad ->
        IO
.puts "Going offroad."
        run
(state)
      message
->
        send
(state.parent, message)
        run
(state)
   
end
 
end
end

 

Давайте разберем приведенный выше код.

 

В функции new/1 мы переопределяем значение свойства типа и создаем экземпляр нового автомобиля. Это становится нашими типизированными данными. Затем мы порождаем наш родительский процесс.

 

# elixir
typed_state
= Map.merge(%{type: "Truck"}, state)
parent
= Car.new(typed_state)

 

Нам нужно поддерживать наш родительский процесс, чтобы мы могли делегировать сообщения, когда подтип не отвечает напрямую. Затем мы вызываем нашу функцию run/1.

 

# elixir
Map.merge(%{parent: parent}, typed_state) |> run

 

Функция run/1 в модуле Truck должна выглядеть знакомо. Мы добавили новое сообщение: offroad, на которое мы отвечаем. Когда процесс Truck получает сообщение, которое он не понимает, он направляет его в родительский процесс Car.

 

Посмотрим, как это работает.

 

# elixir
truck
= Truck.new(%{color: "Blue"})
send
(truck, :drive) #=> [Truck] [Blue] X .. 0 -> 1
send
(truck, :offroad) #=> Going offroad.

 

Вы можете видеть, что тип Truck унаследовал все поведение типа Car.

 

Полиморфизм

Полиморфизм – одно из самых сильных качеств ориентации объекта. Это для программирования, что взаимозаменяемые части для производства. Это позволяет нам заменять подтипы для их родительского типа везде, где используется родительский тип. Кроме того, в Ruby и Elixir он позволяет нам заменять любой тип другим типом, если он отвечает на правильный метод или сообщение.

 

Как и наследование, полиморфизм придерживается принципа подстановки Лискова, хорошо известного признака хорошего объектно-ориентированного проектирования и части принципов проектирования SOLID.

 

Во-первых, полиморфизм в Ruby. Мы будем использовать экземпляры Car и Truck, чтобы показать, что они взаимозаменяемы в отношении метода #drive. Мы случайным образом выберем любой экземпляр и вызовем #drive.

 

# ruby
car
= Car.new(%{color: "Red"})
truck
= Truck.new(%{color: "Blue"})

[car, truck].sample.drive #=> [Car] [Red] X .. 0 -> 1
                         
#=> OR
                         
#=> [Truck] [Blue] X .. 0 -> 1

 

Array#sample метод вернет экземпляр Car или Truck, но, поскольку это объекты типа утка, мы можем успешно вызвать #drive для любого из них. Если бы у нас был другой класс, который не наследовал от Car, но также содержал метод #drive, мы могли бы также заменить здесь экземпляр этого класса. Полиморфизм в лучшем виде.

 

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

 

# elixir
car
= Car.new(%{color: "Red"})
truck
= Truck.new(%{color: "Blue"})

Enum.random([car, truck])
 
|> send(:drive) #=> [Car] [Red] X .. 0 -> 1
                 
#=> OR
                 
#=> [Truck] [Blue] X .. 0 -> 1

 

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

 

Асинхронность

Для большинства целей и задач мы создали основные компоненты объектно-ориентированной системы на функциональном языке. Он имеет некоторые недостатки и не использует самые сложные инструменты Elixir, но он демонстрирует, что можно представить эти шаблоны в Elixir.

 

В этих примерах кода спрятан один не очень тонкий нюанс, который сразу же задумывался о фактической реализации. Вызов методов в Ruby является синхронным, а передача сообщений в Elixir – асинхронной. Другими словами, вызов метода Ruby приостановит программу, выполнит тело этого метода и вернет результат этого метода вызывающей стороне. Это блокирующая синхронная активность. Передача сообщения в Elixir – это неблокирующая, асинхронная активность. Elixir отправит процессу сообщение и сразу же вернется, не дожидаясь получения сообщения.

 

Это может сделать тривиальные вещи в Ruby более громоздкими в Elixir. Возьмем, например, просто попытку вернуть значение из переданного сообщения. В Ruby это просто.

# ruby
class Car
 
def color
   
"Red"
 
end
end

Car.new.color #=> Red

 

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

 

# elixir
defmodule
Car do
 
def color do
   
"Red"
 
end
end

Car.color #=> Red

 

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

 

# elixir
defmodule
Car do
 
def new do
    spawn_link
(&run/0)
 
end

 
def run do
    receive
do
     
:color -> "Red"
   
end
 
end
end

car
= Car.new
send
(car, :color) #=> :color

 

Вы ожидали, что при отправке car процесса, обработавшего сообщение :color, будет возвращено значение “Red”? Вместо этого возвращаемое значение :color. send/2 возвращает сообщение, которое было отправлено процессу, а не значение, которое было возвращено после обработки сообщения.

 

Передача сообщений в Elixir является асинхронной, но если мы хотим смоделировать синхронное поведение вызова метода Ruby, нам нужно проявить немного творчества.

 

Так как получение блокирует процесс и ожидает сообщения, мы можем использовать это в контексте нашего вызывающего. Так что, кто бы ни звонил :color должен был заблокировать и ждать ответа, чтобы продолжить программу, точно так же, как Ruby.

 

В отличие от Ruby, есть немного больше церемоний, чтобы заставить это работать. Нам нужно отправить pid звонящего в вызываемую. Затем вызываемый отправит обратно вызывающему сообщение с окончательным возвращаемым значением.

 

# elixir
defmodule
Car do
 
def new do
    spawn_link
(&run/0)
 
end

 
def run do
    receive
do
     
{:color, caller} ->
        send
(caller, {:color, "Red"})
   
end
 
end
end

car
= Car.new
send
(car, {:color, self})
receive
do
 
{:color, response} => response
end #=> Red

 

В приведенном выше коде мы передаем pid вызывающего в вызываемый, который можно получить, вызвав self/0. Затем вызывающий абонент ожидает сообщения от вызываемого, содержащего ответ. В вызывающей стороне ответ соответствует шаблону для извлечения значения. Возвращаемое значение из блока приема вызывающего абонента является окончательным ответом «Red».

 

Это много церемоний. К счастью, у Elixir есть хорошие абстракции, чтобы избежать литании. Здесь мы посмотрим на агентов. Используя Агенты, мы можем снова обрабатывать наш код синхронно и исключать низкоуровневые функции отправки и получения.

 

# elixir
defmodule
Car do
 
def new do
   
{:ok, car} = Agent.start_link fn -> %{color: "Red"} end
    car
 
end

 
def color(car) do
   
Agent.get(car, fn attrs -> attrs.color end)
 
end
end

car
= Car.new
Car.color(car) #=> Red

 

Elixir имеет множество инструментов, которые помогают поддерживать чистоту нашего кода при синхронном программировании. Одним из таких инструментов является GenServer.call/3. GenServers – это очень полезная абстракция вокруг процессов, которая позволяет нам реализовывать состояние и поведение в упрощенной форме, во многом подобно Ruby.

 

В качестве заключительной мысли об асинхронности я хотел бы упомянуть две вещи.

 

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

 

Передача сообщений в Elixir – это примитивная конструкция параллелизма. Это актерская модель параллелизма. Это не вариант в Ruby, если вы не используете такую ​​библиотеку, как Celluloid. Параллельность в Ruby обычно имеет многопоточность. Актерская модель – это абстракция потоков, созданная в Elixir, которая обеспечивает уровень параллелизма, которого нет в Ruby.

 

Завершение

В течение этой статьи мы смешивали ориентацию объектов и функциональное программирование. Предпочитаете ли вы версию Ruby или версию Elixir, у них обоих есть свое место.

 

Объектная ориентация в Ruby проста, элегантна и радует меня. Он не предоставляет элементы управления параллелизмом, которые предлагает Elixir, но модель программирования приятна в использовании. С другой стороны, Elixir позволяет нам моделировать систему объектно-ориентированным способом, используя более мощные средства управления параллелизмом.

 

Ориентация на объект в Elixir может или не может быть жизнеспособным подходом. У меня пока недостаточно данных, чтобы сделать выводы. Стоит еще раз упомянуть, что функциональное сообщество использует разные шаблоны. Создатель Erlang, Джо Армстронг, оплакивал ООП из-за смешения состояния и поведения, хотя я считаю эту смесь неизбежной с процессами. Таким образом, хотя использование функциональных языков в объектно-ориентированном стиле может быть не совсем обычным явлением, оно, безусловно, возможно и может быть более изящным при моделировании некоторых доменов.

 

Удачного кодирования!

 

Оригинальная статья: http://mikepackdev.com/blog_posts/45-object-orientation-in-ruby-and-elixir