In Progress
Unit 1, Lesson 21
In Progress

Delegate Class

Video transcript & code

Back in episode #197, we talked about the idea of a "decorator" object. We saw how we could use a decorator to add audit logging to a bank account object. In order to implement a decorator, we made use of Ruby's delegate library, and specifically the SimpleDelegator base class.

class AuditLog
  def record(number, action, amount, balance)
    printf(
      "%<time>s #%<number>s %<action>s %<amount>.2f (%<balance>.2f)\n",
      time:    Time.now,
      number:  number,
      action:  action,
      amount:  amount / 100,
      balance: balance / 100)
  end
end
class BankAccount
  attr_reader :number, :balance_cents

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

  def deposit(amount)
    @balance_cents += amount
  end

  def withdraw(amount)
    @balance_cents -= amount
  end

  def inspect
    "#<%s %s ($%0.2f)>" %
      [self.class, @number, @balance_cents / 100.0]
  end
  alias_method :to_s, :inspect
end
require 'delegate'
class AuditedAccount < SimpleDelegator
  def initialize(account, audit_log)
    super(account)
    @audit_log = audit_log
  end

  def deposit(amount)
    super
    @audit_log.record(number, :deposit, amount, balance_cents)
  end

  def withdraw(amount)
    super
    @audit_log.record(number, :withdraw, amount, balance_cents)
  end
end

By inheriting from SimpleDelegator, we get some handy functionality for free. We don't have to maintain an instance variable for the decorated object, because SimpleDelegator already takes care of that. When writing augmented versions of decorated methods, we can easily delegate back to the original version of the method with super, rather than having to spell out the method name and arguments each time.

Most importantly, SimpleDelegator ensures that any methods we don't override are still forwarded on to the wrapped object. So, for example, BankAccount objects have an account number. We haven't defined a number delegator method in the AuditedAccount class. But when we construct an AuditedAccount-decorated BankAccount, we can send it the number message and get the account number back. SimpleDelegator enables us to augment the methods we care about, and ignore all the others.

The delegate library takes care of a lot of other fiddly details too. For instance, if we compare the raw account and the decorated account for equivalence, the result is true. This helps ensure that delegator objects are usable as drop-in replacements for the raw object in as many contexts as possible.

require "./bank_account"
require "./audit_log"
require "./audited_account"

account = BankAccount.new(123456)
log = AuditLog.new

aa = AuditedAccount.new(account, log)

account.number                  # => 123456
aa.number                       # => 123456

aa == account                   # => true

There are limits to what SimpleDelegator can do, however. For example: let's ask the AuditedAccount class if it has an instance method called number. It says that it doesn't. This is because the class has no idea what kind of objects we're going to decorate. Only when we actually apply an AuditedAccount decorator to a real BankAccount can it know the complete list of methods that are available to be forwarded.

If you use development tools that try to suggest methods by using live Ruby introspection, this may render those tools a little less useful. It also may interfere with some forms of metaprogramming.

The delegate library defines more than just SimpleDelegator, though. There is another option, called DelegateClass. Let's define a variant of our AuditedAccount using DelegateClass.

The way we use DelegateClass looks a little weird. When we use DelegateClass we are actually invoking a global method, not a class constant. We provide the name of the class we intend to decorate as an argument to this method. Behind the scenes, this method invocation actually generates a new, anonymous base class for us at runtime. We can see this nameless class if we just use DelegateClass alone.

require "./bank_account"
require "delegate"

DelegateClass(BankAccount)
# => #<Class:0x007f9daf5f9c50>

So when we use DelegateClass, we are actually inheriting from this generated class.

require "delegate"
require "./bank_account"

class AuditedAccount2 < DelegateClass(BankAccount)
  def initialize(account, audit_log)
    super(account)
    @audit_log = audit_log
  end

  def deposit(amount)
    super
    @audit_log.record(number, :deposit, amount, balance_cents)
  end

  def withdraw(amount)
    super
    @audit_log.record(number, :withdraw, amount, balance_cents)
  end
end

Let's create an instance of our new audited account class. Our two audited account classes behave very similarly. We can wrap them around an account object, and both will respond to bank account methods such as number.

But the classes also have some differences. As before, when we ask the SimpleDelegator-based class whether its instances respond to number, it has no idea. But the DelegateClass-derived decorator knows about the number attribute, because it knows what class it is intended to wrap and was able to generate proxy methods ahead of time.

require "./bank_account"
require "./audit_log"
require "./audited_account"
require "./audited_account2"

account = BankAccount.new(123456)
log = AuditLog.new

aa1 = AuditedAccount.new(account, log)
aa2 = AuditedAccount2.new(account, log)

aa1.number                      # => 123456
aa2.number                      # => 123456

AuditedAccount.instance_methods.include?(:number)
# => false

AuditedAccount2.instance_methods.include?(:number)
# => true

There are drawbacks to this knowledge. For one thing, the DelegateClass has to have a dependency on the class it is intended to wrap, so we can't develop it or test it in isolation.

Another difference is in how they are optimized. If we compare the time it takes to send a delegated message, we can see that the DelegateClass version is slightly faster. This is because the SimpleDelegator version checks for the existence of the method on the target object each time a method is invoked. Whereas the DelegateClass version pre-defines proxy methods that can forego this check.

However, as you can see, this difference in speed is pretty small. As always, you should benchmark your own code before making decisions based on optimizations.

require "./bank_account"
require "./audit_log"
require "./audited_account"
require "./audited_account2"
require "benchmark"

account = BankAccount.new(123456)
log = AuditLog.new

aa1 = AuditedAccount.new(account, log)
aa2 = AuditedAccount2.new(account, log)

n = 1_000_000

Benchmark.bm(20) do |x|
  x.report("SimpleDelegator") do
    n.times do
      _ = aa1.number
    end
  end
  x.report("DelegateClass") do
    n.times do
      _ = aa2.number
    end
  end
end

# >>                            user     system      total        real
# >> SimpleDelegator        1.960000   0.030000   1.990000 (  2.028429)
# >> DelegateClass          1.720000   0.040000   1.760000 (  1.811097)

There's one other difference we might expect to see. Let's say we have a new kind of bank account that also has an interest rate.

require "./bank_account.rb"

class InterestAccount < BankAccount
  def interest_rate
    1.3
  end
end

If we take a DelegateClass decorator that was constructed based on the original BankAccount object, and wrap it around an interest-bearing account, we might think that it it will not be able to forward the interest_rate method, since it wasn't aware of that method when the class was defined.

But in fact it works just fine. This is because both SimpleDelegator and DelegateClass are built on the same foundation, and DelegateClass-generated classes just fall back to dynamic method discovery when forwarding to a method they don't know about.

require "./interest_account"
require "./audit_log"
require "./audited_account2"

account = InterestAccount.new(123456)
aa = AuditedAccount2.new(account, AuditLog.new)

account.interest_rate           # => 1.3
aa.interest_rate                # => 1.3

So in the end, how do we choose which delegator base to build on? If we know exactly the class we plan on wrapping, we might as well use DelegateClass and reap the benefits of slightly better performance and introspection. But if we to minimize dependencies, or we think that we will be decorating a range of similar classes rather than just a single type of object, SimpleDelegator is an easy choice.

And that's all for today. Happy hacking!

Responses