C++ Module 8 – Inheritance and polymorphism
Virtual functions, override and virtual destructors
Run the derived class's code through base-class pointers with C++ virtual functions, catch mistakes with override, and avoid slicing and leaky deletes.
What you will learn
- Use virtual functions to run derived-class code through base-class references and pointers
- Catch overriding mistakes at compile time with override and final
- Explain why a base class deleted through pointers needs a virtual destructor
- Avoid object slicing by passing polymorphic objects by reference
- Predict which function a virtual call runs during construction and destruction
Before you start
What you’ll learn · Before you start
What you will learn
- Use virtual functions to run derived-class code through base-class references and pointers
- Catch overriding mistakes at compile time with override and final
- Explain why a base class deleted through pointers needs a virtual destructor
- Avoid object slicing by passing polymorphic objects by reference
- Predict which function a virtual call runs during construction and destruction
Before you start
On this page
One loop over three shapes, each held through a pointer to Shape, prints 0.071 m² for a pizza, 0.960 m² for a table
and 0 for a plain sketch: every call to area() runs the version of the object’s own class. A
virtual function: A member function declared virtual in a base class. A derived class may override it with a function of the same name, parameters and const, and a call through a reference or a pointer to the base then runs the version of the object's own class. buys exactly that, and one slip can undo it: a misspelt name prints
0 without a single warning, and a destructor without virtual leaves 800,000 bytes behind on every delete.
You will run derived-class code through base-class references and pointers, see what the hidden pointer behind each
call costs, catch overriding slips with override and final, avoid slicing and leaky deletes, and predict which
function runs while an object is being built or destroyed. Each rule, and each mistake, runs with Apple clang 21 and
GCC 16.
A virtual call runs the object’s own function
A virtual function is a member function that a derived class can override. When you call it through a reference
or a pointer to the base class, the program runs the version that belongs to the object’s actual class, chosen while
the program runs. This is run-time polymorphism: one loop over a std::vector of shapes can ask each one for its
area, whether it is a circle, a rectangle or a kind of shape written next year. You declare the function virtual in
the base class and write override on each replacement in a derived class, which asks the compiler to check that it
really replaces a virtual function:
// One loop over different kinds of shape: each call to area() runs the version of the object's own class.
#include <format>
#include <memory>
#include <numbers>
#include <print>
#include <string>
#include <utility>
#include <vector>
class Shape {
public:
explicit Shape(std::string name) : name_(std::move(name)) {}
virtual ~Shape() = default;
const std::string& name() const { return name_; }
// virtual: a derived class may override it, and calls through a Shape& or Shape* run the override.
virtual double area() const { return 0.0; }
// Not virtual, but it calls area(), so it works for every kind of shape.
std::string label() const { return std::format("{}: {:.3f} m²", name_, area()); }
private:
std::string name_;
};
class Circle : public Shape {
public:
Circle(std::string name, double radius) : Shape(std::move(name)), radius_(radius) {}
double area() const override { return std::numbers::pi * radius_ * radius_; }
private:
double radius_;
};
class Rect : public Shape {
public:
Rect(std::string name, double width, double height) : Shape(std::move(name)), width_(width), height_(height) {}
double area() const override { return width_ * height_; }
private:
double width_, height_;
};
int main() {
// Each std::unique_ptr owns one object, of any class derived from Shape, and deletes it at the end of main.
std::vector<std::unique_ptr<Shape>> shapes;
shapes.push_back(std::make_unique<Circle>("pizza", 0.15));
shapes.push_back(std::make_unique<Rect>("table", 1.2, 0.8));
shapes.push_back(std::make_unique<Shape>("sketch"));
double total = 0;
for (const auto& shape : shapes) {
std::println("{}", shape->label());
total += shape->area();
}
std::println("total: {:.3f} m²", total);
} from session import run
# shapes.cpp built and run with Apple clang, then with GCC 16.
run("""
clang++ -std=c++23 -Wall -Wextra shapes.cpp -o shapes && ./shapes
g++-16 -std=c++23 -Wall -Wextra shapes.cpp -o shapes-gcc && ./shapes-gcc
""") Output
$ clang++ -std=c++23 -Wall -Wextra shapes.cpp -o shapes && ./shapes pizza: 0.071 m² table: 0.960 m² sketch: 0.000 m² total: 1.031 m² $ g++-16 -std=c++23 -Wall -Wextra shapes.cpp -o shapes-gcc && ./shapes-gcc pizza: 0.071 m² table: 0.960 m² sketch: 0.000 m² total: 1.031 m²
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 shapes_session.py
Every element of shapes has the same static type: The type the compiler sees for an expression, from the declarations alone: through a Shape* the object has the static type Shape, whatever it really is. Calls to ordinary member functions are chosen from it., the type the compiler sees, which is
a pointer to Shape. Each one points to an object of a different dynamic type: The class of the object an expression refers to while the program runs: a Shape* that points to a Circle reaches an object of the dynamic type Circle. Calls to virtual functions are chosen from it., the
class the object really has: a Circle, a Rect and a plain Shape. A call to an ordinary member function is chosen
from the static type when the program is compiled, as in the previous lesson. A call to a virtual function is chosen
from the dynamic type when it runs. Step through the loop and watch one line of code reach three functions:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- Three alike pointers. The compiler sees the same type for all three elements: a pointer to Shape. Each one points to an object of a different class, made by std::make_unique.
- The first pass. shape->label() calls area() through a Shape*. The object is a Circle, so the call runs Circle::area(): pizza: 0.071 m².
- The same line again. The second pass runs the same line of code, but the object is a Rect, so Rect::area() runs: table: 0.960 m².
- The base's own version. The third object is a plain Shape. Shape's own area() runs and returns 0: sketch: 0.000 m².
- Chosen while it runs. The loop printed total: 1.031 m². One call in the source ran three different functions, each chosen from the class of the object while the program ran.
label() is not virtual, yet it prints the right area for every shape, because the call to area() inside it is a
virtual call too. A non-virtual function in the base class can therefore fix the steps of a job while derived classes
supply the details. The next lesson turns that observation into a design.
A few details of the syntax:
virtualis written in the base class. A function in a derived class with the same name, parameters andconstoverrides it, and is itself virtual whether or not it repeats the keyword.overridecomes after the parameter list and theconst, as indouble area() const override.std::unique_ptr<Shape>owns one object, aShapeor an object of any class derived from it, and deletes it when the vector is destroyed. A later module covers smart pointers in depth.
Checkpoint
Check: Virtual calls follow the dynamic type
Virtual functions cost one pointer per object
The standard says which function a virtual call runs, not how. GCC and Clang implement it as the Itanium C++ ABI describes: every object of a class with virtual functions carries a hidden pointer, the vptr: The hidden pointer that GCC and Clang store in each object of a class with virtual functions. It points to the virtual table of the object's class and takes 8 bytes on a 64-bit computer, however many virtual functions the class has., to a table of function addresses that is shared by all objects of its class, the virtual table: A table of function addresses that GCC and Clang build once for each class with virtual functions, shared by all its objects. A virtual call reads the object's vptr, takes the function's entry from this table and jumps to it.. The drawing shows two circles sharing one table:
How GCC and Clang find the function for a virtual call
Text description of the diagram
The diagram follows one virtual call, shape->area(), where shape is a Shape* that points to pizza, a Circle. This is how GCC and Clang implement virtual calls; the C++ standard only says which function must run.
- pizza holds a hidden pointer, the vptr, 8 bytes on a 64-bit computer, before its members name_ and radius_. The call first reads that pointer.
- The vptr points to Circle's virtual table, which lists the virtual functions of the class Circle: its destructor ~Circle() and Circle::area(). The call looks up the entry for area().
- A second object, coin, which is also a Circle, has its own vptr that points to the same table: there is one table for each class, not one for each object.
- The call then jumps to the function found in the entry, so Circle::area() runs. An object of class Rect would carry a vptr to Rect's table, and the same call would run Rect::area().
The hidden pointer has a size you can measure:
// What a virtual function costs in every object: the size of three classes that each hold one double.
#include <print>
struct Plain {
double x;
double area() const { return x; }
};
struct OneVirtual {
double x;
virtual ~OneVirtual() = default;
virtual double area() const { return x; }
};
struct FourVirtual {
double x;
virtual ~FourVirtual() = default;
virtual double area() const { return x; }
virtual double perimeter() const { return x; }
virtual double width() const { return x; }
};
int main() {
std::println("Plain {:>2} bytes", sizeof(Plain));
std::println("OneVirtual {:>2} bytes", sizeof(OneVirtual));
std::println("FourVirtual {:>2} bytes", sizeof(FourVirtual));
} from session import run
# sizes.cpp on 64-bit arm64 with both compilers.
run("""
clang++ -std=c++23 -Wall -Wextra sizes.cpp -o sizes && ./sizes
g++-16 -std=c++23 -Wall -Wextra sizes.cpp -o sizes-gcc && ./sizes-gcc
""") Output
$ clang++ -std=c++23 -Wall -Wextra sizes.cpp -o sizes && ./sizes Plain 8 bytes OneVirtual 16 bytes FourVirtual 16 bytes $ g++-16 -std=c++23 -Wall -Wextra sizes.cpp -o sizes-gcc && ./sizes-gcc Plain 8 bytes OneVirtual 16 bytes FourVirtual 16 bytes
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 sizes_session.py
A class with one double is 8 bytes. Give it virtual functions, as OneVirtual does with a virtual destructor and a
virtual area(), and it grows to 16: the double plus an 8-byte vptr. FourVirtual, with four virtual functions, is
still 16, because the extra entries live in the one table for the class, not in each object. The other cost is the
call itself. Step through one call for the pizza and then for the table:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The class is unknown. shape is a Shape* that points to pizza. The compiler cannot know which class the object will have, so it cannot choose the function when it compiles the call.
- 1. Read the vptr. The call first reads the hidden pointer inside pizza, its vptr: 8 bytes, which is why a class holding one double grew from 8 to 16 bytes.
- 2. Read the table entry. The vptr points to Circle's virtual table, one table shared by every Circle. The call reads the entry for area() from it.
- 3. Jump to the address. The call jumps to the address it found, so Circle::area() runs and returns 0.071 m² for the pizza.
- Another object, same code. With shape pointing to table, the same instructions read table's vptr, which points to Rect's table, so Rect::area() runs: 0.960 m².
- What it costs. Each object pays one pointer, whatever the number of virtual functions: four virtual functions still made 16 bytes. Each call pays two reads and a jump the compiler usually cannot inline.
Instead of jumping to a known function, the program reads the vptr, reads the table entry and jumps to the address it finds, and the compiler usually cannot inline a call whose target is known only while the program runs. A later lesson in this module times virtual calls against the alternatives on this computer.
Key idea
With GCC and Clang a virtual function costs one hidden pointer per object, one table per class, and two reads before each call jumps, however many virtual functions the class has.
Checkpoint
Check: The vptr and the virtual table
override turns a wrong override into an error
Without override, a function meant to override a virtual function compiles even when it overrides nothing. These
two files each contain such a slip, and the session builds each one as written and then with -DWITH_OVERRIDE, which
adds the keyword:
C++ (misspelt.cpp) · misspelt.cpp
// Square means to override perimeter(), but the name is misspelt. Built with -DWITH_OVERRIDE, the declaration says
// override, and the compiler checks it.
#include <print>
class Shape {
public:
virtual ~Shape() = default;
virtual double perimeter() const { return 0.0; }
};
class Square : public Shape {
public:
explicit Square(double side) : side_(side) {}
#ifdef WITH_OVERRIDE
double perimiter() const override { return 4 * side_; }
#else
double perimiter() const { return 4 * side_; }
#endif
private:
double side_;
};
int main() {
Square tile{0.3};
const Shape& shape = tile;
std::println("perimeter through a Shape&: {}", shape.perimeter());
} C++ (missing_const.cpp) · missing_const.cpp
// Square's area() lacks the const of Shape::area(), so it is a different function that hides the virtual one.
#include <print>
class Shape {
public:
virtual ~Shape() = default;
virtual double area() const { return 0.0; }
};
class Square : public Shape {
public:
explicit Square(double side) : side_(side) {}
#ifdef WITH_OVERRIDE
double area() override { return side_ * side_; }
#else
double area() { return side_ * side_; }
#endif
private:
double side_;
};
int main() {
Square tile{0.3};
const Shape& shape = tile;
std::println("area through a Shape&: {}", shape.area());
} from session import run
# Each file is built as written, then with -DWITH_OVERRIDE, which adds override to Square's declaration.
run("""
clang++ -std=c++23 -Wall -Wextra misspelt.cpp -o misspelt && ./misspelt
g++-16 -std=c++23 -Wall -Wextra misspelt.cpp -o misspelt-gcc && ./misspelt-gcc
clang++ -std=c++23 -Wall -Wextra -DWITH_OVERRIDE -c misspelt.cpp
g++-16 -std=c++23 -Wall -Wextra -DWITH_OVERRIDE -c misspelt.cpp
clang++ -std=c++23 -Wall -Wextra missing_const.cpp -o missing_const && ./missing_const
g++-16 -std=c++23 -Wall -Wextra missing_const.cpp -o missing_const-gcc && ./missing_const-gcc
clang++ -std=c++23 -Wall -Wextra -DWITH_OVERRIDE -c missing_const.cpp
g++-16 -std=c++23 -Wall -Wextra -DWITH_OVERRIDE -c missing_const.cpp
""") Output
$ clang++ -std=c++23 -Wall -Wextra misspelt.cpp -o misspelt && ./misspelt
perimeter through a Shape&: 0
$ g++-16 -std=c++23 -Wall -Wextra misspelt.cpp -o misspelt-gcc && ./misspelt-gcc
perimeter through a Shape&: 0
$ clang++ -std=c++23 -Wall -Wextra -DWITH_OVERRIDE -c misspelt.cpp
misspelt.cpp:15:30: error: only virtual member functions can be marked 'override'
15 | double perimiter() const override { return 4 * side_; }
| ^~~~~~~~
1 error generated.
[exit status 1]
$ g++-16 -std=c++23 -Wall -Wextra -DWITH_OVERRIDE -c misspelt.cpp
misspelt.cpp:15:12: error: 'double Square::perimiter() const' marked 'override', but does not override
15 | double perimiter() const override { return 4 * side_; }
| ^~~~~~~~~
[exit status 1]
$ clang++ -std=c++23 -Wall -Wextra missing_const.cpp -o missing_const && ./missing_const
missing_const.cpp:16:12: warning: 'Square::area' hides overloaded virtual function [-Woverloaded-virtual]
16 | double area() { return side_ * side_; }
| ^
missing_const.cpp:7:20: note: hidden overloaded virtual function 'Shape::area' declared here: different qualifiers ('const' vs unqualified)
7 | virtual double area() const { return 0.0; }
| ^
1 warning generated.
area through a Shape&: 0
$ g++-16 -std=c++23 -Wall -Wextra missing_const.cpp -o missing_const-gcc && ./missing_const-gcc
missing_const.cpp:7:20: warning: 'virtual double Shape::area() const' was hidden [-Woverloaded-virtual=]
7 | virtual double area() const { return 0.0; }
| ^~~~
missing_const.cpp:16:12: note: by 'double Square::area()'
16 | double area() { return side_ * side_; }
| ^~~~
area through a Shape&: 0
$ clang++ -std=c++23 -Wall -Wextra -DWITH_OVERRIDE -c missing_const.cpp
missing_const.cpp:14:19: error: non-virtual member function marked 'override' hides virtual member function
14 | double area() override { return side_ * side_; }
| ^
missing_const.cpp:7:20: note: hidden overloaded virtual function 'Shape::area' declared here: different qualifiers ('const' vs unqualified)
7 | virtual double area() const { return 0.0; }
| ^
1 error generated.
[exit status 1]
$ g++-16 -std=c++23 -Wall -Wextra -DWITH_OVERRIDE -c missing_const.cpp
missing_const.cpp:14:12: error: 'double Square::area()' marked 'override', but does not override
14 | double area() override { return side_ * side_; }
| ^~~~
missing_const.cpp:7:20: warning: 'virtual double Shape::area() const' was hidden [-Woverloaded-virtual=]
7 | virtual double area() const { return 0.0; }
| ^~~~
missing_const.cpp:14:12: note: by 'double Square::area()'
14 | double area() override { return side_ * side_; }
| ^~~~
[exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 override_session.py
perimiter()is a new function with a misspelt name. It compiles on both compilers without one warning, even with-Wall -Wextra, and the call throughShape&silently runsShape::perimeter()and prints 0. Withoverride, both compilers reject the declaration.area()withoutconstis a different function fromarea() const, so it hides the virtual one instead of overriding it. Here-Wallhelps: Clang and GCC both warn with-Woverloaded-virtual. The program still prints 0, and onlyoverrideturns the mistake into an error.
Read each slip across the two builds:
| Criterion | Without override | With override |
|---|---|---|
| perimiter(), misspelt | Compiles, no warning; prints 0 | Error on both compilers |
| area() without const | -Woverloaded-virtual; prints 0 | Error on both compilers |
Rule C.128 of the C++ Core Guidelines makes this a habit: write virtual only on the first declaration in a base
class, and override on every function that replaces one. Both compilers also offer -Wsuggest-override, which is
not part of -Wall or -Wextra and reports every overriding function that lacks the keyword, destructors aside.
final goes one step further. A function marked final overrides like override does, and no class below may
override it again; a class marked final may not be derived from at all:
// final on a function: no class below may override it. final on a class: no class may derive from it.
class Shape {
public:
virtual ~Shape() = default;
virtual double area() const { return 0.0; }
};
class Square : public Shape {
public:
double area() const final { return 1.0; }
};
class Tile : public Square {
public:
double area() const override { return 2.0; } // error: Square::area() is final
};
class Circle final : public Shape {};
class Ring : public Circle {}; // error: Circle is final
int main() {} from session import run
# final.cpp with both compilers.
run("""
clang++ -std=c++23 -Wall -Wextra -c final.cpp
g++-16 -std=c++23 -Wall -Wextra -c final.cpp
""") Output
$ clang++ -std=c++23 -Wall -Wextra -c final.cpp
final.cpp:15:12: error: declaration of 'area' overrides a 'final' function
15 | double area() const override { return 2.0; } // error: Square::area() is final
| ^
final.cpp:10:12: note: overridden virtual function is here
10 | double area() const final { return 1.0; }
| ^
final.cpp:20:21: error: base 'Circle' is marked 'final'
20 | class Ring : public Circle {}; // error: Circle is final
| ^
final.cpp:18:7: note: 'Circle' declared here
18 | class Circle final : public Shape {};
| ^ ~~~~~
2 errors generated.
[exit status 1]
$ g++-16 -std=c++23 -Wall -Wextra -c final.cpp
final.cpp:15:12: error: virtual function 'virtual double Tile::area() const' overriding final function
15 | double area() const override { return 2.0; } // error: Square::area() is final
| ^~~~
final.cpp:10:12: note: overridden function is 'virtual double Square::area() const'
10 | double area() const final { return 1.0; }
| ^~~~
final.cpp:20:7: error: cannot derive from 'final' base 'Circle' in derived type 'Ring'
20 | class Ring : public Circle {}; // error: Circle is final
| ^~~~
[exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 final_session.py
Use final for a class that was not designed to be extended. Because no further override can exist, it can also
let the compiler call the function directly instead of through the table.
Checkpoint
Check: override and final
Slicing: a copy keeps only the base part
A polymorphic object only behaves polymorphically through a reference or a pointer. Pass it by value, and you get a new object of the parameter’s type:
// Passing a Circle by value as a Shape copies only the Shape part of it. Built with -DNO_COPY, Shape forbids copies,
// and the slicing lines stop compiling.
#include <numbers>
#include <print>
#include <string>
#include <utility>
class Shape {
public:
explicit Shape(std::string name) : name_(std::move(name)) {}
virtual ~Shape() = default;
#ifdef NO_COPY
Shape(const Shape&) = delete;
Shape& operator=(const Shape&) = delete;
#endif
const std::string& name() const { return name_; }
virtual double area() const { return 0.0; }
private:
std::string name_;
};
class Circle : public Shape {
public:
Circle(std::string name, double radius) : Shape(std::move(name)), radius_(radius) {}
double area() const override { return std::numbers::pi * radius_ * radius_; }
private:
double radius_;
};
void by_reference(const Shape& s) { std::println("by reference: {} has area {:.4f}", s.name(), s.area()); }
void by_value(Shape s) { std::println("by value: {} has area {:.4f}", s.name(), s.area()); }
int main() {
Circle plate{"plate", 0.13};
by_reference(plate);
by_value(plate);
Shape copy = plate; // the same slicing, in an initialisation
std::println("a copy: {} has area {:.4f}", copy.name(), copy.area());
} from session import run
# slicing.cpp as written, then with -DNO_COPY, which deletes Shape's copy operations.
run("""
clang++ -std=c++23 -Wall -Wextra slicing.cpp -o slicing && ./slicing
g++-16 -std=c++23 -Wall -Wextra slicing.cpp -o slicing-gcc && ./slicing-gcc
clang++ -std=c++23 -Wall -Wextra -DNO_COPY -c slicing.cpp
g++-16 -std=c++23 -Wall -Wextra -DNO_COPY -c slicing.cpp
""", max_lines=12) Output
$ clang++ -std=c++23 -Wall -Wextra slicing.cpp -o slicing && ./slicing
by reference: plate has area 0.0531
by value: plate has area 0.0000
a copy: plate has area 0.0000
$ g++-16 -std=c++23 -Wall -Wextra slicing.cpp -o slicing-gcc && ./slicing-gcc
by reference: plate has area 0.0531
by value: plate has area 0.0000
a copy: plate has area 0.0000
$ clang++ -std=c++23 -Wall -Wextra -DNO_COPY -c slicing.cpp
slicing.cpp:38:14: error: call to deleted constructor of 'Shape'
38 | by_value(plate);
| ^~~~~
slicing.cpp:13:5: note: 'Shape' has been explicitly marked deleted here
13 | Shape(const Shape&) = delete;
| ^
slicing.cpp:33:21: note: passing argument to parameter 's' here
33 | void by_value(Shape s) { std::println("by value: {} has area {:.4f}", s.name(), s.area()); }
| ^
slicing.cpp:39:11: error: call to deleted constructor of 'Shape'
39 | Shape copy = plate; // the same slicing, in an initialisation
| ^ ~~~~~
[4 more lines]
[exit status 1]
$ g++-16 -std=c++23 -Wall -Wextra -DNO_COPY -c slicing.cpp
slicing.cpp: In function 'int main()':
slicing.cpp:38:13: error: use of deleted function 'Shape::Shape(const Shape&)'
38 | by_value(plate);
| ~~~~~~~~^~~~~~~
slicing.cpp:13:5: note: declared here
13 | Shape(const Shape&) = delete;
| ^~~~~
slicing.cpp:38:13: note: use '-fdiagnostics-all-candidates' to display considered candidates
38 | by_value(plate);
| ~~~~~~~~^~~~~~~
slicing.cpp:33:21: note: initializing argument 1 of 'void by_value(Shape)'
33 | void by_value(Shape s) { std::println("by value: {} has area {:.4f}", s.name(), s.area()); }
[10 more lines]
[exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 slicing_session.py
by_reference sees the Circle and prints its area, 0.0531 m². by_value takes a Shape, so the compiler builds
a new Shape from the Shape part of plate with Shape’s copy constructor. The radius is left behind, the new
object’s vptr points to Shape’s table, and its area is 0. Neither compiler warns. This is
slicing: What happens when an object of a derived class is copied into a variable or a parameter of a base class type: only the base part is copied, the derived members are left behind, and the copy behaves as a plain base object., and Shape copy = plate; does exactly the same. Step through the three
lines of main and the build that forbids copies:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- A Circle. plate is a Circle: a Shape part with the name "plate", then radius_ 0.13, and a vptr to Circle's table.
- By reference. by_reference takes a const Shape&, so s refers to plate itself. s.area() is a virtual call on a Circle and runs Circle::area(): plate has area 0.0531.
- By value, a new object. by_value takes a Shape, so the call must make a new Shape. The compiler builds it from the Shape part of plate with Shape's copy constructor.
- The radius stays behind. The new Shape gets the name and nothing else: radius_ is not part of a Shape. Its vptr points to Shape's table, so s.area() runs Shape::area(): area 0.0000. Neither compiler warned.
- The same in a variable. Shape copy = plate; slices in exactly the same way, in an initialisation instead of a call: a copy: plate has area 0.0000.
- Copies forbidden. Built with -DNO_COPY, Shape's copy operations are deleted. Both slicing lines then stop compiling on both compilers, while by_reference(plate) compiles as before.
Pass polymorphic objects as const Shape& or through a pointer, as rule C.145 of the C++ Core Guidelines says. Rule
C.67 adds a safety net: a polymorphic base class should not offer public copy operations. Built with -DNO_COPY, which
deletes them, both slicing lines stop compiling. When a program really needs a copy of an object whose class it does
not know, give the base class a virtual clone() function that each derived class implements.
Taking a polymorphic object by value
void by_value(Shape s) receives a new Shape built from the base part of whatever it is given, so a Circle
arrives without its radius and reports an area of 0. Write void by_reference(const Shape& s), and the call reaches
Circle::area().
Checkpoint
Check: Slicing
A base deleted through pointers needs a virtual destructor
delete through a base-class pointer has to run the destructor of the object’s real class. Like any other member
function, the destructor is only chosen at run time if it is virtual:
// A Polygon owns a vector of 100,000 corners and is deleted through a Shape*. Built with -DVIRTUAL_DTOR, Shape's
// destructor is virtual.
#include <print>
#include <vector>
class Shape {
public:
virtual double area() const { return 0.0; }
#ifdef VIRTUAL_DTOR
virtual ~Shape() { std::println("~Shape"); }
#else
~Shape() { std::println("~Shape"); }
#endif
};
class Polygon : public Shape {
public:
Polygon() : corners_(100'000, 0.5) {}
double area() const override { return 1.0; }
~Polygon() { std::println("~Polygon frees {} corners", corners_.size()); }
private:
std::vector<double> corners_;
};
int main() {
Shape* shape = new Polygon;
std::println("area {}", shape->area());
delete shape; // which destructors run?
} from session import run
# polygon.cpp with warnings, then under AddressSanitizer and UndefinedBehaviorSanitizer, then with the fix.
run("""
clang++ -std=c++23 -Wall -Wextra polygon.cpp -o polygon && ./polygon
g++-16 -std=c++23 -Wall -Wextra polygon.cpp -o polygon-gcc && ./polygon-gcc
clang++ -std=c++23 -fsanitize=address,undefined polygon.cpp -o polygon-san && ./polygon-san
g++-16 -std=c++23 -fsanitize=address,undefined polygon.cpp -o polygon-san-gcc && ./polygon-san-gcc
clang++ -std=c++23 -Wall -Wextra -DVIRTUAL_DTOR polygon.cpp -o polygon-fixed && ./polygon-fixed
g++-16 -std=c++23 -Wall -Wextra -DVIRTUAL_DTOR polygon.cpp -o polygon-fixed-gcc && ./polygon-fixed-gcc
""") Output
$ clang++ -std=c++23 -Wall -Wextra polygon.cpp -o polygon && ./polygon
polygon.cpp:29:5: warning: delete called on non-final 'Shape' that has virtual functions but non-virtual destructor [-Wdelete-non-abstract-non-virtual-dtor]
29 | delete shape; // which destructors run?
| ^
1 warning generated.
area 1
~Shape
$ g++-16 -std=c++23 -Wall -Wextra polygon.cpp -o polygon-gcc && ./polygon-gcc
polygon.cpp: In function 'int main()':
polygon.cpp:29:5: warning: deleting object of polymorphic class type 'Shape' which has non-virtual destructor might cause undefined behavior [-Wdelete-non-virtual-dtor]
29 | delete shape; // which destructors run?
| ^~~~~~~~~~~~
area 1
~Shape
$ clang++ -std=c++23 -fsanitize=address,undefined polygon.cpp -o polygon-san && ./polygon-san
area 1
~Shape
$ g++-16 -std=c++23 -fsanitize=address,undefined polygon.cpp -o polygon-san-gcc && ./polygon-san-gcc
area 1
~Shape
$ clang++ -std=c++23 -Wall -Wextra -DVIRTUAL_DTOR polygon.cpp -o polygon-fixed && ./polygon-fixed
area 1
~Polygon frees 100000 corners
~Shape
$ g++-16 -std=c++23 -Wall -Wextra -DVIRTUAL_DTOR polygon.cpp -o polygon-fixed-gcc && ./polygon-fixed-gcc
area 1
~Polygon frees 100000 corners
~Shape
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 polygon_session.py
Step through the delete as polygon.cpp is written, then as the last two commands build it:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- Which destructor?. main deletes the Polygon through a Shape*. The destructor is chosen like any other member function: from the object's class only if it is virtual.
- Not virtual. In polygon.cpp it is not, so the delete is undefined behaviour. Both compilers warned under -Wall, and the program ran ~Shape alone.
- The cost. ~Polygon never ran, so the vector of 100,000 doubles it owns, 800,000 bytes, was never freed, and every such delete loses as much again. Both sanitizer builds ran to the end without a report.
- virtual ~Shape(). Built with -DVIRTUAL_DTOR, the destructor is virtual. The delete starts at the object's real class: ~Polygon frees 100000 corners.
- Then the base part. Then the base part's destructor runs: ~Shape. Every destructor ran, the derived one first, as for any object destroyed in the usual way.
Deleting a Polygon through a Shape* whose destructor is not virtual is
undefined behaviour: What a program does when it breaks a rule for which the C++ standard sets no requirements, such as reading past the end of an array. The compiler may assume it never happens, so the result can change with the compiler and the optimisation level. More in “Object lifetime and undefined behaviour in C++”: the standard sets no requirement on what the program does
from then on. On this computer, only ~Shape ran, so ~Polygon never freed its 100,000 corners, 800,000 bytes of
memory lost on every delete. Both compilers warn under -Wall, Clang with -Wdelete-non-abstract-non-virtual-dtor
and GCC with -Wdelete-non-virtual-dtor, and that warning is the only help you get here: the builds with
AddressSanitizer and UndefinedBehaviorSanitizer ran to the end without reporting anything.
The fix is one word. With a virtual destructor: A destructor declared virtual in a base class, as in virtual ~Shape() = default;. A delete through a pointer to the base then runs the destructor of the object's real class first, then the base's; without it such a delete is undefined behaviour., virtual ~Shape(), the delete
runs ~Polygon first and then ~Shape, as the last two commands show. Rule C.35 of the C++ Core Guidelines gives the
general form: a base class destructor should be public and virtual, or protected and non-virtual, so that nobody can
delete through the base class at all. shapes.cpp declares virtual ~Shape() = default; for exactly this reason,
since a std::unique_ptr<Shape> deletes its object through a Shape*.
Key idea
A delete through a pointer to the base runs the derived destructor only when the base destructor is virtual; give a polymorphic base a public virtual destructor, or a protected one that makes such a delete fail to compile.
Checkpoint
Check: Virtual destructors
A virtual call during construction runs the class being built
A constructor of a base class runs before the derived part exists, so a virtual call inside it cannot reach an override in the derived class:
// Widget's constructor and destructor call the virtual function name(). Which version runs?
#include <print>
#include <string>
class Widget {
public:
Widget() { std::println("Widget constructor calls name(): {}", name()); }
virtual ~Widget() { std::println("Widget destructor calls name(): {}", name()); }
virtual std::string name() const { return "Widget"; }
void show() const { std::println("show() calls name(): {}", name()); }
};
class Button : public Widget {
public:
Button() { std::println("Button constructor calls name(): {}", name()); }
std::string name() const override { return "Button"; }
};
int main() {
Button ok;
ok.show();
} from session import run
# construction_calls.cpp with both compilers.
run("""
clang++ -std=c++23 -Wall -Wextra construction_calls.cpp -o calls && ./calls
g++-16 -std=c++23 -Wall -Wextra construction_calls.cpp -o calls-gcc && ./calls-gcc
""") Output
$ clang++ -std=c++23 -Wall -Wextra construction_calls.cpp -o calls && ./calls Widget constructor calls name(): Widget Button constructor calls name(): Button show() calls name(): Button Widget destructor calls name(): Widget $ g++-16 -std=c++23 -Wall -Wextra construction_calls.cpp -o calls-gcc && ./calls-gcc Widget constructor calls name(): Widget Button constructor calls name(): Button show() calls name(): Button Widget destructor calls name(): Widget
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 construction_calls_session.py
Step through the four lines the program printed and watch where the object’s vptr points at each one:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- The base part first. Button ok; builds the Widget part first. While Widget's constructor runs, the object is only a Widget, and its vptr points to Widget's table.
- Widget() calls name(). The virtual call reads the vptr and runs Widget::name(): Widget constructor calls name(): Widget. Button's part does not exist yet.
- The vptr moves on. Once Widget's constructor has finished, Button's constructor points the vptr at Button's table, before it initialises its own members.
- Button() calls name(). Now the same virtual call runs Button::name(): Button constructor calls name(): Button. The object is complete when this constructor ends.
- An ordinary call later. ok.show() is not virtual, but its call to name() is. The object is a Button, so it prints show() calls name(): Button.
- Back to Widget. At the end of main, Button's part is destroyed first. Widget's destructor then points the vptr back at Widget's table before its body runs.
- ~Widget() calls name(). So the call in Widget's destructor runs Widget::name() again: Widget destructor calls name(): Widget. Both compilers printed the same four lines.
While Widget’s constructor runs, the object is a Widget: its vptr points to Widget’s table, and name() returns
“Widget”. Once that constructor has finished, Button’s constructor switches the object to Button’s table before it
initialises its own members, so from then on every call runs Button::name(). Destruction undoes this in reverse, so
Widget’s destructor sees a Widget again. The standard requires this behaviour, and both compilers produce
identical output.
The rule protects you from reading members that are not built yet, but it breaks a common design: a base constructor
that calls a virtual init() hook never runs the derived class’s init(). Rule C.82 of the C++ Core Guidelines says
not to call virtual functions from constructors and destructors. Pass what the base class needs as constructor
arguments, or create the object fully first and call the virtual function afterwards, for example from a factory
function.
Checkpoint
Check: Virtual calls while building and destroying
Practise: three ways to send a notification
notify.h declares Notifier, whose non-virtual send() cuts a text into messages of at most max_length()
characters and formats each with format(), both virtual. EmailNotifier is finished. SmsNotifier contains two
mistakes of the kind this lesson showed, and PushNotifier is empty.
C header · notify.h
// Exercise: make every check in notify_tests.cpp pass with both compilers. Notifier and EmailNotifier are complete.
#pragma once
#include <cstddef>
#include <string>
#include <vector>
class Notifier {
public:
virtual ~Notifier() = default;
// The channel's name, for logs.
virtual std::string channel() const { return "none"; }
// The most characters one message on this channel may carry.
virtual std::size_t max_length() const { return 1000; }
// One message as the channel sends it.
virtual std::string format(const std::string& to, const std::string& text) const { return to + ": " + text; }
// The same steps for every channel: cut the text into pieces of at most max_length() characters, and format
// each piece as one message. Not virtual: derived classes change the steps' details, never the steps.
std::vector<std::string> send(const std::string& to, const std::string& text) const {
std::vector<std::string> messages;
for (std::size_t start = 0; start < text.size(); start += max_length())
messages.push_back(format(to, text.substr(start, max_length())));
return messages;
}
};
class EmailNotifier : public Notifier {
public:
std::string channel() const override { return "email"; }
std::string format(const std::string& to, const std::string& text) const override {
return "To: " + to + " | " + text;
}
};
// TODO 1: an SMS carries at most 160 characters. Two of these functions do not override anything: mark all three
// with override, then fix what the compilers report.
class SmsNotifier : public Notifier {
public:
std::string channel() const { return "sms"; }
std::size_t max_lenght() const { return 160; }
std::string format(const std::string& to, const std::string& text) { return to + ": " + text; }
};
// TODO 2: a push notification has the channel "push", carries at most 100 characters and is formatted as
// "[push] " followed by the text: the device it goes to is not part of the message.
class PushNotifier : public Notifier {}; C++ · notify_tests.cpp
// Sample tests for notify.h. Each check prints what it expects when it fails; the exit status is 0 only when every
// check passes.
#include <memory>
#include <print>
#include <string>
#include <vector>
#include "notify.h"
namespace {
int checks = 0;
int passed = 0;
void check(bool ok, const char* what) {
++checks;
if (ok) ++passed;
else std::println("FAILED: {}", what);
}
} // namespace
int main() {
std::vector<std::unique_ptr<Notifier>> channels;
channels.push_back(std::make_unique<EmailNotifier>());
channels.push_back(std::make_unique<SmsNotifier>());
channels.push_back(std::make_unique<PushNotifier>());
std::string names;
for (const auto& n : channels) names += n->channel() + " ";
check(names == "email sms push ", "each channel reports its own name");
const std::string text(250, 'x');
auto email = channels[0]->send("asha@example.com", "Your parcel is out for delivery");
check(email.size() == 1 && email[0] == "To: asha@example.com | Your parcel is out for delivery",
"an email is one message in the email format");
auto sms = channels[1]->send("+91 90000 00001", text);
check(sms.size() == 2, "250 characters by SMS are 2 messages of at most 160");
check(!sms.empty() && sms[0] == "+91 90000 00001: " + std::string(160, 'x'), "an SMS starts with the number");
auto push = channels[2]->send("device-7", "Your parcel is out for delivery");
check(push.size() == 1 && push[0] == "[push] Your parcel is out for delivery", "a push shows only the text");
check(channels[2]->send("device-7", text).size() == 3, "250 characters by push are 3 messages of at most 100");
std::println("{} of {} checks passed", passed, checks);
return passed == checks ? 0 : 1;
} from session import run
# The sample tests built against the starter code with both compilers.
run("""
clang++ -std=c++23 -Wall -Wextra notify_tests.cpp -o notify_tests && ./notify_tests
g++-16 -std=c++23 -Wall -Wextra notify_tests.cpp -o notify_tests-gcc && ./notify_tests-gcc
""") Output
$ clang++ -std=c++23 -Wall -Wextra notify_tests.cpp -o notify_tests && ./notify_tests
In file included from notify_tests.cpp:8:
./notify.h:42:17: warning: 'SmsNotifier::format' hides overloaded virtual function [-Woverloaded-virtual]
42 | std::string format(const std::string& to, const std::string& text) { return to + ": " + text; }
| ^
./notify.h:16:25: note: hidden overloaded virtual function 'Notifier::format' declared here: different qualifiers ('const' vs unqualified)
16 | virtual std::string format(const std::string& to, const std::string& text) const { return to + ": " + text; }
| ^
1 warning generated.
FAILED: each channel reports its own name
FAILED: 250 characters by SMS are 2 messages of at most 160
FAILED: an SMS starts with the number
FAILED: a push shows only the text
FAILED: 250 characters by push are 3 messages of at most 100
1 of 6 checks passed
[exit status 1]
$ g++-16 -std=c++23 -Wall -Wextra notify_tests.cpp -o notify_tests-gcc && ./notify_tests-gcc
In file included from notify_tests.cpp:8:
notify.h:16:25: warning: 'virtual std::string Notifier::format(const std::string&, const std::string&) const' was hidden [-Woverloaded-virtual=]
16 | virtual std::string format(const std::string& to, const std::string& text) const { return to + ": " + text; }
| ^~~~~~
notify.h:42:17: note: by 'std::string SmsNotifier::format(const std::string&, const std::string&)'
42 | std::string format(const std::string& to, const std::string& text) { return to + ": " + text; }
| ^~~~~~
FAILED: each channel reports its own name
FAILED: 250 characters by SMS are 2 messages of at most 160
FAILED: an SMS starts with the number
FAILED: a push shows only the text
FAILED: 250 characters by push are 3 messages of at most 100
1 of 6 checks passed
[exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 notify_session.py
- TODO 1: add
overrideto the three functions ofSmsNotifier. Read the errors you get, and fix the two functions that do not override anything. The warning in the session already points at one of them. - TODO 2: write
PushNotifier: the channelpush, at most 100 characters, and messages that read[push]followed by the text, without the device.
Both compilers should then print 6 of 6 checks passed without a warning. Notice that you never changed send():
the loop in Notifier works for every channel, including the one you just wrote.
Then put the six ideas to work on code this lesson has not run, from the loop over shapes to the vptr that moves during construction. A round opens with whichever idea your answers make weakest, and each choice says what it gets right or misses:
Practice that adapts to you
Practise this lesson
17 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Fresher · What does the override keyword do, and what happens without it?
override, written after a member function’s parameters and const, tells the compiler that the function is meant
to override a virtual function of a base class. The compiler then checks it: if no base class has a virtual function
with the same name, parameters and qualifiers, the program does not compile. Without the keyword, a mistake such as a
misspelt name or a missing const still compiles, but it declares a new function. Calls through a base-class
reference or pointer then keep running the base version, and the bug shows up only at run time, as a wrong result.
override changes nothing about how a correct override behaves; it only turns that silent mistake into an error. A
complete answer also mentions final, which overrides and forbids further overriding.
The habit is what an interviewer probes next: a large code base written without the keyword, and a function that
repeats virtual instead. Decide both:
Checkpoint
Interview round: two follow-up questions
Two harder questions of this module start from this lesson: plugins whose memory grows because their base class has no virtual destructor, and a container of shapes whose areas all come out as the base class’s default:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A plugin host deletes plugins through a Plugin* whose class has virtual functions but no virtual destructor. Memory grows in production, and some plugins never close their files. Explain what happens, how you would confirm it, and how you would fix it without breaking existing plugins.
-
Fresher to mid
A teammate stores shapes in a std::vector<Shape> and pushes Circle and Rect objects into it. Every area comes out as the base class's default. Explain the bug, give two ways to fix the container, and show how to make the mistake impossible to compile.
Key takeaways
- A virtual function is chosen from the object’s dynamic type while the program runs; an ordinary member function is chosen from the static type when the program is compiled.
- GCC and Clang add one hidden pointer, 8 bytes here, to each object of a class with virtual functions, whatever their number, and share one virtual table per class.
- Write
overrideon every function that replaces a virtual one, so that a misspelt name or a missingconstfails to compile instead of silently calling the base version;finalstops further overriding or derivation. - Pass polymorphic objects by reference or pointer: a copy into a base-class value is sliced, and deleting the base class’s copy operations makes slicing a compile error.
- Give a polymorphic base class a public virtual destructor, or a protected non-virtual one. Deleting through a base pointer without one is undefined behaviour that the sanitizers did not catch here.
- A virtual call in a constructor or destructor runs the version of the class being constructed or destroyed.
Terms in this lesson
- dynamic type
- The class of the object an expression refers to while the program runs: a Shape* that points to a Circle reaches an object of the dynamic type Circle. Calls to virtual functions are chosen from it.
- slicing
- What happens when an object of a derived class is copied into a variable or a parameter of a base class type: only the base part is copied, the derived members are left behind, and the copy behaves as a plain base object.
- static type
- The type the compiler sees for an expression, from the declarations alone: through a Shape* the object has the static type Shape, whatever it really is. Calls to ordinary member functions are chosen from it.
- virtual destructor
- A destructor declared virtual in a base class, as in virtual ~Shape() = default;. A delete through a pointer to the base then runs the destructor of the object's real class first, then the base's; without it such a delete is undefined behaviour.
- virtual function
- A member function declared virtual in a base class. A derived class may override it with a function of the same name, parameters and const, and a call through a reference or a pointer to the base then runs the version of the object's own class.
- virtual table
- A table of function addresses that GCC and Clang build once for each class with virtual functions, shared by all its objects. A virtual call reads the object's vptr, takes the function's entry from this table and jumps to it.
- vptr
- The hidden pointer that GCC and Clang store in each object of a class with virtual functions. It points to the virtual table of the object's class and takes 8 bytes on a 64-bit computer, however many virtual functions the class has.
References
- Working Draft, Programming Languages: C++ (N4950): [class.virtual], [class.copy.ctor], [class.dtor], [class.cdtor], [expr.delete] (ISO/IEC JTC1/SC22/WG21)
- C++ Core Guidelines: C.128, virtual functions should specify exactly one of virtual, override, or final (Standard C++ Foundation)
- C++ Core Guidelines: C.145, access polymorphic objects through pointers and references (Standard C++ Foundation)
- C++ Core Guidelines: C.35, a base class destructor should be either public and virtual, or protected and non-virtual (Standard C++ Foundation)
- C++ Core Guidelines: C.67, a polymorphic class should suppress public copy/move (Standard C++ Foundation)
- C++ Core Guidelines: C.82, don't call virtual functions in constructors and destructors (Standard C++ Foundation)
- C++ FAQ: why does a base constructor calling a virtual function not reach the override? (Standard C++ Foundation)
- Itanium C++ ABI (virtual table layout) (Itanium C++ ABI project)
- Options Controlling C++ Dialect (Using the GNU Compiler Collection) (GNU Project)
- Diagnostic flags in Clang (LLVM Project)
Report a problem with this lesson
Kept only in this browser. Your Learn progress