Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 20 minutes
  • Examples run with Clang 21.0.0, GCC 16.2.0 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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:

shapes.cpp C++ · shapes.cpp
// 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);
}
Built and run with both compilers Python · shapes_session.py
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:

Interactive · step by step One line of code, three different area() functions Step through the loop of shapes.cpp. The left column is what the compiler sees, three pointers to Shape; the right one is what each points to while the program runs.
One line of code, three different area() functions, step 5 of 5. Showing shapes[0], shapes[1], shapes[2], total (1.031 m²), pizza (Circle::area(): 0.071), table (Rect::area(): 0.960), sketch (Shape::area(): 0.000). In focus: total.points topoints topoints toshapes[0]a std::unique_ptr<Shape>shapes[1]a std::unique_ptr<Shape>shapes[2]a std::unique_ptr<Shape>total1.031 m²pizzaCircle::area(): 0.071tableRect::area(): 0.960sketchShape::area(): 0.000

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. 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.
  2. 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².
  3. 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².
  4. The base's own version. The third object is a plain Shape. Shape's own area() runs and returns 0: sketch: 0.000 m².
  5. 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:

  • virtual is written in the base class. A function in a derived class with the same name, parameters and const overrides it, and is itself virtual whether or not it repeats the keyword.
  • override comes after the parameter list and the const, as in double area() const override.
  • std::unique_ptr<Shape> owns one object, a Shape or 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

  1. Question 1 of 2 On the second pass of the loop, shape points to the Rect called table. Which area() does shape->area() run?

    Choose one answer.

    A hint

    The compiler sees a pointer to Shape; what is the object?

    Show the answer: Question 1 of 2

    Answer: Rect::area(), because the call is chosen from the object's class while the program runs

    A call to a virtual function is chosen from the dynamic type, the class of the object, when it runs. The same line of the loop ran Circle::area(), Rect::area() and Shape::area() in turn.

  2. Question 2 of 2 Rect does not override label(), which is not virtual and calls area(). What does table.label() return?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: table: 0.960 m²

    A non-virtual function in the base can fix the steps of a job while a virtual call inside it lets each derived class supply a detail. shapes.cpp printed table: 0.960 m².

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:

shape->area() reads the vptr inside pizza, a Circle, looks up area() in the table shared by all Circles, and calls Circle::area.shape->area()where shape points to pizzapizza, a Circle hidden vptr, 8 bytesname_radius_coin, another Circle hidden vptr, 8 bytesname_radius_Circle's virtual tableone per class, not per object ~Circle()Circle::area()Circle::area() runs1. read the hidden vptr2. look up thearea() entrythe same table3. call it

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.

  1. 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.
  2. 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().
  3. 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.
  4. 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:

sizes.cpp C++ · sizes.cpp
// 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));
}
Three classes that each hold one double Python · sizes_session.py
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:

Interactive · step by step One virtual call, read by read Step through shape->area() for the pizza, then for the table. The left column holds the objects, the right one the table of each object's class.
One virtual call, read by read, step 6 of 6. Showing shape->area() (shape points to table), The function that runs (Rect::area(): 0.960), pizza, a Circle, Circle's virtual table, table, a Rect, Rect's virtual table. In focus: pizza, a Circle, table, a Rect.its vptrits vptrshape->area()shape points to tableThe function that runsRect::area(): 0.960pizza, a Circlevptr, name_, radius_Circle's virtual table~Circle(), Circle::area()table, a Rectvptr, name_, width_, height_Rect's virtual table~Rect(), Rect::area()

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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².
  6. 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

  1. Question 1 of 2 A class holds one double and has five virtual functions, its destructor among them. Built like sizes.cpp with GCC or Clang on this computer, what does sizeof give?

    Type a number.

    A hint

    Count what each object holds, not what its class's table holds.

    Show the answer: Question 1 of 2

    Answer: 16 bytes

    Each object holds its double and one vptr, 8 + 8 = 16 bytes, however many virtual functions the class has: the entries live in the one table for the class. FourVirtual, with four, was already 16.

  2. Question 2 of 2 A program creates 1,000 Circle objects. How many virtual tables of Circle does it hold?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: One, shared by all 1,000 objects through their vptrs

    GCC and Clang build one virtual table per class and give each object a vptr to it. That is why the cost per object is one pointer, whatever the number of objects or functions.

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:

