Использование паттерна «Protocol» в Ruby

в 13:11, , рубрики: Elixir/Phoenix, metaprogramming, protocols, 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.

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

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

Автор: am-amotion-city

Источник

Поделиться

* - обязательные к заполнению поля