In Progress
Unit 1, Lesson 1
In Progress

Ruby 2.0: Rebinding Methods

Video transcript & code

This episode is about a Ruby 2.0 feature I'm pretty excited about. Fair warning: we'll be getting into some esoteric stuff in this one. Today's subject matter may be of greater interest to framework and library writers than to people working at the application level.

There's been a lot of discussion in the Ruby community about the DCI paradigm. DCI stands for "Data, Context, Interaction". I'm not going to spend a lot of time in this episode explaining what DCI is all about, or even whether I think it's a good idea. We can save that for another time. Instead, I want to jump straight into the nuts and bolts of implementing DCI-style code.

DCI separates the concept of domain model objects and that of specific roles those objects may play. A common example is a bank account. A bank account has a current balance. It also may take part in transfers from one account to another. In the context of a given transfer, an account may play the role of either the source account or the destination account. Each role has unique logic associated with it; for instance a source account may need to check that it has sufficient funds before initiating the transfer.

class BankAccount
  attr_reader :balance_cents

  def initialize(starting_balance_cents=0)
    @balance_cents = starting_balance_cents
  end
end

# Roles:
# - Transfer Source
# - Transfer Destination

DCI says that rather than putting methods for these roles directly on the account object, we should break up responsibilities by representing the roles a model can play across separate traits. Among other arguments in favor of DCI, this approach keeps the model classes from growing perpetually larger as we add more roles that they may play.

One way we can implement DCI in Ruby is with modules and the #extend method. We can write a TransferSourceAccount with a #transfer_to method, and a TransferDestinationAccount with a #receive method.

Then, when it comes time to make a transfer, we can extend the source and destination accounts with the corresponding modules. Using #extend augments the individual objects, unlike include which augments a whole class. We can then use the added methods to complete the transaction.

module TransferSourceAccount
  def transfer_to(recipient, amount_cents)
    if @balance_cents < amount_cents
      raise "Insufficient funds"
    else
      @balance_cents -= amount_cents
      recipient.receive(amount_cents)
    end
  end
end

module TransferDestinationAccount
  def receive(amount_cents)
    @balance_cents += amount_cents
  end
end

source_account = BankAccount.new(100_00)
dest_account   = BankAccount.new(0)
source_account.extend(TransferSourceAccount)
dest_account.extend(TransferDestinationAccount)

source_account.transfer_to(dest_account, 50_00)
source_account.balance_cents    # => 5000
dest_account.balance_cents      # => 5000

One of the objections to this approach is that once an object has been extended with a module, it can never be un-extended. So there is no way to mix in a role and then remove it, or swap it out for another role, during the lifetime of that object. We'd have to create new account objects if we wanted them to play new roles.

Another concern is that at least on some Ruby implementations, calling #extend can have a widespread performance impact, affecting more than just the objects being extended, because of how it invalidates method lookup tables.

Let's look at a second approach. This time, rather than modeling the roles using modules, we represent them with classes inheriting from SimpleDelegator. This standard library base class acts as a forwarding proxy. Any messages sent to it that it doesn't implement, it will forward on to a target object specified on construction. In effect, these objects will "wrap" the base BankAccount objects and add some new methods on top.

A potential downside of this technique is that the wrapper objects are separate objects from the wrapped domain models. When interrogated they report two different classes, and comparisons between wrapped object and the wrapper object will fail. This may cause problems in frameworks like Rails where knowledge of the class of an object is often used in implementing "convention over configuration".

A much bigger problem, however, is that since the added methods aren't actually being added to the model objects themselves, they have no access to private parts of those models. That means they can't call private methods, and they also can't access any instance variables. So this code as it stands now won't work. We'd have to expose a public interface for updating a bank account balance in order to make this work.

require 'delegate'

class TransferSourceAccount < SimpleDelegator
  def transfer_to(recipient, amount_cents)
    if @balance_cents < amount_cents # !> instance variable @balance_cents not initialized
      raise "Insufficient funds"
    else
      @balance_cents -= amount_cents
      recipient.receive(amount_cents)
    end
  end
