Table of Contents
Introduction
Recipe
State
New
Polymorphism
If
Naming
Documentation
Unit Testing
Refactoring
Conclusion
Cheat Sheet
Polymorphism
If I paint a fine shark upon this page, will you say, “Fine shark!”
or will you complain that it is flat and does not eat you?
Kaimu, The Codeless Code, Case 175
Polymorphism is the most effective way to make composable pieces that build into a flexible system. Organize code to leverage the polymorphism of the language.
Polymorphism is one of those Computer Science words that gets bandied about frequently, but is rarely explained. The term originated from the fields of biology and genetics, and simply refers to how members of the same species can have different forms. For example, individual butterflies of the same species vary in the kinds of patterns and colors found on their wings. Computer Science re-purposed the term.
Here is a simple, straightforward definition: Polymorphism is having multiple behaviors that adhere to a single interface.
Polymorphism lets us create interchangeable pieces and modularity. This means that instead of needing to completely rewrite a program from the ground up when adding new functionality, we can add functions that implement the new behavior, and then call those functions when the new behavior is required. Polymorphism is the bedrock of creating correctable code, discussed in the Introduction as a core objective of this book. Understanding its use is essential to writing maintainable programs.
This is achieved through function dispatch, which has two high-level categories: static dispatch and dynamic dispatch. Dynamic dispatch has two subcategories: single dispatch and multiple dispatch.
This chapter covers function dispatch, as well as how polymorphism relates to the concepts of composition and inheritance. It has a stronger focus on programming theory than the rest of the book, because understanding that theory is essential to proper understanding of the topics covered later in Chapter 6, “If.” Additionally, although polymorphism’s application is generally oriented towards software design, we will mostly cover it in terms of code construction techniques.
Static dispatch
Static dispatch occurs when the function to be called is determined at compile time based on reference (e.g. object or struct associations). The following code is an example of this, with the association between Logger and Writer acting to determine the function dispatch.
package main
import "fmt"
type Writer interface {
Write(p []byte) (n int, err error)
}
type HexWriter struct{}
func (w HexWriter) Write(p []byte) (int, error) {
fmt.Printf("%x\n", p)
return len(p), nil
}
type StringWriter struct{}
func (s StringWriter) Write(p []byte) (int, error) {
fmt.Printf("%s\n", p)
return len(p), nil
}
type Logger struct {
w Writer
}
func (l *Logger) Log(s string) {
l.w.Write([]byte(s))
}
func main() {
l1 := Logger{
&StringWriter{},
}
l2 := Logger{
&HexWriter{},
}
l1.Log("hello, world!") // prints "hello, world!"
l2.Log("hello, world!") // prints "68656c6c6f2c20776f726c6421"
}
In the above Golang example, the HexWriter
and StringWriter
have identical Write
functions, and the code in main
determines which one to call based on the compile-time evaluation.
Many programming languages support classes, which are simply a way of passing consistent scope to a set of related functions (see Chapter 3, “State”). The scope is referenced by a variable such as this
, or self
, or receiver
(or whatever you want to call the structure associated with the function). That reference acts as an input to the function, and the function uses that scope to achieve polymorphism.
Dynamic Dispatch
Dynamic dispatch operates the similarly to static dispatch, except which function to call is determined at runtime rather than compile time, based on the type of arguments it can receive, or its arity (number of arguments). This allows for more sophisticated behavior, such as adding plugins to extend a program’s capability without rebuilding the system, but has the drawback that the compiler cannot easily detect errors.
The two forms of dynamic dispatch are single dispatch, and multiple dispatch. They differ in whether the function to be called is determined by one or multiple values.
Single Dispatch
Single dispatch is a form of dynamic dispatch where the selected function is based on a single value.
In some languages, this might be through a “special” object, such as this
or self
. In this example, the “special” object is self
, and contains the related scope defined by the associated class the object was initialized from.
class Comparator:
def compare(self, a, b):
return self.bigger(a, b)
class IntComparator(Comparator):
# Returns True if a is bigger than b
def bigger(self, a, b):
return a > b
class StringComparator(Comparator):
# Returns True if a is longer than b
def bigger(self, a, b):
return len(a) > len(b)
def compare(a, b, comparator):
return comparator.compare(a, b)
compare(1, 2, IntComparator())
compare("hi", "bye", StringComparator())
In other languages, function selection may be based on the type of a single argument passed into the function. In this example, taken from the Elixir programming language docs, the Utility.type/1
function is defined for both BitString
and Integer
types. Depending on the argument types it is called with, a different implementation will be invoked.
defprotocol Utility do
@spec type(t) :: String.t()
def type(value)
end
defimpl Utility, for: BitString do
def type(_value), do: "string"
end
defimpl Utility, for: Integer do
def type(_value), do: "integer"
end
iex> Utility.type("foo")
"string"
iex> Utility.type(123)
"integer"
The benefit of single dispatch is its simplicity. Since a single value is used to determine which function to call, it is straightforward to understand how the code will execute.
Multiple Dispatch
Multiple dispatch is when functions have the same name, but differ in either the types of arguments they can receive, their return type, or their arity. These differences are then used to select the matching function for a caller at runtime. In this Elixir example, the function is selected based on the first matching function definition.
defmodule Utility do
def type(a, true) when is_bitstring(a), do: "string|true"
def type(a, false) when is_bitstring(a), do: "string|false"
def type(a, true) when is_number(a), do: "number|true"
def type(a, false) when is_number(a), do: "number|false"
def type(a, true) when is_boolean(a), do: "boolean|true"
def type(a, false) when is_boolean(a), do: "boolean|false"
end
IO.puts(Utility.type("hi", true)) # string|true
IO.puts(Utility.type("hi", false)) # string|false
IO.puts(Utility.type(1, true)) # number|true
IO.puts(Utility.type(true, false)) # boolean|false
The benefit of multiple dispatch is its expressiveness. The ability to reuse a common name and simply redefine the argument types allows the code to be extended easily, without any need to change existing behavior. This provides a lot of potential flexibility, often at the cost of some simplicity.
Using Function Dispatch
Most languages do not support all forms of function dispatch, so picking which option will work best for a given scenario is often dependent on the language you are working in.
In general, static dispatch is a good default choice because it is the simplest and has the best compiler support, so is the easiest to debug. In terms of complexity, static dispatch is the simplest, followed by single dispatch, then multiple dispatch. Prefer the lowest level of complexity that can solve the problem, without introducing additional problems. If you are unsure which type to choose, it is almost always easier to go from a lower complexity level into a higher one, than from a higher complexity level into a lower one.
The most important activity is to think critically about the problem being solved, and ensure that you have a comprehensive understanding of the behaviors you need to achieve.
Composition
Composition is a way to achieve polymorphism through explicit configuration and references, rather than a compiler or runtime inferring it.
See the following Python example where an object is composed with a function to achieve different behaviors:
# Returns True if a is bigger than b
def compare_ints(a, b):
return a > b
# Returns True if a is longer than b
def compare_strs(a, b):
return len(a) > len(b)
class Comparator:
def __init__(self, compare):
self.compare = compare
int_comp = Comparator(compare_ints)
int_comp.compare(3, 1) # True
str_comp = Comparator(compare_strs)
str_comp.compare("Bigger", "Smaller") # False
Note that there is still a function compare
that will behave differently, but instead of composing at the functional level, we are composing at the object level, creating a reference to the appropriate function. The primary difference is that both int_comp
and str_comp
will behave differently for their entire lifecycle (excluding mutation).
Let's Get Technical
Some may note that this is redundant- we could simply pass function references to any object requiring them, as in the previous example. But that would fail to demonstrate how composition can be polymorphic at the object level.
We can achieve a high level of customization by using this style of compositional polymorphism, where properties are set and functionality is created through association.
This is how programmers create modularity, and we will explore its impacts further in Chapter 6, “If”.
Inheritance
Inheritance is when an object can derive properties and behavior from one or more other objects.
Inheritance is not intrinsically bad, but it lends itself to easy misuse.
Inheritance is good when you have a set of objects that, by definition, have the same set of functions, but some of those functions need to have different behavior.
Here is an impractical, but reasonable, use of inheritance:
class Car:
def __init__(self):
self._speed = 0
@property
def speed(self):
return self.speed
def accelerate(self):
raise NotImplemented("accelerate is not implemented in Car")
class Buick(Car):
@property
def max_speed(self):
return 150
def accelerate(self):
self._speed += 10
class Ferrari(Car):
@property
def max_speed(self):
return 250
def accelerate(self):
self._speed += 20
We can see that both the Buick
and the Ferrari
inherit from Car
and share the speed
property, but they differ by max_speed
(150 vs 250) and they have their own acceleration rates (10 vs 20). This means that the two Car
classes provide the same functions, but will behave differently when those functions are called.
Here is another, slightly practical example:
class Iterator:
def __init__(self, condition):
"""
condition is some object with a `status` function,
which will return "complete" once done.
"""
self.condition = condition
@property
def is_complete(self):
return self.condition.status() == "complete"
class NumericIterator(Iterator):
def __init__(self, condition):
super().__init__(condition)
self.value = 0
def next(self):
if self.is_complete:
raise StopIteration()
ret_val = self.value
self.value += 1
return ret_val
class AlphabeticIterator(Iterator):
def __init__(self, condition):
super().__init__(condition)
self.alphabet = string.ascii_lowercase
def next(self):
if self.is_complete or len(self.alphabet) == 0:
raise StopIteration()
return self.values.pop(0)
A key thing to note here is that the NumericIterator
is bounded only by the condition.status()
value, unlike the AlphabeticIterator
which is also bounded by the number of letters in the alphabet. This means we could continue to call next
on the NumericIterator
and count as high as Python will let us, but as soon as the AlphabeticIterator
’s next
function returns “z”, iteration will end, regardless of the condition.status()
result.
Let’s look at a bad example of inheritance:
class TipCalculator:
def __init__(self, percentage):
self.percentage = percentage
def calculate(self, cost):
return cost * (1 + self.percentage / 100)
class RestaurantGuest(TipCalculator):
def __init__(self, percentage, preferences):
super().__init__(percentage)
self.preferences = preferences
def order(self):
return self.preferences.order()
Here, RestaurantGuest
extends TipCalculator
, not because of any behavioral relationship, but because we wanted to “reuse” the calculate
method of TipCalculator
.
This is a poor use of inheritance. The convenience of not needing to compose a single function into a class does not outweigh the burden of inheriting all the irrelevant functionality. This irrelevant functionality includes not only what currently exists, but all future additions as TipCalculator
is updated. There is a high MTC cost to poor inheritance: code updates that should be straightforward will have unclear impacts, and so substantially more of the system must be understood before changes can be made.
Instead, we should recognize that the TipCalculator
functionality is what we require, and pass a TipCalculator
into the constructor of RestaurantGuest
following the guidelines in the section “Composition”. Doing so allows us to add new TipCalculator
s in the future, and update which one RestaurantGuest
uses, without ever modifying the RestaurantGuest
class.
This is the fundamental danger of inheritance: it is easy in the moment to simply extend existing behavior to accomplish a goal, without realizing that doing so may have substantially increased the overall MTC. Because of this, every use of inheritance should be carefully scrutinized to ensure it remains consistent with the original intent of the code.
Conclusion
Polymorphism may be the most powerful tool in a programmer’s toolbox, but perhaps also the most dangerous. It allows us to model any behavior, any complex system, in ways that can be orthogonally extended. It also allows us to create incredible complexity.
Think deeply about the problem space you are modeling, and the behaviors you are trying to achieve, before creating some polymorphic abstraction. Do not create unnecessary abstractions, which results in more code to understand and maintain within a system.
When used properly, polymorphism is the single most effective tool for making systems that stand the test of time.