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

http://speakerdeck.com/u/michaelfairley/p/extending-ruby-with-ruby
  • Перевод
От переводчика: предлагаю вам перевод начала презентации Michael Fairley — Extending 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. Используйте это, потому что я думаю это может сделать ваш код чище, повысить его читаемость и упростить его поддержку.
Поделиться публикацией

Комментарии 20

    0
    Мне одному кажется что bank.send_money как-то не по дзену? Логичнее было бы from.send_money 10, to

    Но за статью спасибо.
      +1
      Возможно это не область ответственности классов аккаунтов — знать как переводить деньги, они просто хранят свое текущее состояние. А вообще я думаю автор об этом не задумывался даже, главное же показать принцип и сделать это просто, а не по дзену :)
        +1
        Вполне себе хорошо. А еще лучше Transaction.create и параметры. Но точно не from.send_money 10, to
          0
          если уж придираться то: account.send_money(10).to another_account
          –4
          Кавай и няша. Вот только на питоне ЕСТЬ блоки. with называются.
            0
            Я не знаком с Python, но немного гугления дало понять, что with — это все-таки не совсем аналог блоков Ruby, хотя и похоже. Или я не прав?
              +4
              Вы правы. Оператор with не имеет никакого отношения к блокам Ruby.

              Скорее он близок к оператору using в C#.
                0
                да, это не совсем аналог, тем не менее, для транзакций это как раз самое то —
                with transaction():
                do…
                если свалися — rollback автоматом, если закончится — commit автоматом.
              –1
              You Win an Internet!
                +6
                Хм… Извините, но ruby-код (1):

                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
                


                с Python-кодом (2):

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


                и тем более с ruby-кодом (3) даже с учётом вашей реализации декораторов:

                +Synchronized
                +Timed
                +Memoized
                
def fib(n)
                  if n <= 1
                    1
                  else
                    fib(n - 1) * fib(n - 2)
                  end
                end
                


                Никак не могут считаться эквивалентными друг другу.

                Вы похоже на ruby пытаетесь писать как на python. Код (3) сложно назвать естественным для руби. Eсть несколько вариантов сделать это более естественным для ruby способом.

                Например, я бы код (3) реализовал так:

                synchronize time memoize { fib(n) }

                где synchronize, time, memoize это функции, принимающие в качестве параметра объект класса proc.

                На Ruby надо писать как на Ruby, а на Python как на Python. Это разные языки. У каждого языка свои языковые конструкции, инструменты и ограничения, и соответственно свои методы и стиль решения задач. Что-то есть интересное в Python, но нет в Ruby. Что-то наоборот. И это нормально.

                Один язык уже есть, куда попытались всё втиснуть. У него даже название характерное — Язык Ада.

                Но статья небесполезная. Дает пищу для размышления. Мне по крайней мере. Спасибо…
                  0
                  Точнее не вы, а автор. Пардон…
                    +1
                    В оригинальной презентации, автор в конце говорит, что: «ruby > python, поскольку любую фичу из python мы можем реализовать в ruby, но не наоборот»
                    Думаю он просто сделал proof of concept, не особо намекая на то, что это надо использовать.
                    0
                    Но как красиво автор жонглирует синтаксисом! И манки-патчинг, и операторы, и элегантное решение с +Class. Я задумался о том, как плохо я знаю Ruby.
                      0
                      Э-э… Есть подозрение, что автор статьи (не переводчик :)) именно это и хотел продемонстрировать своей статьей.

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

                      Было время я «умирал» от крутости перегрузки операторов в С++ и использовал это где надо и где не надо. Пока однажды не пришлось самому разбираться в коде такого же как я любителя перегрузки операторов. После этого зарёкся…
                    0
                    Вы это реализовали с точки зрения вызова. А декораторы описываются с точки зрения определения метода и повторного использования этих кода в декораторах. Тут ближе подход реализованный методом synchronize в AS.
                    github.com/rails/rails/blob/d9c288207731e61c40ce6276fd91f0b800d3fabb/activesupport/lib/active_support/core_ext/module/synchronization.rb#L20

                    Но он, естественно, неуниверсальный.
                      –4
                      Магическая палочка типа @synchronize вообще мне кажется бредовой идеей, нечто похожее на аннотации Java. Сильно усложняет понимание кода
                        0
                        в итоге получился сферический конь. нет, я не против задачек во имя академического интереса, но тут совсем мало практической пользы — все можно сделать гораздо красивей на чистом руби
                          0
                          Было бы здорово увидеть пример.
                            0
                            Тут далеко ходить не надо
                            ActiveRecord::Base.transaction do
                            end
                            

                            выглядит читабельней и понятней и гораздо проще в отладке и тестах
                            таким же блоком я ловлю ошибки и посылаю оповещения о них
                            ruby суперский язык, в котором можно вывернуться как угодно (ну или почти как угодно), но не стоит придумывать гемор. проще надо быть — KISS
                            все это имхо
                              0
                              даже alias_method и то понятней :)

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

                        Самое читаемое