От переводчика: предлагаю вам перевод начала презентации 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