A misspelt name, and a forgotten const

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());
}
Each file without and with override Python · override_session.py
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 through Shape& silently runs Shape::perimeter() and prints 0. With override, both compilers reject the declaration.
  • area() without const is a different function from area() const, so it hides the virtual one instead of overriding it. Here -Wall helps: Clang and GCC both warn with -Woverloaded-virtual. The program still prints 0, and only override turns the mistake into an error.

Read each slip across the two builds:

The two slips of misspelt.cpp and missing_const.cpp, built without and with override
Criterion Without overrideWith override
perimiter(), misspelt Compiles, no warning; prints 0Error on both compilers
area() without const -Woverloaded-virtual; prints 0Error 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.cpp C++ · final.cpp
// 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() {}
Both uses of final, rejected by both compilers Python · final_session.py
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

  1. Question 1 of 2 missing_const.cpp, built as written (without override), declares double area() in Square where Shape has virtual double area() const. What did both builds report and print?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: A -Woverloaded-virtual warning, and area through a Shape&: 0

    area() and area() const are different functions, so Square's hides the virtual one instead of overriding it. -Wall catches this case with -Woverloaded-virtual; override turns it into an error.

  2. Question 2 of 2 class Circle final : public Shape {}; is followed by class Ring : public Circle {};. What happens?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Both compilers reject Ring, because Circle may not be derived from

    final on a function stops further overriding of that function; final on a class stops derivation altogether. Both builds of final.cpp failed at both lines.

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:

slicing.cpp C++ · slicing.cpp
// 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());
}
By reference, by value, and copies forbidden Python · slicing_session.py
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:

Interactive · step by step What by_reference and by_value receive of plate Step through the three lines of main that pass plate on. The left column is the Circle itself, the right one what each function or variable holds.
What by_reference and by_value receive of plate, step 6 of 6. Showing plate, a Circle, Shape(const Shape&), by_reference(plate) (area 0.0531), by_value(plate) (does not compile), Shape copy = plate; (does not compile). In focus: by_value(plate), Shape copy = plate;.refers toits Shape partplate, a Circleradius_ 0.13, a vptrShape(const Shape&)Shape's copy constructorby_reference(plate)area 0.0531by_value(plate)does not compileShape copy = plate;does not compile

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. A Circle. plate is a Circle: a Shape part with the name "plate", then radius_ 0.13, and a vptr to Circle's table.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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

  1. Question 1 of 2 plate is a Circle of radius 0.13 whose area by reference is 0.0531. What area does by_value(plate) print?

    Type a number.

    A hint

    What is left of plate inside the parameter s?

    Show the answer: Question 1 of 2

    Answer: 0 m²

    The parameter is a new Shape built from plate's Shape part. Its vptr points to Shape's table, so s.area() runs Shape::area() and returns 0: the program printed by value: plate has area 0.0000.

  2. Question 2 of 2 Built with -DNO_COPY, Shape's copy constructor and copy assignment are deleted. Which lines of main stop compiling?

    Choose every answer that is right.

    Show the answer: Question 2 of 2

    Answer:

    • by_value(plate);
    • Shape copy = plate;

    Deleting the copy operations of a polymorphic base turns every slicing copy into a compile error, while uses through references stay as they are. That is rule C.67 of the C++ Core Guidelines.

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:

polygon.cpp C++ · polygon.cpp
// 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?
}
Warnings, two sanitizer builds and the fix Python · polygon_session.py
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:

