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