πŸ“œ ⬆️ ⬇️

Extending Ruby with Ruby: borrowing Python function decorators

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.

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

Why didn't the Python community like this solution? Two reasons:

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_decorators

I 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.

Source: https://habr.com/ru/post/143127/


All Articles