Feb 20, 2022

Getting the hang of Polymorphism in object-oriented languages

In object-oriented languages, Polymorphism is an elegant usage of inheritance that allows creating generic and specific pieces of functionality for a set of classes. The common example of vehicles is useful here: Imagine having a top-level class for all vehicles with some common information such as a license plate and more specific classes for different types of vehicles like bikes, cars, and motorbikes.

While polymorphism often makes sense intuitively, there’s a strict underlying process languages like Java go through to find the code that needs to run in the end. In the following, I’ll stick to explaining the basics of how Java handles polymorphism, but other object-oriented languages might have similar constructs and internals, as the same challenges need to be solved.

To get started, we’ll walk through the basic concept of creating classes extending each other, creating a hierarchy used throughout our application. Then, we’ll look at the steps Java takes to determine which method should be invoked at runtime based on the information it has, through a two-step process. Finally, we’ll take a look at overriding attributes.

Classes and Inheritance

In Java, classes can extend up to one superclass and implement multiple interfaces.

class Animal {
	public String sound() {
		return "generic animal sound";
	}
}

class Dog extends Animal {
	public String sound() {
		return "bark";
	}
}

In this example, we see that there’s not a lot of sense in implementing a default function on the animal level, so we can use an abstract class instead.

abstract class Animal {
	public abstract String sound();
}

class Dog extends Animal {
	public String sound() {
		return "bark";
	}
}

This implementation does no longer allow to instantiate an animal by itself, but we must use a concrete subclass instead, like this

Dog d = new Dog();
d.sound(); // "bark"

Now to get some polymorphism going, we’ll implicitly cast our dog to an animal

Animal a = dog;
a.sound();

What do you expect our call to .sound() returns now? As you would have guessed intuitively, we return bark. We were able to implicitly cast our dog to a variable with the static type Animal. We’ll explain static types in the next part, but for now, it’s sufficient to say that the static type of a variable is the type used when declaring it, in our case Animal.

Phase 1: Determining the method signature at compile time

Determining the static type

Alright, so to start our journey in finding the method to execute, Java needs to know at compile-time whether an invocation is somewhat possible. There may be more than one implementation of a method along the inheritance chain, but if the method does not exist at all or is inaccessible to the caller, we have to fail at compile time.

For this, we’ll determine the static type of the object the method is called on. To make this section more easily understandable, let’s use our earlier example

class Animal {
	public String sound() { return "animal sound"; }
}

class Dog extends Animal {
	public String sound() { return "woof"; }
}

class Cat extends Animal {
	public String sound() { return "meow"; }
}

class Test {
	public static void main(String[] args) {
		Animal a = new Animal();
		a.sound();
	}
}

Calling a.sound() starts the process of determining the static type. Following back the variable to its declaration, we can see that a represents an Animal. This is its static type.

While it’s usually straightforward to determine the static type of an object, there are certain edge cases:

First, this refers to the current class it appears in. Similarly, super refers to the superclass of the current class it appears in. The following example should help:

class Dog extends Animal {
	public void something() {
		this.somethingElse(); // static type of this is Dog
	}

	public void somethingElse() {
		// ...
		super.something(); // static type of super is Animal
	}
}

Another case occurs when dealing with generics

class List<T> {
	private T t;
}

class Stack<T extends String> {
	private T t;
}

In these situations, calling a method on the attribute t runs into a process called type erasure. Due to the classes using generic types, it can only make limited assumptions when it comes to the static type of a type parameter. For the first case, the static type of T is Object, the least specific type that all classes inherit. The static type of the second case using bounded generics is the base type String.

To conclude, the static type of a variable can be found on the left-hand side of the variable declaration, but may be subject to some special rules when dealing with generics.

Temporarily changing the static type by casting

Earlier we saw an example of implicit casting, assigning a variable to another one with the same static class or a subclass thereof.

When you try to assign variables in the opposite direction (i.e. assigning the superclass to a subclass) you must use an explicit cast to tell Java you’re confident the type will match at runtime.

class Animal {}
class Dog extends Animal {}
class Cat extends Animal {}

// intentionally left out extends Animal below
class Parrot {}

Animal a = new Dog(); // works as Dog extends Animal
Cat c = new Animal(); // does not compile as Animal is not a subclass of Cat
Cat c = (Cat) new Animal(); // will compile and throw ClassCastException at runtime

Animal a = new Parrot(); // fails, no implicit cast possible
Animal a = (Animal) new Parrot(); // fails, Parrot is not related to Animal

Casting changes the static type of a variable temporarily, and Java will defer the check of matching types to runtime. You may only use casting if types are related.

There are limits for casting generics, you may not cast to the same type with a subclass of the type parameter.

class Stack<T extends String>
class MyString extends String {}

Stack<String> a = ... // fine
Stack<MyString> a2 = a; // fails

Determining the method signature

Now that we know our static types, let’s try to collect all methods that could be run based on our static types. First, we’ll search for all methods in the static type that 1) are accessible 2) have the same name as the invoked method, and 3) accept the static type of the class (or a superclass) of the parameters we passed. If we cannot find a method conforming to those criteria, we’ll continue searching in the superclass and interfaces.

When there are multiple possible methods matching the criteria we’ll select the most specific implementation.

If there are multiple methods with the same specificity or no method at all matching our criteria, the Java compiler will return an error.

With the best-fitting method signature known, we’ll memorize it and finish compiling.

Phase 2: Determining the method at runtime

From the compilation phase, we know the signature of the method to be invoked. During runtime, we might be dealing with different dynamic types than during compile time.

In certain cases, Java will invoke the method it found in phase 1. This happens when the method is static, final, or in case the dynamic type matches the static type.

The dynamic type of an object is the type used during instantiation. Before we move on, let’s look at another example to understand the difference between static and dynamic types.

class Animal {
	public String sound() { return "animal sound"; }
}

class Dog extends Animal {
	public String sound() { return "bark"; }
}

Animal a = new Dog();
a.sound(); // bark

In this case, a has the static type Animal but the dynamic type Dog.

With both the static and dynamic type known, Java will search for the method matching the exact signature memorized earlier, starting at the dynamic type and going up the inheritance chain until the static type is reached. This logic is the reason why calling sound() on a variable with the dynamic type Dog will invoke the method on that class and not the superclass.

Overriding Attributes

When referencing class attributes, Java only searches through the static type and its superclasses.

class Animal {
    public String color;

    public Animal(String color) {
        this.color = color;
    }
}

class Dog extends Animal {
    public String color;

    public Dog(String color, String color1) {
        super(color);
        this.color = color1;
    }
}

class Test {
    public static void main(String[] args) {
        Animal a = new Dog("brown", "black");
        Dog d = new Dog("brown", "black");

        System.out.println(a.color); // brown
        System.out.println(d.color); // black
    }
}

This very subtle difference can have a huge impact! This way, casting changes the outcome of accessing an attribute.


This concludes the basics of polymorphism in Java, and it’s admittedly a lot of rules to remember. I think the most important concept is that for method calls, Java goes through a two-phase process of determining the static type of the object the method is called on and the passed parameters, searching for the method with the best fit in the static type and its superclass and interfaces and once it has found this method, memorizing its signature to identify the method to be called during runtime.

This is done by starting on the dynamic type of the object and going up until the static type is reached, finding the method with the memorized signature. When methods are static or final or the dynamic and static types match, the runtime step is skipped and the method found during compile time is used.