Marshall Bowers

Conjurer of code. Devourer of art. Pursuer of æsthetics.

A Primer on Polymorphism

Friday, March 20, 2020
3117 words
16 minute read

Welcome to my primer on polymorphism. By the end of this post the goal is for you to be able to effectively apply polymorphism in your own programs.

The examples in this post are written to satisfy a sample set of requirements. We'll take a look at a variety of different ways to satisfy these requirements.

Here are the requirements:

  1. We have three different kinds of animals: dogs, cats, and geese
  2. All animals can make noise:
    • Dogs bark
    • Cats meow
    • Geese quack
  3. Geese can fly, but dogs and cats cannot

No Polymorphism

The first approach that we'll cover is one where no polymorphism is used. This style of code is most common among beginner programmers.

Don't worry if your own code looks like this. It just means you stand to gain the most from reading this!

Modeling the animals

We'll start by modeling the animals. Our requirements state that there are three different "kinds" of animals, so it makes sense to declare an AnimalKind enum to represent the various kinds.

We can then declare an Animal class containing a Kind property which can be used to differentiate between the various kinds of animals:

public enum AnimalKind
{
    Dog,
    Cat,
    Goose
}

public class Animal
{
    public AnimalKind Kind { get; }

    public Animal(AnimalKind kind)
    {
        Kind = kind;
    }
}

So far so good.

Make some noise

Next we'll move on to the implementing the second requirement by giving our animals the ability to make noise:

public class Animal
{
    // ...

    public string MakeNoise()
    {
        switch (Kind)
        {
            case AnimalKind.Dog:
                return "Bark";

            case AnimalKind.Cat:
                return "Meow";

            case AnimalKind.Goose:
                return "Honk";

            default:
                throw new ArgumentOutOfRangeException(nameof(Kind));
        }
    }
}

While this works, we've already run into a bit of a rough edge by taking this approach. The compiler can't determine that we've handled all of the cases of AnimalKind, so we're forced to add a default case that throws an exception if we ever receive an animal that we're not expecting.

Under normal circumstances this could never happen, as MakeNoise handles every possible case, but if we were to add a fourth kind of animal—perhaps a Pig—down the road, we'd have to remember to update MakeNoise to handle the new case. Failing to handle the new case would result in an exception at runtime if MakeNoise is called for the new kind of animal.

It's also worth pointing out that the compiler will not provide any assistance if a new AnimalKind case is added. We're on our own to remember to add support for the new case everywhere.

Fly away home

Finally we'll move on to implementing the third requirement of giving geese the ability to fly. However, we'll soon realize that we have a big problem:

public class Animal
{
    // ...

    public string Fly()
    {
        switch (Kind)
        {
            case AnimalKind.Goose:
                return "The goose flies away!";

            case AnimalKind.Dog:
            case AnimalKind.Cat:
                throw new NotImplementedException();

            default:
                throw new ArgumentOutOfRangeException(nameof(Kind));
        }
    }
}

It's easy enough to make Fly work for geese: we just make them fly. But now we're left with the question of what to do about animals that can't fly. In this case we've chosen to throw a NotImplementedException if a dog or cat tries to fly. We could throw a custom exception, like a CannotFlyException, but we're still left with the problem that someone could accidentally call Fly for a cat or dog and would only discover their mistake when the exception is thrown at runtime:

var goose = new Animal(AnimalKind.Goose);
Console.WriteLine(goose.Fly()); // "The goose flies away!"

var dog = new Animal(AnimalKind.Dog);
Console.WriteLine(dog.Fly()); // Oops, this throws an exception!

In the above code, the compiler will not provide any warnings or errors when dog.Fly() is called, even though we know that it's not valid.

Inheritance-based Polymorphism

Now that we've seen the problems that a lack of polymorphism causes, let's try implementing this again, but this time taking an inheritance-based approach to polymorphism by using subclassing. After all, inheritance is what you learn in school, so it has to be good, right?

Modeling the animals

This time around we'll create a base Animal class and declare three subclasses, one for each kind of animal, which all inherit from the base class:

public abstract class Animal
{
}

public class Dog : Animal
{
}

public class Cat : Animal
{
}

public class Goose : Animal
{
}

Make some noise

