Course program← Объектно ориентированное программирование
1История ООП
2Объект и Класс
3Базовые концепции ООП
4Принцип проектирования GRASP
5Принцип проектирования SOLID
6Паттерны GOF
7Методические материалы

Ways of implementing polymorphism

This lesson was translated automatically – it may contain errors.

Polymorphism is a property, not a particular language construct. It can be written down in different ways, and every language has its own set: a couple in some, a dozen in others, while things like type classes or multimethods appear in only a few languages at all.

We will go through the most common ones – those you meet in almost any project: which languages have them, how they look and what exactly is polymorphic about them.

subtype polymorphism virtual methods overloading generics duck typing dynamic typing type switching type classes multimethods Polymorphism subtype polymorphism C#, C++, Go, Java, Kotlin, Python, TypeScript virtual methods C#, C++, Java, Kotlin, Python, TypeScript overloading C#, C++, Java, Kotlin, TypeScript generics C#, C++, Go, Java, Kotlin, Python, TypeScript duck typing Go, Python, TypeScript dynamic typing Python, JavaScript, Ruby, Go and C# – partly type switching C#, C++, Go, Java, Kotlin, Python, TypeScript type classes Haskell, Rust, Scala multimethods Clojure, Julia, Common Lisp

Seven of them are covered below. Type classes and multimethods do not appear in our languages – they come from Haskell, Rust, Clojure and Julia – but they are polymorphism too.

Interface

Polymorphism static dynamic

The academic name is subtype polymorphism, also known as inclusion polymorphism.

The contract is declared separately from those who fulfil it. The calling code sees only the contract; which implementation is substituted is settled when the object is created.

public interface IStorage { void Save(string data); }

// The method takes the contract, not a concrete class
void Backup(IStorage storage) => storage.Save("data");
// In C++ an abstract class without data plays the role of an interface
class IStorage {
public:
    virtual void Save(const std::string& data) = 0;
    virtual ~IStorage() = default;
};

void Backup(IStorage& storage) { storage.Save("data"); }
// A Go interface is implemented silently: no kinship to declare
type Storage interface{ Save(data string) error }

func Backup(s Storage) error { return s.Save("data") }
from typing import Protocol

# Protocol describes the contract; inheriting from it is optional
class Storage(Protocol):
    def save(self, data: str) -> None: ...

def backup(storage: Storage) -> None:
    storage.save("data")
interface Storage {
    save(data: string): void;
}

function backup(storage: Storage): void {
    storage.save("data");
}
public interface Storage {
    void save(String data);
}

static void backup(Storage storage) {
    storage.save("data");
}
interface Storage {
    fun save(data: String)
}

fun backup(storage: Storage) = storage.save("data")

Inheritance and virtual methods

Polymorphism static dynamic

This is the same subtype polymorphism, only the contract is a base class rather than an interface.

The method is declared on the parent and the subclass replaces it. The calling code works with the parent and does not know whose code will actually run.

This works through late binding: the implementation is chosen not at compile time but at the moment of the call – by the actual type of the object, not by the type of the variable. Methods that behave this way are called virtual: C# and C++ mark them with virtual, Kotlin with open, while in Java, Python and TypeScript every method is virtual by default.

Do not confuse this with virtual inheritance in C++ (class B : virtual public A) – that is about a shared subobject in a diamond hierarchy and has nothing to do with polymorphism.

public class Notifier
{
    // virtual lets a subclass replace the implementation
    public virtual void Send(string text) => Console.WriteLine($"log: {text}");
}

public class EmailNotifier : Notifier
{
    public override void Send(string text) => Console.WriteLine($"email: {text}");
}

Notifier notifier = new EmailNotifier();
notifier.Send("hi"); // email: hi – the variable type has nothing to do with it
class Notifier {
public:
    // Without virtual there is no replacement: the parent method runs
    virtual void Send(const std::string& text) { std::cout << "log: " << text << "\n"; }
    virtual ~Notifier() = default;
};

class EmailNotifier : public Notifier {
public:
    void Send(const std::string& text) override { std::cout << "email: " << text << "\n"; }
};

std::unique_ptr<Notifier> notifier = std::make_unique<EmailNotifier>();
notifier->Send("hi"); // email: hi

Go has no inheritance, and embedding does not replace methods: the embedded type always calls its own. Polymorphism in Go is done with interfaces.

class Notifier:
    def send(self, text):
        print(f"log: {text}")

class EmailNotifier(Notifier):
    # No keywords needed: any method can be overridden
    def send(self, text):
        print(f"email: {text}")

notifier = EmailNotifier()
notifier.send("hi")  # email: hi
class Notifier {
    send(text: string): void {
        console.log(`log: ${text}`);
    }
}

class EmailNotifier extends Notifier {
    send(text: string): void {
        console.log(`email: ${text}`);
    }
}

