You’ve probably heard the advice to prefer composition over inheritance. But to implement this in composition can mean writing a lot of tedious, repetitive forwarding methods. Fortunately, Ruby has a shortcut.
Episode Script
One of the most important concepts in object-oriented programming is composition. Composition enables an object’s behavior to be implemented in terms of other collaborator objects.
For instance, here’s a User class. In order to work with multiple sources of user authentication info, it delegates certain fields, like first_name
, last_name
, and email_address
, to an “account” collaborator.
class User
attr_reader :account
def initialize(account)
@account = account
end
def first_name
account.first_name
end
def last_name
account.last_name
end
def full_name
"#{first_name} #{last_name}"
end
def email_address
account.email_address
end
end
The account may be any of several different account types. For instance, a Github account, or a Facebook account. For these examples I’m just using simple structs, but in a real app these might have substantially different implementations from each other.
GithubAccount = Struct.new(:uid, :email_address, :first_name, :last_name)
FacebookAccount = Struct.new(:login, :email_address, :first_name, :last_name)
When we instantiate a user account, we pass an instance of an account object, and the user’s methods use that account object internally. We can say that the user is composed of an account plus it’s own added behavior.
avdi = User.new(GithubAccount.new("avdi", "[email protected]", "Avdi", "Grimm"))
avdi.full_name # => "Avdi Grimm"
That definition of User
is awfully verbose, though, as well as repetitive. Most of the methods are simply delegating directly to an identically-named method on the account
attribute.
The Ruby standard library gives us a tool to clean up this repetition. It’s called “forwardable”.
To use it, We require the ‘forwardable’ library, and extend the User
class with the Forwardable module. Take note that we use extend
here, not include
. Then, we can use the new def_delegators class method to declare which methods are delegated to the account
.
require 'forwardable'
class User
attr_reader :account
extend Forwardable
def_delegators :account, :first_name, :last_name, :email_address
def initialize(account)
@account = account
end
def full_name
"#{first_name} #{last_name}"
end
end
This substantially trims down the class. It also ensures that if we ever change the name of the account
attribute, we won’t have to update it in so many places.
Now, what if we didn’t want to expose account
as a readable attribute? What if we wanted to keep it as a private instance variable only? That’s fine; we need only update the def_delegators
declaration to use the name of the instance variable, including the ‘@
‘ sign.
require 'forwardable'
class User
extend Forwardable
def_delegators :@account, :first_name, :last_name, :email_address
# ...
end
In fact, the target of delegation can be anything which can be converted to a string and evaluated as code. So we can easily delegate to objects at a two-step remove from self. For instance, here’s a somewhat contrived example of a Store
class which delegates the owner_email
attribute to account attribute of the store’s owner
.
class Store
extend Forwardable
def_delegator '@owner.account', :email_address, :owner_email
def initialize(owner)
@owner = owner
end
end
avdi = User.new(GithubAccount.new("avdi", "[email protected]", "Avdi", "Grimm"))
store = Store.new(avdi)
store.owner_email # => "[email protected]"
This demonstrates three new concepts:
- Using an evaluatable string as the target of the delegation.
- The singular form def_delegator instead of def_delegators
- The use of the “alias” argument, which is only available in the
singulardef_delegator
method. Here, we alias the delegated
method to the nameowner_email
, instead of simply
email_address
.
Of course, like I said this is pretty contrived. We’ve already provided direct access to the user account email address, so we don’t actually need the two-step delegation here. We could just as well delegate email_address
to the owner
object and let it worry about where it gets an email address from.
class Store
extend Forwardable
def_delegator '@owner', :email_address, :owner_email
def initialize(owner)
@owner = owner
end
end
This version has less structural coupling between the Store
and User
classes, so it’s preferable from a design point of view.
If you use Rails, you may already be familiar with the similar delegate
method provided by ActiveSupport. While Forwardable
isn’t quite as featureful as delegate
, and has an arguably more awkward syntax, it’s available out of the box in any Ruby installation, so it’s a good library to know how to use. It’s especially handy when building gems, where adding dependencies to extension libraries like ActiveSupport
is generally frowned-upon.
OK, that’s it for today. Happy hacking!