Расширяем Ruby с помощью Ruby: заимствуем у Python декораторы функции

в 12:53, , рубрики: decorators, python, ruby, ruby on rails, декоратор функции, декораторы, расширение ruby

От переводчика: предлагаю вам перевод начала презентации Michael Fairley — Exing Ruby with Ruby. Я перевел только первую часть из трех, потому что она имеет максимальные практические ценность и пользу, на мой взгляд. Тем не менее, настоятельно рекомендую ознакомиться с полной презентацией, в которой помимо Python приводятся примеры заимствования фишек из Haskell и Scala.

Декораторы функции

В Python есть такая штука — декораторы, которая представляет собой синтаксический сахар для добавления в методы и функции кусочков часто используемой функциональности. Сейчас я покажу вам некоторые примеры того, что такое декораторы и почему они могли бы быть полезны и в Ruby.

Раньше я очень много работал с Python и декораторы функции определенно являются тем, чего мне так не хватает с тех пор, и кроме того тем, что может помочь практически всем нам сделать наш код на Ruby чище.

Возьмем Ruby и притворимся, что нам нужно перевести деньги с одного банковского аккаунта на другой. Вроде все просто, так?

def send_money(from, to, amount)
  from.balance -= amount
  to.balance += amount
  from.save!
  to.save!
end


Мы вычитаем сумму из баланса аккаунта «from»…

from.balance -= amount

И прибавляем эту сумму к балансу аккаунта «to»…

to.balance += amount

И сохраняем оба аккаунта.

from.save!
to.save!

Но тут есть пара недочетов, самый очевидный из которых — отсутствие транзакции (если «from.save!» завершится успешно, а «to.save!» — нет, то деньги растворятся в воздухе).

К счастью, ActiveRecord делает решение этой проблемы очень простым. Мы просто оборачиваем наш код в блок метода транзакции и это гарантирует нам, что внутри блока все завершается либо успешно, либо нет.

def send_money(from, to, amount)
  ActiveRecord::Base.transaction do
    from.balance -= amount
    to.balance += amount
    from.save!
    to.save!
  end
end

Давайте теперь посмотрим на этот же пример в Python. Версия без транзакции выглядит почти точь в точь как в Ruby.

def send_money(from, to, amount):
  from.balance -= amount
  to.balance += amount
  from.save()
  to.save()

Но стоит добавить транзакцию и код начинает выглядеть уже не так изящно.

def send_money(from, to, amount):
  try:
    db.start_transaction()
    from.balance -= amount
    to.balance += amount
    from.save()
    to.save()
    db.commit_transaction()
  except:
    db.rollback_transaction()
    raise

В этом методе 10 строк кода, но только 4 из них реализуют нашу бизнес-логику.

from.balance -= amount
to.balance += amount
from.save()
to.save()

Другие же 6 строк — шаблон для запуска нашей логики внутри транзакции. Это уродливо и слишком многословно, но что еще хуже — вы должны помнить все эти строки, включая правильную обработку ошибок и семантику отката.

def send_money(from, to, amount):
  try:
    db.start_transaction()
    ...
    db.commit_transaction()
  except:
    db.rollback_transaction()
    raise

Так как же нам сделать это красивее и меньше повторять себя? В Python нет блоков, поэтому фокус как в Ruby здесь не пройдет. Однако в Python есть возможность легко передавать и переназначать методы. Поэтому мы можем написать функцию «transactional», которая будет принимать в качестве аргумента другую функцию и возвращать эту же функцию, но уже обернутую в шаблонный код транзакции.

def send_money(from, to, amount):
  from.balance -= amount
  to.balance += amount
  from.save()
  to.save()
send_money = transactional(send_money)

А вот как может выглядеть функция «transactional»…

def transactional(fn):
  def transactional_fn(*args):
    try:
      db.start_transaction()
      fn(*args)
      db.commit_transaction()
    except:
      db.rollback_transaction()
      raise

  return transactional_fn

Она получает функцию («send_money» в нашем примере) как свой единственный аргумент.

def transactional(fn):

Определяет новую функцию.

def transactional_fn(*args):

Новая функция содержит в себе шаблон для оборачивания бизнес-логики в транзакцию.

try:
  db.start_transaction()
  ...
  db.commit_transaction()
except:
  db.rollback_transaction()
  raise

Внутри шаблона вызывается оригинальная функция, которой передаются аргументы, которые были переданы новой функции.

fn(*args)

И наконец, новая функция возвращается.

return transactional_fn

Таким образом, мы передаем функцию «send_money» в функцию «transactional», которую только что определили, которая в свою очередь возвращает новую функцию, которая делает все тоже самое что и функция «send_money», но делает все это внутри транзакции. И далее мы присваиваем эту новую функцию нашей функции «send_money», переопределяя ее оригинальное содержимое. Теперь, когда бы мы не вызвали функцию «send_money», будет вызвана версия с транзакцией.