const notifier: Notifier = new EmailNotifier();
notifier.send("hi"); // email: hi
public class Notifier {
    public void send(String text) {
        System.out.println("log: " + text);
    }
}

public class EmailNotifier extends Notifier {
    @Override
    public void send(String text) {
        System.out.println("email: " + text);
    }
}

Notifier notifier = new EmailNotifier();
notifier.send("hi"); // email: hi
// open allows inheritance and overriding
open class Notifier {
    open fun send(text: String) = println("log: $text")
}

class EmailNotifier : Notifier() {
    override fun send(text: String) = println("email: $text")
}

val notifier: Notifier = EmailNotifier()
notifier.send("hi") // email: hi

Overloading

Polymorphism static dynamic

The academic name is ad hoc polymorphism, also known as overloading polymorphism.

One method name for several sets of parameters. Which one is called is decided by the compiler from the argument types – before the program starts.

void Print(int value) => Console.WriteLine($"number {value}");
void Print(string value) => Console.WriteLine($"string {value}");

Print(42);      // number 42
Print("hello"); // string hello
void Print(int value) { std::cout << "number " << value << "\n"; }
void Print(const std::string& value) { std::cout << "string " << value << "\n"; }

Print(42);        // number 42
Print(std::string("hello")); // string hello

Go has no overloading at all: two methods with the same name are a compile error. Instead people use different names or take an interface.

Python has no overloading: the second definition simply overwrites the first. The same job is done by default values and functools.singledispatch.

// Several signatures, one implementation – the types are checked at compile time
function print(value: number): void;
function print(value: string): void;
function print(value: number | string): void {
    console.log(typeof value === "number" ? `number ${value}` : `string ${value}`);
}
static void print(int value) {
    System.out.println("number " + value);
}

static void print(String value) {
    System.out.println("string " + value);
}
fun print(value: Int) = println("number $value")

fun print(value: String) = println("string $value")

Generics

Polymorphism static dynamicIt depends on the language. In C++ and Rust a template is expanded for every type at build time – pure static. In C# generics over value types are expanded too, while reference types share one body. In Java generics are erased at compile time and casts remain at run time, so the work moves to execution.

The academic name is parametric polymorphism, also known as generics or templates.

The same code works with different types, and the concrete type is substituted at the point of use.

// T is substituted at the call site: Box<int>, Box<string>, Box<Order>
public class Box<T>
{
    public T Value { get; set; }
}
// The template is expanded by the compiler for every type used
template <typename T>
class Box {
public:
    T value;
};
// Generics arrived in Go 1.18. any is the «any type» constraint
type Box[T any] struct {
	Value T
}
# Type annotations are checked by an external tool like mypy, not by the runtime
class Box[T]:
    def __init__(self, value: T) -> None:
        self.value = value
class Box<T> {
    constructor(public value: T) {}
}
public class Box<T> {
    private T value;

    public T getValue() {
        return value;
    }
}
class Box<T>(val value: T)

Duck typing

Polymorphism static dynamic

The academic name is structural typing, also known as duck typing or latent typing.

The contract is declared nowhere: an object fits if it simply has the required method. The name comes from the saying – if it walks like a duck and quacks like a duck, it is a duck.

Notice where this appears: in languages that are not strictly object-oriented – Python, JavaScript, Ruby, and partly Go with its implicit interfaces. Classic object languages such as C#, Java and Kotlin require the contract to be declared explicitly, and that is a deliberate decision.

C# requires the contract to be declared: a type must implement the interface explicitly. Only dynamic behaves similarly, and then no checks remain at all.

C++ has no duck typing in ordinary code, but templates do: any type with the required method fits – only it is checked at compile time.

// A type implements the interface if it has the required methods.
// There is nowhere and no need to write «implements» – the compiler finds the link.
type Speaker interface{ Say() }

type Dog struct{}

func (d Dog) Say() { fmt.Println("Woof") }

var s Speaker = Dog{} // fits, because the method is there
# No declarations at all: the object just needs a say method
def speak(animal):
    animal.say()

class Dog:
    def say(self):
        print("Woof")

speak(Dog())
// Types are compared by structure, not by name:
// an object literal fits as long as the fields and methods match
type Speaker = { say(): void };

const dog = { say: () => console.log("Woof") };

function speak(s: Speaker): void {
    s.say();
}

speak(dog); // fits, even though Speaker is never mentioned

Java requires an explicit implements: a type with matching methods does not count as the interface.

Kotlin also declares the contract explicitly. The closest analogue is function types: you pass a function instead of an object.

The reason for that caution is the downsides, and they are substantial:

  • the user finds the mistakes, not the compiler. Mistype a method name and you learn about it at the moment of the call, in production, not during the build;
  • the contract is written nowhere. To find out what an object must be able to do you have to read the whole function body instead of one line with a type;
  • matching names are accidental. A shape on a canvas has draw and so does a cowboy with a revolver – the language does not tell those two draws apart at all;
  • the editor barely helps. Completion and renaming work blind: the IDE does not know which objects will end up here;
  • refactoring is dangerous. Rename a method and every place that relied on it breaks silently, because the link was never declared.