Just like before we can define a MakeNoise method on the base class. However, since we now have a class hierarchy we make the method abstract so that each of the subclasses can provide their own implementation:

public abstract class Animal
{
    public abstract string MakeNoise();
}

public class Dog : Animal
{
    public override string MakeNoise() => "Bark";
}

public class Cat : Animal
{
    public override string MakeNoise() => "Meow";
}

public class Goose : Animal
{
    public override string MakeNoise() => "Honk";
}

In contrast to our previous MakeNoise implementation, this time we actually receive some help from the compiler. When we first declare the abstract MakeNoise method on the Animal class, the compiler helpfully points out that we're missing an implementation for each of our subclasses:

'Dog' does not implement inherited abstract member 'Animal.MakeNoise()'
'Cat' does not implement inherited abstract member 'Animal.MakeNoise()'
'Goose' does not implement inherited abstract member 'Animal.MakeNoise()'

Fly away home

Implementing Fly works the same way. We declare an abstract Fly method on the base class and then implement it for each of the subclasses:

public abstract class Animal
{
    // ...

    public abstract string Fly();
}

public class Dog : Animal
{
    // ...

    public override string Fly() => throw new NotImplementedException();
}

public class Cat : Animal
{
    // ...

    public override string Fly() => throw new NotImplementedException();
}

public class Goose : Animal
{
    // ...

    public override string Fly() => "The goose flies away!";
}

Just like with MakeNoise, when we declare the abstract Fly method on the base class we get compiler warnings alerting us that we have to implement the method for each subclass.

Once again, we're faced with the same problem as before: only geese should be able to fly!

Additionally, we still have the issue where the compiler will not alert us if we try to call Fly on something that isn't a Goose:

var goose = new Goose();
Console.WriteLine(goose.Fly()); // "The goose flies away!"

var dog = new Dog();
Console.WriteLine(dog.Fly()); // Oops, this throws an exception!

Composition-based Polymorphism

At this point perhaps you've remembered that one should favor composition over inheritance when doing object-oriented programming. There are many reasons for this, and in this case eschewing inheritance is exactly what we need.

Modeling the animals

This time around we'll use an IAnimal marker interface to indicate that something is an animal:

public interface IAnimal
{
}

public class Dog : IAnimal
{
}

public class Cat : IAnimal
{
}

public class Goose : IAnimal
{
}

The IAnimal interface could just as easily be an abstract class, but it doesn't make any difference in the context of this example.

Make some noise

In order to meet our noise-making requirements we'll declare a new IMakeNoise interface with our MakeNoise method on it. We can then implement this interface for each of our animals:

public interface IMakeNoise
{
    string MakeNoise();
}

public class Dog : IAnimal, IMakeNoise
{
    public string MakeNoise() => "Bark";
}

public class Cat : IAnimal, IMakeNoise
{
    public string MakeNoise() => "Meow";
}

public class Goose : IAnimal, IMakeNoise
{
    public string MakeNoise() => "Honk";
}

Since all animals can make noise we could also tweak the above code to make IAnimal inherit from IMakeNoise:

public interface IAnimal : IMakeNoise
{
}

Doing this would mean each of our animal classes would only have to implement IAnimal as opposed to both IAnimal and IMakeNoise.

Fly away home

So far we've had no success in implementing Fly in such a way that we're able to statically enforce that only geese can fly. Will it be any different this time around?

We'll declare a new ICanFly interface to represent animals that can fly. We can then implement this interface for Goose and only for Goose:

public interface ICanFly
{
    string Fly();
}

public class Goose : IAnimal, IMakeNoise, ICanFly
{
    // ...

    public string Fly() => "The goose flies away!";
}

Let's see it in action:

var goose = new Goose();
Console.WriteLine(goose.Fly()); // "The goose flies away!"

var dog = new Dog();
Console.WriteLine(dog.Fly()); // Does not compile!

Success! The compiler is now able to alert us when we try to call Fly on an animal that is unable to fly:

'Dog' does not contain a definition for 'Fly' and no accessible extension method 'Fly' accepting a first argument of type 'Dog' could be found (are you missing a using directive or an assembly reference?)

It's important to note that this only works when we're dealing with a concrete animal class. If we're working with an IAnimal we need to perform type-testing at runtime in order to check if an animal implements ICanFly:

