CNK's Blog

Notes on 'Practical Object-Oriented Design In Ruby'

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.

    class Bicycle
      def default_tire_size
        raise NotImplementedError, "Instances of #{self.class} cannot respond to default_tire_size"
      end
    end

    irb> RecumbentBicycle.new.default_tire_size
      NotImplementedError:
        Instances of RecumbentBicycle cannot respond to: 'default_tire_size'

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 send super 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 send super.

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.