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
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
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!