send_money = transactional(send_money)

И вот то, к чему я все это время вел. Эта идиома настолько часто используется в Python, что для ее поддержки добавили специальный синтаксис — декоратор функции. И именно так вы делаете что-либо транзакционным в Django ORM.

@transactional
def send_money(from, to, amount):
  from.balance -= amount
  to.balance += amount
  from.save()
  to.save()

И что?

Теперь вы думаете: «Ну и что? Ты только что показал как эта декораторная мумба-юмба решает ту же проблему, которую решают блоки. Зачем нам эта шляпа в Ruby?» Ну что ж, давайте взглянем на случай, в котором блоки уже не выглядят так элегантно.

Пускай у нас есть метод, который вычисляет значение n-ого элемента в последовательности Фибоначчи.

def fib(n)
  if n <= 1
    1 
  else
    fib(n - 1) * fib(n - 2)
  end
end

Он медленный, поэтому мы хотим его мемоизовать. Общепринятый подход для этого — распихать повсюду «||=», который страдает тем же недугом, что и первый пример с транзакцией — мы смешиваем код нашего алгоритма с дополнительным поведением, которым хотим его окружить.

def fib(n)
  @fib ||= {}

  @fib[n] ||= if n <= 1
    1
  else
    fib(n - 1) * fib(n - 2)
  end
end

К тому же мы забыли тут пару вещей, как например, тот факт, что «nil» и «false» не могут быть мемоизованы таким способом: еще один момент, о котором необходимо постоянно помнить.

def fib(n)
  @fib ||= {}
  return @fib[n] if @fib.has_key?(n)
  @fib[n] = if n <= 1
    1
  else
    fib(n - 1) * fib(n - 2)
  end
end

Хорошо, мы можем решить это с помощью блока, но у блоков нет доступа к имени или аргументам функции, которая их вызывает, поэтому нам придется передавать эту информацию явно.

def fib(n)
  memoize(:fib, n) do
    if n <= 1
      1
    else
      fib(n - 1) * fib(n - 2)
    end
  end
end

А теперь, если мы начнем добавлять больше блоков вокруг основной функциональности…

def fib(n)
  memoize(:fib, n) do
    time(:fib, n) do
      if n <= 1
        1
      else
        fib(n - 1) * fib(n - 2)
      end
    end
  end
end

… мы будем вынуждены снова и снова перепечатывать имя метода и его аргументы.

def fib(n)
  memoize(:fib, n) do
    time(:fib, n) do
      synchronize(:fib) do
        if n <= 1
          1
        else
          fib(n - 1) * fib(n - 2)
        end
      end
    end
  end
end

Это довольно хрупкая конструкция и она сломается в тот же миг, как только мы решим каким-либо образом изменить сигнатуру метода.

Тем не менее, это можно решить путем добавления вот такой штуки сразу после определения нашего метода.

def fib(n)
  if n <= 1
    1
  else
    fib(n - 1) * fib(n - 2)
  end
end
ActiveSupport::Memoizable.memoize :fib

И это должно вам напомнить то, что мы видели в Python — когда модификация метода шла сразу после самого метода.

# Ruby
def fib(n)
  ...
end
ActiveSupport::Memoizable.memoize :fib

# Python
def fib(n):
  ...
fib = memoize(fib)

Почему же сообществу Python не понравилось такое решение? Две причины:

  • вы больше не можете отследить выполнение вашего кода сверху вниз;
  • слишком просто переместить куда-то метод и забыть это сделать с кодом, который шел после него.

Давайте посмотрим на наш пример с Фибоначчи в Python.

def fib(n):
  if n <= 1:
    return 1
  else
    return fib(n - 1) * fib(n - 2)

Мы хотим его мемоизовать, поэтому мы декорируем его функцией «memoize».

@memoize
def fib(n):
  if n <= 1:
    return 1
  else
    return fib(n - 1) * fib(n - 2)

И если мы хотим измерять время работы нашего метода или синхронизировать его вызовы, то мы просто добавляем еще один декоратор. Вот и все.

@synchronize
@time
@memoize
def fib(n):
  if n <= 1:
    return 1
  else
    return fib(n - 1) * fib(n - 2)

А теперь я покажу вам как добиться этого в Ruby (используя «+» вместо «@» и первую букву как заглавную). И самое прикольное, что мы можем добавить этот синтаксис декоратора в Ruby, который очень близок к синтаксису в Python, с помощью всего лишь 15 строчек кода.

+Synchronized
+Timed
+Memoized

def fib(n)
  if n <= 1
    1
  else
    fib(n - 1) * fib(n - 2)
  end
end

Погружаемся

