This article targets Rails 2.3 Rails 3
The information contained in this page might not apply to different versions.
This is article is part of my series Understanding Ruby and Rails. Please see the table of contents for the series to view the list of all posts.
If you looked into the changes in Ruby 1.9, chances are that you already noticed Object
is no longer the class at the top of the object-hierarchy in Ruby.
ruby-1.8.7-p249 > String.ancestors
# => [String, Enumerable, Comparable, Object, Kernel]
ruby-1.9.1-p378 > String.ancestors
# => [String, Comparable, Object, Kernel, BasicObject]
Prior 1.9, every Ruby object is a child of Object
or, from an other point of view, Object
is the parent of every object including Class
and Modules
.
ruby-1.8.7-p249 > String.ancestors
# => [String, Enumerable, Comparable, Object, Kernel]
ruby-1.8.7-p249 > String.class
# => Class
ruby-1.8.7-p249 > String.class.ancestors
# => [Class, Module, Object, Kernel]
ruby-1.8.7-p249 >
In Ruby 1.9 the grandpa role is played by a new special object called BasicObject
. BasicObject
can be considered a minimalist Object
, an object with a very few defined methods.
ruby-1.9.1-p378 > BasicObject.instance_methods
=> [:==, :equal?, :!, :!=, :instance_eval, :instance_exec, :__send__]
Proxy Pattern and Dynamic Delegation
For the most part of Ruby code, this change won't mean anything special. However the BasicObject
plays a fundamental role to implement the proxy pattern and dynamic delegation.
Let me show you an example. Let's assume you need to log all method calls made to a bank Account
object without changing the original Account
definition. You can create a new class to wrap the Account
object, log any method call then forward the request to the target object.
Here's a super simple bank Account
.
class Account
def initialize(amount)
@amount = amount
end
def deposit(amount)
@amount += amount
end
def withdraw(amount)
@amount -= amount
end
def balance
@amount
end
def inspect
puts "Amount: #{@amount}"
end
end
and here's our AccountLogger
class.
class AccountLogger
def initialize(amount)
@operations = []
@target = Account.new(amount)
@operations << [:initialize, [amount]]
end
def method_missing(method, *args, &block)
@operations << [method, args]
@target.send(method, *args, &block)
end
def operations
@operations
end
end
The AccountLogger
is our Proxy object. It doesn't know anything about the target object, in fact it limits itself to log method calls and forward the request to the underlying @target
using the method_missing hook.
Let's run a few operations on a new bank Account
.
account = AccountLogger.new(10)
account.operations
# => [[:initialize, [10]]]
account.balance
# => 10
account.operations
# => [[:initialize, [10]], [:balance, []]]
account.deposit(20)
account.balance
# => 30
account.operations
# => [[:initialize, [10]], [:balance, []], [:deposit, [20]], [:balance, []]]
So far, everything seems to work as expected. The AccountLogger
is executing all the operations without complaining and it also logs every single operation in the internal stack.
But let's try something different. Let's print the human-readable representation of the Account
.
account.inspect
# => Amount: 30
"#"
account.operations
[[:initialize, [10]], [:balance, []], [:deposit, [20]], [:balance, []]]
Hey, it doesn't work! Not only it returns a messy response, it also forgot to log the call. The problem is that #inspect
is actually defined in our AccountLogger
.
#inspect
is an instance method of the Object
class and because every object inherits from Object
, then our AccountLogger
responds to #inspect
. The method_missing
is never triggered and our proxy miserably fails to work.
Here we have a serious method clashing problem. Because we can't change our Account
implementation, we need to manually remove every method in the AccountLogger class which might conflict with the Account
object.
class AccountLogger
undef_method :inspect
# ...
end
This is an error-prone approach, usually not applicable for libraries with a complex API.
Here comes the BasicObject
. BasicObject
actually prevents this issue by exposing a limited set of instance methods. Simply put, it copes with the problem from a different point of view: instead of taking a rich object and removing all the unnecessary methods, take a poor object and only define what you actually need.
At this point, we can change the AccountLogger
to inherit from BasicObject
.
class AccountLogger < BasicObject
# ...
end
If you are looking for a real-world example, check out the Refactoring & Dynamic Delegator video from Railscasts.
What about BasicObject in Ruby 1.8?
There have been plenty of hacks and attempts to create a BasicObject
in Ruby 1.8 with many different approaches: Sequel::BasicObject, HTTParty BasicObject and Builder::BlankState.
The latter is by far the most famous BlankObject implementation for Ruby 1.8. AFAIK it has been the real first attempt to introduce a basic object in the Ruby community and, if remember well, the Ruby 1.9 core implementation was largely inspired by the Builder
library.
Recently, a proposal have been made to back-port BasicObject
to Ruby 1.8.8. Here's Matz comment:
I see very little (virtually no) chance for the idea, that even enhance the gap between 1.8 and 1.9.
BasicObject and Rails
At this point, it's time to talk a bit about Rails. After all, this series is called understanding Ruby and Rails. As you might guess, ActiveSupport provides its own implementation of the BasicObject
called ActiveSupport::BasicObject
.
Actually, in Rails 2.3.5 it doesn't create anything new. It simply returns a modified instance of BasicObject
or Builder::BlankSlate
depending on whether the Ruby version is 1.9 or not.
The library has been modified in Rails 3 dropping the Builder
dependency. That said, in Ruby 1.8.7 you can define a BasicObject
-like object in 4 lines of code:
class BasicObject
instance_methods.each do |m|
undef_method(m) if m.to_s !~ /(?:^__|^nil?$|^send$|^object_id$)/
end
end
As usual, if you need to inherit from a BasicObject
in your Rails code, use ActiveSupport::BasicObject
. In this way you don't have to reinvent the wheel again and you can safely rely on a cross-version implementation.