Design Techniques: Duck Typing 🦆
You may already be familiar with the idea of public and private interfaces—they are the methods that are implemented within a class, and only the public ones are made available to other objects. But interfaces are not limited to single classes—some interfaces cut across multiple classes, and an object can have many interfaces.
We call these across-class interfaces duck types, and every duck-typed class responds to a set of the same messages. It is this set of messages themselves that define the duck type. Sandi Metz describes them further on page 61 of Practical Object-Oriented Design in Ruby:
It’s almost as if the interface defines a vitual class; that is, any class that implements the required methods can act like the interface kind of a thing.
Thinking about your application in terms of interfaces, including the more abstract duck types, is paramount to good object-oriented design.
An example of duck typing
In my Ruby implementation of a Battleship game, I’ve been using duck typing to make my application more flexible.
When the Battleship program is run, the first thing it does is allow the users to place their ships on a board. For the human players, this involves asking them for the initial location of a ship, and then for the direction of the ship (vertical or horizontal).
I handle the acquisition of these two pieces of data in separate classes: GetShipLocation
and GetShipDirection
:
class GetShipLocation
def perform(ship)
# ask the user for a location on the board
end
end
class GetShipDirection
def perform(ship)
# ask the user for a direction
end
end
The shared perform
methods comprise a duck type, which allows us to use these objects without caring which class they belong to, only that they respond to the perform
message. While the perform
methods share the same name, they do not share the same behaviour.
I make use of this perform
interface in the GetShipPlacementData
class:
class GetShipPlacementData
attr_reader :services
private :services
def initialize(services:)
@services = services
end
def perform(ship)
services.each_with_object({}) do |service, results|
result = service.perform(ship)
results.merge!(result)
end
end
end
services = [ GetShipLocation.new, GetShipDirection.new ]
get_ship_placement_data = GetShipPlacementData.new(services: services)
get_ship_placement_data.perform(Ship.new) #=> { ... }
When the instance of GetShipPlacementData
receives perform
with a Ship
object, it calls perform
on each service. It expects each perform
call to return a hash (or rather, it expects it to return an object that responds to merge!
). This expectation placed on the return value of perform
is also a part of the interface’s contract.
How to spot hidden duck types
Let’s rewrite the code above to rely on the objects’ classes rather than the duck type interface:
class GetShipLocation
def get_location(ship)
# ...
end
end
class GetShipDirection
def get_direction(ship)
# ...
end
end
These classes no longer share an interface via the perform
message, so GetShipPlacementData#perform
has to handle each object in its services
array differently:
class GetShipPlacementData
# ...
def perform(ship)
services.each_with_object({}) do |service, results|
result = case service
when GetShipLocation
service.get_location(ship)
when GetShipDirection
service.get_direction(ship)
end
results.merge!(result)
end
end
end
This is just one of many common code patterns that indicate a duck type would be a better solution. In fact, any time you find yourself asking an object what it is, you should consider your reasoning.
Other ways of asking an object about it’s class include sending it the kind_of
and is_a?
messages. responds_to?
is another method to watch out for:
class GetShipPlacementData
# ...
def perform(ship)
services.each_with_object({}) do |service, results|
result =
if service.responds_to?(:get_location)
service.get_location(ship)
elsif service.responds_to?(:get_direction)
service.get_direction(ship)
end
results.merge!(result)
end
end
end
In the example above, we may no longer be explicitly checking which class an object belongs to, but we are still very much expecting each object to be of a specific class.
Why use duck typing?
The code examples above that do not make use of duck types are brittle and prone to breaking as a result of changes made to distant parts of the code base.
This is because checking for an object’s class can introduce unnecessary dependencies. In the case statement example above, GetShipPlacementData
knows not only the name of two other classes, but also the names of two methods on those classes and what kind of arguments they expect. If any of these qualities change, GetShipPlacementData
would also have to be changed, or the perform
method would not work as expected. And even though the responds_to?
example is slightly better for not knowing about the GetShipLocation
and GetShipDirection
class names, it still knows too much about the individual method calls.
Furthermore, consider what it would take to extend these dependency-laden examples. If we wanted GetShipPlacementData
to perform another service, it would require adding yet another check to the case statement, or another if
statement to check whether the object responds to a particular message. Each addition would only further increase the number of GetShipPlacementData
’s dependencies.
The code that does make use of duck typing, however, is much more tolerant of change. Not only does it have fewer dependencies, but it is easily extendable—if we ever need to get another piece of information to place a ship, it would not be difficult to simply create and pass in another object that subscribes to this perform
interface.
For example, soon I am going to have a computer player that can place it’s own ships on the board; however, the process of getting each ship’s position is going to look very different to how it is done for a human player. Instead of printing out instructions and waiting for the responses from a user, a computer player will randomly select positions for its ships. To acheive this, a new instance of GetShipPlacementData
will simply accept an array of other service objects that still respond to perform
, but that return hashes of randomly selected position data:
class GenerateShipLocation
def perform(ship)
# randomly select a location on the board
end
end
class GenerateShipDirection
def perform(ship)
# randomly select a direction
end
end
class GetShipPlacementData
# nothing has changed here
end
services = [ GenerateShipLocation.new, GenerateShipDirection.new ]
get_ship_placement_data = GetShipPlacementData.new(services: services)
get_ship_placement_data.perform(Ship.new) #=> { ... }
If it feels like you are placing a lot of trust in the duck types to respond to a shared message in a particular way, you are right. But this is desired, and you should embrace that feeling. Trusting an interface shifts the sentiment of your code from knowing exactly how an object works and telling it how to behave, to telling it I don’t know who you are, but I trust you to do your job. It is your responsibility as a programmer to make sure these objects are trustworthy.