Давайте вернемся к нашему примеру «send_money». Мы хотим добавить к нему декоратор «Transactional».

+Transactional
def send_money(from, to, amount)
  from.balance -= amount
  to.balance += amount
  from.save!
  to.save!
end

«Transactional» является подклассом «Decorator», который мы обсудим чуть ниже.

class Transactional < Decorator
  def call(orig, *args, &blk)
    ActiveRecord::Base.transaction do
      orig.call(*args, &blk)
    end
  end
end

У него всего один метод «call», который будет вызван вместо нашего оригинального метода. В качестве аргументов он получает метод, который должен «обернуть», его аргументы и его блок, которые будут переданы ему при вызове.

def call(orig, *args, &blk)

Открываем транзакцию.

ActiveRecord::Base.transaction do

И далее вызываем оригинальный метод внутри блока транзакции.

orig.call(*args, &blk)

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

Что же внутри класса «Decorator»?

class Decorator
  def self.+@
    @@decorator = self.new
  end

  def self.decorator
    @@decorator
  end

  def self.clear_decorator
    @@decorator = nil
  end
end

Эта штука — «+@» — оператор «унарный плюс», поэтому этот метод будет вызван, когда мы вызываем «+DecoratorName», как мы сделали с «+Transactional».

def self.+@

Так же нам нужен способ получить текущий декоратор.

def self.decorator
    @@decorator
end

И способ обнулить текущий декоратор.

def self.clear_decorator
    @@decorator = nil
end

Класс, который хочет иметь декорируемые методы должен быть расширен модулем «MethodDecorators».

class Bank
  extend MethodDecorators

  +Transactional
  def send_money(from, to, amount)
    from.balance -= amount
    to.balance += amount
    from.save!
    to.save!
  end
end

Можно было бы расширить сразу класс «Class», но я думаю, что лучшей практикой в данном случае будет оставить такое решение на усмотрение конечного пользователя.

module MethodDecorators
  def method_added(name)
    super
    decorator = Decorator.decorator
    return unless decorator
    Decorator.clear_decorator
    orig_method = instance_method(name)
    define_method(name) do |*args, &blk|
      m = orig_method.bind(self)
      decorator.call(m, *args, &blk)
    end
  end
end

«method_added» — это приватный метод класса, который вызывается каждый раз когда в классе определяется новый метод, давая нам удобный способ поймать момент начала создания метода.

def method_added(name)

Вызываем родительский «method_added». Об этом можно легко забыть, переопределяя методы вроде «method_added», «method_missing» или «respond_to?», но если вы этого не делаете, то легко можете сломать другие библиотеки.

super

Получаем текущий декоратор и прерываем функцию, если декоратора нет, иначе обнуляем текущий декоратор. Декоратор важно обнулить, потому что дальше мы переопределяем метод, что снова вызывает наш «method_added».

decorator = Decorator.decorator
return unless decorator
Decorator.clear_decorator

Извлекаем оригинальную версию метода.

orig_method = instance_method(name)

И переопределяем его.

define_method(name) do |*args, &blk|

«instance_method» на самом деле возвращает объект класса «UnboundMethod», который представляет собой метод, который не знает какому объекту он принадлежит, поэтому мы должны привязать его к текущему объекту.

m = orig_method.bind(self)

И затем мы вызываем декоратор, передавая ему оригинальный метод и аргументы для него.

decorator.call(m, *args, &blk)

Что еще?

Конечно тут есть еще ряд невероятно важных моментов, которые должны быть решены, прежде чем этот код можно будет считать готовым к production среде.

Множественные декораторы

Реализация, которую я привел позволяет использовать только один декоратор для каждого метода, но мы хотим иметь возможность использовать больше одного декоратора.

+Timed
+Memoized
def fib(n)
  ...
end
Область видимости

«define_method» определяет публичные методы, но мы хотим приватные и защищенные методы, которые можно было бы декорировать с соблюдением их области видимости.

private
  +Transactional
  def send_money(from, to, amount)
    ...
  end
Методы класса

«method_added» и «define_method» работают только для методов экземпляра класса, поэтому нужно придумать что-то еще, чтобы декораторы стали работать для методов самого класса.

+Memoize
def self.calculate
  ...
end
Аргументы

В примере с Python я показал, что мы можем передавать декоратору значения. Мы хотим, чтобы у нас была возможность создавать какие угодно индивидуальные экземпляры декораторов для наших методов.

+Retry.new(3)
def post_to_facebook
  ...
end

gem install method_decorators

github.com/michaelfairley/method_decorators

Я реализовал все эти возможности, добавил исчерпывающий набор тестов и выкатил все это в виде gem. Используйте это, потому что я думаю это может сделать ваш код чище, повысить его читаемость и упростить елять в личку, спасибо.

Автор: Svyatov


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


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js