ruby

Ruby's Hidden Gems -Delegator and Forwardable

Michael Kohl

Michael Kohl on

Ruby's Hidden Gems -Delegator and Forwardable

In today's exploration of the hidden gems in Ruby's standard library, we're going to look at delegation.

Unfortunately, this term—like so many others—has become somewhat muddled over the years and means different things to different people. According to Wikipedia:

Delegation refers to evaluating a member (property or method) of one object (the receiver) in the context of another original object (the sender). Delegation can be done explicitly, by passing the sending object to the receiving object, which can be done in any object-oriented language; or implicitly, by the member lookup rules of the language, which requires language support for the feature.

However, more often than not, people also use the term to describe an object calling the corresponding method of another object without passing itself as an argument, which can more precisely be referred to as "forwarding".

With that out of the way, we'll use "delegation" to describe both of these patterns for the rest of the article.

Delegator

Let's start our exploration of delegation in Ruby by looking at the standard library's Delegator class which provides several delegation patterns.

SimpleDelegator

The easiest of these, and the one I've encountered most in the wild, is SimpleDelegator, which wraps an object provided via the initializer and then delegates all missing methods to it. Let's see this in action:

1require 'delegate'
2
3User = Struct.new(:first_name, :last_name)
4
5class UserDecorator < SimpleDelegator
6  def full_name
7    "#{first_name} #{last_name}"
8  end
9end

First, we needed to require 'delegate' to make SimpleDelegator available to our code. We also used a Struct to create a simple User class with first_name and last_name accessors. We then added UserDecorator which defines a full_name method combining the individual name parts into a single string. This is where SimpleDelegator comes into play: since neither first_name nor last_name are defined on the current class, they will instead be called on the wrapped object:

1decorated_user = UserDecorator.new(User.new("John", "Doe"))
2decorated_user.full_name
3#=> "John Doe"

SimpleDelegator also lets us override delegated methods with super, calling the corresponding method on the wrapped object. We can use this in our example to only show the initial instead of the full first name:

1class UserDecorator < SimpleDelegator
2  def first_name
3    "#{super[0]}."
4  end
5end
1decorated_user.first_name
2#=> "J."
3decorated_user.full_name
4#=> "J. Doe"

Delegator

While reading the above examples, did you wonder how our UserDecorator knew which object to delegate to? The answer to that lies in SimpleDelegator's parent class—Delegator. This is an abstract base class for defining custom delegation schemes by providing implementations for __getobj__ and __setobj__ to get and set the delegation target respectively. Using this knowledge, we can easily build our own version of SimpleDelegator for demonstration purposes:

1class MyDelegator < Delegator
2  attr_accessor :wrapped
3  alias_method :__getobj__, :wrapped
4
5  def initialize(obj)
6    @wrapped = obj
7  end
8end
9
10class UserDecorator < MyDelegator
11  def full_name
12    "#{first_name} #{last_name}"
13  end
14end

This differs slightly from SimpleDelegator's real implementation which calls __setobj__ in its initialize method. Since our custom delegator class has no need for it, we completely left out that method.

This should work exactly like our previous example; and indeed it does:

1UserDecorator.superclass
2#=> MyDelegator < Delegator
3decorated_user = UserDecorator.new(User.new("John", "Doe"))
4decorated_user.full_name
5#=> "John Doe"

DelegateMethod

The last delegation pattern Delegate provides for us is the somewhat oddly named Object.DelegateClass method. This generates and returns a delegator class for a specific class, which we can then inherit from:

1  class MyClass < DelegateClass(ClassToDelegateTo)
2    def initialize
3      super(obj_of_ClassToDelegateTo)
4    end
5  end

While this may look confusing at first—especially the fact that the right-hand side of inheritance can contain arbitrary Ruby code—it actually follows the patterns we explored previously, i.e. it's similar to inheriting from SimpleDelegator.

Ruby's standard library uses this feature to define its Tempfile class which delegates much of its work to the File class while setting up some special rules regarding storage location and file deletion. We could use the same mechanism to set up a custom Logfile class like this:

1class Logfile < DelegateClass(File)
2  MODE = File::WRONLY|File::CREAT|File::APPEND
3
4  def initialize(basename, logdir = '/var/log')
5    # Create logfile in location specified by logdir
6    path = File.join(logdir, basename)
7    logfile = File.open(path, MODE, 0644)
8
9    # This will call Delegator's initialize method, so below this point
10    # we can call any method from File on our Logfile instances.
11    super(logfile)
12  end
13end

Forwardable

Interestingly enough, Ruby's standard library provides us with another library for delegation in the form of the Forwardable module and its def_delegator and def_delegators methods.

Let's rewrite our original UserDecorator example with Forwardable.

1require 'forwardable'
2
3User = Struct.new(:first_name, :last_name)
4
5class UserDecorator
6  extend Forwardable
7  def_delegators :@user, :first_name, :last_name
8
9  def initialize(user)
10    @user = user
11  end
12
13  def full_name
14    "#{first_name} #{last_name}"
15  end
16end
17
18decorated_user = UserDecorator.new(User.new("John", "Doe"))
19decorated_user.full_name
20#=> "John Doe"

The most noticeable difference is that delegation isn't automatically provided via method_missing, but instead, needs to be explicitly declared for each method we want to forward. This allows us to "hide" any methods of the wrapped object we don't want to expose to our clients, which gives us more control over our public interface and is the main reason I generally prefer Forwardable over SimpleDelegator.

