Decorator
Video transcript & code
We've all heard the recommendation that an object should have a single responsibility. For instance, here is a BankAccount
object. It has one major responsibility: track the account balance.
class BankAccount
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
This class is really simple: it has an account number and an account balance stored in cents. It provides methods for depositing and withdrawing funds.
It also customizes the #inspect
method, and aliases #to_s to be the same as #inspect
. This is something I like to do in my classes. In this case, the most important thing about the BankAccount is the current balance, so I want the string representation to clearly show the balance. In order to do so I'm using a string format, which we learned about in Episode 194 and Episode 195. I'm being US-centric here and assuming that the balance is held in US dollars.
Using the class is straightforward. We can deposit funds, and we can withdraw funds. Remember that the balance is stored in cents, so 1000 represents 10 dollars.
require "./bank_account"
account = BankAccount.new(123)
account.deposit 1000
account
# => #<BankAccount 123 ($10.00)>
account.withdraw 500
account
# => #<BankAccount 123 ($5.00)>
Now along comes a new requirement: in some cases, we need to keep an audit log of transactions. Here's a simple audit logger that's just enough to demonstrate the interface we need audited bank accounts to talk to. It has a #record
method that receives an account number, a symbolic representation of the action being recorded, the amount involved, and the ending balance.
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
At first, we might think to just add the auditing functionality to the BankAccount
class.
class BankAccount
attr_accessor :audit_log
def initialize(number)
@number = number
@balance_cents = 0
end
def deposit(amount)
@balance_cents += amount
if audit_log
audit_log.record(@number, :deposit, amount, @balance_cents)
end
end
def withdraw(amount)
@balance_cents -= amount
if audit_log
audit_log.record(@number, :withdraw, amount, @balance_cents)
end
end
def inspect
"#<%s %s ($%0.2f)>" %
[self.class, @number, @balance_cents / 100.0]
end
alias_method :to_s, :inspect
end
In both the #deposit
and #withdraw
methods, we add a conditional case for logging the transaction to an audit log.
Now when we use the account object and add an audit log, we can see the transactions being logged:
require "./bank_account_with_auditing"
require "./audit_log"
account = BankAccount.new(123)
account.audit_log = AuditLog.new
account.deposit 1000
account.withdraw 500
# >> 2014-03-25 16:29:03 -0400 #123 deposit 10.00 (10.00)
# >> 2014-03-25 16:29:03 -0400 #123 withdraw 5.00 (5.00)
But let's take a step back. BankAccount
now has more responsibilities. As always, it must track an account balance. But now it also has to keep track of whether it should be auditing. And if it should audit, it is responsible for recording its transactions to the audit log.
So much for the Single Responsibility Principle. If this is the way we respond to each new feature, pretty soon BankAccount
is going to have a whole raft of responsibilities.
If only there was a way to encapsulate the audit logging feature in its own object, while leaving BankAccount
to do what it does best: tracking an account balance.
Well, as it happens there is, and it's called a Decorator. Let's create a simple audit logging decorator.
The fundamental idea behind the decorator pattern is do "wrap" an object with a thin layer of extra functionality, while still having the same interface as the object being wrapped. Since we will be delegating calls to a wrapped object, a typical starting point for a Decorator is to start with an object which simply delegates all method calls to another object, and then build custom functionality on top of that.
Along those lines, we could start to build an AuditedAccount
by defining a #method_missing which simply forwards all method calls to another object.
class AuditedAccount
def initialize(object)
@object = object
end
def method_missing(method, *args, &block)
@object.public_send(method, *args, &block)
end
end
However, there are a number of considerations we need to make when building robust delegation in Ruby. For instance, we should also define a #respond_to_missing
callback. And what if this object is queried for its list of methods? It should take into account methods defined on the delegation target. And so on, and so forth.
Instead of writing all of this from scratch, let's lean on the Ruby standard library.
We'll start by require the delegate
library. Then we create out AuditedAccount
as a child of the SimpleDelegator
class. SimpleDelegator
gives us robust delegation capabilities for free.
In our initializer, we accept both the vanilla account
object, and an audit_log
. We then call super
with the account
as the sole argument. SimpleDelegator
provides a single-argument initializer which accepts the object which will be the target of delegation.
Our initializer also saves the audit_log
into an instance variable for later use.
Next we define a #deposit
method which takes an amount, just like the one on BankAccount
. It starts by invoking super
. This falls back to the default SimpleDelegator
behavior, which is to forward the call on to the delegate object. Note that we use a "bare" super
with no parens to pass all parameters through; if you're not familiar with this technique check out Episode 14.
After the call to super
, we augment the delegated behavior with auditing by sending the record
message to the audit log. In order to complete this message, we need the account number and balance from the wrapped bank account. In order to make give the AuditedAccount
access to these attributes, we go back to the BankAccount
class and generate attribute accessor methods for them.
class BankAccount
attr_accessor :number, :balance_cents
end
Returning to the AuditedAccount
decorator, we can now treat those new accessor methods as if they were local methods, since any unrecognized method calls will be automatically forwarded to the target object. We fill in the @audit_log.record
message to include the account number, the action type, the amount, and the ending balance.
Next, we do the exact same thing for the withdraw
method. We delegate to the original bank account object using super
, and then send a #record
message to the audit log.
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
Here's how it looks in use. We instantiate a bank account. Then we instantiate an AuditedAccount
, giving it the bank account and a new audit log object. When we deposit funds and withdraw funds, we can see that the actions are logged. When we inspect the resulting account object, it we can see the account number and balance appear just as before.
require "./bank_account"
require "./audited_account"
require "./audit_log"
account = BankAccount.new(123)
account = AuditedAccount.new(account, AuditLog.new)
account.deposit(1000)
account.withdraw(500)
# >> 2014-03-25 19:55:48 -0400 #123 deposit 10.00 (10.00)
# >> 2014-03-25 19:55:48 -0400 #123 withdraw 5.00 (5.00)
2014-04-25 13:33:26 -0400 #123 deposit 10.00 (10.00) 2014-04-25 13:33:26 -0400 #123 withdraw 5.00 (5.00)
Notice how for all intents and purposes, including our customized #inspect
method, this decorated account object behaves just like a bare BankAccount
object. That's the essence of a Decorator: add a little functionality on top, without changing the object's interface.
When we look at the objects we wound up with, the responsibilities are still nicely separated. Bank accounts are still just responsible for maintaining balances. Audited bank accounts are responsible for adding audit logging to a bank account, without worrying about how to maintain a balance.
What about the responsibility to decide whether an account should be audited? That's the responsibility of our top-level code, which makes the decision to wire together a BankAccount
with an AuditLog
using an AuditedAccount
object. The BankAccount
remains blissfully unaware.
The Decorator pattern is a great choice when we need to add a little extra functionality without the close collaboration of the object being extended. And Ruby's SimpleDelegator base class is a great way to quickly construct decorators. That's all for today. Happy hacking!
Responses