[Из песочницы] Использование паттерна «Protocol» в Ruby
В 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.
Наслаждайтесь!
Оригинал статьи