Another nice feature of Forwardable is the ability to rename delegated methods via def_delegator, which accepts an optional third argument that specifies the desired alias:

1class UserDecorator
2  extend Forwardable
3  def_delegator :@user, :first_name, :personal_name
4  def_delegator :@user, :last_name, :family_name
5
6  def initialize(user)
7    @user = user
8  end
9
10  def full_name
11    "#{personal_name} #{family_name}"
12  end
13end

The above UserDecorator only exposes the aliased personal_name and family_name methods, while still forwarding to the first_name and last_name of the wrapped User object:

1decorated_user = UserDecorator.new(User.new("John", "Doe"))
2decorated_user.first_name
3#=> NoMethodError: undefined method `first_name' for #<UserDecorator:0x000000010f995cb8>
4decorated_user.personal_name
5#=> "John"

This feature can come in quite handy at times. I've successfully used it in the past for things like migrating code between libraries with similar interfaces but different expectations regarding method names.

Outside the Standard Library

Despite the existing delegation solutions in the standard library, the Ruby community has developed several alternatives over the years and we'll explore two of them next.

delegate

Considering Rails' popularity, its delegate method may well be the most commonly used form of delegation used by Ruby developers. Here's how we could use it to rewrite our trusty old UserDecorator:

1# In a real Rails app this would most likely be a subclass of ApplicationRecord
2User = Struct.new(:first_name, :last_name)
3
4class UserDecorator
5  attr_reader :user
6  delegate :first_name, :last_name, to: :user
7
8  def initialize(user)
9    @user = user
10  end
11
12  def full_name
13    "#{first_name} #{last_name}"
14  end
15end
16
17decorated_user = UserDecorator.new(User.new("John", "Doe"))
18decorated_user.full_name
19#=> "John Doe"

This is quite similar to Forwardable, but we don't need to use extend since delegate is directly defined on Module and therefore available in every class or module body (for better or worse, you decide). However, delegate has a few neat tricks up its sleeve. First, there's the :prefix option which will prefix the delegated method names with the name of the object we're delegating to. So,

1delegate :first_name, :last_name, to: :user, prefix: true

will generate user_first_name and user_last_name methods. Alternatively we can provide a custom prefix:

1delegate :first_name, :last_name, to: :user, prefix: :account

We can now access the different parts of the user's name as account_first_name and account_last_name.

Another interesting option of delegate is its :allow_nil option. If the object we delegate to is currently nil—for example because of an unset ActiveRecord relation—we would usually end up with a NoMethodError:

1decorated_user = UserDecorator.new(nil)
2decorated_user.first_name
3#=> Module::DelegationError: UserDecorator#first_name delegated to @user.first_name, but @user is nil

However, with the :allow_nil option, this call will succeed and return nil instead:

1class UserDecorator
2  delegate :first_name, :last_name, to: :user, allow_nil: true
3
4  ...
5end
6
7decorated_user = UserDecorator.new(nil)
8decorated_user.first_name
9#=> nil

Casting

The last delegation option we'll be looking at is Jim Gay's Casting gem, which allows developers to "delegate methods in Ruby and preserve self". This is probably the closest to the strict definition of delegation, as it uses Ruby's dynamic nature to temporarily rebind the receiver of a method call, akin to this:

1UserDecorator.instance_method(:full_name).bind(user).call
2#=> "John Doe"

The most interesting aspect of this is that developers can add behavior to objects, without changing their superclass hierarchies.

1require 'casting'
2
3User = Struct.new(:first_name, :last_name)
4
5module UserDecorator
6  def full_name
7    "#{first_name} #{last_name}"
8  end
9end
10
11user = User.new("John", "Doe")
12user.extend(Casting::Client)
13user.delegate(:full_name, UserDecorator)

Here we extended user with Casting::Client, which gives us access to the delegate method. Alternatively, we could have used include Casting::Client inside the User class to give this ability to all instances.

Additionally, Casting provides options for temporarily adding behaviors for the lifetime of a block or until manually removed again. For this to work, we first need to enable delegation of missing methods:

1user.delegate_missing_methods

To add behavior for the duration of a single block, we can then use Casting's delegating class method:

1Casting.delegating(user => UserDecorator) do
2  user.full_name #=> "John Doe"
3end
4
5user.full_name
6#NoMethodError: undefined method `full_name' for #<struct User first_name="John", last_name="Doe">

Alternatively, we can add behavior until we explicitly call uncast again:

1user.cast_as(UserDecorator)
2user.full_name
3#=> "John Doe"
4user.uncast
5NoMethodError: undefined method `full_name' for #<struct User first_name="John", last_name="Doe">

While slightly more complex than the other presented solutions, Casting provides a lot of control and Jim demonstrates its various uses and more in his Clean Ruby book.

Summary

Delegation and method forwarding are useful patterns for dividing responsibilities between related objects. In plain Ruby projects, both Delegator and Forwardable can be used, whereas Rails code tends to gravitate towards its delegate method. For maximum control on what is delegated, the Casting gem is an excellent choice, though it's slightly more complex than the other solutions.

Guest Author Michael Kohl’s love affair with Ruby started around 2003. He also enjoys writing and speaking about the language and co-organizes Bangkok.rb and RubyConf Thailand.

Share this article

RSS

AppSignal monitors your apps

AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!

Discover AppSignal
AppSignal monitors your apps