The Elements of Code

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

Rule: Use Polymorphism

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 TipCalculators 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.