В Elixir’е есть концепция behaviours
, или «поведенческих шаблонов», если угодно. Обратимся к официальной документации:
Протоколы — механизм, позволяющий реализовать полиморфизм в Elixir. Использование протокола доступно для любого типа данных, реализующего этот протокол.
О чем это вообще? Ну, сущности Elixir, или, как их иногда называют, «термы», неизменяемы. В Ruby мы привыкли определять методы на объектах, и эти методы просто изменяют объекты, как требуется. В Elixir’е это невозможно. Наверное, каждый, кто изучал ООП, разбирал стандартный пример, демонстрирующий полиморфизм: класс Animal
, с подклассами, по разному определяющими метод sound
:
class Animal def sound raise "Я — абстрактный зверь, я хранитель тишины (и тайны по совместительству)." end end class Dog < Animal def sound puts "[LOG] Я собака, я лаю." "гав" end end class Cat < Animal def sound puts "[LOG] Я кот, я мяучу" "мяу" end end
Теперь мы можем вызвать метод sound
на экземпляре любого животного, не утруждая себя предварительным определением класса. В Elixir’е все иначе, ведь там нет «методов, определенных на объектах». Для того, чтобы добиться примерно такой функциональности (типичным примером того, где это необходимо, является интерполяция в строках "#{object}"
), мы можем определить протокол.
Заметка на полях: еще можно использовать behaviours
, но для простоты и краткости мы остановимся именно на протоколах.
Протокол — это интерфейс, объявленный с использованием макроса defprotocol
. Для животного примера, приведенного выше, он выглядит так:
defprotocol Noisy do @doc "Produces a sound for the animal given" def sound(animal) end
Реализация располагается в defimpl
:
defimpl Noisy, for: Dog do def sound(animal), do: "woof" end defimpl Noisy, for: Cat do def sound(animal), do: "meow" end
Теперь мы можем использовать протокол, не заботясь о проверках, что там за зверь:
ExtrernalSource.animal |> Noisy.sound
Ладно. А зачем нам вообще может потребоваться этот паттерн в руби? У нас уже есть полиморфизм, прямо из коробки, разве нет? Ну да. И нет. Наиболее очевидным примером ситуации, когда использование протоколов уместно, будет выделение общего поведения у классов, которые определены не нашим собственным кодом. Путь Рельс, получивший широкое распространение в руби благодаря DHH (Давиду Хайнемайеру Ханссону, создателю Ruby on Rails — перев.), — это манкипатчинг. Ирония заключается в том, что я лично люблю манкипатчинг.
Но все же иногда подход, использующий протоколы, выглядит более работоспособным. Вместо переоткрытия класса Integer
для переопределения методов для работы с датами, мы просто определяем соответствующий протокол с методами типа to_days
.
Таким образом, вместо something.to_days
у нас будет DateGuru.to_days(something)
. Весь код, отвечающий за преобразование дат и вообще операции над датами, будет располагаться в одном месте, гарантируя, что никто эти методы не перезапишет, и вообще, целостность не пострадает.
Я не говорю, что такой подход лучше. Я говорю, что он другой.
Чтобы все это попробовать, нам придется предоставить какой-нибудь DSL
для упрощения создания протоколов в руби. Давайте создадим его. Начнем, как обычно, с тестов. Вот так должно выглядеть объявление протокола:
module Protocols::Arithmetics include Dry::Protocol defprotocol do defmethod :add, :this, :other defmethod :subtract, :this, :other defmethod :to_s, :this def multiply(this, other) raise "Умеем умножать только на целое" unless other.is_a?(Integer) (1...other).inject(this) { |memo,| memo + this } end end defimpl Protocols::Arithmetics, target: String do def add(this, other) this + other end def subtract(this, other) this.gsub /#{other}/, '' end def to_s this end end defimpl target: [Integer, Float], delegate: :to_s, map: { add: :+, subtract: :- } end
Давайте разберем этот код. Мы определили протокол Arithmetics
, отвечающий за сложение и вычитание. Как только эти операции определены для экземпляров какого-нибудь класса, умножение (multiply
) мы получаем бесплатно. Пример использования такого протокола: Arithmetics.add(42, 3) #⇒ 45
. Наш DSL
поддерживает делегирование методов, маппинг и явное определение.
Этот надуманный и упрощенный пример не выглядит очень уж осмысленным, но он достаточно хорош для прогона наших тестов. Пора к ним уже и приступить:
expect(Protocols::Adder.add(5, 3)).to eq(8) expect(Protocols::Adder.add(5.5, 3)).to eq(8.5) expect(Protocols::Adder.subtract(5, 10)).to eq(-5) expect(Protocols::Adder.multiply(5, 3)).to eq(15) expect do Protocols::Adder.multiply(5, 3.5) end.to raise_error(RuntimeException, "We can multiply by integers only")
Ну вот, мы и готовы заняться реализацией. Это на удивление просто.
Весь код помещается в один-единственный модуль. Мы назовем его BlackTie
, поскольку мы тут разговариваем про протоколы. Прежде всего, этот модуль будет хранить список соответствий объявленных протоколов и их реализаций.
module BlackTie class << self def protocols @protocols ||= Hash.new { |h, k| h[k] = h.dup.clear } end def implementations @implementations ||= Hash.new { |h, k| h[k] = h.dup.clear } end end ...
Заметка на полях: трюк с default_proc
в объявлении хэша (Hash.new { |h, k| h[k] = h.dup.clear }
) помогает создать хэш с прозрачным «глубоким» доступом (обращение по несуществующему ключу сколь угодно глубоко вернет пустой хэш).
Реализация defmethod
тривиальна: мы просто сохраняем метод в глобальном списке соответствий для текущего протокола (в глобальном хэше @protocols
):
def defmethod(name, *params) BlackTie.protocols[self][name] = params end
Объявление самого протокола чуть более заковыристо (некоторые детали в этой заметке опущены, чтобы не замусоривать общую картину; полный код доступен тут).
def defprotocol raise if BlackTie.protocols.key?(self) || !block_given? ims = instance_methods(false) class_eval(&Proc.new) (instance_methods(false) - ims).each { |m| class_eval { module_function m } } singleton_class.send :define_method, :method_missing do |method, *args| raise Dry::Protocol::NotImplemented.new(:method, self.inspect, method) end BlackTie.protocols[self].each do |method, *| singleton_class.send :define_method, method do |receiver = nil, *args| impl = receiver.class.ancestors.lazy.map do |c| BlackTie.implementations[self].fetch(c, nil) end.reject(&:nil?).first raise Dry::Protocol::NotImplemented.new(:protocol, self.inspect, receiver.class) unless impl impl[method].(*args.unshift(receiver)) end end end
Вкратце, в этом коде четыре блока. Прежде всего мы проверяем, что определение протокола отвечает всем необходимым условиям. Затем мы выполняем блок, переданный в этот метод, запоминая, какие методы добавились. Эти методы мы экспортируем посредством module_function
. В третьем блоке мы определяем method_missing
, который отвечает за выброс исключения с внятным сообщением об ошибке при попытке вызывать не существующие методы. И, наконец, мы определяем методы, либо делегируя их соответствующей реализации, если таковая существует, или выбрасывая внятное исключение, в случае, если для данного объекта реализация не найдена.
Ну, осталось только определить defimpl
. Код ниже тоже слегка упрощен, полная версия там же по ссылке.
def defimpl(protocol = nil, target: nil, delegate: [], map: {}) raise if target.nil? || !block_given? && delegate.empty? && map.empty? # builds the simple map out of both delegates and map mds = normalize_map_delegates(delegate, map) Module.new do mds.each(&DELEGATE_METHOD.curry[singleton_class]) # delegation impl singleton_class.class_eval(&Proc.new) if block_given? # block takes precedence end.tap do |mod| mod.methods(false).tap do |meths| (BlackTie.protocols[protocol || self].keys - meths).each_with_object(meths) do |m, acc| logger.warn("Implicit delegate #{(protocol || self).inspect}##{m} to #{target}") DELEGATE_METHOD.(mod.singleton_class, [m] * 2) acc << m end end.each do |m| [*target].each do |tgt| BlackTie.implementations[protocol || self][tgt][m] = mod.method(m).to_proc end end end end module_function :defimpl
Невзирая на кажущуюся невнятность этого кода, он очень прост: мы создаем анонимный модуль, определяем на нем методы и назначаем его главным исполняющим методов, делегированных из протокола. Вызов Arithmetics.add(5, 3)
приведет к определению ресивера (5
), ретроспективного поиска реализации (defimpl Arithmetics, target: Integer
) и вызову соответствуюзего метода (:+
). Это все определяется строкой
defimpl target: [Integer, ...], ..., map: { add: :+, ... }
Если мне не удалось убедить вас в полезности изложенного подхода, я попробую еще раз. Представьте себе протокол Tax
(налог). Он можен быть определен для таких классов, как ItemToSell
, Shipment
, Employee
, Lunch
и так далее. Даже если эти классы пришли в ваш проект из разных гемов и источников данных.
→ Репозиторий dry-behaviour гема на github.
Наслаждайтесь!
ссылка на оригинал статьи https://habrahabr.ru/post/318138/
Добавить комментарий