You’re an object – Stand up straight and act like one!
Imagine you have this code:
Code 1:
class MyView
attr_reader :target
def initialize(target)
@target = target
end
def double
case target
when Numeric then target * 2
when String then target.next # lazy example fail
when Array then target.collect {|o| MyView.new(o).double}
else
raise “don’t know how to double #{target.class} #{target.inspect}”
end
end
end
It does just what you’d expect.
Output 1:
>> MyView.new(‘aaa’).double
=> "aab"
>> MyView.new(49).double
=> 98
>> MyView.new([‘b’, 6]).double
=> ["c", 12]
>> MyView.new({‘x’=>‘y’}).double
RuntimeError: don’t know how to double Hash {"x"=>"y"}
from (irb):73:in `double'
from (irb):80
from :0
You’re probably familiar with this pattern. Its everywhere in Rails and you likely use it in your own code.
I want to say, in the nicest possible way, that this style of code is wrong, wrong, wrong and that you should do a different thing.
Okay, now that I have your attention, I’m not trying to start a fight. I’m not the best Ruby person around and I’m definitely not the best OO designer, but I do have an alternative pattern to suggest.
I’m aiming to start a discussion, not a religious war. Strong opinions are welcome.
What’s happening up there?
MyView needs to operate on several other objects. It knows:
- the classes of all the objects that it can interact with, and
- the behavior that should occur for each of those classes.
The case statement above is really an if
statement that checks ‘kind_of?’
on each collaborating object.
I object to this code because:
- use of
kind_of?
is a code smell that says your code is procedural, not object oriented, and - if you write procedural code your application will gradually become impossible to change and everyone will hate you.
Why is it wrong?
If I change how double
works on any of these classes, MyView
must change, but that’s not the real problem. What happens if MyView
wants to double some new kind of object? I have to go into MyView
and add a new branch to the case statement. How annoying is that?
But that’s the least of it. If I’m writing code that follows this pattern, I likely have many classes that do stuff based on the classes of their collaborators. My entire application behaves this way. Every time I add a new collaborating object I have to go change code everywhere. Each subsequent change makes things worse. My application is a teetering house of cards and eventually it will come tumbling down.
Also, what if someone else wants to use MyView
with their new SuperDuper
object? They can’t reuse MyView
without changing it since MyView
has a very limited notion of what kinds of objects can be doubled.
MyView
is both rigid and closed for extension.
What should I have done instead?
Something like this.
Code 2:
class Numeric
def double
self * 2
end
end
class String
def double
self.next
end
end
class Array
def double
collect {|e| e.double}
end
end
class Object
def double
raise "don't know how to double #{self.class} #{self.inspect}"
end
end
class MyView
attr_reader :target
def initialize(target)
@target = target
end
def double
target.double
end
end
Using this new code, Output 1 will be the same as before, but now we can also:
Output 2:
>> 'aaa'.double
=> "aab"
>> 49.double
=> 98
>> ['b', 6].double
=> ["c", 12]
In this example, objects are what they are and because of that they behave the way they do.
That statement is deceptively simple but incredibly important. Objects are what they are so they do what they do.
It is not the job on any object to tell any other object how to behave. Objects create behavior by passing messages back and forth. They tell each other what, not how.
What is the event/notification/request and it is the responsibility of the sender.
How is the behavior/implementation and it should be completely hidden in the receiver.
Code 2 is object oriented because it relies on a network of interacting objects to produce behavior. Each object knows its own implementation and it exhibits that behavior when it receives a message.
No object in your system should have to know the class of any other object in order to know how to behave. Everything is a Duck. Tell the Duck what and the Duck should know how.
This way you can change how any object is doubled by changing its own specific implementation. More importantly, MyView
can now collaborate with any kind of object, as long as the object implements double
.
This is a nice theory, but it seems impossible in practice.
Nah, it’s easy. But it means thinking about objects in a more Object Oriented way.
In order to write code like Code 2, you have to believe in objects.
In Ruby, everything is an object. I know that you know this, but I suspect that you don’t feel it in your bones. You came from those languages where you couldn’t change String
so now you operate as if String
(and Symbol
and Array
and …) are static types.
Throw off your shackles. Ruby is a network of interacting objects and you can add behavior to all of them. When you find yourself saying, if you’re this kind of thing, do this, but if you’re that kind of thing, do that, it’s your cue to push the behavior back into those individual objects.
Sound bites.
Always send messages if you can.
Implement behavior only when there’s no one left to ask.
The last object that could possibly receive the message is the object that should implement the behavior.
Therefore:
MyView
should not know howNumeric
implementsdouble
.MyView
should just ask target to double itself.- All possible targets must implement
double
. - Object should implement
double
to help you get your Ducks in a row.
Lest you think this is all academic…
Here’s the code to use this Pattern in some of the form_for methods in ActionView::Helpers
. Ponder the implications.
Diff of the changes.
actionpack/lib/action_view/helpers/form_helper.rb
actionpack/lib/action_view/helpers/prototype_helper.rb
actionpack/lib/action_view/helpers/form_helper_core_extensions.rb