IAnimal dog = new Dog();
if (dog is ICanFly canFly)
{
    Console.WriteLine(canFly.Fly());
}

In the above snippet, nothing will be printed since Dog does not implement ICanFly.

Functional Polymorphism (in F#)

Up until now we've only seen object-oriented approaches to polymorphism. Now we're going to switch gears and take a look at a functional approach to polymorphism using F#.

Modeling the animals

In F# we can use a discriminated union—also known as a "sum type"—to represent the various kinds of animals:

type Animal =
    | Dog
    | Cat
    | Goose

If we wanted to put this into words we could say that "an animal can be either a dog or a cat or a goose".

Make some noise

In functional programming it's good practice to separate the data from the behavior. This means that rather than creating a MakeNoise method on the Animal type itself we'll opt for a makeNoise function in an associated Animal module:

module Animal =
    let makeNoise animal =
        match animal with
        | Dog -> "Bark"
        | Cat -> "Meow"
        | Goose -> "Honk"

A common functional programming pattern is to declare a module with the same name as the type to hold the functions that operate on that type.

You may have noticed that our makeNoise implementation bears a striking resemblance to the one from our example without polymorphism. There is one key difference, though: the compiler will actually show us a warning if not all the cases are handled.

If we were to extend our Animal type with a new case for Pig, we'd see the following compiler warning in our makeNoise function:

Incomplete pattern matches on this expression. For example, the value 'Pig' may indicate a case not covered by the pattern(s).

This is a welcome improvement! If new kinds of animals are added—either by us or another team member—the compiler will show us all the spots that need to account for the new cases. This compiler warning can even be promoted to an error, which means that the code will not compile unless all of the cases are accounted for.

Fly away home

To implement our flying requirement we can add a fly function to Animal module:

module Animal =
    // ...

    let fly animal =
        match animal with
        | Goose -> Some "The goose flies away!"
        | Dog
        | Cat -> None

Now, this approach does contain the same problem we had in the first two approaches: we're able to call fly with any animal, not just ones that can fly. However, there is one small difference that makes this more acceptable.

In all of the other approaches the Fly method always returned a string. This time, however, the fly function returns a string option. Let's unpack this.

F# has an option type that is used to represent a value that may or may not be there. The compiler then forces us to handle both cases when trying to access the value contained in option.

Whereas in the previous implementations we threw an exception when given an animal that cannot fly, this time around we return a None instead. What this means is that we now need to handle this case when we call fly:

let goose = Goose
match Animal.fly goose with
| Some message -> printfn "%s" message // "The goose flies away!"
| None -> ()

let dog = Dog
match Animal.fly dog with
| Some message -> printfn "%s" message
| None ->
    // Dogs can't fly, so we'll enter the `None` case
    // and do nothing.
    ()

The above code compiles and runs successfully, without any exceptions being thrown. This is because when we handle the None case we simply do nothing (by returning ()).

We can also reduce the boilerplate of printing in the Some case and doing nothing in the None case, by moving it into its own function:

let flyAndPrint animal =
    match Animal.fly animal with
    | Some message -> printfn "%s" message
    | None -> ()

let goose = Goose
flyAndPrint goose // "The goose flies away!"

let dog = Dog
flyAndPrint dog // Does nothing.

At this point, what we have is functionally identical to the code from the composition example when working with an IAnimal.

One thing that the object-oriented compositional approach gives us that we don't have here is compile-time type checking when working with concrete instances like Dog or Goose.

Unfortunately, achieving this same behavior in a functional paradigm requires the use of type classes, which F# does not have. With that said, we've done all we can do for now.

It is probably possible to make use of statically resolved type parameters (SRTPs) in F# to work around the lack of type classes, but this is left as an exercise for the reader.

Functional Polymorphism (in Haskell)

This section will explore how to implement the object-oriented composition example in a functional paradigm using Haskell.

This isn't required reading for the rest of the post, so if you're not interested in learning about Haskell you can skip ahead to the next section.

Modeling the animals

We can model the animals using standard Haskell data types. We declare a data type for each individual animal, as well as an Animal type that encompasses all of the kinds of animals:

data Animal
  = Dog' Dog
  | Cat' Cat
  | Goose' Goose
  deriving (Show)

data Dog =
  Dog
  deriving (Show)

data Cat =
  Cat
  deriving (Show)

data Goose =
  Goose
  deriving (Show)

Make some noise

To implement makeNoise we first declare a MakeNoise type class and then create an instance of it for each of our animals:

class MakeNoise a where
  makeNoise :: a -> String

instance MakeNoise Dog where
  makeNoise _ = "Bark"

instance MakeNoise Cat where
  makeNoise _ = "Meow"

instance MakeNoise Goose where
  makeNoise _ = "Honk"

Fly away home

We can implement flying the same way. We declare a CanFly type class, but this time we only create an instance for our Goose type:

class CanFly a where
  fly :: a -> String

instance CanFly Goose where
  fly _ = "The goose flies away!"

If we test this out in GHCi we'll see that trying to call fly on an instance of Dog results in a compiler error:

ghci> goose = Goose
ghci> fly goose
"The goose flies away!"

ghci> dog = Dog
ghci> fly dog
<interactive>:6:1: error:
    • No instance for (CanFly Dog) arising from a use of ‘fly’
    • In the expression: fly dog
      In an equation for ‘it’: it = fly dog

The Expression Problem

Having seen an object-oriented approach and a functional approach to writing a program that satisfies our outlined requirements we're left with a question: "which one should I pick?"

Answering this question requires an understanding of the expression problem, which is best summed up by the following table:

ParadigmAdding new typesAdding new behavior
Object-orientedEasyDifficult
FunctionalDifficultEasy

In the object-oriented paradigm it is easy to add new types, but difficult to add new behavior.

In the functional paradigm it is easy to add new behavior, but difficult to add new types.

In this context "easy" means "does not require changing and recompiling existing code" and "difficult" means "requires changing and recompiling existing code".

Example: object-oriented paradigm

Adding a new type

Adding a new type is easy, as we just need to define a new Pig class that implements IAnimal and any of our other interfaces:

public class Pig : IAnimal, IMakeNoise, ICanFly
{
    public string MakeNoise() => "Oink";

    public string Fly() => "Yea right, when pigs fly!"
}

Adding new behavior

Adding new behavior is difficult, as we have to implement ICanSwim for any existing types that require it:

public interface ICanSwim
{
    string Swim();
}

public class Dog : IAnimal, IMakeNoise, ICanSwim
{
    // ...

    public string Swim() => "The dog happily doggy paddles in the water.";
}

public class Cat : IAnimal, IMakeNoise, ICanSwim
{
    // ...

    public string Swim() => "The cat flails about in the water.";
}

public class Goose : IAnimal, IMakeNoise, ICanFly, ICanSwim
{
    // ...

    public string Swim() => "The goose calmly floats on the water.";
}

Example: functional paradigm

Adding a new type

Adding a new type is difficult, as we have to add a new case to Animal and update any code using Animal to handle the new case:

type Animal =
    | Dog
    | Cat
    | Goose
    | Pig

module Animal =
    let makeNoise animal =
        match animal with
        | Dog -> "Bark"
        | Cat -> "Meow"
        | Goose -> "Honk"
        | Pig -> "Oink"

    let fly animal =
        match animal with
        | Goose -> Some "The goose flies away!"
        | Pig -> Some "Yea right, when pigs fly!"
        | Dog
        | Cat -> None

Adding new behavior

Adding new behavior is easy, as we can just define a new function that operates on the existing types:

let swim animal =
    match animal with
    | Dog -> "The dog happily doggy paddles in the water."
    | Cat -> "The cat flails about in the water."
    | Goose -> "The goose calmly floats on the water."

"Which one should I pick?"

It depends on the environment in which the code resides.

Personally, I have found that most of the applications I've worked on have a core set of types that change infrequently, but are always in need of having new behaviors added to them.

This observation causes me to lean towards the functional paradigm, as it lends itself to easily adding new behavior to existing types.

Closing Remarks

We've covered a lot of ground in this post, and I hope you've come out the other side with a better understanding of polymorphism and how to apply it.

The compositional object-oriented approach and the functional approaches are the two that I would suggest following, depending on what paradigm you are using. Inheritance—especially in the form of deep inheritance hierarchies—is a well-known antipattern and should be avoided.


If you would like to play around with the code examples, you can find them on GitHub.

As always, if you have questions, comments, or other feedback, I'd love to hear it.