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.
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
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.
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.
delegate library defines more than just
SimpleDelegator, though. There is another option, called
DelegateClass. Let's define a variant of our
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
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
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
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!