end

class TransferDestinationAccount < SimpleDelegator
  def receive(amount_cents)
    @balance_cents += amount_cents
  end
end

account1 = BankAccount.new(100_00)
account2 = BankAccount.new(0)
source_account = TransferSourceAccount.new(account1)
dest_account   = TransferDestinationAccount.new(account2)

account1.class                  # => BankAccount
source_account.class            # => TransferSourceAccount
account1 == source_account      # => false
source_account.transfer_to(dest_account, 50_00)
# ~> -:17:in `transfer_to': undefined method `<' for nil:NilClass (NoMethodError)
# ~>    from -:40:in `<main>'

Now let's look at a third approach, one that is enabled by Ruby 2.0. First, we have to lay some groundwork. We add a DomainModel module which contains the concept of a current role. This role is expected to be a reference to a module. We define #method_missing on DomainModel such that when a domain model has a role set, any unrecognized methods calls will be routed to the role module if it has a public definition for that method.

How this forwarding is done is where the magic happens. We use #instance_method to get an UnboundMethod object. Then, we #bind this object to self. We then invoke the resulting bound method object.

This line is only possible in Ruby 2.0. In earlier versions of Ruby, there was a strict requirement that in order to bind an unbound method to an instance, that instance had to be of the same class as, or a inheritor of, the class or module the method originated from. But in Ruby 2.0 this requirement has been relaxed, and methods defined in a module can be rebound to any object in the system.

Moving onwards, we put in an else clause for the case when the unrecognized method is not defined on the role. We also add a #respond_to_missing? predicate matching the logic of #method_missing. Finally, we add a convenience method to help us temporarily bind a domain model object to a given role.

module DomainObject
  attr_accessor :role

  def method_missing(method_name, *args, &block)
    if role && role.public_method_defined?(method_name)
      role.instance_method(method_name).bind(self).call(*args, &block)
    else
      super
    end
  end

  def respond_to_missing?(method_name, include_all=false)
    if role && role.public_method_defined?(method_name)
      true
    else
      super
    end
  end

  def play_role(role)
    self.role = role
    yield(self)
  ensure
    self.role = nil
  end
end

We include our new DomainModel module into the BankAccount class, and revert the two role classes back to modules.

class BankAccount
  include DomainObject

  attr_reader :balance_cents

  def initialize(starting_balance_cents=0)
    @balance_cents = starting_balance_cents
  end
end

module TransferSourceAccount
  def transfer_to(recipient, amount_cents)
    if @balance_cents < amount_cents
      raise "Insufficient funds"
    else
      @balance_cents -= amount_cents
      recipient.receive(amount_cents)
    end
  end
end

module TransferDestinationAccount
  def receive(amount_cents)
    @balance_cents -= amount_cents
  end
end

To use this code, we tell our source account and our destination account to temporarily take on the corresponding roles, using our #play_role method. Then within the block we tell the source account to transfer funds to the destination account. Afterwards, we can see that the source account balance has been reduced, and the destination balance has increased.

Unlike when we used the module extension approach, once the transaction is done, neither account object retains its temporary role or the methods associated with it. And unlike the approach using SimpleDelegator, we worked directly with the domain model objects at all times, never with proxies. What we accomplished with this method rebinding technique is a little bit like mixing in a module, and then un-mixing it when we no longer need it.

source_account = BankAccount.new(100_00)
dest_account = BankAccount.new

source_account.play_role(TransferSourceAccount) do
  dest_account.play_role(TransferDestinationAccount) do
    source_account.transfer_to(dest_account, 25_00)
  end
end
source_account.balance_cents    # => 7500
dest_account.balance_cents      # => 2500
source_account.respond_to?(:transfer_to) # => false
dest_account.respond_to?(:receive)       # => false

This is highly experimental code, and I'm sure there are a number of objections that can be made to it, on performance grounds if nothing else. But it's an interesting example of the sort of things that are possible with Ruby 2. I'm looking forward to exploring more of the power of Ruby 2 in coming months. Happy hacking!

Responses