Delegating things in Ruby

Delegating things in Ruby

Delegator design pattern

Delegation is one of those design patterns that are implemented in the Ruby standard library, as external ruby gems and separate implementation in the Ruby on Rails framework. It’s simple yet powerful.

This article is a deep dive into the delegation with Ruby to understand how we can implement it and when. There are a few ways to achieve delegation and it is good to know when to use which.

All my notes are based on years of hands-on experience at iRonin.IT - a top software development company, where we provide custom software development and IT staff augmentation services for a wide array of technologies.

Five ways of delegation with Ruby

In my opinion, we can distinguish five main types of delegation. Some of them are less formal and some are very explicit. Here they are:

  • Explicit delegation - it happens when we call some method and the method inside is invoking the method of the same name but on a different object.

  • Forwardable module - implementation in Ruby standard library. We can extend the given class with this module and define which methods should be delegated.

  • Simple delegator - another implementation in Ruby standard library but a little bit different than the Forwardable module. With SimpleDelegator class we can delegate methods invocation to the object passed while initializing the class.

  • Rails delegation - the most flexible and quickest solution if you want to delegate calls and you are using the Ruby on Rails framework.

  • Method missing - the least obvious way to implement delegation. Useful when you don’t want to stick to explicit definitions and simply delegate all invocations if the invoked method is present in the given class.

I will explain in detail each mentioned way of delegating calls and compare all of them to see which one is the fastest and which one allocates the biggest amount of objects.

Explicit delegation

Explicit delegation is very close to dependency injection and, I believe, in some cases, we can use these terms alternately. Let’s say that we want to read users' information from a file. We can read the data from CSV, XML, or even a plain text file.

Below is the simple implementation of the class that reads the CSV file where the user data is stored in the following columns: first_name, last_name, and email.

require 'csv'

class CsvAdapter
 def initialize(file_name)
   @file_name = file_name
 end

 def all
   table = CSV.read(@file_name, headers: true, header_converters: :symbol)
   table.map(&:to_h)
 end
end

We can use the above class to parse the CSV file and get the array of hashes as a result of parsing. I mentioned before that we can handle multiple formats of files as a data source so it would be good to have a service that can handle it all:

class UsersDb
 def initialize(adapter)
   @adapter = adapter
 end

 def all
   @adapter.all
 end
end

adapter = CsvAdapter.new('./users.csv')
users_db = UsersDb.new(adapter)
users_db.all

In the above class, we have an example of explicit delegation. We call UsersDb#all and in this method, we explicitly call the all method on the instance of the adapter. This approach is very readable and easy to understand even for not experienced developers.

However, this approach forces us to write some additional code to make the delegation happen. Let’s consider now the forwardable module that can save us some time.

Forwardable module

The Forwardable module is a part of the standard Ruby library. We can extend our UsersDb class with this module and then define which methods called on our service will be delegated to the adapter:

require 'forwardable'

class UsersDb
 extend Forwardable

 def initialize(adapter)
   @adapter = adapter
 end

 def_delegator :@adapter, :all
end

It’s obvious that the more methods we want to delegate, the less code we have to type. In the above example, we didn’t save much time in terms of writing the code but we implemented the delegation less explicitly.

Alternatively, you can use the delegate method which sounds like a more Rails way of doing things:

delegate :all => :@adapter

If we would like to delegate multiple methods to the adapter, we can do it in a one-liner:

require 'forwardable'

class UsersDb
 extend Forwardable

 def initialize(adapter)
   @adapter = adapter
 end

 def_delegators :@adapter, :all, :class
end

A good practice is to use def_delegators only when we want to delegate multiple methods, otherwise, just use def_delegator.

You are not limited to instance variables when it comes to delegation. You can also delegate the call to the constant:

def_delegator 'CsvAdapter::VERSION', :to_i

As you can see the usage of the Forwardable module is pretty straightforward and it might be helpful if you are writing pure Ruby and you need to delegate a few methods and it should be quite explicit.

SimpleDelegator class

Let’s keep looking into the Ruby standard library. Besides Forwardable, we can also use a SimpleDelegator class. It’s even simpler to use and the delegation is less explicit - a little bit of magic happens. Here is the basic usage example based on our previous code:

class UsersDb < SimpleDelegator; end

That’s it. You can now use the previous code to achieve the desired result:

adapter = CsvAdapter.new('./users.csv')
db = UsersDb.new(adapter)
db.all # => [...]

When you inherit from SimpleDelegator class, and then you invoke some method on the instance of UsersDb class, Ruby first looks if the invoked method exists inside the class which inherits from SimpleDelegator, in our case, it’s UsersDb.

If Ruby won't find the invoked method inside the class that inherits from SimpleDelegator , it looks if the invoked method exists on the object that you passed as the initialization argument - in our case, it’s the adapter variable that holds the instance of CsvAdapter class. If it won’t find the method, it will throw an error.

