Dynamic Dispatch with Virtual Functions
Use the virtual keyword to enable runtime method dispatch.
What Is a Virtual Function?
A virtual function is a member function marked with the virtual keyword, which tells the program to decide at runtime which version of that function to run based on the object actually sitting in memory, rather than on the type written in your source code. That single change is what lets you drive an entire family of related classes through one base class interface.
The previous lesson left us holding a specific frustration. A base class pointer or reference will happily bind to a derived object, but every call made through it lands in the base class version of the function. Here is that frustration in its smallest form:
#include <iostream>
#include <string_view>
class Sensor
{
public:
std::string_view unit() const { return "counts"; }
};
class Barometer : public Sensor
{
public:
std::string_view unit() const { return "hPa"; }
};
int main()
{
Barometer roofTop{};
Sensor& channel{ roofTop };
std::cout << "reported in " << channel.unit() << '\n';
return 0;
}
reported in counts
channel is bound to a Barometer, and yet Barometer::unit() never runs. Removing that gap is the whole subject of this lesson.
Static Type Versus Dynamic Type
Before we reach for the keyword, it pays to name the two types that are in play on every member function call.
The static type of an expression is the type the compiler sees while reading your code. Above, channel has static type Sensor&, and nothing in the source of that one line says otherwise.
The dynamic type is the type of the object that actually exists at that address when the call happens. Above, that object is a Barometer.
For an ordinary member function, only the static type gets a vote. The compiler chooses the function while compiling, wires the call directly to Sensor::unit(), and by the time the program runs there is nothing left to decide. virtual moves the deciding vote from the static type to the dynamic type:
| Kind of member function | Which type chooses the body | When the choice is made |
|---|---|---|
| Non-virtual | static type of the pointer, reference, or object | compile time |
| Virtual | dynamic type of the referenced object | run time |
The formal names for the two columns on the right are early binding and late binding, and a later lesson in this chapter takes them apart in detail.
Turning the Rule Around with virtual
Making a function virtual takes one keyword, placed before the function declaration in the base class:
#include <iostream>
#include <string_view>
class Sensor
{
public:
virtual std::string_view unit() const { return "counts"; }
};
class Barometer : public Sensor
{
public:
virtual std::string_view unit() const { return "hPa"; }
};
int main()
{
Barometer roofTop{};
Sensor& channel{ roofTop };
std::cout << "reported in " << channel.unit() << '\n';
return 0;
}
reported in hPa
The compiler still sees a call on a Sensor&, but it now sees that Sensor::unit() is virtual, so instead of wiring the call to a fixed address it emits code that asks the object itself which version to run. At runtime that object is a Barometer, so Barometer::unit() runs. The derived class function that gets chosen this way is called an override of the base class function.
Once a function is virtual in a base class, every matching override further down the hierarchy is implicitly virtual too, whether or not you repeat the keyword. Writing
virtual again in a derived class is legal and documents intent, but it does not add anything. The relationship does not run the other way: marking a derived class function virtual never makes the base class function virtual, so a call through a base reference will still be resolved statically.
What Counts as an Override
A derived function only overrides a base function when it matches it exactly. The name, the parameter types, and whether the function is const all have to agree, and the return type has to agree as well.
| How the derived function differs | What you get |
|---|---|
| Nothing differs | a real override, dispatched at runtime |
| A parameter type differs | an unrelated function, no dispatch |
The derived function drops const |
an unrelated function, no dispatch |
| Only the return type differs | a compile error |
The middle two rows are the dangerous ones, because your program still builds. The version below is wrong on purpose: Barometer::unit() forgot its const, so it is not an override at all, merely a different function that happens to share a name.
#include <iostream>
#include <string_view>
class Sensor
{
public:
virtual std::string_view unit() const { return "counts"; }
};
class Barometer : public Sensor
{
public:
virtual std::string_view unit() { return "hPa"; }
};
int main()
{
Barometer roofTop{};
Sensor& channel{ roofTop };
std::cout << "reported in " << channel.unit() << '\n';
return 0;
}
reported in counts
GCC does flag this one, with a warning that Sensor::unit() const was hidden by Barometer::unit() under -Woverloaded-virtual. A mismatched parameter type will often slip through with no diagnostic at all.
A near miss in the signature produces a silent loss of polymorphism, not an error. Never assume a derived function is overriding something because it looks like it should be. C++ has a dedicated specifier,
override, that turns this class of mistake into a compile error, and the next lesson is devoted to it.
Dispatch Needs a Pointer or a Reference
Virtual resolution has one hard prerequisite: the call has to travel through a pointer or a reference to a class type. Only then are there two different types for the program to choose between.
#include <iostream>
#include <string_view>
class Sensor
{
public:
virtual std::string_view unit() const { return "counts"; }
};
class Barometer : public Sensor
{
public:
virtual std::string_view unit() const { return "hPa"; }
};
int main()
{
Barometer roofTop{};
Sensor* handle{ &roofTop };
Sensor& channel{ roofTop };
Sensor copied{ roofTop };
std::cout << "object: " << roofTop.unit() << '\n';
std::cout << "pointer: " << handle->unit() << '\n';
std::cout << "reference: " << channel.unit() << '\n';
std::cout << "copy: " << copied.unit() << '\n';
return 0;
}
object: hPa
pointer: hPa
reference: hPa
copy: counts
The first line is not dynamic dispatch at all. roofTop is a Barometer object, so its static and dynamic types are the same thing and there is nothing to resolve. The last line is the one worth studying: copied is a Sensor object, and initializing it from a Barometer copies only the Sensor part of that object. The Barometer part is left behind, and what remains genuinely is a Sensor, so Sensor::unit() is the honest answer. This is called object slicing, and it gets a lesson of its own later in this chapter.
The dispatch machinery is reachable through exactly two doors: a pointer to a class type, or a reference to one. Call the member on a plain object instead and there is nothing to resolve, so you always get that object's own version.
Resolving Through a Deeper Hierarchy
When the hierarchy runs more than two levels deep, the program picks the most-derived override that lies between the static type and the dynamic type. Classes below the dynamic type are never candidates, because the object is not one of them.
#include <iostream>
#include <string_view>
class Sensor
{
public:
virtual std::string_view unit() const { return "counts"; }
};
class Thermometer : public Sensor
{
public:
virtual std::string_view unit() const { return "degC"; }
};
class SoilProbe : public Thermometer
{
};
class CryoProbe : public SoilProbe
{
public:
virtual std::string_view unit() const { return "K"; }
};
int main()
{
SoilProbe bed{};
CryoProbe freezer{};
Sensor& first{ bed };
Sensor& second{ freezer };
std::cout << "bed: " << first.unit() << '\n';
std::cout << "freezer: " << second.unit() << '\n';
return 0;
}
bed: degC
freezer: K
bed is a SoilProbe, which declares no unit() of its own, so the search walks back up the chain and stops at Thermometer::unit(). CryoProbe::unit() is not considered for bed at any point, even though CryoProbe is more derived than SoilProbe: the object simply is not a CryoProbe. For freezer the same search starts one level lower and finds CryoProbe::unit() immediately.
Two Kinds of Polymorphism
Polymorphism means an entity having more than one form. C++ gives you two varieties, and they differ in when the form is chosen.
Compile-time polymorphism is settled before the program ever runs. Function overloading is the everyday case: the name scale below has two forms, and the argument types tell the compiler which one to bake into the call.
int scale(int reading);
double scale(double reading);
Template instantiation belongs in the same category, since the compiler generates and selects the code before the program ever runs.
Runtime polymorphism is resolved while the program is running, and virtual function resolution is the mechanism that provides it. It is the only one of the two that can react to information the compiler did not have, such as which kind of sensor a configuration file asked for.
One Function, Many Sensor Types
The payoff is not that a single call prints a different string. It is that you can write a function against the base class once and have it work for every class in the hierarchy, including classes that do not exist yet.
#include <iostream>
#include <string>
#include <string_view>
#include <vector>
class Sensor
{
protected:
std::string m_id{};
double m_value{};
public:
Sensor(std::string_view id, double value)
: m_id{ id }, m_value{ value }
{
}
const std::string& label() const { return m_id; }
double value() const { return m_value; }
virtual std::string_view unit() const { return "counts"; }
};
class Barometer : public Sensor
{
public:
Barometer(std::string_view id, double value)
: Sensor{ id, value }
{
}
virtual std::string_view unit() const { return "hPa"; }
};
class Hygrometer : public Sensor
{
public:
Hygrometer(std::string_view id, double value)
: Sensor{ id, value }
{
}
virtual std::string_view unit() const { return "%RH"; }
};
class Thermometer : public Sensor
{
public:
Thermometer(std::string_view id, double value)
: Sensor{ id, value }
{
}
virtual std::string_view unit() const { return "degC"; }
};
void publish(const Sensor& sensor)
{
std::cout << sensor.label() << " = " << sensor.value() << ' ' << sensor.unit() << '\n';
}
int main()
{
Barometer roofTop{ "roof-1", 1013.2 };
Hygrometer atrium{ "atrium-4", 41.5 };
Thermometer soilBed{ "soil-9", 18.4 };
std::vector<const Sensor*> installed{ &roofTop, &atrium, &soilBed };
for (const Sensor* sensor : installed)
{
publish(*sensor);
}
return 0;
}
roof-1 = 1013.2 hPa
atrium-4 = 41.5 %RH
soil-9 = 18.4 degC
Three things follow from this program. publish() was written once and knows about exactly one class. The three sensors could be stored in a single container, which is only possible because they share a base type. And an Anemometer added next month would print correctly through both publish() and that loop without a line of either being touched.
Notice also that label() and value() were left non-virtual. Neither one is meant to behave differently in a derived class, so neither has any reason to pay for dispatch. Virtual is a decision you make per function, not per class.
The One Place Dispatch Goes Quiet
There is a window during which the dynamic type of an object is not what you would expect: while its constructors and destructors are running.
A derived object is built base part first. When Sensor's constructor runs, the Barometer part of the object does not exist yet, so the language treats the object as a Sensor for the duration of that constructor. A virtual call made there resolves to the base version, not because the compiler is confused, but because there is nothing more derived to call yet.
#include <iostream>
#include <string_view>
class Sensor
{
public:
Sensor()
{
std::cout << "calibrating in " << unit() << '\n';
}
virtual std::string_view unit() const { return "counts"; }
};
class Barometer : public Sensor
{
public:
virtual std::string_view unit() const { return "hPa"; }
};
int main()
{
Barometer roofTop{};
std::cout << "measuring in " << roofTop.unit() << '\n';
return 0;
}
calibrating in counts
measuring in hPa
The same object answers the same question two different ways, seconds apart. Destruction has the mirror image of this problem: derived parts are torn down first, so by the time a base destructor runs, the derived part is already gone and a virtual call again reaches only the base version.
Never call a virtual function from a constructor or a destructor. If a base class needs derived behaviour during setup, pass the result in as a constructor argument or call the function from the outside once the object is fully built.
What Virtual Costs
Given how useful this is, why not mark every function virtual and be done with it? Because dispatch is not free.
A virtual call cannot be a direct jump to a known address, so it costs more than an ordinary call, and it is much harder for the compiler to inline. On top of that, every object of a class with virtual functions carries a hidden pointer that the runtime uses to find the right function, which enlarges every instance. For a class that holds a couple of small members, that pointer can be a noticeable fraction of the object.
In practice these costs are small and rarely the thing that makes a program slow. Correctness comes first: mark a function virtual when a derived class is genuinely expected to change its behaviour and the call will arrive through a base pointer or reference, and leave everything else alone.
A class with virtual functions almost always needs a virtual destructor as well, otherwise deleting a derived object through a base class pointer has undefined behaviour. A later lesson in this chapter covers virtual destructors properly.
Summary
Virtual function: A member function declared with the virtual keyword. Calls made through a base class pointer or reference resolve to the most-derived matching version for the object's actual type, instead of to the base class version.
Static and dynamic type: The static type is what the compiler reads in your source; the dynamic type is what the object really is at runtime. Non-virtual calls are decided by the static type at compile time, virtual calls by the dynamic type at runtime.
Override: A derived function that matches a base virtual function in name, parameter types, constness, and return type. Any mismatch in the signature quietly produces an unrelated function rather than an override, and a mismatch in the return type alone is a compile error.
Implicit virtuality: Once a function is virtual in a base class, matching overrides in every derived class are virtual whether or not the keyword is repeated. Marking a derived function virtual does not make the base class function virtual.
Pointer or reference required: Virtual resolution only happens when the call goes through a pointer or reference to a class type. Calling on an object by value always runs that object's own version, and copying a derived object into a base object slices away the derived part.
Polymorphism: An entity having many forms. Overload resolution and template instantiation are compile-time polymorphism; virtual function resolution is runtime polymorphism.
Constructors and destructors: Never call a virtual function from either. During construction the derived part does not exist yet, and during destruction it has already been destroyed, so both resolve to the base class version.
Cost: Virtual calls are slower than direct calls and resist inlining, and every object of a class with virtual functions carries an extra hidden pointer. Make individual functions virtual where polymorphic behaviour is needed rather than making whole classes virtual by reflex.
How did you find this lesson?
Your rating helps us improve the content.
Create an account to track your progress and access interactive exercises. Already have one? Sign in.
Dynamic Dispatch with Virtual Functions - Quiz
Test your understanding of the lesson.
Practice Exercises
Virtual Functions
Implement virtual functions to enable runtime polymorphism. Practice overriding virtual functions in derived classes to provide specialized behavior.
Lesson Discussion
Share your thoughts and questions
No comments yet. Be the first to share your thoughts!