Interactive · stage by stage delete shape; with and without a virtual destructor Step through the delete of a Polygon through a Shape*, first as polygon.cpp is written, then with virtual ~Shape().
delete shape; with and without a virtual destructor, step 5 of 5. Showing delete shape;, ~Polygon() (ran 1st: corners freed), ~Shape() (ran 2nd: ~Shape). In focus: ~Shape().delete shape;a Shape* to a Polygon~Polygon()ran 1st: corners freed~Shape()ran 2nd: ~Shape

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. virtual ~Shape(). Built with -DVIRTUAL_DTOR, the destructor is virtual. The delete starts at the object's real class: ~Polygon frees 100000 corners.
  5. 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

  1. Question 1 of 2 Each Polygon owns 100,000 corners stored as doubles of 8 bytes. How many bytes did one delete shape; without a virtual destructor leave allocated?

    Type a number.

    A hint

    Which destructor would have freed the corners, and did it run?

    Show the answer: Question 1 of 2

    Answer: 800000 bytes

    Only ~Shape ran, so ~Polygon never freed its vector: 100,000 × 8 = 800,000 bytes per delete. With virtual ~Shape(), ~Polygon ran first and freed them.

  2. Question 2 of 2 In the recorded session, which tool reported the delete through a Shape* without a virtual destructor?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The -Wall warnings of both compilers

    The compiler warnings under -Wall pointed at the delete; the sanitizer builds said nothing. Build with -Wall, and make the base destructor virtual.

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:

construction_calls.cpp C++ · construction_calls.cpp
// 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();
}
Which name() runs, and when Python · construction_calls_session.py
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:

Interactive · step by step Which name() runs while Button ok is built and destroyed Step through the four lines construction_calls.cpp printed. The left column is the object ok, the right one the table its vptr points to at each moment.
Which name() runs while Button ok is built and destroyed, step 7 of 7. Showing Button ok (being destroyed: a Widget), Printed so far (4. Widget, from ~Widget()), Widget's table, Button's table. In focus: Widget's table.vptrButton okbeing destroyed: a WidgetPrinted so far4. Widget, from ~Widget()Widget's tablename(): WidgetButton's tablename(): Button

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. Button() calls name(). Now the same virtual call runs Button::name(): Button constructor calls name(): Button. The object is complete when this constructor ends.
  5. 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.
  6. 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.
  7. ~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

  1. Question 1 of 2 Which line does Button's constructor print?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Button constructor calls name(): Button

    During construction a virtual call runs the version of the class whose constructor is running. Button's constructor runs after Widget's, so it sees a Button.

  2. Question 2 of 2 At the end of main, ok is destroyed. What does Widget's destructor print?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Widget destructor calls name(): Widget

    Destruction undoes construction in reverse, and a virtual call in a destructor runs the version of the class being destroyed. The run printed Widget destructor calls name(): Widget.

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.

