Использование паттерна «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.

Наслаждайтесь!

Оригинал статьи

ссылка на оригинал статьи https://habrahabr.ru/post/318138/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *