From the translator: I offer you a translation of the start of the presentation Michael Fairley - Extending Ruby with Ruby . I translated only the first part of the three, because it has the maximum practical value and benefit, in my opinion. Nevertheless, I strongly recommend that you familiarize yourself with the full presentation, in which, in addition to Python, are examples of borrowing chips from Haskell and Scala.Function decorators
In Python there is such a thing - decorators, which is a syntactic sugar for adding to the methods and functions of the pieces of frequently used functionality. Now I will show you some examples of what decorators are and why they could be useful in Ruby.
I used to work a lot with Python and function decorators are definitely something that Iβm lacking since then, and besides that that can help almost all of us make our Ruby code cleaner.
')
Take Ruby and pretend that we need to transfer money from one bank account to another. It seems simple, right?
def send_money(from, to, amount) from.balance -= amount to.balance += amount from.save! to.save! end
We deduct the amount from the β
from β account balance ...
from.balance -= amount
And we add this amount to the account balance β
to β ...
to.balance += amount
And save both accounts.
from.save! to.save!
But there are a couple of shortcomings, the most obvious of which is the lack of a transaction (if β
from.save! β Completes successfully and β
to.save! β Is not, then the money will dissolve in the air).
Fortunately, ActiveRecord makes solving this problem very simple. We simply wrap our code in a transaction method block and this ensures that everything inside the block either completes successfully or not.
def send_money(from, to, amount) ActiveRecord::Base.transaction do from.balance -= amount to.balance += amount from.save! to.save! end end
Let's now look at the same example in Python. A version without a transaction looks almost exactly like Ruby.
def send_money(from, to, amount): from.balance -= amount to.balance += amount from.save() to.save()
But it is necessary to add a transaction and the code starts to look not so elegant.
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
There are 10 lines of code in this method, but only 4 of them implement our business logic.
from.balance -= amount to.balance += amount from.save() to.save()
The other 6 lines are a pattern for running our logic inside a transaction. This is ugly and too verbose, but what's even worse is that you have to remember all these lines, including the correct error handling and rollback semantics.
def send_money(from, to, amount): try: db.start_transaction() ... db.commit_transaction() except: db.rollback_transaction() raise
So how do we make it more beautiful and less repeat ourselves? There are no blocks in Python, so the focus like in Ruby will not work here. However, Python has the ability to easily transfer and reassign methods. Therefore, we can write a β
transactional β function that will take another function as an argument and return the same function, but already wrapped in a template transaction code.
def send_money(from, to, amount): from.balance -= amount to.balance += amount from.save() to.save() send_money = transactional(send_money)
But what the
transactional function might look like ...
def transactional(fn): def transactional_fn(*args): try: db.start_transaction() fn(*args) db.commit_transaction() except: db.rollback_transaction() raise return transactional_fn
It receives the function ("
send_money " in our example) as its only argument.
def transactional(fn):
Defines a new function.
def transactional_fn(*args):
The new function contains a template for wrapping business logic into a transaction.
try: db.start_transaction() ... db.commit_transaction() except: db.rollback_transaction() raise
Inside the template, the original function is called, to which the arguments that were passed to the new function are passed.
fn(*args)
Finally, the new feature returns.
return transactional_fn
Thus, we pass the
send_money function to the
transactional function that we just defined, which in turn returns a new function that does everything the same as the
send_money function, but does it all inside a transaction. And then we assign this new function to our β
send_money β function, overriding its original content. Now, whenever we call the "
send_money " function, the version with the transaction will be called.
send_money = transactional(send_money)
And this is what I was leading to all this time. This idiom is so often used in Python that a special syntax is added to support it - the function decorator. And this is how you make something transactional in Django ORM.
@transactional def send_money(from, to, amount): from.balance -= amount to.balance += amount from.save() to.save()
So what?
Now you are thinking: βSo what? You have just shown how this decorator mumba jumba solves the same problem that blocks solve. Why do we need this hat in Ruby? βWell, let's take a look at a case in which the blocks no longer look so elegant.
Let us have a method that calculates the value of the n-th element in the Fibonacci sequence.
def fib(n) if n <= 1 1 else fib(n - 1) + fib(n - 2) end end
It is slow, so we want to memorize it. A common approach for this is to stuff β
|| = β everywhere, which suffers from the same ailment as the first example with a transaction β we mix the code of our algorithm with the additional behavior we want to surround it with.
def fib(n) @fib ||= {} @fib[n] ||= if n <= 1 1 else fib(n - 1) + fib(n - 2) end end
In addition, we have forgotten here a couple of things, such as the fact that βnilβ and βfalseβ cannot be memorized in this way: one more thing that must be constantly remembered.
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
Well, we can solve this with a block, but blocks do not have access to the name or arguments of the function that calls them, so we have to pass this information explicitly.
def fib(n) memoize(:fib, n) do if n <= 1 1 else fib(n - 1) + fib(n - 2) end end end
And now, if we start adding more blocks around the core functionality ...
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
... we will have to retype the method name and its arguments again and again.
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
This is a rather fragile construction and it will break at the same moment as soon as we decide to change the method signature in any way.
However, this can be solved by adding such a thing right after defining our method.
def fib(n) if n <= 1 1 else fib(n - 1) + fib(n - 2) end end ActiveSupport::Memoizable.memoize :fib
And this should remind you of what we saw in Python β when the method was modified immediately after the method itself.
Why didn't the Python community like this solution? Two reasons:
- you can no longer track the execution of your code from top to bottom;
- it is too easy to move a method somewhere and forget to do it with the code that came after it.
Let's take a look at our Fibonacci example in Python.
def fib(n): if n <= 1: return 1 else return fib(n - 1) + fib(n - 2)
We want to memorize it, so we decorate it with the β
memoize β function.
@memoize def fib(n): if n <= 1: return 1 else return fib(n - 1) + fib(n - 2)
And if we want to measure the time of our method or synchronize its calls, then we simply add another decorator. That's all.
@synchronize @time @memoize def fib(n): if n <= 1: return 1 else return fib(n - 1) + fib(n - 2)
And now I will show you how to achieve this in Ruby (using β+β instead of β@β and the first letter as the capital letter). And the cool thing is that we can add this syntax to the Ruby decorator, which is very close to the Python syntax, with just 15 lines of code.
+Synchronized +Timed +Memoized def fib(n) if n <= 1 1 else fib(n - 1) + fib(n - 2) end end
Dive
Let's go back to our
send_money example. We want to add to it the β
Transactional β decorator.
+Transactional def send_money(from, to, amount) from.balance -= amount to.balance += amount from.save! to.save! end
β
Transactional β is a subclass of β
Decorator β, which we will discuss below.
class Transactional < Decorator def call(orig, *args, &blk) ActiveRecord::Base.transaction do orig.call(*args, &blk) end end end
He has only one method β
call β, which will be called instead of our original method. As arguments, he gets a method that should βwrapβ, his arguments and his block, which will be passed to him when he is called.
def call(orig, *args, &blk)
We open transaction.
ActiveRecord::Base.transaction do
And then we call the original method inside the transaction block.
orig.call(*args, &blk)
Please note that the structure of our decorator is different from the way decorators work in Python. Instead of defining a new function that will receive arguments, our Ruby decorator will receive the method itself and its arguments with each call. We are forced to do so because of the semantics of binding methods to objects in Ruby, which we will talk about below.
What is inside the
Decorator class?
class Decorator def self.+@ @@decorator = self.new end def self.decorator @@decorator end def self.clear_decorator @@decorator = nil end end
This thing is β
+ @ β - the βunary plusβ operator, so this method will be called when we call β
+ DecoratorName β, as we did with β
+ Transactional β.
def self.+@
We also need a way to get the current decorator.
def self.decorator @@decorator end
And a way to reset the current decorator.
def self.clear_decorator @@decorator = nil end
The class that wants to have methods to decorate must be extended by the β
MethodDecorators β module.
class Bank extend MethodDecorators +Transactional def send_money(from, to, amount) from.balance -= amount to.balance += amount from.save! to.save! end end
It would be possible to expand the class at once, but I think that the best practice in this case would be to leave such a decision at the discretion of the end user.
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 β is a private class method that is called every time a new method is defined in a class, giving us a convenient way to catch the moment when the method is created.
def method_added(name)
Call the parent "
method_added ". You can easily forget about this by overriding methods like β
method_added β, β
method_missing β or β
respond_to? β, But if you donβt, you can easily break other libraries.
super
We get the current decorator and interrupt the function if there is no decorator, otherwise reset the current decorator. It is important to set the decorator to zero, because then we redefine the method, which again calls our "
method_added ".
decorator = Decorator.decorator return unless decorator Decorator.clear_decorator
We retrieve the original version of the method.
orig_method = instance_method(name)
And redefine it.
define_method(name) do |*args, &blk|
β
Instance_method β actually returns an object of class β
UnboundMethod β, which is a method that does not know which object it belongs to, so we have to bind it to the current object.
m = orig_method.bind(self)
And then we call the decorator, passing him the original method and arguments for it.
decorator.call(m, *args, &blk)
What else?
Of course there are a number of incredibly important points that need to be resolved before this code can be considered ready for the production environment.
Multiple Decorators
The implementation I gave allows using only one decorator for each method, but we want to be able to use more than one decorator.
+Timed +Memoized def fib(n) ... end
Area of ββvisibility
Define_method defines public methods, but we want private and protected methods that can be decorated according to their scope.
private +Transactional def send_money(from, to, amount) ... end
Class methods
β
Method_added β and β
define_method β work only for the class instance methods, so you need to think of something else so that the decorators can work for the methods of the class itself.
+Memoize def self.calculate ... end
Arguments
In the Python example, I showed that we can pass values ββto the decorator. We want us to be able to create any kind of individual instances of decorators for our methods.
+Retry.new(3) def post_to_facebook ... end
gem install method_decorators
github.com/michaelfairley/method_decoratorsI implemented all these features, added a comprehensive test suite and rolled it all out in the form of a gem. Use this because I think it can make your code cleaner, improve its readability and simplify its support.