C++ Module 8 – Inheritance and polymorphism
Alternatives to inheritance: variant, composition
Choose between virtual functions and std::variant in C++, replace class explosions with composition, and compare dispatch costs measured on two compilers.
What you will learn
- Choose between virtual functions and std::variant for a set of types
- Replace a hierarchy of combined classes with composition
- Measure the cost of each dispatch style instead of guessing
- Convert a small class hierarchy to a std::variant with identical results
Before you start
What you’ll learn · Before you start
What you will learn
- Choose between virtual functions and std::variant for a set of types
- Replace a hierarchy of combined classes with composition
- Measure the cost of each dispatch style instead of guessing
- Convert a small class hierarchy to a std::variant with identical results
Before you start
On this page
Two report formats and two destinations already take four classes in a hierarchy, from CsvFileReport to
JsonEmailReport, and three of each would take nine. Inheritance is one way to give types a common interface, and
often not the best one: this lesson replaces it with std::variant where the set of types is closed and with
composition: Building a class from members that each do part of its work instead of inheriting from classes that do it: a Report holds a Format and a Destination, so every format works with every destination without a class for each pair. where classes only share behaviour, and keeps virtual functions for a set of
types that other code extends.
You will rewrite the shapes of the virtual-functions lesson as a variant, see which change each design makes easy, build four reports from four small types, and measure three ways to dispatch a call over 1,000,000 shapes on Apple clang 21 and GCC 16 instead of guessing which one is fastest.
std::variant holds a closed set of types by value
When the set of types is closed set of types: A set of types that one piece of code owns and knows completely, such as the shapes of one program or the messages of a fixed protocol. std::variant suits it: std::visit refuses a visitor that misses one of the types., because you know every one of them,
std::variant can hold any of them by value, and std::visit runs the right code for each, with the compiler
checking that no type is forgotten. Here are the circle and the rectangle of the virtual-functions lesson in both
designs:
C++ (shapes_virtual.cpp) · shapes_virtual.cpp
// The shapes of the virtual-functions lesson: an open set of classes behind one base class.
#include <memory>
#include <numbers>
#include <print>
#include <vector>
struct Shape {
virtual ~Shape() = default;
virtual double area() const = 0;
virtual double perimeter() const = 0;
};
struct Circle final : Shape {
double r;
explicit Circle(double r) : r(r) {}
double area() const override { return std::numbers::pi * r * r; }
double perimeter() const override { return 2 * std::numbers::pi * r; }
};
struct Rect final : Shape {
double w, h;
Rect(double w, double h) : w(w), h(h) {}
double area() const override { return w * h; }
double perimeter() const override { return 2 * (w + h); }
};
int main() {
std::vector<std::unique_ptr<Shape>> shapes; // each shape is a separate allocation on the heap
shapes.push_back(std::make_unique<Circle>(0.15));
shapes.push_back(std::make_unique<Rect>(1.2, 0.8));
for (const auto& s : shapes) std::println("area {:.3f}, perimeter {:.3f}", s->area(), s->perimeter());
std::println("in the vector: {} bytes per shape, plus the object on the heap", sizeof(shapes[0]));
} C++ (shapes_variant.cpp) · shapes_variant.cpp
// The same shapes as a closed set: plain structs in one std::variant, and each operation as a visitor.
#include <numbers>
#include <print>
#include <variant>
#include <vector>
struct Circle {
double r;
};
struct Rect {
double w, h;
};
using Shape = std::variant<Circle, Rect>; // holds a Circle or a Rect, inside itself
// One operation, written once for every alternative.
struct Area {
double operator()(const Circle& c) const { return std::numbers::pi * c.r * c.r; }
double operator()(const Rect& r) const { return r.w * r.h; }
};
// A new operation is a new visitor: no shape type changes.
struct Perimeter {
double operator()(const Circle& c) const { return 2 * std::numbers::pi * c.r; }
double operator()(const Rect& r) const { return 2 * (r.w + r.h); }
};
int main() {
std::vector<Shape> shapes{Circle{0.15}, Rect{1.2, 0.8}}; // the values sit in the vector itself
for (const Shape& s : shapes)
std::println("area {:.3f}, perimeter {:.3f}", std::visit(Area{}, s), std::visit(Perimeter{}, s));
std::println("in the vector: {} bytes per shape, and nothing on the heap", sizeof(Shape));
} from session import run
# Both designs built and run with both compilers.
run("""
clang++ -std=c++23 -Wall -Wextra shapes_virtual.cpp -o virtual && ./virtual
clang++ -std=c++23 -Wall -Wextra shapes_variant.cpp -o variant && ./variant
g++-16 -std=c++23 -Wall -Wextra shapes_virtual.cpp -o virtual-gcc && ./virtual-gcc
g++-16 -std=c++23 -Wall -Wextra shapes_variant.cpp -o variant-gcc && ./variant-gcc
""") Output
$ clang++ -std=c++23 -Wall -Wextra shapes_virtual.cpp -o virtual && ./virtual area 0.071, perimeter 0.942 area 0.960, perimeter 4.000 in the vector: 8 bytes per shape, plus the object on the heap $ clang++ -std=c++23 -Wall -Wextra shapes_variant.cpp -o variant && ./variant area 0.071, perimeter 0.942 area 0.960, perimeter 4.000 in the vector: 24 bytes per shape, and nothing on the heap $ g++-16 -std=c++23 -Wall -Wextra shapes_virtual.cpp -o virtual-gcc && ./virtual-gcc area 0.071, perimeter 0.942 area 0.960, perimeter 4.000 in the vector: 8 bytes per shape, plus the object on the heap $ g++-16 -std=c++23 -Wall -Wextra shapes_variant.cpp -o variant-gcc && ./variant-gcc area 0.071, perimeter 0.942 area 0.960, perimeter 4.000 in the vector: 24 bytes per shape, and nothing on the heap
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 shapes_session.py
Both programs print the same areas and perimeters, but they are built differently. In the virtual design, Circle
and Rect derive from Shape, every shape is a separate object on the heap, and the vector holds 8-byte pointers to
them. In the variant design, Circle and Rect are plain structs with no base class, no virtual destructor and no
constructors, and the vector holds the shapes themselves, 24 bytes each: room for the larger alternative, two
doubles, plus the index of the alternative held. The standard requires a variant’s value to be nested within the
variant object, so a variant never allocates memory for it, and a vector of them is one block of memory, read from
start to end. Step through both vectors and two calls of std::visit:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- Pointers to the heap. The virtual design's vector holds one std::unique_ptr per shape, 8 bytes each, and every shape is a separate allocation on the heap, with its own vptr.
- The shapes themselves. The variant design's vector holds the shapes themselves, 24 bytes each: room for the larger alternative, a Rect of two doubles, plus the index of the alternative it holds.
- The index decides. std::visit(Area{}, s) on the first element finds index 0, a Circle, and calls Area's operator() for a const Circle&: area 0.071.
- The other alternative. On the second element the index is 1, so Area's operator() for a const Rect& runs: area 0.960. No vptr is involved; the index alone decides.
- One block of memory. The standard requires a variant's value to live inside the variant, so the vector of variants is one block of memory, read from start to end, while the pointers lead to two other places.
Each operation is a visitor: The callable that std::visit calls with the alternative a variant holds. It must accept every alternative, or the call does not compile; overloaded{…} builds one from one lambda per alternative. More in “std::optional, std::variant and std::any”, a struct with one operator() per
alternative (of a variant): One of the types a std::variant lists, such as Circle in std::variant<Circle, Rectangle, Triangle>. The variant holds exactly one alternative at a time; index() says which, counting from 0, and a default-constructed variant holds the first. More in “std::optional, std::variant and std::any”, and std::visit(Area{}, s) calls the one that matches what s
holds. Rule C.10 of the C++ Core Guidelines asks for exactly this kind of plain, concrete type unless there is a
reason for a hierarchy, and rule C.120 keeps hierarchies for concepts that really are hierarchical.
Checkpoint
Check: Closed sets with std::variant
Each design makes one kind of change easy
Suppose the shapes gain a third type, a triangle, or a third operation, a bounding box. In the virtual design the triangle is one new class and nothing else changes, but the bounding box is a new virtual function in the base and in every class. In the variant design the bounding box is one new visitor, while the triangle must be added to the variant and then to every visitor, each of which stops compiling until it handles the new alternative. Answer three questions about your own set of types, and the table marks the design that fits:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Design | A new type | A new operation | A forgotten case | Who can add types | Where the values live |
|---|---|---|---|---|---|
| Virtual functions A call goes through: the object's vptr, to the function of its class | Good one new class that overrides the functions; nothing else changes | Poor a new virtual function in the base and in every class | Mixed falls back to the base version, or the class stays abstract | Good anyone: plugins and users of a library too | Mixed usually one heap allocation each, behind an 8-byte pointer |
| std::variant A call goes through: the index the variant holds, to one operator() of the visitor | Poor joins the variant, then every visitor, each refused until it does | Good one new visitor; no type changes | Good std::visit does not compile | Poor only the code that owns the variant | Good inside the variant: 24 bytes per shape here, no allocation per shape |
How the answers decide
- “Must code you do not own, such as plugins or users of a library, be able to add new types?” Yes, other code adds types: rules out std::variant. Only the code that owns the variant can add an alternative. Plugins need a base class to derive from, which is what virtual functions give.
- “Which will you add more often, new types or new operations on the types you have?” New operations: adds a caveat to Virtual functions. Each new operation is a virtual function in the base and in every class; with a variant it is one new visitor.
- “Which will you add more often, new types or new operations on the types you have?” New types: adds a caveat to std::variant. Each new type joins the variant and then every visitor, which the compiler lists for you; with virtual functions it is one new class.
- “Must many small values sit next to each other in memory, with no allocation per object?” Yes, packed values: adds a caveat to Virtual functions. The virtual design usually allocates each object and keeps a pointer to it; a vector of variants holds the values themselves, 24 bytes each here.
Neither design is better in general. A drawing program whose plugins add new shapes needs the
open set of types: A set of types that other code can extend without changing yours, such as the shapes that plugins add to a drawing program. Virtual functions suit it: a new class derives from the base and overrides its functions., so it uses virtual functions. A parser whose tokens are fixed by a
grammar, or a protocol with a fixed set of messages, has a closed set and gains the checked visitors and the compact
storage of std::variant. When the set is open but you would rather not make every type derive from a base class,
the A way to hold values of unrelated types behind one class with a fixed set of operations, without a common base class: std::function holds any callable this way.
of the idioms module combines both, at the price of more code.
Ask which side grows
If new operations keep coming for a set of types you own, use a variant and one visitor per operation. If new types keep coming, or come from code you do not own, use a base class with virtual functions.
Checkpoint
Check: Which change each design makes easy
Composition holds the parts instead of inheriting them
Inheritance also gets used for sharing behaviour that has nothing to do with “is a”. A report system with two formats
and two destinations is a typical case: a hierarchy needs one class for every pair, such as CsvFileReport or
JsonEmailReport, and every new format or destination multiplies the classes, a
class explosion: A hierarchy that needs one class for every combination of independent choices: 2 formats and 2 destinations make 4 report classes, and 3 of each make 9. Composition needs one small type per choice instead.. Composition gives Report one member for each independent choice:
// Composition: a Report holds a format and a destination instead of inheriting them. Two formats and two
// destinations make four kinds of report from four small types; a class hierarchy would need one class per pair.
#include <array>
#include <format>
#include <print>
#include <string>
#include <utility>
#include <variant>
#include <vector>
struct Sales {
std::string region;
int amount;
};
// The formats.
struct Csv {
std::string render(const std::vector<Sales>& rows) const {
std::string out = "region,amount";
for (const auto& r : rows) out += std::format("\n{},{}", r.region, r.amount);
return out;
}
};
struct Json {
std::string render(const std::vector<Sales>& rows) const {
std::string out = "[";
for (std::size_t i = 0; i < rows.size(); ++i)
out += std::format("{}{{\"region\":\"{}\",\"amount\":{}}}", i > 0 ? "," : "", rows[i].region, rows[i].amount);
return out + "]";
}
};
using Format = std::variant<Csv, Json>;
// The destinations. Here they only describe what a real one would do.
struct FileSink {
std::string path;
std::string deliver(const std::string& body) const { return std::format("wrote {} bytes to {}", body.size(), path); }
};
struct EmailSink {
std::string to;
std::string deliver(const std::string& body) const { return std::format("mailed {} bytes to {}", body.size(), to); }
};
using Destination = std::variant<FileSink, EmailSink>;
class Report {
public:
Report(Format format, Destination destination) : format_(std::move(format)), destination_(std::move(destination)) {}
std::string send(const std::vector<Sales>& rows) const {
// A lambda with an auto parameter works for every alternative of the variant it visits.
const std::string body = std::visit([&](const auto& f) { return f.render(rows); }, format_);
return std::visit([&](const auto& d) { return d.deliver(body); }, destination_);
}
private:
Format format_;
Destination destination_;
};
int main() {
const std::vector<Sales> rows{{"north", 1200}, {"south", 950}};
const std::array<Format, 2> formats{Csv{}, Json{}};
const std::array<Destination, 2> destinations{FileSink{"sales.out"}, EmailSink{"team@example.com"}};
for (const auto& f : formats)
for (const auto& d : destinations) std::println("{}", Report{f, d}.send(rows));
} from session import run
# report.cpp built and run with both compilers.
run("""
clang++ -std=c++23 -Wall -Wextra report.cpp -o report && ./report
g++-16 -std=c++23 -Wall -Wextra report.cpp -o report-gcc && ./report-gcc
""") Output
$ clang++ -std=c++23 -Wall -Wextra report.cpp -o report && ./report wrote 34 bytes to sales.out mailed 34 bytes to team@example.com wrote 66 bytes to sales.out mailed 66 bytes to team@example.com $ g++-16 -std=c++23 -Wall -Wextra report.cpp -o report-gcc && ./report-gcc wrote 34 bytes to sales.out mailed 34 bytes to team@example.com wrote 66 bytes to sales.out mailed 66 bytes to team@example.com
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 report_session.py
Class explosion against composition
Text description of the diagram
The diagram compares two designs for the reports of report.cpp, which come in two formats and go to two destinations.
- The upper box, "Inheritance: a class per pair", lists four classes: CsvFileReport, CsvEmailReport, JsonFileReport and JsonEmailReport. Below them it notes that 3 formats and 3 destinations would need 9 classes, because every combination is its own class.
- A dashed arrow labelled "the same four reports" leads to the lower box.
- The lower box, "Composition: two parts", shows the two parts a Report holds: a Format, which is Csv or Json, and a Destination, which is FileSink or EmailSink. Any format works with any destination, so 3 formats and 3 destinations need only 3 + 3 small types.
Two formats and two destinations give four reports from four small types, and a third format and a third destination
would add two types instead of five classes. Step through one call of send(), for the JSON report sent by email:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- The first part. send() visits format_ with a lambda whose parameter is auto. The variant holds a Json, so the lambda runs with a const Json& and calls render().
- The body. Json::render() writes the two rows as a JSON array, 66 bytes, and returns it to send().
- The second part. send() visits destination_ the same way. It holds an EmailSink, so the second lambda calls EmailSink::deliver() with the body.
- Delivered. deliver() returns mailed 66 bytes to team@example.com, the last line of the output. Report never names Json or EmailSink, so any format works with any destination.
- Four reports. The loops in main pair each of the two formats with each of the two destinations: four reports from four small types, and no class for any pair.
Report::send() uses each part through std::visit with a generic lambda: A lambda with an auto parameter, or a template parameter list since C++20, whose operator() is a template, so one lambda works for every argument type its body supports, as [](auto x) { return x + x; } does. More in “C++ lambdas: captures and generic lambdas”, so that one lambda works for every alternative. If new formats must come from outside the code base,
from plugins for example, make Format an interface held by a std::unique_ptr instead: that is still composition,
with the open set that virtual functions give.
A useful test: inherit when the new class is a kind of the base class and can be used wherever the base is used, and hold a member when the new class only uses another class to do its job.
Checkpoint
Check: Composition instead of a hierarchy
The order of the data mattered more than the dispatch
“Virtual calls are slow” is a common claim, and rule Per.6 of the C++ Core Guidelines says never to make claims about
performance without measurements. This benchmark times one pass over 1,000,000 shapes in three designs: virtual calls
through std::unique_ptr, std::visit over a vector of variants, and one vector for each shape type, which needs no
dispatch at all. Each pass runs 25 times and the fastest is kept, and the shapes come in two orders: grouped by type,
and shuffled.
// Times one pass over 1,000,000 shapes in three designs: virtual calls through std::unique_ptr, std::visit over a
// std::vector of std::variant, and one std::vector per shape type with no dispatch at all. Each pass runs 25 times
// and the fastest is kept, which filters out interruptions by the operating system. The shapes come in two orders:
// grouped by type, and shuffled.
#include <algorithm>
#include <chrono>
#include <memory>
#include <numbers>
#include <print>
#include <random>
#include <utility>
#include <variant>
#include <vector>
namespace oop {
struct Shape {
virtual ~Shape() = default;
virtual double area() const = 0;
};
struct Circle final : Shape {
double r;
explicit Circle(double r) : r(r) {}
double area() const override { return std::numbers::pi * r * r; }
};
struct Rect final : Shape {
double w, h;
Rect(double w, double h) : w(w), h(h) {}
double area() const override { return w * h; }
};
struct Triangle final : Shape {
double b, h;
Triangle(double b, double h) : b(b), h(h) {}
double area() const override { return 0.5 * b * h; }
};
} // namespace oop
namespace closed {
struct Circle {
double r;
};
struct Rect {
double w, h;
};
struct Triangle {
double b, h;
};
using Shape = std::variant<Circle, Rect, Triangle>;
struct Area {
double operator()(const Circle& c) const { return std::numbers::pi * c.r * c.r; }
double operator()(const Rect& r) const { return r.w * r.h; }
double operator()(const Triangle& t) const { return 0.5 * t.b * t.h; }
};
} // namespace closed
// Tells GCC and Clang that memory may have changed, so that they really run every pass instead of reusing a result.
inline void clobber_memory() { asm volatile("" ::: "memory"); }
// The fastest of 25 runs of pass(), in nanoseconds per shape.
template <class Pass>
double fastest_ns(Pass pass, std::size_t n, double& checksum) {
double best = 1e30;
for (int run = 0; run < 25; ++run) {
clobber_memory();
const auto start = std::chrono::steady_clock::now();
checksum += pass();
const std::chrono::duration<double, std::nano> took = std::chrono::steady_clock::now() - start;
best = std::min(best, took.count() / static_cast<double>(n));
}
return best;
}
int main() {
constexpr std::size_t n = 1'000'000;
std::vector<int> grouped(n);
for (std::size_t i = 0; i < n; ++i) grouped[i] = static_cast<int>(i % 3);
std::ranges::sort(grouped); // all circles, then all rectangles, then all triangles
std::vector<int> shuffled = grouped;
std::ranges::shuffle(shuffled, std::mt19937{42});
std::println("1,000,000 shapes, nanoseconds per shape, fastest of 25 passes");
std::println("order virtual variant per-type");
double checksum = 0;
for (const auto& [label, kinds] : {std::pair{"grouped ", &grouped}, std::pair{"shuffled", &shuffled}}) {
std::vector<std::unique_ptr<oop::Shape>> heap;
std::vector<closed::Shape> flat;
std::vector<closed::Circle> circles;
std::vector<closed::Rect> rects;
std::vector<closed::Triangle> triangles;
for (int k : *kinds) {
if (k == 0) heap.push_back(std::make_unique<oop::Circle>(1.0)), flat.emplace_back(closed::Circle{1.0}), circles.push_back({1.0});
if (k == 1) heap.push_back(std::make_unique<oop::Rect>(2.0, 3.0)), flat.emplace_back(closed::Rect{2.0, 3.0}), rects.push_back({2.0, 3.0});
if (k == 2) heap.push_back(std::make_unique<oop::Triangle>(4.0, 5.0)), flat.emplace_back(closed::Triangle{4.0, 5.0}), triangles.push_back({4.0, 5.0});
}
const double v = fastest_ns([&] { double s = 0; for (const auto& p : heap) s += p->area(); return s; }, n, checksum);
const double w = fastest_ns([&] { double s = 0; for (const auto& x : flat) s += std::visit(closed::Area{}, x); return s; }, n, checksum);
const double t = fastest_ns([&] {
const closed::Area area;
double s = 0;
for (const auto& c : circles) s += area(c);
for (const auto& r : rects) s += area(r);
for (const auto& x : triangles) s += area(x);
return s;
}, n, checksum);
std::println("{} {:8.2f} {:8.2f} {:8.2f}", label, v, w, t);
}
std::println("checksum {:.0f}", checksum); // printed so that no pass can be left out
} from session import run
# The processor of the computer that ran it, then the benchmark built with -O2 by each compiler.
run("""
/usr/sbin/sysctl -n machdep.cpu.brand_string
clang++ -std=c++23 -O2 dispatch_bench.cpp -o bench && ./bench
g++-16 -std=c++23 -O2 dispatch_bench.cpp -o bench-gcc && ./bench-gcc
""") Output
$ /usr/sbin/sysctl -n machdep.cpu.brand_string Apple M5 Pro $ clang++ -std=c++23 -O2 dispatch_bench.cpp -o bench && ./bench 1,000,000 shapes, nanoseconds per shape, fastest of 25 passes order virtual variant per-type grouped 1.15 1.17 0.98 shuffled 7.43 7.40 0.98 checksum 957079147 $ g++-16 -std=c++23 -O2 dispatch_bench.cpp -o bench-gcc && ./bench-gcc 1,000,000 shapes, nanoseconds per shape, fastest of 25 passes order virtual variant per-type grouped 0.97 0.90 1.32 shuffled 7.51 5.62 1.31 checksum 957079147
This output changes from run to run: the times depend on the computer and on what else it is doing; the lesson quotes the times of this run
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 dispatch_session.py
Read the table by rows first. With the shapes grouped by type, all three designs cost about a nanosecond per shape on
both compilers, so the dispatch style hardly matters. Shuffled, virtual calls and std::visit both become several
times slower, because the processor can no longer predict which function comes next, and every wrong guess costs it
time. One vector per type takes the same time in both orders, because each of its loops calls one known function.
Only then compare the columns. In the recorded run, std::visit was faster than virtual calls with GCC when the
shapes were shuffled, and the two were close with Clang, a difference far smaller than the one the order of the data
made. That is the lesson of the measurement: the order and the layout of the data usually matter more than the choice
between virtual functions and std::variant, much as rule Per.19 of the Guidelines asks for memory to be read in a
predictable order. This is one micro-benchmark on one computer, so measure your own workload before you change a
design for speed.
Key idea
Measure before you choose a dispatch style for speed: here the order of the shapes changed the cost six to eight
times over, while virtual calls against std::visit changed it far less.
Checkpoint
Check: Measuring the cost of dispatch
Practise: tickets as a variant
fares.h contains a finished ticket hierarchy in the namespace oop and the start of the same tickets as a
std::variant in the namespace closed. The tests compare every ticket at three distances in both designs:
C header · fares.h
// Exercise: the namespace closed is a second design of the tickets in oop, as one std::variant. Finish name_of()
// and price_of() so that every check in fares_tests.cpp passes: both designs must give the same answers.
// The fare rules are this exercise's own.
#pragma once
#include <string>
#include <variant>
// The design with inheritance (finished).
namespace oop {
class Ticket {
public:
virtual ~Ticket() = default;
virtual std::string name() const = 0;
virtual int price(int km) const = 0; // in whole units of money
};
class Regular : public Ticket {
public:
std::string name() const override { return "regular"; }
int price(int km) const override { return 10 + 2 * km; }
};
class Student : public Ticket {
public:
std::string name() const override { return "student"; }
int price(int km) const override { return (10 + 2 * km) / 2; } // half fare, rounded down
};
class Season : public Ticket {
public:
explicit Season(int rides_left) : rides_left_(rides_left) {}
std::string name() const override { return "season, " + std::to_string(rides_left_) + " rides left"; }
int price(int) const override { return rides_left_ > 0 ? 0 : 10; } // free while rides are left
private:
int rides_left_;
};
} // namespace oop
// The same tickets as a closed set. The alternatives are declared for you.
namespace closed {
struct Regular {};
struct Student {};
struct Season {
int rides_left = 0;
};
using Ticket = std::variant<Regular, Student, Season>;
// TODO: return what oop's name() returns for the same ticket.
inline std::string name_of(const Ticket& ticket) {
(void)ticket;
return "";
}
// TODO: return what oop's price() returns for the same ticket and distance.
inline int price_of(const Ticket& ticket, int km) {
(void)ticket;
(void)km;
return -1;
}
} // namespace closed C++ · fares_tests.cpp
// Sample tests for fares.h: every ticket and distance must give the same name and price in both designs.
#include <memory>
#include <print>
#include <string>
#include <vector>
#include "fares.h"
namespace {
int checks = 0;
int passed = 0;
void check(bool ok, const std::string& what) {
++checks;
if (ok) ++passed;
else std::println("FAILED: {}", what);
}
} // namespace
int main() {
std::vector<std::unique_ptr<oop::Ticket>> old_design;
old_design.push_back(std::make_unique<oop::Regular>());
old_design.push_back(std::make_unique<oop::Student>());
old_design.push_back(std::make_unique<oop::Season>(3));
old_design.push_back(std::make_unique<oop::Season>(0));
const std::vector<closed::Ticket> new_design{closed::Regular{}, closed::Student{}, closed::Season{3}, closed::Season{0}};
for (std::size_t i = 0; i < old_design.size(); ++i) {
const std::string expected = old_design[i]->name();
check(closed::name_of(new_design[i]) == expected, "the name of ticket " + std::to_string(i) + " is \"" + expected + "\"");
for (int km : {0, 7, 15}) {
const int fare = old_design[i]->price(km);
check(closed::price_of(new_design[i], km) == fare,
"\"" + expected + "\" for " + std::to_string(km) + " km costs " + std::to_string(fare));
}
}
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 fares_tests.cpp -o fares_tests && ./fares_tests
g++-16 -std=c++23 -Wall -Wextra fares_tests.cpp -o fares_tests-gcc && ./fares_tests-gcc
""", max_lines=8) Output
$ clang++ -std=c++23 -Wall -Wextra fares_tests.cpp -o fares_tests && ./fares_tests FAILED: the name of ticket 0 is "regular" FAILED: "regular" for 0 km costs 10 FAILED: "regular" for 7 km costs 24 FAILED: "regular" for 15 km costs 40 FAILED: the name of ticket 1 is "student" FAILED: "student" for 0 km costs 5 FAILED: "student" for 7 km costs 12 FAILED: "student" for 15 km costs 20 [9 more lines] [exit status 1] $ g++-16 -std=c++23 -Wall -Wextra fares_tests.cpp -o fares_tests-gcc && ./fares_tests-gcc FAILED: the name of ticket 0 is "regular" FAILED: "regular" for 0 km costs 10 FAILED: "regular" for 7 km costs 24 FAILED: "regular" for 15 km costs 40 FAILED: the name of ticket 1 is "student" FAILED: "student" for 0 km costs 5 FAILED: "student" for 7 km costs 12 FAILED: "student" for 15 km costs 20 [9 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 fares_session.py
Write name_of() and price_of() in closed, each as a visitor with one operator() per alternative, until both
compilers print 16 of 16 checks passed. The season ticket’s visitor needs the alternative’s own data, and
price_of() needs the distance: store it in the visitor. Then add a Child ticket that costs half the student fare
to both designs, and count the places you had to change in each.
Then carry the four ideas of this lesson to new cases, from a parser’s tokens to a game character’s weapons. Your weakest idea comes up first, and each choice explains what it gets right or where it goes wrong:
Practice that adapts to you
Practise this lesson
11 questions about 4 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Fresher · When would you use std::variant instead of virtual functions?
Use std::variant when the set of types is closed: you know all of them, and they change rarely, while new operations on them are added often. A variant holds its value inside itself without a heap allocation, the types need no common base class, and std::visit refuses to compile a visitor that misses a type, so adding an operation is one new visitor. Use virtual functions when the set is open: other code, such as plugins or users of a library, must be able to add types without changing yours. Adding a type to a variant means editing the variant and every visitor; adding an operation to a hierarchy means editing every class. A good answer adds that the speed difference is usually small next to the order and layout of the data, and that it should be measured, not assumed.
The follow-ups usually test the edges of that answer: a variant in a library’s public interface, and what a variant costs instead of the heap. Decide both:
Checkpoint
Interview round: two follow-up questions
Two harder questions of this module start from this lesson: a colleague who wants every class hierarchy replaced because virtual calls are slow, and an exporter hierarchy that must grow to new formats and destinations:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Senior to staff
A reviewer rejects your class hierarchy because "virtual calls are slow" and asks for std::variant instead. How would you decide, and what exactly would you measure?
-
Mid to senior
An exporter hierarchy has CsvFileExporter, CsvEmailExporter, JsonFileExporter and JsonEmailExporter, and product now wants XML and cloud storage too. How would you redesign it, and what does your design make harder?
Key takeaways
- A closed set of types fits
std::variantandstd::visit: values without heap allocations, no common base class, and a compile error for every visitor that misses a type. - Virtual functions make new types cheap and new operations expensive, and variants do the opposite; an open set of types, extended by other code, needs virtual functions, or the type erasure of a later module.
- When classes only share behaviour, hold the parts as members. Two independent choices of two options each need four classes in a hierarchy, but four small types with composition, and the gap grows with every option.
- In the measured run, shuffling the data slowed both dispatch styles several times over, while virtual against variant changed far less. Measure before you redesign for speed.
Terms in this lesson
- class explosion
- A hierarchy that needs one class for every combination of independent choices: 2 formats and 2 destinations make 4 report classes, and 3 of each make 9. Composition needs one small type per choice instead.
- closed set of types
- A set of types that one piece of code owns and knows completely, such as the shapes of one program or the messages of a fixed protocol. std::variant suits it: std::visit refuses a visitor that misses one of the types.
- composition
- Building a class from members that each do part of its work instead of inheriting from classes that do it: a Report holds a Format and a Destination, so every format works with every destination without a class for each pair.
- open set of types
- A set of types that other code can extend without changing yours, such as the shapes that plugins add to a drawing program. Virtual functions suit it: a new class derives from the base and overrides its functions.
References
- Working Draft, Programming Languages: C++ (N4950): [variant.variant.general], [variant.visit] (ISO/IEC JTC1/SC22/WG21)
- C++ Core Guidelines: C.10, prefer concrete types over class hierarchies (Standard C++ Foundation)
- C++ Core Guidelines: C.120, use class hierarchies to represent concepts with inherent hierarchical structure (Standard C++ Foundation)
- C++ Core Guidelines: Per.6, don't make claims about performance without measurements (Standard C++ Foundation)
- C++ Core Guidelines: Per.19, access memory predictably (Standard C++ Foundation)
Report a problem with this lesson
Kept only in this browser. Your Learn progress