C++ Module 23 – Design, idioms and maintainable C++
Classic design patterns in modern C++
Strategy, observer, factory, command and visitor in modern C++: lambdas, smart pointers and std::variant where class hierarchies used to be.
What you will learn
- Implement strategy, observer, factory, command and visitor in idiomatic modern C++
- Replace class-heavy pattern code with lambdas, smart pointers and variants where that is simpler
- Prevent the lifetime and re-entrancy bugs of observers and self-registering factories
- Compare the memory cost of three ways to store commands for undo and redo
- Choose between a callable, a class hierarchy and a variant for an interface
Before you start
What you’ll learn · Before you start
What you will learn
- Implement strategy, observer, factory, command and visitor in idiomatic modern C++
- Replace class-heavy pattern code with lambdas, smart pointers and variants where that is simpler
- Prevent the lifetime and re-entrancy bugs of observers and self-registering factories
- Compare the memory cost of three ways to store commands for undo and redo
- Choose between a callable, a class hierarchy and a variant for an interface
Before you start
On this page
One “order shipped” feature here needs five classic designs: telling every screen about the event, creating the customer’s channel by name, choosing how the message is delivered, keeping each send so that it can be undone, and pricing each kind of message. The classic design pattern: A named, reusable design for a problem that keeps coming back in object-oriented code, such as strategy, observer, factory, command or visitor, so that a whole design can be described in a word. In C++ the same idea often needs fewer classes than its classic form. are named, reusable designs for problems like these that keep coming back in object-oriented code, and in their classic form each one is a small hierarchy of classes.
Modern C++ keeps the ideas and drops most of the classes. A strategy becomes a callable, an observer’s list holds
std::weak_ptr, a factory is a map from names to functions that return std::unique_ptr, a command is a value that
carries enough data to undo itself, and a visitor over a closed set of types is a std::variant with std::visit.
Every program below ran with Apple clang 21 and GCC 16.
Five classic patterns lose most of their classes in modern C++
| The pattern in its classic form | In modern C++ |
|---|---|
| Strategy: an abstract class and a subclass per algorithm | A callable: a lambda, a function or a template parameter |
| Observer: a list of pointers to subscribers | A list of std::weak_ptr |
| Factory: a creator class per product | A map from names to functions that return std::unique_ptr |
| Command: a command class per action | A struct of data, or a std::variant of structs |
Visitor: double dispatch through accept() |
std::variant and std::visit |
Each pattern solves one problem. Strategy swaps one piece of behaviour, such as how a message is delivered,
without changing the code that uses it. observer pattern: A design in which an event keeps a list of subscribers and tells each of them when it happens. In modern C++ the list holds std::weak_ptr, so a subscriber that has been destroyed is skipped instead of called. lets an event tell everyone
who subscribed, and std::weak_ptr lets a destroyed subscriber be skipped instead of called. Factory creates an
object of a class chosen at run time, by a name from a configuration file or a request. Command turns an action
into an object that can be stored, queued and reversed, which is what undo and redo need. Visitor runs one
operation over each type of a 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. More in “Alternatives to inheritance: variant, composition”, and with std::visit the compiler
reports a type that has no handler. The diagram follows one notification through all five:
Five classic patterns in one notification feature
Text description of the diagram
The diagram is one column of six boxes joined by arrows, following one notification from top to bottom.
- An order ships.
- Observer: the event tells every subscriber; the subscribers are held by std::weak_ptr.
- Factory: the customer's channel is made from its name, "sms" or "push", by a map from names to functions that return std::unique_ptr.
- Strategy: the channel delivers the message; it is a callable, such as a lambda.
- Command: each send is kept as data that can be undone or redone.
- Visitor: each kind of message is priced and described, with std::variant and std::visit.
Checkpoint
Check: The modern form of each pattern
A strategy is a callable the caller passes in
Notifier sends “order shipped” messages and does not know how they are delivered. Its strategy is a
std::function<void(const Message&)>, so a plain function, a function object: An object of a class that defines operator(), so it can be called like a function and can carry state in its members, as a counter of calls. Every lambda is one, of a class the compiler writes. Also called a functor. More in “Function pointers, functors and std::function” with
state of its own and a lambda all qualify, and each customer’s preference picks one at run time:
// strategy.cpp: one notification feature. How a message is delivered is a strategy, and the strategy is just a
// callable: a function, a function object with state, or a lambda, chosen at run time.
#include <format>
#include <functional>
#include <map>
#include <print>
#include <string>
#include <utility>
#include <vector>
struct Message {
std::string to;
std::string text;
};
using Deliver = std::function<void(const Message&)>; // the strategy: anything callable with a Message
class Notifier {
public:
explicit Notifier(Deliver deliver) : deliver_(std::move(deliver)) {}
void set_delivery(Deliver deliver) { deliver_ = std::move(deliver); }
void order_shipped(const std::string& customer, int order) const {
deliver_({customer, std::format("order {} has shipped", order)});
}
private:
Deliver deliver_;
};
void by_sms(const Message& m) { std::println(" SMS to {}: {}", m.to, m.text); }
struct ByEmail { // a strategy with state of its own
std::string sender;
void operator()(const Message& m) const { std::println(" e-mail from {} to {}: {}", sender, m.to, m.text); }
};
int main() {
const std::map<std::string, Deliver, std::less<>> channels{
{"sms", by_sms},
{"email", ByEmail{"orders@example.com"}},
{"push", [](const Message& m) { std::println(" push to {}'s phone: {}", m.to, m.text); }},
};
const std::vector<std::pair<std::string, std::string>> customers{{"Asha", "sms"}, {"Ravi", "push"}, {"Meera", "email"}};
Notifier notifier{channels.at("sms")};
int order = 1001;
for (const auto& [name, preference] : customers) {
notifier.set_delivery(channels.at(preference)); // the customer's preference picks the strategy
notifier.order_shipped(name, order++);
}
std::vector<Message> sent; // in a test: a strategy that records instead of sending
const Notifier under_test{[&sent](const Message& m) { sent.push_back(m); }};
under_test.order_shipped("Kabir", 2001);
std::println("the test strategy recorded {} message: \"{}\"", sent.size(), sent.at(0).text);
} from session import run
# Three delivery strategies chosen at run time, and one for tests, with both compilers.
run("""
clang++ -std=c++23 -Wall -Wextra strategy.cpp -o strategy && ./strategy
g++-16 -std=c++23 -Wall -Wextra strategy.cpp -o strategy-gcc && ./strategy-gcc
""") Output
$ clang++ -std=c++23 -Wall -Wextra strategy.cpp -o strategy && ./strategy SMS to Asha: order 1001 has shipped push to Ravi's phone: order 1002 has shipped e-mail from orders@example.com to Meera: order 1003 has shipped the test strategy recorded 1 message: "order 2001 has shipped" $ g++-16 -std=c++23 -Wall -Wextra strategy.cpp -o strategy-gcc && ./strategy-gcc SMS to Asha: order 1001 has shipped push to Ravi's phone: order 1002 has shipped e-mail from orders@example.com to Meera: order 1003 has shipped the test strategy recorded 1 message: "order 2001 has shipped"
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 strategy_session.py
Step through main() and watch the strategy the Notifier holds for each customer:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Three strategies, one type. The map holds a plain function, by_sms, a function object, ByEmail, with a sender of its own, and a lambda. All three are stored as Deliver, a std::function<void(const Message&)>.
- Asha prefers SMS. set_delivery(channels.at("sms")) gives the Notifier the plain function. order_shipped builds the message and calls deliver_: "SMS to Asha: order 1001 has shipped".
- Ravi prefers push. The next set_delivery swaps in the lambda, and the same order_shipped prints "push to Ravi's phone: order 1002 has shipped". Notifier's code did not change.
- Meera prefers e-mail. ByEmail carries its sender in a member, so the strategy has state of its own: "e-mail from orders@example.com to Meera: order 1003 has shipped".
- A strategy for tests. under_test gets a lambda that captures sent by reference and records each message instead of sending it. No mock class is needed.
- What the test sees. Kabir's order adds one message to sent instead of sending it, and the last line of the run says that the test strategy recorded 1 message.
The classic version would need an abstract DeliveryStrategy class and three subclasses for the same result. The
last lines show the other gain: a test passes a lambda that records the messages instead of sending them, and needs
no mock class. When the strategy is fixed while compiling, a template parameter,
template <class Deliver> class Notifier, lets the compiler inline the call instead of going through
std::function. Rule F.50 of the C++ Core Guidelines recommends a lambda wherever a function would need to capture
local state, as the test strategy does with sent.
Checkpoint
Check: A strategy as a callable
A class hierarchy is still right for wide or open interfaces
Lambdas and variants are not always simpler. 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. More in “Abstract classes and interfaces in C++” with several related
operations and some shared rules, such as a channel that sends, prices and retries, reads better as an abstract class
with virtual functions. The Core Guidelines recommend a pure abstract class for an interface (rule C.121), and class
hierarchies for concepts that really form a hierarchy (rule C.120). Code that other teams compile separately, such as
plugins, needs that stable interface too. And a family of types that other code must be able to extend cannot be a
std::variant, which lists every type it holds. Choose the smallest form that keeps the design honest; two questions
about your own interface decide most of it:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Form | Several related operations | Types added by other code |
|---|---|---|
| A callable (std::function) Holds: one operation behind one call signature | Poor one std::function holds one operation, such as deliver in strategy.cpp | Good any function, function object or lambda with the signature |
| An abstract class Holds: an interface of several virtual functions | Good one interface for a channel that sends, prices and retries (C.121) | Good a new class derives from it, even in a plugin compiled elsewhere |
| std::variant and std::visit Holds: one of a list of types named in advance | Mixed each operation is a visitor over every type of the family | Poor it lists every type it holds, so only its owner can add one |
How the answers decide
- “Does the interface have several related operations, such as send, price and retry, with rules they share?” Yes, several operations: adds a caveat to A callable (std::function). One callable holds one operation. Several related operations with shared rules read better as one pure abstract class (rule C.121).
- “Must code you do not compile, such as plugins, be able to add new types?” Yes, other code adds types: rules out std::variant and std::visit. A variant names every type it can hold, so a family that other code must extend cannot be a variant.
- “Does the interface have several related operations, such as send, price and retry, with rules they share?” No, one operation and “Must code you do not compile, such as plugins, be able to add new types?” No, my code has every type: adds a caveat to An abstract class. A class per algorithm for a single operation is the class-heavy form: a callable gives the same result with no classes.
This lesson covers the C++ side: lifetimes, value types and what the compiler checks. The Low-Level Design track uses these patterns in complete case studies with class designs, and the design-patterns track covers the whole catalogue, pattern by pattern, in several languages.
Checkpoint
Check: When a class hierarchy is still right
Practise: patterns in their modern form
Nine questions on the modern forms, on strategy and on choosing a class hierarchy, one at a time, starting with the idea your answers have found hardest so far.
Practice that adapts to you
Practise this lesson
9 questions about 7 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Observers that read freed memory, two ways, reproduced and fixed Pro: opens with a Pro, Premium or Ultimate pass
An observer stores references to objects it does not own, and that is where its bugs come from. This part reproduces the two classic failures under AddressSanitizer, a destroyed subscriber and a list that changes during a notification, then fixes both.
In this part:
- A raw-pointer observer that calls a destroyed subscriber
- A subscriber that subscribes during the notification
- The fixed event: weak pointers, a snapshot and pruning
1 diagram · 4 runnable examples (C++, Python) · about 5 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Factories that lose their entries, and commands sized for undo Pro: opens with a Pro, Premium or Ultimate pass
Two patterns store things for later: a factory keeps a table of creators, and a command history keeps every edit. This part reproduces how a self-registering factory loses entries when it moves into a static library, measures what an undo history costs in three designs, and ends with an exercise.
In this part:
- A self-registering factory that loses its channels in a static library
- Commands for undo and redo, sized three ways
- Your turn: undo and redo for an editor
1 diagram · 6 runnable examples (C header, C++, C++ (main.cpp), C++ (push.cpp), C++ (registry.cpp), C++ (sms.cpp), Python) · about 6 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Visitors with std::variant: the compiler lists the case you forgot Pro: opens with a Pro, Premium or Ultimate pass
The visitor pattern exists so that one operation can handle every type of a family. With std::variant the family
is closed and known to the compiler, which lets it refuse a visitor that misses a type. This part shows that check
and compares it with classic double dispatch.
In this part:
- Two visitors over three kinds of message
- A new kind of message, and the error that names it
- Double dispatch or std::variant
1 diagram · 3 runnable examples (C++, Python) · about 5 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Interview questions
A warm-up answered in full, then three design questions about events, plugins and undo:
-
Fresher to mid · Warm-up
Which classic design patterns get simpler in modern C++, and with which language or library features?
Several of them lose most of their classes. Strategy becomes a callable: a lambda stored in a std::function when it is chosen at run time, or a template parameter when it is fixed while compiling. Observer keeps its subscribers through std::weak_ptr, so a destroyed subscriber is skipped instead of called. Factory becomes a map from names to functions that return std::unique_ptr, which also makes ownership of the new object explicit. Command becomes a plain struct, or a std::variant of structs, that holds enough data to apply and reverse an action. Visitor over a closed set of types becomes std::visit on a std::variant, and the compiler rejects a visitor that misses a type. A class hierarchy is still the right form for an interface with several related operations, or for types that other code must be able to add.
-
Mid to senior
Design an event bus for a desktop app whose subscribers are windows that can close at any moment. How do you avoid calling into a closed window, and what happens if a callback subscribes or unsubscribes while an event is being delivered?
-
Senior to staff
Plugins register themselves in a factory through static objects in their own .cpp files. After the plugins move into a static library, some are missing at run time, with no error at build time. Explain why, and give fixes with their trade-offs.
-
Senior to staff
Implement undo and redo for a text editor that must keep the last 10,000 edits. How do you represent a command, how much memory does the history take, and how do you keep it bounded?
Key takeaways
- The classic patterns are still useful designs; modern C++ replaces most of their classes with callables, smart
pointers and
std::variant. - A strategy is a callable:
std::functionwhen it is chosen at run time, a template parameter when it is fixed while compiling, and a lambda that records makes a test strategy with no mock class. - An observer that holds
std::weak_ptrskips subscribers that no longer exist, and a visitor over a closed family is astd::variantwhosestd::visitreports a type with no handler. - Prefer an abstract class when an interface has several related operations or must be extended by other code.
Terms in this lesson
- design pattern
- A named, reusable design for a problem that keeps coming back in object-oriented code, such as strategy, observer, factory, command or visitor, so that a whole design can be described in a word. In C++ the same idea often needs fewer classes than its classic form.
- observer pattern
- A design in which an event keeps a list of subscribers and tells each of them when it happens. In modern C++ the list holds std::weak_ptr, so a subscriber that has been destroyed is skipped instead of called.
References
- Working Draft, Programming Languages: C++ (N4950): [variant.visit], [util.smartptr.weak], [basic.start.dynamic], [container.reqmts] (ISO/IEC JTC1/SC22/WG21)
- C++ Core Guidelines: C.120, use class hierarchies to represent concepts with inherent hierarchical structure (only) (Standard C++ Foundation)
- 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: F.50, use a lambda when a function won’t do (Standard C++ Foundation)
- C++ Core Guidelines: R.24, use std::weak_ptr to break cycles of shared_ptrs (Standard C++ Foundation)
- std::visit (cppreference.com)
- std::weak_ptr (cppreference.com)
- GNU ld: Command-line Options (--whole-archive) (GNU Project)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress