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 theSimpleDelegator
. 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 type | Time of execution | Memory allocation |
Explicit | 0.000080s | 0 kb |
Rails | 0.000178s | 40 kb |
Forwardable | 0.000239s | 40 kb |
Metaprogramming | 0.000312s | 80 kb |
SimpleDelegator | 0.000641s | 80 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.