In a procedural language, behavior and data are separate. Data gets packages into variables and pass around to the behaviors. But an OO language combines them into what we refer to as an object. Objects have behavior and may contain data. They invoke behavior in one another by sending each other messages. For code to be agile, it must be easy to change. Change to meet future demands of our application.
Code that is easy to change:
- Changes have no unexpected side effects
- Small changes in requirements require correspondingly small changes in code
- Existing code is easy to reuse
- The easiest way to make a change is to add code that in itself is easy to change.
Code should be:
- Transparent: The consequences of change should be obvious in the code that is changing and in distant code relies upon it
- Reasonable: The cost of any change should be proportional to the benefits the change achieves
- Usable: Existing code should be usable in new and unexpected contexts
- Exemplary: The code itself should encourage those who change it to perpetuate these qualities
The best way to meet these criteria is to make sure your classes each have a single, well defined responsibility. Classes should do the smallest possible useful thing.
class Gear
attr_reader :chainring, :cog
def initialize(chainring, cog)
@chainring = chainring
@cog = cog
end
def ratio
chainring / cog.to_f
end
end
puts Gear.new(52, 11).ratio # -> 4.72727272727273
puts Gear.new(30, 27).ratio # -> 1.11111111111111
The class above has two instance variables, and the only method uses both. It is a good representation of a simple class that has a single responsibility. We could determine its single responsibility by asking the class about its behaviors, “Mr. Gear, what is your ratio?” is a great question, “Mr. Gear, what is your tire size?” is not! If the gear class was able to respond to the tire size question, it has too many responsibilities. Responsibilities that may get caught up against other reasonable questions such as "what is your ratio?". We don't want our ratio to change when the tire size changes, we don't want a ArgumentError: wrong number of arguments
error when we ask about the ratio and didn't provide a tire size. Good practice is to try and describe the class in one sentence.
Cohesion:
The sticking together of particles of the same substance. Classes have high cohesion when they are also of a single responsibility. When everything that the class does, is highly related to its purpose.
Coupling:
The manner and degree of interdependence between software modules; a measure of how closely connected two routines or modules are; the strength of the relationships between modules.
Wrap instance variables in accessor methods instead of directly referring to them. Hide variables, even from classes that define them. attr_reader
is an easy way to create a encapsulating method.
class Gear
attr_reader :chainring, :cog
def ratio
chainring / cog.to_f # <-- good
end
def ratio
@chainring / @cog.to_f # <-- bad
end
end
Changing the instance variables to methods allows for changes when the class changes. If we have to take into account a tire
for some reason, we can make the changes very easily instead of having to change the @cog
instance variable everywhere that its written.
def cog
tire? ? tire * @cog : @cog
end
This also helps in a more abstract way. Because it’s possible to wrap every instance variable in a method and to therefore treat any variable as if it’s just another object, the distinction between data and a regular object begins to disappear. While it’s sometimes expedient to think of parts of your application as behavior-less data, most things are better thought of as plain old objects.
Overly complex instance variables leads to multiple issues, the referencing code has to know more of the inner structure of the instance variable to access the data it needs. Code that received an instance variable that looked like [[622, 20], [622, 23], [559, 30]]
would need to know that there is an inner array, then call upon those indexes for it values. Code such as @nested_array.each {|array| tire_sizes << array[1]}
would have to be everywhere in order to access the array's data. The references are leaky, they would escape encapsulation, and eventually become a maintenance nightmare. It would be practical to use Struct
class to organize our complex data.
Wheel = Struct.new(:rim, :tire)
def wheelify(data)
data.collect {|cell| Wheel.new(cell[0], cell[1])}
end
def gear_inches
ratio * wheel.diameter
end
Wheel = Struct.new(:rim, :tire) do
def diameter
rim + (tire * 2)
end
end
Now we have an array of Structs
that we could call wheel.tire
on. Ruby defines Struct as “a convenient way to bundle a number of attributes together, using accessor methods, without having to write an explicit class.”. If the input changes, the only place to change the code is in this one place. This allows it to be more readable and intention revealing. We can also define Struct
methods by using the syntax in the lower portion of the example. If our class has too many responsibilities, we can isolate them in a Struct
class, and move them into their own class when we are good and ready.
def gear_inches
ratio * (rim + (tire * 2)) # bad
end
def gear_inches
ratio * diameter # good
end
def diameter
rim + (tire * 2) # good
end
def gear_inches
ratio * Wheel.new(rim, tire).diameter
end
Wheel = Struct.new(:rim, :tire) do
def diameter
rim + (tire * 2)
end
end
The Wheel.new
instantiation in #gear_inches
is another example of a dependency that we could change. If class name Wheel
every changes, it must also change in our Gear class. When hard coded with Wheel
, the Gear
class can only calculate gear_inches
using the Wheel
class, it will not collaborate with any other object. The class doesn't need to know the name of the other class, It just needs an object that responds to #diameter
. It would be better to pass in the Wheel
class when we initialize the Gear
class Gear.new(52, 11, Wheel.new(26,1.5))
, then we could set this object as an instance variable and call our method on it. This is know as Dependency Injection, we are injecting our dependencies when we instantiate our class.
When we must have dependencies, we have to isolate them where it is easy to make minor changes, if the need were to ever arise. If we can not use dependency injection, we need to instantiate the class where it is easy to find.
def initialize
@foo = Foo.new
end
def bar
@bar ||= Bar.new
end
It is the same with sending messages to other classes. When we have multiple calls to and object, such as wheel.diameter
, it is best practice to isolate these calls to its own method.
def gear_inches
ivar * wheel.diameter # bad if more of these calls
end
def gear_inches
### math ###
foo = num * diameter # good
end
def diameter
wheel.diameter
end
If we are unable to change a class, it is possible to wrap the class up in a module to have change the interface we have to work with. Our GearWrapper
allows us to create a Gear
instance using a hash instead of having multiple dependencies on the ordering of the argument. This makes our GearWrapper
a type of factory. It is always ideal to wrap an external dependency in something that is owned by your own code.
class Gear
def initialize(cog, gear, sprocket, tire)
### assigns to respective ivars ###
end
end
module GearWrapper
def self.gear(args)
Gear.new(args[:cog], args[:gear], args[:sprocket], args[:tire])
end
end
It's best to focus on the messages, not the objects. If we fixate on the domain objects, we tend to coerce behavior onto them. Changing the fundamental design question from “I know I need this class, what should it do?” to “I need to send this message, who should respond to it?” is the first step in that direction. You don’t send messages because you have objects, you have objects because you send messages. Asks for what the sender wants instead of sending a message telling the receiver how to behave.
In the first sequence diagram, Trip
is telling Mechanic
how to behave. It's very procedural code. The second sequence diagram is more object-oriented. Trip
asks Mechanic
to prepare a Bicycle
. This lets Mechanic
have a smaller public interface. And in the last, Trip knows nothing about Mechanic but still manages to collaborate with it to get bicycles ready. Trip
merely tells Mechanic
what it wants, which is to be prepared, and passes itself along as an argument. Then Mechanic
knows the argument can respond to #bicycles
and is able to preform its duties. Mechanic class is saying "Hey, I'm a bicycle mechanic, I expect to be given bicycles".
This way, we can populate the Trip
's array with certain set of objects and have a preparer that knows how to prepare how to interact with those objects. You can extend Trip
without modifying it. Trip
trusts the preparing class that it knows how to get what it needs.
Message chains like customer.bicycle.wheel.rotate
occur when your design thoughts are unduly influenced by objects you already know. Your familiarity with the public interfaces of known objects may lead you to string together long message chains to get at distant behavior.Focusing on messages reveals objects 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. Its not the class of the object that matters, its what the object does.
class Trip
attr_reader :bicycles
def prepare(mechanic)
mechanic.prepare_bicycles(bicycles)
end
def plan
travel_agent.plan(self)
end
end
treat your objects as if they are defined by their behavior rather than by their class.
Polymorphism in OOP refers to the ability of many different objects to respond to the same message.