C++ Module 8 – Inheritance and polymorphism
Abstract classes and interfaces in C++
Define C++ interfaces with pure virtual functions, keep shared checks in one place with the non-virtual interface idiom, and test code with fakes.
What you will learn
- Define an interface as a class with pure virtual functions and a virtual destructor
- Explain why an abstract class cannot be instantiated and what a derived class must override
- Test code that depends on an interface by giving it a fake implementation
- Apply the non-virtual interface idiom to keep checks and logging in one place
- Predict what happens when a constructor reaches a pure virtual function
Before you start
What you’ll learn · Before you start
What you will learn
- Define an interface as a class with pure virtual functions and a virtual destructor
- Explain why an abstract class cannot be instantiated and what a derived class must override
- Test code that depends on an interface by giving it a fake implementation
- Apply the non-virtual interface idiom to keep checks and logging in one place
- Predict what happens when a constructor reaches a pure virtual function
Before you start
On this page
The Checkout of this lesson charges 2,499.00 to a card and 999.00 to a wallet, and its five tests run against a
fake that records every charge without touching either. It can do both because it depends on nothing but an
interface: In C++, a class with only pure virtual functions and a public virtual destructor, and no data: it says what a type can do and leaves how to the classes that implement it. C++ has no interface keyword; this is a convention.: a class of pure virtual functions that says what a payment method can do
and leaves how to each class that implements it.
You will make a class abstract with pure virtual functions, build that interface and the fake that tests the code using it, keep checks and logging in one place with the non-virtual interface idiom, and see what happens when a pure virtual function is called anyway. Every example runs on Apple clang 21 and GCC 16.
Pure virtual functions make a class abstract
An abstract class: A class with at least one pure virtual function that it has not overridden. No object of it can be created, not even as a by-value parameter, but references and pointers to it can refer to objects of its concrete derived classes. is a class with at least one
pure virtual function: A virtual function declared with = 0 instead of a body, as in virtual std::string name() const = 0;. It makes its class abstract, and each derived class must override it before objects of that class can be created., a virtual function declared with = 0 instead of a
body: virtual std::string name() const = 0;. It states what every derived class must be able to do without saying
how, and the compiler refuses to create an object of it. A derived class becomes usable, or concrete, once it
overrides every pure virtual function it inherits. payment.h declares the interface PaymentMethod and a Checkout
that uses nothing else:
// An interface for ways to pay, and a checkout that only knows the interface.
#pragma once
#include <cstdint>
#include <format>
#include <string>
using Money = std::int64_t; // in the smallest unit of the currency (paise, cents)
// Every way to pay can charge an amount and say what it is called. How it does that is up to each class.
class PaymentMethod {
public:
virtual ~PaymentMethod() = default;
// Charges amount for an order; returns a transaction id, or an empty string when the payment is declined.
virtual std::string charge(Money amount, const std::string& order_id) = 0;
virtual std::string name() const = 0;
};
// The checkout works with any PaymentMethod: it never names a concrete class.
class Checkout {
public:
explicit Checkout(PaymentMethod& method) : method_(method) {}
std::string pay(const std::string& order_id, Money amount) {
if (amount <= 0) return std::format("{}: nothing to pay", order_id);
const std::string transaction = method_.charge(amount, order_id);
if (transaction.empty()) return std::format("{}: {} declined", order_id, method_.name());
return std::format("{}: paid {}.{:02} by {} ({})", order_id, amount / 100, amount % 100, method_.name(),
transaction);
}
private:
PaymentMethod& method_;
}; PaymentMethod has no data and no code, only promises: anything that is a PaymentMethod can charge() an amount
and report its name(). Because it is abstract, neither the interface itself nor a class that keeps one of its pure
virtual functions can be created:
// Neither the interface nor a class that overrides only part of it can be created.
#include <string>
#include "payment.h"
class GiftCard : public PaymentMethod {
public:
std::string charge(Money, const std::string&) override { return "gift"; }
// name() is not overridden, so GiftCard is still abstract
};
int main() {
PaymentMethod method;
GiftCard card;
} from session import run
# abstract_errors.cpp with both compilers.
run("""
clang++ -std=c++23 -Wall -Wextra -c abstract_errors.cpp
g++-16 -std=c++23 -Wall -Wextra -c abstract_errors.cpp
""") Output
$ clang++ -std=c++23 -Wall -Wextra -c abstract_errors.cpp
abstract_errors.cpp:13:19: error: variable type 'PaymentMethod' is an abstract class
13 | PaymentMethod method;
| ^
./payment.h:14:25: note: unimplemented pure virtual method 'charge' in 'PaymentMethod'
14 | virtual std::string charge(Money amount, const std::string& order_id) = 0;
| ^
./payment.h:15:25: note: unimplemented pure virtual method 'name' in 'PaymentMethod'
15 | virtual std::string name() const = 0;
| ^
abstract_errors.cpp:14:14: error: variable type 'GiftCard' is an abstract class
14 | GiftCard card;
| ^
./payment.h:15:25: note: unimplemented pure virtual method 'name' in 'GiftCard'
15 | virtual std::string name() const = 0;
| ^
2 errors generated.
[exit status 1]
$ g++-16 -std=c++23 -Wall -Wextra -c abstract_errors.cpp
abstract_errors.cpp: In function 'int main()':
abstract_errors.cpp:13:19: error: cannot declare variable 'method' to be of abstract type 'PaymentMethod'
13 | PaymentMethod method;
| ^~~~~~
In file included from abstract_errors.cpp:4:
payment.h:10:7: note: because the following virtual functions are pure within 'PaymentMethod':
10 | class PaymentMethod {
| ^~~~~~~~~~~~~
• 'virtual std::string PaymentMethod::charge(Money, const std::string&)'
payment.h:14:25:
14 | virtual std::string charge(Money amount, const std::string& order_id) = 0;
| ^~~~~~
• 'virtual std::string PaymentMethod::name() const'
payment.h:15:25:
15 | virtual std::string name() const = 0;
| ^~~~
abstract_errors.cpp:14:14: error: cannot declare variable 'card' to be of abstract type 'GiftCard'
14 | GiftCard card;
| ^~~~
abstract_errors.cpp:6:7: note: because the following virtual functions are pure within 'GiftCard':
6 | class GiftCard : public PaymentMethod {
| ^~~~~~~~
• 'virtual std::string PaymentMethod::name() const'
payment.h:15:25:
15 | virtual std::string name() const = 0;
| ^~~~
[exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 abstract_errors_session.py
Both compilers name every function that is still missing. GiftCard overrides charge() but not name(), so it
inherits a pure virtual function and stays abstract. Follow the three classes of this lesson from abstract to
concrete:
| Criterion | Pure functions left | An object of it |
|---|---|---|
| PaymentMethod, overrides nothing | charge() and name() | Error |
| GiftCard, overrides charge() | name() | Error |
| CardPayment, overrides both | None | Compiles |
The rule reaches wherever an object of the class would have to exist. A function may even be declared with an abstract class as a by-value parameter or return type, but defining such a function does not compile, and neither does passing an object to such a parameter, because each would need an object of the abstract class. References and pointers to an abstract class are fine, and they are how an interface is used.
Checkpoint
Check: Pure virtual functions make a class abstract
An interface is a pure abstract class
Two real implementations, and a Checkout for each:
// Two implementations of PaymentMethod, used by the same Checkout.
#include <format>
#include <print>
#include <string>
#include <utility>
#include "payment.h"
class CardPayment : public PaymentMethod {
public:
explicit CardPayment(std::string last_digits) : last_digits_(std::move(last_digits)) {}
std::string charge(Money amount, const std::string& order_id) override {
// A real class would call the card network here. This one approves amounts up to 50,000.00.
if (amount > 5'000'000) return "";
return std::format("card-{}-{}", order_id, ++count_);
}
std::string name() const override { return "card ending " + last_digits_; }
private:
std::string last_digits_;
int count_ = 0;
};
class WalletPayment : public PaymentMethod {
public:
explicit WalletPayment(Money balance) : balance_(balance) {}
std::string charge(Money amount, const std::string& order_id) override {
if (amount > balance_) return "";
balance_ -= amount;
return "wallet-" + order_id;
}
std::string name() const override { return std::format("wallet ({}.{:02} left)", balance_ / 100, balance_ % 100); }
private:
Money balance_;
};
int main() {
CardPayment card{"4242"};
WalletPayment wallet{120'000};
Checkout by_card{card};
Checkout by_wallet{wallet};
std::println("{}", by_card.pay("A-1001", 249'900));
std::println("{}", by_wallet.pay("A-1002", 99'900));
std::println("{}", by_wallet.pay("A-1003", 99'900));
} from session import run
# checkout.cpp built and run with both compilers.
run("""
clang++ -std=c++23 -Wall -Wextra checkout.cpp -o checkout && ./checkout
g++-16 -std=c++23 -Wall -Wextra checkout.cpp -o checkout-gcc && ./checkout-gcc
""") Output
$ clang++ -std=c++23 -Wall -Wextra checkout.cpp -o checkout && ./checkout A-1001: paid 2499.00 by card ending 4242 (card-A-1001-1) A-1002: paid 999.00 by wallet (201.00 left) (wallet-A-1002) A-1003: wallet (201.00 left) declined $ g++-16 -std=c++23 -Wall -Wextra checkout.cpp -o checkout-gcc && ./checkout-gcc A-1001: paid 2499.00 by card ending 4242 (card-A-1001-1) A-1002: paid 999.00 by wallet (201.00 left) (wallet-A-1002) A-1003: wallet (201.00 left) declined
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 checkout_session.py
Checkout was written without knowing that cards or wallets exist. It calls charge() and name() through its
PaymentMethod&, and each call runs the override of the object it was given: the card approves 2,499.00, the wallet
pays 999.00 and then declines the same amount, because only 201.00 is left. Step through the three payments:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- A-1001 by card. by_card.pay("A-1001", 249'900) has a positive amount, so Checkout calls charge() through its PaymentMethod&. The object behind it is the card.
- Approved. CardPayment::charge() approves amounts up to 50,000.00 and returns a transaction id: card-A-1001-1.
- Its name, the same way. Checkout asks name(), which runs CardPayment::name(), and prints A-1001: paid 2499.00 by card ending 4242 (card-A-1001-1).
- A-1002 by wallet. by_wallet is another Checkout, given the wallet. The same code calls charge(), and this time WalletPayment::charge() runs.
- Paid from the balance. The wallet holds 1,200.00, so it pays 999.00 and keeps 201.00: A-1002: paid 999.00 by wallet (201.00 left) (wallet-A-1002).
- A-1003 by wallet. The third order asks the wallet for 999.00 again. Only 201.00 is left, so charge() returns an empty string, which means declined.
- Declined. Checkout reports A-1003: wallet (201.00 left) declined. It never named CardPayment or WalletPayment: every call went through the interface.
The drawing shows the same design as dependencies: Checkout points at the interface only, and every
implementation hangs below it.
Code that depends on an interface works with every implementation
Text description of the diagram
The diagram shows the classes of payment.h, checkout.cpp and checkout_tests.cpp, from top to bottom.
- At the top, Checkout holds a reference to a PaymentMethod. An arrow labelled "calls only these functions" points down to PaymentMethod.
- PaymentMethod is an interface: it declares the pure virtual functions charge() and name(), each written with = 0, and a virtual destructor, and it has no data members.
- A dashed arrow labelled "implemented by, each overrides both" leads down to a box of three implementations: CardPayment and WalletPayment, used by the program, and FakePayment, used by the tests.
- Checkout never names any of the three, so each of them can be given to it without changing a line of Checkout.
A good interface in C++ looks like PaymentMethod:
- Only pure virtual functions. Rule C.121 of the C++ Core Guidelines says that a base class used as an interface should be a pure abstract class, and rule I.25 prefers such empty classes to hierarchies that carry data.
- A public virtual destructor, so that an implementation can be deleted through the interface, for example by a
std::unique_ptr<PaymentMethod>. If you declare it pure,virtual ~PaymentMethod() = 0;, you must still define it outside the class, because the destructor of every derived class calls it. - No data members and no constructors. Data belongs to the implementations, which keeps the interface stable when they change.
Rule C.129 of the Guidelines names the distinction behind this: inheriting an interface promises what a class can do, while inheriting an implementation reuses code. An interface keeps the two apart, so a class can implement it without inheriting anything it did not ask for.
Payments in India
A UpiPayment class for UPI would be one more implementation of the same interface. Checkout and its tests would
not change.
Checkpoint
Check: Interfaces
A fake tests the code without the real thing
The real payment classes would contact a card network or a wallet service. A test does not need either, because
Checkout only depends on the interface. A fake: A small implementation of an interface written for tests, such as a payment method that approves or declines on command and records every charge, so that a test can run the code without the real service and check how it was used. is a small implementation written for tests:
this one approves or declines on command and records every charge it receives.
// Tests for Checkout with a fake PaymentMethod: no card network, no wallet, and every call is recorded.
#include <print>
#include <string>
#include <vector>
#include "payment.h"
// A fake: it approves or declines as the test says, and remembers what it was asked to do.
class FakePayment : public PaymentMethod {
public:
bool approve = true;
std::vector<Money> charged;
std::string charge(Money amount, const std::string& order_id) override {
charged.push_back(amount);
return approve ? "fake-" + order_id : "";
}
std::string name() const override { return "fake"; }
};
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() {
FakePayment fake;
Checkout checkout{fake};
check(checkout.pay("T-1", 12'345) == "T-1: paid 123.45 by fake (fake-T-1)", "an approved payment is reported");
check(fake.charged == std::vector<Money>{12'345}, "the method is charged once, with the exact amount");
fake.approve = false;
check(checkout.pay("T-2", 500) == "T-2: fake declined", "a declined payment is reported");
check(checkout.pay("T-3", 0) == "T-3: nothing to pay", "a zero amount needs no payment");
check(fake.charged.size() == 2, "a zero amount never reaches the payment method");
std::println("{} of {} checks passed", passed, checks);
return passed == checks ? 0 : 1;
} from session import run
# The tests built with both compilers, the first build with the sanitizers on.
run("""
clang++ -std=c++23 -Wall -Wextra -fsanitize=address,undefined checkout_tests.cpp -o checkout_tests && ./checkout_tests
g++-16 -std=c++23 -Wall -Wextra checkout_tests.cpp -o checkout_tests-gcc && ./checkout_tests-gcc
""") Output
$ clang++ -std=c++23 -Wall -Wextra -fsanitize=address,undefined checkout_tests.cpp -o checkout_tests && ./checkout_tests 5 of 5 checks passed $ g++-16 -std=c++23 -Wall -Wextra checkout_tests.cpp -o checkout_tests-gcc && ./checkout_tests-gcc 5 of 5 checks passed
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 checkout_tests_session.py
Step through the test and watch what the fake records at each payment:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- T-1, approved. The test pays 12,345, which is 123.45. Checkout calls charge() through its PaymentMethod&, and the object behind it is the fake, which records the amount and approves.
- Two checks pass. Checkout reports T-1: paid 123.45 by fake (fake-T-1), and the fake's record says it was charged once, with the exact amount: two checks pass.
- T-2, declined. The test sets fake.approve = false and pays 500. The fake records it and returns an empty string, so Checkout reports T-2: fake declined.
- T-3, nothing to pay. An order of 0 never reaches the payment method: Checkout returns T-3: nothing to pay without calling charge(), and the record still holds two amounts.
- No network at all. Both compilers printed 5 of 5 checks passed, the Clang build under both sanitizers without a report. No card network, no wallet, and the run takes no time.
The tests check more than return values. They check how Checkout used the interface: one charge per payment, the
exact amount, and no charge at all for an order that costs nothing. A fake makes such questions easy to ask, and the
run takes no time and needs no network. The first build also runs under AddressSanitizer and UndefinedBehaviorSanitizer,
which reported nothing.
The cost of this design is small but real: every call through the interface is a virtual call, and each implementation is a separate class. For code where that cost matters, later modules show interfaces checked at compile time, with templates and concepts.
Key idea
Code that depends only on an interface works with every class that implements it, including a fake that a test controls and inspects, so the test needs neither the network nor the real service.
Checkpoint
Check: Testing through a fake
The non-virtual interface idiom keeps the checks in one place
Every exporter of a report should refuse an empty report and log each export the same way. If those steps live in each derived class, one of them will forget them. The non-virtual interface idiom (NVI): A design in which a base class's public function is not virtual: it runs the steps every implementation shares, such as checks and logging, and calls private virtual hooks that derived classes override for the part that differs. (NVI) puts them in the base class instead: the public function is not virtual and does the shared work, and it calls private virtual functions, the hooks, for the part that differs.
// The non-virtual interface idiom: export_report() is public and not virtual. It checks the report and writes the
// log lines, once, for every format. Derived classes override only the private hooks.
#include <array>
#include <print>
#include <string>
#include <vector>
struct Report {
std::string title;
std::vector<int> values;
};
class Exporter {
public:
virtual ~Exporter() = default;
// The only way in: the same check and the same log lines for every format.
bool export_report(const Report& report) const {
if (report.values.empty()) {
std::println("[{}] refused \"{}\": no values", format_name(), report.title);
return false;
}
std::println("[{}] exporting \"{}\" ({} values)", format_name(), report.title, report.values.size());
const std::string body = write_body(report);
std::println("{}", body);
std::println("[{}] done, {} bytes", format_name(), body.size());
return true;
}
private:
// The hooks: each format decides what they do, but never when they run.
virtual std::string format_name() const = 0;
virtual std::string write_body(const Report& report) const = 0;
};
class CsvExporter : public Exporter {
private:
std::string format_name() const override { return "csv"; }
std::string write_body(const Report& report) const override {
std::string out = "title,value";
for (int v : report.values) out += "\n" + report.title + "," + std::to_string(v);
return out;
}
};
class JsonExporter : public Exporter {
private:
std::string format_name() const override { return "json"; }
std::string write_body(const Report& report) const override {
std::string out = "{\"title\":\"" + report.title + "\",\"values\":[";
for (std::size_t i = 0; i < report.values.size(); ++i) out += (i > 0 ? "," : "") + std::to_string(report.values[i]);
return out + "]}";
}
};
int main() {
const CsvExporter csv;
const JsonExporter json;
const std::array<const Exporter*, 2> exporters{&csv, &json};
const Report sales{"sales", {120, 95}};
const Report returns{"returns", {}};
for (const Exporter* e : exporters) {
e->export_report(sales);
e->export_report(returns);
}
#ifdef CALL_HOOK
csv.write_body(sales); // outside code cannot skip the checks
#endif
} from session import run
# exporter.cpp built and run with both compilers, then with -DCALL_HOOK, which calls a hook directly.
run("""
clang++ -std=c++23 -Wall -Wextra exporter.cpp -o exporter && ./exporter
g++-16 -std=c++23 -Wall -Wextra exporter.cpp -o exporter-gcc && ./exporter-gcc
clang++ -std=c++23 -Wall -Wextra -DCALL_HOOK -c exporter.cpp
g++-16 -std=c++23 -Wall -Wextra -DCALL_HOOK -c exporter.cpp
""", max_lines=14) Output
$ clang++ -std=c++23 -Wall -Wextra exporter.cpp -o exporter && ./exporter
[csv] exporting "sales" (2 values)
title,value
sales,120
sales,95
[csv] done, 30 bytes
[csv] refused "returns": no values
[json] exporting "sales" (2 values)
{"title":"sales","values":[120,95]}
[json] done, 35 bytes
[json] refused "returns": no values
$ g++-16 -std=c++23 -Wall -Wextra exporter.cpp -o exporter-gcc && ./exporter-gcc
[csv] exporting "sales" (2 values)
title,value
sales,120
sales,95
[csv] done, 30 bytes
[csv] refused "returns": no values
[json] exporting "sales" (2 values)
{"title":"sales","values":[120,95]}
[json] done, 35 bytes
[json] refused "returns": no values
$ clang++ -std=c++23 -Wall -Wextra -DCALL_HOOK -c exporter.cpp
exporter.cpp:68:9: error: 'write_body' is a private member of 'CsvExporter'
68 | csv.write_body(sales); // outside code cannot skip the checks
| ^
exporter.cpp:39:17: note: declared private here
39 | std::string write_body(const Report& report) const override {
| ^
1 error generated.
[exit status 1]
$ g++-16 -std=c++23 -Wall -Wextra -DCALL_HOOK -c exporter.cpp
exporter.cpp: In function 'int main()':
exporter.cpp:68:19: error: 'virtual std::string CsvExporter::write_body(const Report&) const' is private within this context
68 | csv.write_body(sales); // outside code cannot skip the checks
| ~~~~~~~~~~~~~~^~~~~~~
exporter.cpp:39:17: note: declared private here
39 | std::string write_body(const Report& report) const override {
| ^~~~~~~~~~
[exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 exporter_session.py
Step through one call of export_report() for each report, then the build that tries to skip it:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- The only way in. main calls export_report(sales) on the CSV exporter. It is the only public function, and it is not virtual, so its steps are the same for every format.
- The shared check. The sales report holds 2 values, so the check lets it through. CsvExporter never wrote this check.
- The shared log line. Exporter prints [csv] exporting "sales" (2 values). The format name comes from a hook too, so the line is right for every format.
- The hook. Only now does the part that differs run: CsvExporter's write_body() builds the CSV text, title,value and two rows, 30 bytes in all.
- The shared end. Exporter prints the body, then [csv] done, 30 bytes. CsvExporter contains no log line at all.
- An empty report. export_report(returns) meets an empty list of values, so the check sends it aside: [csv] refused "returns": no values. The hook never runs.
- The second format. JsonExporter overrides the same two hooks and nothing else, yet it runs the same check and the same log lines: its body is 35 bytes, and it refuses returns too.
- No way around it. Built with -DCALL_HOOK, main calls csv.write_body(sales) directly, and both compilers refuse: the hook is a private member of CsvExporter.
CsvExporter and JsonExporter contain no checks and no log lines, yet both refuse the empty “returns” report and
log every export in the same words, because only Exporter::export_report() is public. The derived classes override
private functions, which surprises many people: access controls who may call a function, not who may override
it. The standard checks access at the call, with the static type, so export_report() may call its own private
hooks and outside code may not, as the last two builds show.
The C++ FAQ of the Standard C++ Foundation describes this design under private virtuals: public non-virtual functions define the interface, and private or protected virtual functions define what derived classes may customise. Use it when every implementation must follow the same steps, such as checks, logging, locking or timing.
Checkpoint
Check: The non-virtual interface idiom
A pure virtual call during construction is undefined behaviour
A pure virtual function has no body to run. Normally no call can reach it, because every object you can create has overridden it, but there is one moment when the object is still only the abstract class: during the base class’s constructor and destructor.
// Job's constructor calls start(), which calls the pure virtual function title(). While Job is being constructed,
// the object is only a Job, and Job has no title().
#include <print>
class Job {
public:
Job() { start(); }
virtual ~Job() = default;
void start() const { std::println("starting {}", title()); }
private:
virtual const char* title() const = 0;
};
class Backup : public Job {
private:
const char* title() const override { return "backup"; }
};
int main() {
Backup nightly;
std::println("this line is never printed");
} from session import run
# pure_call.cpp: both builds compile without a warning; each program is then started on its own, so that the
# session names the signal that stops it.
run("""
clang++ -std=c++23 -Wall -Wextra pure_call.cpp -o pure_call
./pure_call
g++-16 -std=c++23 -Wall -Wextra pure_call.cpp -o pure_call-gcc
./pure_call-gcc
""") Output
$ clang++ -std=c++23 -Wall -Wextra pure_call.cpp -o pure_call $ ./pure_call libc++abi: Pure virtual function called! [stopped by SIGABRT] $ g++-16 -std=c++23 -Wall -Wextra pure_call.cpp -o pure_call-gcc $ ./pure_call-gcc pure virtual method called terminate called without an active exception [stopped by SIGABRT]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 pure_call_session.py
Step through the creation of nightly up to the moment the program stops:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The base part first. Backup nightly; must build its Job part before anything of Backup exists.
- Only a Job, for now. While Job's constructor runs, the object is a Job, as in the previous lesson. For this moment it is an object of an abstract class.
- A call hidden in a helper. Job() calls start(), an ordinary function, and start() calls title(), a virtual one. Neither compiler warned, because the call is hidden inside start().
- Nothing to run. The virtual call runs the version of the class being constructed, Job, and Job's title() is pure virtual: there is no body to run. The standard calls this undefined behaviour.
- Both programs stop. Here both libraries stopped the program with SIGABRT: libc++abi printed Pure virtual function called! and libstdc++ pure virtual method called. The next line of main never ran.
- The fix. Never call virtual functions, directly or through a helper, from a constructor or a destructor. Create the Backup first, then call nightly.start() from outside.
Neither compiler warns, because the call is hidden inside start(), and the standard calls the result
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++”. Here both runtime libraries stopped the program with
SIGABRT, after libc++abi printed “Pure virtual function called!” and libstdc++ “pure virtual method called”. The fix is
the one from the previous lesson: never call virtual functions, directly or through a helper, from constructors and
destructors.
Starting work from the base constructor
Job() { start(); } runs start() while the object is only a Job, so its call to the pure title() has nothing
to run. Let the constructor only build the object, and call nightly.start() once the Backup exists.
Checkpoint
Check: A pure virtual call while building
Practise: a logger interface and a fake
Thermostat logs a warning for each reading above its limit and an error from the third such reading in a row. It
writes through a Logger&, but Logger is not an interface yet, so the tests cannot see what it logs.
C header · logger.h
// Exercise: make every check in logger_tests.cpp pass with both compilers. Thermostat is finished: do not change it.
#pragma once
#include <format>
#include <print>
#include <string>
#include <vector>
enum class Level { info, warning, error };
inline const char* level_name(Level level) {
switch (level) {
case Level::info: return "info";
case Level::warning: return "warning";
case Level::error: return "error";
}
return "?";
}
// TODO 1: make Logger an interface. write() becomes a pure virtual function, and the class gets a virtual
// destructor; the printing it does now moves to ConsoleLogger.
class Logger {
public:
void write(Level level, const std::string& message) { std::println("{}: {}", level_name(level), message); }
};
// TODO 2: ConsoleLogger prints each entry as "<level>: <message>".
class ConsoleLogger : public Logger {};
// TODO 3: FakeLogger keeps each entry in entries() instead of printing it, so that tests can check it.
class FakeLogger : public Logger {
public:
struct Entry {
Level level;
std::string message;
};
const std::vector<Entry>& entries() const { return entries_; }
private:
std::vector<Entry> entries_;
};
// The code under test. It logs a warning for each reading above the limit, and an error from the third such
// reading in a row.
class Thermostat {
public:
Thermostat(Logger& log, double limit) : log_(log), limit_(limit) {}
void reading(double celsius) {
if (celsius <= limit_) {
over_ = 0;
return;
}
++over_;
log_.write(over_ >= 3 ? Level::error : Level::warning,
std::format("{:.1f} °C is over the limit of {:.1f} °C", celsius, limit_));
}
private:
Logger& log_;
double limit_;
int over_ = 0;
}; C++ · logger_tests.cpp
// Sample tests for logger.h. Each check prints what it expects when it fails; the exit status is 0 only when every
// check passes.
#include <print>
#include <type_traits>
#include <vector>
#include "logger.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() {
check(std::is_abstract_v<Logger>, "Logger is an interface: no object of it can be created");
check(std::has_virtual_destructor_v<Logger>, "Logger has a virtual destructor");
FakeLogger fake;
Thermostat thermostat{fake, 30.0};
for (double celsius : {29.5, 31.0, 30.0, 32.5, 33.0, 34.0}) thermostat.reading(celsius);
const auto& entries = fake.entries();
check(entries.size() == 4, "four readings were over the limit, so the fake holds four entries");
std::vector<Level> levels;
for (const auto& e : entries) levels.push_back(e.level);
check(levels == std::vector{Level::warning, Level::warning, Level::warning, Level::error},
"the third reading over the limit in a row is an error");
check(!entries.empty() && entries[0].message == "31.0 °C is over the limit of 30.0 °C",
"a message names the reading and the limit");
ConsoleLogger console;
Logger& any = console;
any.write(Level::info, "the console logger prints through the interface");
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 logger_tests.cpp -o logger_tests && ./logger_tests
g++-16 -std=c++23 -Wall -Wextra logger_tests.cpp -o logger_tests-gcc && ./logger_tests-gcc
""") Output
$ clang++ -std=c++23 -Wall -Wextra logger_tests.cpp -o logger_tests && ./logger_tests FAILED: Logger is an interface: no object of it can be created FAILED: Logger has a virtual destructor warning: 31.0 °C is over the limit of 30.0 °C warning: 32.5 °C is over the limit of 30.0 °C warning: 33.0 °C is over the limit of 30.0 °C error: 34.0 °C is over the limit of 30.0 °C FAILED: four readings were over the limit, so the fake holds four entries FAILED: the third reading over the limit in a row is an error FAILED: a message names the reading and the limit info: the console logger prints through the interface 0 of 5 checks passed [exit status 1] $ g++-16 -std=c++23 -Wall -Wextra logger_tests.cpp -o logger_tests-gcc && ./logger_tests-gcc FAILED: Logger is an interface: no object of it can be created FAILED: Logger has a virtual destructor warning: 31.0 °C is over the limit of 30.0 °C warning: 32.5 °C is over the limit of 30.0 °C warning: 33.0 °C is over the limit of 30.0 °C error: 34.0 °C is over the limit of 30.0 °C FAILED: four readings were over the limit, so the fake holds four entries FAILED: the third reading over the limit in a row is an error FAILED: a message names the reading and the limit info: the console logger prints through the interface 0 of 5 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 logger_session.py
The warning: and error: lines in the output are the clue: Thermostat prints through Logger::write(), so
FakeLogger never sees a thing.
- TODO 1: make
Loggeran interface, withwrite()pure virtual and a virtual destructor. - TODO 2: give
ConsoleLoggerthe printing thatLogger::write()does now. - TODO 3: make
FakeLoggerstore every call inentries()instead of printing it.
When both compilers print 5 of 5 checks passed, the only line before it should be the console logger’s own message.
Then add a test of your own: a reading exactly at the limit must not be logged.
Then take the five ideas of this lesson, from the class that stays abstract to the call with no body, to cases it has not run. Whatever your answers show as weakest comes first, and every choice says what it gets right or misses:
Practice that adapts to you
Practise this lesson
15 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Fresher · What is the difference between an abstract class and an interface in C++?
An abstract class is any class with at least one pure virtual function, declared with = 0, so no object of it can be created; it may also have data members, constructors and ordinary member functions with code. An interface is a convention for the strictest kind of abstract class: only pure virtual functions and a public virtual destructor, and no data. C++ has no interface keyword, so both are written as classes. Use an abstract class when implementations share code or data; use an interface when you only want to promise what a type can do, which keeps implementations independent and makes fakes for tests easy to write. A good answer mentions that a class can implement several interfaces through multiple inheritance.
The follow-ups usually test the edges of that answer: one class with two interfaces, and an interface whose destructor is pure. Decide both:
Checkpoint
Interview round: two follow-up questions
Two harder questions of this module start from this lesson: a service whose tests send real payment requests, and a crash that reports a pure virtual function call:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
OrderService calls a payment gateway's client class directly, so its tests send real payment requests. How would you change the design so that OrderService can be tested without the network, and what does each option cost?
-
Mid to senior
A service occasionally crashes with "pure virtual function called". What can cause this message, and how would you find the call that produced it?
Key takeaways
- A pure virtual function,
virtual … = 0;, makes its class abstract: no object of it can be created, and a derived class stays abstract until it overrides every pure virtual function it inherits. - An interface in C++ is a class with only pure virtual functions and a public virtual destructor, and no data, used through references and pointers.
- Code that depends on an interface works with every implementation, including a fake that a test controls and inspects.
- The non-virtual interface idiom keeps shared steps in one public non-virtual function, which calls private virtual hooks; derived classes may override private virtual functions.
- A virtual call to a pure virtual function from a constructor or destructor is undefined behaviour; with both standard libraries here, it stopped the program.
Terms in this lesson
- abstract class
- A class with at least one pure virtual function that it has not overridden. No object of it can be created, not even as a by-value parameter, but references and pointers to it can refer to objects of its concrete derived classes.
- fake
- A small implementation of an interface written for tests, such as a payment method that approves or declines on command and records every charge, so that a test can run the code without the real service and check how it was used.
- interface
- In C++, a class with only pure virtual functions and a public virtual destructor, and no data: it says what a type can do and leaves how to the classes that implement it. C++ has no interface keyword; this is a convention.
- non-virtual interface idiom (NVI)
- A design in which a base class's public function is not virtual: it runs the steps every implementation shares, such as checks and logging, and calls private virtual hooks that derived classes override for the part that differs.
- pure virtual function
- A virtual function declared with = 0 instead of a body, as in virtual std::string name() const = 0;. It makes its class abstract, and each derived class must override it before objects of that class can be created.
References
- Working Draft, Programming Languages: C++ (N4950): [class.abstract], [dcl.fct.def.general], [class.access], [class.virtual], [class.cdtor] (ISO/IEC JTC1/SC22/WG21)
- C++ Core Guidelines: C.121, if a base class is used as an interface, make it a pure abstract class (Standard C++ Foundation)
- C++ Core Guidelines: I.25, prefer empty abstract classes as interfaces to class hierarchies (Standard C++ Foundation)
- C++ Core Guidelines: C.129, distinguish between implementation inheritance and interface inheritance (Standard C++ Foundation)
- C++ Core Guidelines: C.82, don't call virtual functions in constructors and destructors (Standard C++ Foundation)
- C++ FAQ: When should someone use private virtuals? (Standard C++ Foundation)
Report a problem with this lesson
Kept only in this browser. Your Learn progress