The starter code and its sample tests

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;
}
The sample tests against the starter code Python · notify_session.py
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

  1. TODO 1: add override to the three functions of SmsNotifier. 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.
  2. TODO 2: write PushNotifier: the channel push, 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.

  1. Question 1 of 17 shapes[0] is a std::unique_ptr<Shape> that owns the Circle called pizza. What are the static and the dynamic type of *shapes[0]?

    Choose one answer.

    Show the answer: Question 1 of 17

    Answer: Static type Shape, dynamic type Circle

    The static type is what the compiler sees from the declarations, here Shape. The dynamic type is the class of the object while the program runs, here Circle, and a virtual call follows it.

  2. Question 2 of 17 Shape::label() is not virtual and calls area(), which is virtual and overridden by Circle. A Shape* points to a Circle. Which area() does ptr->label() run?

    Choose one answer.

    Show the answer: Question 2 of 17

    Answer: Circle::area(), because the call inside label() is a virtual call too

    label() runs Shape's code, but inside it area() is called through this, and a virtual call always runs the version of the object's dynamic type. That is how a non-virtual function can define steps that derived classes fill in.

  3. Question 3 of 17 Next year a Triangle class derived from Shape overrides area(), and main pushes one into shapes. What else must change in the loop that prints and adds up the areas?

    Choose one answer.

    Show the answer: Question 3 of 17

    Answer: Nothing; the same loop calls Triangle::area() for the new object

    Run-time polymorphism lets code written for the base work with classes that did not exist when it was written, as long as they override the base's virtual functions.

  4. Question 4 of 17 Plain holds one double and is 8 bytes. OneVirtual is the same class with a virtual destructor and a virtual area(). How many bytes did those virtual functions add, with both compilers?

    Type a number.

    Show the answer: Question 4 of 17

    Answer: 8 bytes

    OneVirtual printed 16 bytes against Plain's 8: the 8 bytes of the vptr, the hidden pointer to the class's virtual table. Its two virtual functions share that one pointer.

  5. Question 5 of 17 Why is FourVirtual, with four virtual functions, no bigger than OneVirtual?

    Choose one answer.

    Show the answer: Question 5 of 17

    Answer: Each object holds one vptr; the entries for the functions live in the one table of its class

    With GCC and Clang an object of a class with virtual functions holds one vptr, whatever the number of functions, so both classes are a double plus a vptr: 16 bytes.

  6. Question 6 of 17 What does double area() const final in Square mean?

    Choose one answer.

    Show the answer: Question 6 of 17

    Answer: It overrides Shape::area(), and no class derived from Square may override it again

    A function marked final is an override that ends the chain, which can also let the compiler call it directly. Tile tried to override Square's final area() in final.cpp, and both compilers refused.

  7. Question 7 of 17 What does this print, built with -Wall -Wextra?

    Read the code, then choose one answer.

    struct Shape {
        virtual ~Shape() = default;
        virtual double perimeter() const { return 0.0; }
    };
    struct Square : Shape {
        double perimiter() const { return 4.0; }
    };
    int main() {
        Square s;
        const Shape& r = s;
        std::println("{}", r.perimeter());
    }
    Show the answer: Question 7 of 17

    Answer: 0, with no warning from either compiler

    perimiter is a new function with a different name, so it overrides nothing and hides nothing, and no warning of -Wall or -Wextra applies. Writing override after it turns the mistake into an error on both compilers.

  8. Question 8 of 17 The base class declares virtual double area() const;. Which of these declarations in a derived class fail to compile?

    Choose every answer that is right.

    Show the answer: Question 8 of 17

    Answer:

    • float area() const override; (another return type)
    • double area() override; (const left out)
    • double aera() const override; (name misspelt)

    The first two declare functions that do not override anything, and override makes the compiler say so; without it, both would compile, the first with only a -Wall warning, and leave Shape::area() in charge. A different return type, like float, is an error even without override, because a function with the same name and parameters overrides, and an override may change the return type only to a covariant one. The last declaration is a correct override.

  9. Question 9 of 17 Which of these lines slices a Circle called plate?

    Choose one answer.

    Show the answer: Question 9 of 17

    Answer: Shape copy = plate;

    Slicing happens whenever a derived object is copied into an object of a base class type: a by-value parameter, a variable or a container element. References and pointers never slice.

  10. Question 10 of 17 void show(Shape s) prints a Circle's area as 0, while void show(const Shape& s) prints the right area. Why?

    Choose one answer.

    Show the answer: Question 10 of 17

    Answer: Passing by value copies only the Shape part of the Circle into a new Shape object

    The parameter s is a Shape, so the Circle is sliced: its radius is not copied, and s has Shape's vptr. Pass polymorphic objects by reference or pointer, or delete the base class's copy operations so that slicing does not compile.

  11. Question 11 of 17 A teammate changes void show(Shape s) to void show(const Shape s) to fix the area of 0. What does show(plate) print now?

    Choose one answer.

    Show the answer: Question 11 of 17

    Answer: Still 0, because a const parameter is still a new Shape copied from the base part

    Slicing comes from copying into a base class object, and const Shape s is still such a copy. The fix is const Shape& s, or deleting Shape's copy operations so that the mistake cannot compile.

  12. Question 12 of 17 Shape has virtual functions but a non-virtual destructor. What does the standard say about delete shape; when shape points to a Polygon?

    Choose one answer.

    Show the answer: Question 12 of 17

    Answer: The behaviour is undefined

    Deleting an object through a pointer to a base class whose destructor is not virtual is undefined behaviour. In the recorded run only ~Shape ran and the Polygon's vector was never freed, and neither sanitizer said anything; only the -Wall warnings found it. Make the base destructor virtual.

  13. Question 13 of 17 Why does shapes.cpp, which never writes delete, still declare virtual ~Shape() = default;?

    Choose one answer.

    Show the answer: Question 13 of 17

    Answer: Each std::unique_ptr<Shape> deletes its object through a Shape*, so the destructor must be virtual

    std::unique_ptr<Shape> calls delete on a Shape*. With a virtual destructor that delete runs Circle's or Rect's destructor first, as it must.

  14. Question 14 of 17 Shape gets a protected, non-virtual destructor, protected: ~Shape() = default;. What happens to delete shape; in main, where shape is a Shape*?

    Choose one answer.

    Show the answer: Question 14 of 17

    Answer: It does not compile, because main may not call a protected destructor

    A protected destructor may be called by derived classes' destructors, but not by other code, so nobody can delete a derived object through a pointer to the base. That is the other valid form of a base destructor in rule C.35.

  15. Question 15 of 17 ok.show() runs after Button ok; has been fully constructed. Which name() does the call inside show() run?

    Choose one answer.

    Show the answer: Question 15 of 17

    Answer: Button::name(), because the object is a complete Button

    The special rule covers only constructors and destructors. Once construction ends, a virtual call follows the object's real class: show() calls name(): Button.

  16. Question 16 of 17 Widget's constructor calls the virtual function name(), and Button, derived from Widget, overrides it. What does that call return while a Button is being constructed?

    Choose one answer.

    Show the answer: Question 16 of 17

    Answer: Widget's version, because the Button part does not exist yet

    While Widget's constructor runs, the object is a Widget, so a virtual call goes to the function of the class being constructed. Button's members have not been constructed yet, so calling Button's override would read uninitialised data. The same rule applies in destructors.

  17. Question 17 of 17 Device's constructor calls a virtual init(), which Camera overrides to open its log file. A Camera is created. What happens to the log file?

    Choose one answer.

    Show the answer: Question 17 of 17

    Answer: It is never opened, because the call runs Device::init() while Device's constructor runs

    A base constructor that calls a virtual hook never runs the derived override. Pass what the base needs as constructor arguments, or finish constructing the object and call init() afterwards, for example from a factory function.

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

  1. Question 1 of 2 A code base has hundreds of overrides written before anyone used the keyword. Which compiler option lists every override that lacks override?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: -Wsuggest-override, which neither -Wall nor -Wextra turns on

    -Wsuggest-override reports every function that overrides a virtual function without saying override. Adding the keyword to each of them makes every later slip a compile error.

  2. Question 2 of 2 Square declares virtual double perimiter() const (misspelt) instead of using override. What do both compilers do with -Wall -Wextra?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Compile it without a warning, as a new virtual function, and the call through Shape& still prints 0

    Repeating virtual in a derived class documents nothing the compiler checks. Write virtual only on the first declaration, and override on every replacement, as rule C.128 of the C++ Core Guidelines says.

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.

  1. 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.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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.

    Model answer, Pro. First step and follow-ups

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 override on every function that replaces a virtual one, so that a misspelt name or a missing const fails to compile instead of silently calling the base version; final stops 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

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.