I have been looking forward to reading Sandi Metz’s “Practical Object-Oriented Design In Ruby” since I heard she was writing it. The LA Ruby Study Group has chosen it as our next book, so I’ll have some folks to discuss it with. But I still want record some ideas I have been struggling with as I read.
First, I am surprised that Sandi manages to be so thought-provoking with such concise examples. Chapters 2 and 3 revolve around a code example that contains about 50 lines of code. But she still manages to create several plausible alternative implementations, each with it’s own advantages and faults. Her examples remind me of problems I have run into in other code. More importantly, the book offers ideas for for refactoring such messes - but with the following caution against over-engineering:
Do not feel compelled to make design decisions prematurely. … When the future cost of doing nothing is the same as the current cost, postpone the decision. Make the decision only when you must with the information you have at the time.
Chapters 2 & 3 - Constructing Objects
Depend on behavior, not data.
Concretely this usually amounts to accessing data/attributes via their
getters (and setters). At first that seems a bit high-ceremony for
Ruby - but being Ruby, it really isn’t. If you don’t need the getter
to do anything special, you can create it with attr_reader
:blah
. 99% of the time the result of the method #blah is just going
to be @blah. When when you find something in that last 1%, it is great
that the only refactoring you need to do is to define a more complex
getter #blah.
If you have some data that needs to travel together but isn’t really
enough to warrant its own class (yet), then use a ruby Struct
to
make bundle it up - with named attributes to make its meaning clearer.
Reactor to reveal intent
The book is filled with gems like this one - after a section demonstrating several very small refactoring:
Do these refactoring even when you don’t know the ultimate design. They are needed, not because the design is clear, but because it isn’t. You do not have to know where you’re going to use good design practices to get there. Good practices reveal design.
Isolate dependencies
One of the best ways to reduce coupling between classes is though dependency injection. Where possible, pass in the things you depend on as parameters. One immediate pay off for this is that it makes your testing easier. Instead of using mocks and stubs to intercept method calls while running your tests, you can just pass in an appropriately constructed fake that provides just enough support so you can write your tests. For example, if the current test depends on data from the class you are depending on, instead of passing in the entire object, pass in a Struct containing the data you need for the current test.
Sometimes it isn’t feasible to refactor to use dependency injection. When your code already has some issues with tight coupling, you may not be able to fully extract a hidden object right away - or you may not be able to change the class’s initialization signature without breaking a ton of other things. So the book shows examples of using a wrapper to initialize your object using the interface you wish you had - or of isolating the methods that are making you wish you had a separate, dependent object so they are ready to extract when you can (p 32).
Sandi also showed an example of creating a method in your class whose entire purpose is to wrap a call made on a dependent object. This can be particularly useful if that dependent object is in active development and frequently changes its method signatures - or if you are afraid that call to the external dependency will be overlooked within a much larger method (p 50).
Using hashes for initialization (and merging them with a hash of default attributes) is very useful. It frees you from trying to recall a order for the initialization parameters and helps instance creation code serve as some of the documentation about what the object contains.
Chapter 4 - Creating Flexible Interfaces
Once your object has a single responsibility, then you need to work on giving it an optimal interface.
Object-oriented applications are defined by the messages that pass between objects.
This chapter focuses on how to determine if your messages are right: are you sending the right messages? and are you sending them to the right receiver? On the sending side, the message should specify what it wants, not how the receiving object should behave. If the sender is doing a lot of micro-management, then perhaps the sender needs to fully delegate to the receiver. If the receiver does not have all the knowledge to take care of the delegated request, that may be a sign that you need some other intermediate object that manages the request.
Context
The things that Trip knows about other objects make up its context…. The context that an object expects has a direct effect on how difficult it is to reuse…. Objects that have a complicated context are hard to use and hard to test; they require complicated setup before they can do anything.
I recognize the complicated setup code smell but I hadn’t explicity thought about having a lot of context in terms of an object knowing too much about it’s collaborators. Does your class make a bunch of calls to methods in other objects? If so, even if you have minimized coupling by using dependency injection, your object knows the names of many methods in its collaborators - and may need to know a lot about the parameters for those methods. The second refactoring of this chapter (fig 4.7 on p 72) gives an example of how to reduce what a trip needs to know about its collaborator, the mechanic. Instead of handing the mechanic individual bicycles and asking him to prepare them, the trip just tells the mechanic to make the preparations it needs to make for this trip. This is how you move to specifying what you want done, not how you want it done - but increasing the trust with which one object delegates to another.
The examples in the book are great, but I do have one question about the example on p 72, figure 4.7. Doesn’t passing the trip instance along to the mechanic as the argument to the prepare method potentially increase the coupling between the trip and mechanic classes? Not really - it merely changes which object is in control. One of the two classes needs to know that they collaborate around preparing bicycles. In the initial code, the trip knows about bicycles and it knows that the mechanic needs to prepare them. In the final example, the mechanic knows it is responsible for preparing bicycles and asks the trip to hand them over. The point of the trip passing ‘self’ when calling the mechanic’s prepare method is 1) it is a form of dependency injection that facilitates isolated testing and 2) if sometime later the mechanic’s preparations change to need more information from my_trip than just the list of bicycles, then we don’t have to add additional parameters to my_trip’s call to my_mechanic#prepare. When I first saw that it felt like the mechanic instance suddenly had a much closer relationship with EVERYTHING about a trip, but in practice, my_mechanic could always have queried my_trip for that information anyway using my_trip’s public interface. Passing the trip instance into my_mechanic encourages the mechanic class to access what ever information it needs from my_trip via that injected dependency.
Perhaps I am so wowed by POODR because it seems to anticipate the exact difficulties I have. The very next section, “Trusting Other Objects”, directly addresses my unease with example 4.7 and points out that now what trip is full delegating the bicycle preparations to the mechanic, you could use the same strategy to delegate different preparations to other classes - using the exact same interface. For example, you could loop over an array of collaborators and call prepare(self) on each.
This blind trust is a keystone of object-oriented design. It allows objects to collaborate without binding themselves to context and is necessary in any application that expects to grow and change.
So I guess the answer is that I just must get comfortable with this design paradigm, sometimes summarized as “Don’t ask, tell”.
Law of Demeter
The last section of the chapter discusses how to fix long message chains (Law of Demeter violations) using a message passing perspective. Long method chains are problematic because they tie your object to specific public methods of several other objects. This increases the chances that your object may need to change because of changes in a distant object.
The train wrecks of Demeter violations are clues that there are objects whose public interfaces are lacking.
Instead of using the existing public interfaces of the intermediate objects to construct these long chains, you need to figure out what additional public interfaces you need.
Focusing on messages reveals object that might otherwise be overlooked. When messages are trusting and ask for what the sender wants instead of telling the receiver how to behave, objects naturally evolve public interfaces that are flexible and reusable in novel and unexpected ways.
Chapter 5 - Duck Typing
Methods that check kind_of?
or responds_to?
before sending a
message are both indications that your object doesn’t trust its
collaborators to do the right thing. When you see this, you know you
are a missing an abstraction which would unify your
collaborators. When you have discovered this abstraction, sometimes it
is sufficient to add a single method to each of the collaborators. In
Sandi’s example, each collaborator class got a prepare_trip
method
in which their part of the trip preparations could be defined. Then
instead of trip micro-managing the preparations, it can just call
prepare_trip on each of its collaborators and let them take care of it.
Chapter 6 - Acquiring Behavior Through Inheritance
In Ruby you can affect an object’s method lookup tree (aka inheritance
hierarchy) in a couple of ways. You can create a Class -> SubClass
relationship. You can use extend
and include
to add modules. Or
you may add methods to a class’s Singleton class. There are some
differences (e.g. you can not create an instance of a module, only a
class) but to a first order approximation, these three things are the
same. All of them add methods which can be found automatically by your
object. If you set up these inheritance relationships correctly,
that’s great. But done incorrectly it’s a recipe for unexpected
failures. Fortunately Sandi provides some great advice on how to stay
out of trouble.
First, how do you know you need subclasses? One clue is often having a
variable called type
or category
and methods that check the value
of that variable to decide what to do. Sandi’s first piece of advice
is to take note of this sign - but to wait until your category or list
gets a third member before refactoring to use inheritance. Having more
examples makes it easier for you to figure out what behavior should be
in the parent class and what is specific to the subclasses. When you
have enough information to create your class hierarchy, create the
super class as an empty class and have your existing class inherit
from it. Then start fleshing out your other subclasses. Any time your
second subclass needs a method (or version of a method) that is in
your original class (now considered your first subclass), refactor the
method to move the shared behavior up to the superclass. If you are
rigorous about only moving abstract behavior up into the superclass,
you avoid much unnecessary overriding of methods to work around an
imperfect abstraction in your superclass.
Template Method Pattern
One thing that often differs between different subclasses are the defaults; in the example in chapter 6, road bikes and mountain bikes have different default tire sizes. So each subclass will need to have a default_tire_size method with a different value. In addition, it is important that the parent class also have a default_tire_size method - even if all it does is raise a NotImplementedError. This is important so that any additional bike types you create will immediately implement the shared Bicycle behavior.
If RecumbentBicycle is a Bicycle, then by some perspectives the two
classes are by definition tightly coupled. But you should still employ
techniques to spare your subclass from having to know details of how
its superclasses implement methods it wants to extend. A class’s
initialize
method is one that subclasses often need to override. And
a common mistake is to forget to call super
at the appropriate point
in your subclass’s initialize
method.
…forcing a subclass to know how to interact with its abstract superclass causes many problems. It pushes knowledge of the algorithm down into the subclasses, forcing each to explicitly send
super
to participate. It causes duplication of code across subclasses, requiring that all sendsuper
in exactly the same places. And it raises the chance that future programmers will create errors when writing new subclasses, because programmers can be relied upon to include the correct specializations but can easily forget to sendsuper
.
Hook Messages
One way around the super
problem is for the superclass to send hook
messages at appropriate integration points. If a subclass needs to add
or modify the behavior of the superclass, it can implement an
appropriate hook method. As noted above, the superclass must always
have an implementation any shared methods; though usually the
superclass’s hook method is just a no-op.
A similar pattern when dealing with shared and specialized data is to
have the shared attributes defined in the parent class, e.g. in the
spares
method in the book’s example. Then each subclass overrides the
method to add additional attributes. Again this can be an invitation
to forget to merge the shared data from the parent class with your
specializations. A safer pattern is for the parent class to manage the
shared data - and to manage melding in the specialized data from each
subclass. In our spares example, the parent class declares the
spares
method with all the shared information. Then it calls
local_spares
to get any additional data and merges it into the
method’s output. So instead of declaring it’s own spares
method,
the subclass adds specialized data by implementing local_spares
.