Updated on 29 Jun, 202624 mins read 24 views

Historical Context

To appreciate polymorphism, we need to understand how programs were written before object-oriented languages became popular.

In procedural programming, behavior was usually selected using conditional statements.

Imagine writing a graphics application in C.

struct Shape
{
	int type;
};

Then:

void draw(Shape* shape)
{
    switch (shape->type)
    {
        case CIRCLE:
            drawCircle(shape);
            break;

        case RECTANGLE:
            drawRectangle(shape);
            break;

        case TRIANGLE:
            drawTriangle(shape);
            break;
    }
}

This worked initially.

But imagine that the application grows.

Now it supports:

  • Circle
  • Rectangle
  • Triangle
  • Ellipse
  • Polygon
  • Star
  • Hexagon
  • Bezier Curve

Every time a new shape is introduced:

  1. Add a new type.
  2. Modify the switch
  3. Recompile
  4. Retest everything.

The central function grows larger and becomes responsible for knowing every possible shape.

This design has several drawbacks:

  • It violates the Open/Closed Principle
  • It tightly couples behavior to a central dispatcher.
  • Every time a new feature requires modifying existing code.
  • The code becomes increasingly difficult to maintain.

Object-oriented programming introduced a different idea.

Instead of asking:

β€œWhat type are you?”

and then decide what to do,

we simply tell the object:

β€œDraw yourself.”

The object knows how to perform the operation appropriate to its own type.

This shift from external decision-making to object responsibility is one of the defining ideas of object-oriented design.

What Is Polymorphism?

The word polymorphism comes from two Greek words:

  • Poly = many
  • Morph = forms

Literally:

Many forms

In software engineering, polymorphism means:

One interface, many implementations.

Consider a remote control.

Press Power Button
  ↓
TV turns on
  ↓
Air Conditioner turns on
  ↓
Projector turns on

The user performs the same action:

Power()

Different devices perform different implementations.

The interface is the same.

The behavior varies.

That is polymorphism.

Formal Definition

Polymorphism is the ability to use a common interface while allowing different object types to provide their own behavior.

What Does β€œMany Forms” Really Mean?

Recall the literal meaning of polymorphism:

One interface, many forms.

It doesn't say:

One object, many forms.

It says:

One interface can have many implementations.

The way those implementations are selected differs depending on the type of polymorphism.

Sometimes the compiler decides.

Sometimes the runtime decides.

This leads to the two major categories.

                    Polymorphism

                  /               \

      Compile-Time             Run-Time

      (Static)                 (Dynamic)

Compile-Time (Static) Polymorphism

As the name suggests, the decision is made before the program runs.

The compiler already knows exactly which implementation should be called.

Source Code
  ↓
Compiler
  ↓
Choose Function
  ↓
Executable

When the executable runs, there is no decision left to make.

Everything has already been resolved.

Forms of Compile-Time Polymorphism

C++ provides three primary mechanisms.

Compile-Time Polymorphism

β”œβ”€β”€ Function Overloading

β”œβ”€β”€ Operator Overloading

└── Templates

Each one represents polymorphism, but in a different way.

Function Overloading

Suppose we write a calculator.

We want one function named

add()

But users may add:

  • integers
  • doubles
  • three numbers
  • vectors

Instead of inventing new names,

addInt()
addDouble()
addThreeNumbers()

we write

class Calculator
{
public:

    int add(int a, int b)
    {
        return a + b;
    }

    double add(double a, double b)
    {
        return a + b;
    }

    int add(int a, int b, int c)
    {
        return a + b + c;
    }
};

Usage:

Calculator calculator;

calculator.add(2, 3);

calculator.add(2.5, 3.7);

calculator.add(1, 2, 3);

The interface

add()

remains the same.

Different parameter lists produce different implementations.

The compiler decides which one to call. This is compile-time polymorphism.

How Does the Compiler Decide?

Suppose:

calculator.add(10, 20);

Compiler reasoning

Arguments
  ↓
(int, int)
  ↓
Best Match
  ↓
add(int,int)

No runtime decision exists.

Everything is known during compilation.

Operator Overloading

Operators are simply functions with special syntax.

Normally,

+

add integers.

5 + 3

But suppose we create a

Complex

number.

class Complex
{
public:

    int real;

    int imaginary;
};

How should this work?

Complex c1;
Complex c2;

Complex c3 = c1 + c2;

The compiler doesn't know.

We teach it.

class Complex
{
public:

    int real;

    int imaginary;

    Complex operator+(const Complex& other)
    {
        return
        {
            real + other.real,

            imaginary + other.imaginary
        };
    }
};

Now

+

has different meanings.

Integers
  ↓
Addition

Complex Numbers
  ↓
Complex Addition

Same operator.

Different implementation.

Compile-time polymorphism.

Template Polymorphism

Templates provide another form.

Suppose we write:

int maximum(int a, int b);

Later, users want

double
string
Employee
Date

Instead of writing many functions,

we write

template<typename T>

T maximum(T a, T b)
{
	return (a > b) ? a : b;
}

Usage:

maximum(5, 10);
maximum(2.3, 5.5);

maximum(std::string("A), std::string("B"));

The compiler generates specialized code.

Again,

everything happens before execution.

Run-Time (Dynamic) Polymorphism

Now consider a different situation.

Suppose we have

Animal
 ↓
Dog
 ↓
Cat
 ↓
Horse

At compile time, the compiler only knows

Animal*

It does not know whether the object will actually be

Dog
Cat
Horse

That decision is made while the program is running.

Program Starts
  ↓
Object Created
  ↓
Pointer Assigned
  ↓
Correct Function Selected

This is runtime polymorphism.

Example:

class Animal
{
public:

    virtual void speak()
    {
        std::cout << "Animal\n";
    }
};

class Dog : public Animal
{
public:

    void speak() override
    {
        std::cout << "Bark\n";
    }
};

class Cat : public Animal
{
public:

    void speak() override
    {
        std::cout << "Meow\n";
    }
};

Output:

Bark

Later

animal = new Cat();
animal->speak();

Output:

Meow

Notice, the code

animal->speak();

never changes. Only the object changes.

Why Can't the Compiler Decide?

Suppose:

Animal* animal;

Compiler asks

Will this point to

Dog?
Cat?
Horse?
Tiger?

It cannot know.

Maybe

animal = loadANimalFromDatabase();

Maybe

animal = createAnimalBasedOnUserInput();

Maybe

animal = receiveAnimalOverNetwork();

The object depends on runtime information.

Therefore the decision must wait until runtime.

Static vs Dynamic Polymorphism

FeatureCompile-TimeRun-Time
Decision TimeCompilationExecution
MechanismOverloading, Operators, TemplatesVirtual Functions
PerformanceFasterSlightly Slower
FlexibilityLessMore
Uses InheritanceNoYes (typically)
Uses virtualNoYes
Buy Me A Coffee

Leave a comment

Your email address will not be published. Required fields are marked *