The lookup flow

So again, if CsvAdapter inherits from SimpleDelegator and you will call the #all method on the instance, Ruby will perform the following lookup flow:

  • Check if the #all method is present in the class that inherits from the SimpleDelegator. If it is present, execute it. Otherwise keep looking.

  • Check if the #all method is present on the argument passed to the initialization method. If it is present, execute it.

  • Raise the method_missing error.

There is one thing interesting about the last step of lookup. Ruby won’t rise the NoMethodError error from the main context but from the method_missing method.

The method_missing is a special method that is invoked when we try to invoke a method that does not exist on a given object. It can also be used to handle delegation when we want to go with a metaprogramming approach.

Approach with metaprogramming

Let’s see how we can implement delegation most implicitly. As I mentioned before, we can use metaprogramming which means the ability of code to create code:

class UsersDb
 def initialize(adapter)
   @adapter = adapter
 end

 private

 def method_missing(method_name, *args, &block)
   @adapter.public_send(method_name, *args)
 end
end

When we would call this code:

adapter = CsvAdapter.new('./users.csv')
db = UsersDb.new(adapter)
db.all # => [...]

You can say that it won’t work because there is no #all method defined. However, when there is no method on the instance on which we are invoking it, Ruby calls the method_missing method to handle additional logic. We can overwrite this method and add our handling.

Which such an approach the flow is the same as flow with SimpleDelegator . In fact, the SimpleDelegator works the same but it's just a boilerplate that you can use straight away.

  • Check if the #all method is present on the object. If it is present, execute it otherwise keep looking.

  • Check if the #all method is present on the instance of the adapter class. If it is present, execute it.

  • Raise the NoMethodError error.

There is one detail that is not right. The NoMethodError will be raised but not in the context of UsersDb class (which is something we would expect) but in terms of CsvAdapter class. We can simply fix it by ensuring that the adapter implements the method before invoking it:

def method_missing(method_name, *args, &block)
 if @adapter.respond_to?(method_name)
   @adapter.public_send(method_name, *args)
 else
   super
 end
end

The implicit delegation with metaprogramming gives us the advantage of adding the next methods to the adapter class without the need to explicitly tell us that they can be delegated. It all happens behind the scenes.

The disadvantage of this approach is that the code is way less readable and we can lead to a case where we should get the NoMethodError but it won’t happen as the method will exist on the adapter and we will get the value we didn’t expect. We should be really careful when implementing this solution.

Rails’ approach

Last but not least is the approach suggested by the Rails framework. The approach sits somewhere between the explicit and implicit delegation as it implements some magic but the definition of the delegation is visible enough to not harm the readability of the code:

class User < ApplicationRecord
 delegate :all, to: :users_db

 private

 def users_db
   @users_db ||= begin
     adapter = CsvAdapter.new('./users.csv')
     UsersDb.new(adapter)
   end
 end
end

You define the delegation using the delegate method, where you put the list of method names you want to delegate. Then, you need to specify the to: argument so Rails knows to which object the calls should be delegated.

Additionally, you can also pass the prefix or allow_nil option which will help you to add additional context to the name or not throw an error when delegated method would be invoked on nil.

Performance comparison

Which solution is the best for the given case? It depends. Which solution is the best in terms of performance? Let’s measure it.

For the test purpose, I created a simple service:

class SimpleService
  def call
    true
  end
end

Now, let’s create code for each delegation type.

Explicit delegation

class OtherService
  def initialize(service)
    @service = service
  end

  def call
    @service.call
  end
end

Forwardable module

require 'forwardable'

class OtherService
  extend Forwardable

  def initialize(service)
    @service = service
  end

  def_delegator :@service, :call
end

Simple Delegator

class OtherService < SimpleDelegator; end

Metaprogramming

class OtherService
 def initialize(service)
   @service = service
 end

 private

 def method_missing(method_name, *args, &block)
   @service.public_send(method_name, *args)
 end
end

Rails delegation

class User < ApplicationRecord
 delegate :call, to: :simple_service

 private

 def simple_service
   @simple_service ||= SimpleService.new
 end
end

Summary

I checked the time of execution and memory allocation for 1000 invocations of each option. Here are the details:

Delegation typeTime of executionMemory allocation
Explicit0.000080s0 kb
Rails0.000178s40 kb
Forwardable0.000239s40 kb
Metaprogramming0.000312s80 kb
SimpleDelegator0.000641s80 kb

The cost is not big no matter what solution you will use. If you are using Rails, it's a good idea to always go with their implementation of delegation. In pure Ruby, I would suggest using SimpleDelegator or explicit approach for the readability of code.

Thanks to TastyPi for pointing out the correct behavior of SimpleDelegator

Didn't get enough of Ruby?

Check out our free books about Ruby to level up your skills and become a better software developer.