Some of this is patched from above: type annotations and Protocol in Python, TypeScript on top of JavaScript. In essence that is bringing the contract back – not into the runtime, but into an external analyser.

Dynamic typing

Polymorphism static dynamic

The academic name is dynamic typing, also called late type determination.

A value does have a type, but it is checked at the moment of access rather than at build time: a variable can hold anything, and what is in it is found out as the program goes. Every call becomes polymorphic here, and no declarations are required for that.

C# is statically typed, but there is a loophole: the dynamic type defers the check until run time. The compiler lets any call through, and a missing method gives a RuntimeBinderException.

C++ has no dynamic typing. The closest are std::any and std::variant: they hold a value whose type is not known in advance, but getting it out takes an explicit request.

// any (formerly interface{}) holds a value of any type,
// but to do anything with it the type has to be asked back
var value any = 42

n, ok := value.(int) // ok == true, n == 42
# The same variable happily changes type
value = 42
value = "now a string"
value = Dog()

# The method is looked up on the object at call time
value.say()
// any turns the checks off; unknown makes you narrow the type first
let value: any = 42;
value = "now a string";
value.whatever(); // the compiler says nothing, it fails at run time

Java is statically typed. The role of «any type» is played by Object, but calling a method on it takes a cast or reflection.

Kotlin is the same: Any holds anything, but the methods of a concrete type appear only after an is check and a smart cast.

There is one big upside: the code comes out short and polymorphism is free, nothing has to be declared. The downsides are the same as with duck typing: the user finds the mistakes, the contract is written nowhere, and the IDE helps blind.

Type switching

Polymorphism static dynamic

The academic name is type switching; in everyday speech – a type check, pattern matching, a cast.

The most straightforward way: ask the object who it is and branch the code by hand. Formally this is polymorphism too – one call, different behaviour – except the choice is made by your switch rather than by the language.

// Pattern matching: the branch is chosen by the actual type
decimal Price(Shape shape) => shape switch
{
    Circle c => c.Radius * 2,
    Square s => s.Side * 4,
    _ => throw new ArgumentException("unknown shape"),
};
// Through dynamic_cast: the cast returns nullptr if the type is wrong
double Price(Shape* shape) {
    if (auto* c = dynamic_cast<Circle*>(shape)) return c->radius * 2;
    if (auto* s = dynamic_cast<Square*>(shape)) return s->side * 4;
    throw std::invalid_argument("unknown shape");
}
// A type switch is a built-in construct of the language
func Price(shape any) (float64, error) {
	switch s := shape.(type) {
	case Circle:
		return s.Radius * 2, nil
	case Square:
		return s.Side * 4, nil
	default:
		return 0, errors.New("unknown shape")
	}
}
def price(shape):
    if isinstance(shape, Circle):
        return shape.radius * 2
    if isinstance(shape, Square):
        return shape.side * 4
    raise ValueError("unknown shape")
// The check narrows the type: inside the branch TypeScript knows it is a Circle
function price(shape: Shape): number {
    if (shape instanceof Circle) return shape.radius * 2;
    if (shape instanceof Square) return shape.side * 4;
    throw new Error("unknown shape");
}
// switch with patterns – since Java 21
static double price(Shape shape) {
    return switch (shape) {
        case Circle c -> c.radius() * 2;
        case Square s -> s.side() * 4;
        default -> throw new IllegalArgumentException("unknown shape");
    };
}
// when with is: the smart cast checks and casts in one go
fun price(shape: Shape): Double = when (shape) {
    is Circle -> shape.radius * 2
    is Square -> shape.side * 4
    else -> throw IllegalArgumentException("unknown shape")
}

This way turns out to be unnecessary more often than the others. Add a new shape and you have to find and extend every such switch in the project, while a forgotten branch fails silently at run time. Usually the same thing is solved by a method on the object itself: let the shape compute its own price. More on this in GRASP: Polymorphism and OCP.

There are useful cases too: parsing incoming messages of different formats, handling errors by exception type, working with foreign types you cannot attach a method to.

In summary

Unlike the other two basic concepts, polymorphism never got a single implementation. Encapsulation is written down differently too, but within one language the mechanism is usually one, maybe two: access modifiers – and possibly properties. Inheritance comes down to a single construct altogether.

Polymorphism is different. Take C#: overloading, generics, interfaces, virtual methods, dynamic – five different mechanisms in one language, each with its own syntax, its own moment of choice and its own price. And that is not redundancy: each covers its own case, which is why none of them displaced the others.

When you hear «there is polymorphism here», ask when the choice is made and how it is written down. Both the speed and the place a mistake surfaces depend on it.