Design Patterns in Modern C++: What Singleton, Factory, Observer, Strategy and Visitor Look Like After C++17
Key takeaways
Most GoF patterns still exist in modern C++, but several change shape: strategies become callables, visitors become std::variant + std::visit, singletons become function-local statics, and CRTP replaces virtual dispatch when the type set is known at compile time. This overview shows each shift, the bugs the classic versions invite, and links to the per-pattern deep dives.
The Gang of Four book was written for C++98-era languages with no lambdas, no std::variant and no guaranteed thread-safe statics. The problems it describes are still real. Many of its solutions, though, now have a shorter and safer form. This page is an overview. For each pattern it shows what changed, the bug the classic version tends to cause, and a link to the detailed article.
| Pattern | Classic form | Modern C++ form | Deep dive |
|---|---|---|---|
| Singleton | static pointer + new, maybe double-checked lock | function-local static (or no singleton at all) | below |
| Factory | switch returning raw new | std::unique_ptr return, registry of creator callables | Factories in C++ |
| Observer | vector<Observer*> | weak_ptr subscribers or connection handles | Observer pattern |
| Strategy | abstract class + subclasses | std::function, lambda, or template parameter | Strategy pattern |
| Visitor | double dispatch via accept/visit | std::variant + std::visit | Visitor pattern |
| Template Method / static interface | virtual hooks | CRTP | CRTP vs virtual |
| Command | command class hierarchy | std::function<void()> pairs for do/undo | Command pattern |
All snippets below compile with g++ -std=c++17 -Wall -Wextra (tested on g++ 10.3).
Singleton: the shortest correct version, and why you may not want it
The version that still shows up in older tutorials looks like this:
class Config {
static Config* instance_;
static std::mutex mtx_;
public:
static Config* get() {
if (instance_ == nullptr) { // unsynchronized read
std::lock_guard<std::mutex> lock(mtx_);
if (instance_ == nullptr) {
instance_ = new Config(); // write under lock
}
}
return instance_;
}
};
This is double-checked locking with a plain pointer. It has a data race. One thread reads instance_ without the lock while another writes it under the lock, and that is undefined behavior in the C++11 memory model. In practice, a reader can see a non-null pointer before the object’s constructor writes are visible to it. You could fix it with std::atomic<Config*> and acquire/release ordering. But C++11 already gives you the same guarantee for free:
class Config {
public:
static Config& instance() {
static Config c; // initialized once, even with concurrent first calls
return c;
}
Config(const Config&) = delete;
Config& operator=(const Config&) = delete;
int retries = 3;
private:
Config() = default;
};
The compiler emits a guard variable and a one-time initialization lock. After the first call, the cost is roughly a load and a branch. Thread safety is the easy part. These problems remain:
- Tests share state. The instance lives until the process exits. If one test sets
retries = 0, every later test in the same binary sees it, and the result depends on test order. - Destruction order. Function-local statics are destroyed in reverse order of construction. Say
Logger’s destructor callsConfig::instance(), andConfigfinished constructing afterLoggerdid. ThenConfigis destroyed first, and the logger touches a dead object during shutdown. The mirror problem at startup, globals in different.cppfiles initializing in an unspecified order, is covered in static initialization order. - Hidden dependencies. A function that calls
Config::instance()does not declare that it depends on configuration, so you can’t see the coupling at call sites.
I have lost more time to the first problem than to any threading bug. A test suite passed when run in full and failed when I ran one test alone, because an earlier test had quietly changed the singleton the lone test relied on. The fix that stuck was not a reset() method for tests. It was creating one Config in main and passing a Config& to the classes that need it. “There is exactly one” is often a fact about the program’s wiring. It doesn’t need to be enforced by the type.
Factory: return ownership, and register creators instead of switching
A factory should return std::unique_ptr<Base>, so the caller owns the object and can’t leak it. Returning a raw new pointer makes every caller responsible for delete. Once the list of products grows, a switch that every new type has to edit turns into a merge-conflict hotspot. A map from keys to creator callables keeps the list in one place, and plugins can extend it:
#include <functional>
#include <map>
#include <memory>
#include <string>
struct Codec { virtual ~Codec() = default; virtual std::string name() const = 0; };
struct Gzip : Codec { std::string name() const override { return "gzip"; } };
struct Zstd : Codec { std::string name() const override { return "zstd"; } };
std::unique_ptr<Codec> makeCodec(const std::string& key) {
static const std::map<std::string, std::function<std::unique_ptr<Codec>()>> registry{
{"gzip", [] { return std::make_unique<Gzip>(); }},
{"zstd", [] { return std::make_unique<Zstd>(); }},
};
auto it = registry.find(key);
return it == registry.end() ? nullptr : it->second();
}
makeCodec("zstd")->name() prints zstd, and makeCodec("lz4") returns nullptr, which the caller has to check. If an unknown key is a programming error rather than bad user input, throwing is usually better than returning null. Self-registering plugins, and the linker dropping their object files, are covered in Factories in C++.
Observer: the bugs are about lifetime and re-entrancy, not the interface
The textbook Subject holds std::vector<Observer*>. That leaves two problems the pattern diagram doesn’t show:
- If an observer is destroyed without detaching, the next
notify()calls through a dangling pointer. - If an observer detaches during
notify(), the vector changes while a range-for is iterating over it.
The second one is easy to write by accident, for example with a “one-shot” listener that unsubscribes itself:
struct Subject {
std::vector<Observer*> obs;
void detach(Observer* o) { obs.erase(std::remove(obs.begin(), obs.end(), o), obs.end()); }
void notify(int v) { for (auto* o : obs) o->update(v); } // range-for over obs
};
struct OneShot : Observer {
Subject& s;
explicit OneShot(Subject& s) : s(s) {}
void update(int v) override { std::cout << "got " << v << "\n"; s.detach(this); }
};
// attach a, b, c, then notify(1)
In my g++ 10.3 build this printed got 1 three times and left one observer attached. b was never notified and c ran twice, because erase shifted the elements under the loop and the loop’s cached end iterator was stale. This is undefined behavior, so a different build may crash instead. The usual fixes are to iterate over a copy of the list, to mark entries as dead and compact them after the loop, or to store std::weak_ptr and drop expired entries. These approaches are compared in the Observer article.
Strategy: an interface with one virtual method is usually a callable
If a strategy interface has exactly one virtual function, a callable says the same thing with less code, and a lambda can capture parameters that would otherwise need a constructor:
class Sorter {
public:
using Compare = std::function<bool(int, int)>;
explicit Sorter(Compare cmp) : cmp_(std::move(cmp)) {}
void sort(std::vector<int>& v) const { std::sort(v.begin(), v.end(), cmp_); }
private:
Compare cmp_;
};
int threshold = 4;
Sorter s([threshold](int a, int b) { // values > threshold first, then ascending
bool aa = a > threshold, bb = b > threshold;
return aa != bb ? aa : a < b;
});
std::vector<int> v{5, 2, 8, 1, 9};
s.sort(v); // 5 8 9 1 2
std::function buys runtime swapping. The cost is an indirect call the optimizer usually can’t inline, and a heap allocation when the captured state is larger than the small internal buffer. When the strategy is fixed at construction and the call is in a hot loop, template <class Compare> class Sorter lets the comparison inline, as std::sort does with its comparator. The trade-offs, including the function-pointer option, are measured in the Strategy article and std::function vs function pointers. Keep the class hierarchy when a strategy has several related methods or its own state that has to be inspected. Packing three lambdas into a struct is a class hierarchy in disguise.
Visitor: std::variant turns “forgot a case” into a compile error
Classic Visitor exists to fake double dispatch. Every element gets accept(Visitor&), and every visitor gets one visit overload per element type. When the set of types is closed, std::variant does the same with no base classes:
struct Circle { double r; };
struct Rect { double w, h; };
using Shape = std::variant<Circle, Rect>;
template <class... Ts> struct overloaded : Ts... { using Ts::operator()...; };
template <class... Ts> overloaded(Ts...) -> overloaded<Ts...>; // not needed in C++20
double area(const Shape& s) {
return std::visit(overloaded{
[](const Circle& c) { return 3.14159 * c.r * c.r; },
[](const Rect& r) { return r.w * r.h; },
}, s);
}
The real payoff shows up when someone adds Triangle to the variant and forgets area. g++ refuses to compile it:
error: no matching function for call to '__invoke(overloaded<area(const Shape&)::<lambda(const Circle&)>,
area(const Shape&)::<lambda(const Rect&)> >, const Triangle&)'
The message is noisy, but the last argument, const Triangle&, names the missing case. The trade-off goes the other way from classic Visitor. Adding an operation is cheap, because it’s one more std::visit call. Adding a type touches every visit site, and the variant has to list every type up front, so plugins can’t add types at runtime. Also avoid a catch-all [](const auto&) lambda in these overload sets. It quietly swallows the new alternative and gives back the exact bug the variant was protecting you from. See the Visitor article for the virtual version and std::variant for valueless_by_exception and conversion traps.
CRTP: static polymorphism when the type is known at compile time
The Curiously Recurring Template Pattern gives you “call the derived implementation” without a vtable. The base class casts *this to the derived type:
template <class Derived>
class Shape {
public:
double area() const { return static_cast<const Derived&>(*this).areaImpl(); }
private:
Shape() = default;
friend Derived; // only the real Derived can construct this base
};
class Square : public Shape<Square> {
public:
explicit Square(double s) : s_(s) {}
double areaImpl() const { return s_ * s_; }
private:
double s_;
};
The private constructor plus friend Derived guards against a copy-paste bug. Without it, class Circle : public Shape<Square> compiles, and area() would static_cast a Circle to a Square, which is undefined behavior. With the guard, g++ rejects it:
error: use of deleted function 'Circle::Circle()'
error: 'constexpr Shape<Derived>::Shape() [with Derived = Square]' is private within this context
CRTP can’t put a Square and a Circle in one std::vector, because Shape<Square> and Shape<Circle> are unrelated types. If you need a heterogeneous container, you need virtual functions, a variant, or type erasure. CRTP vs virtual functions goes through the benchmark and code-size trade-offs.
When a pattern makes the code worse
A pattern is justified when it removes a specific pain you can name. Examples: “adding a codec edits five files”, “tests can’t swap the clock”, “every new shape needs a visitor method in twelve classes”. Without such a sentence, it is usually just indirection.
The most common over-application I run into is an interface with a single implementation and a factory that only ever returns that implementation, added “in case we need another one”. Every reader then has to jump through an extra file to find the code that runs. The second implementation rarely arrives. When it does, it often needs a different interface than the one that was guessed in advance. I now wait until the second concrete case exists before extracting the abstraction. In C++, extracting an interface later with the compiler’s help is cheap. Removing a wrong one after other code depends on it is not.
Related Articles
- The Strategy Pattern in C++: Virtual Classes vs Function Pointers vs Lambdas vs std::function
- C++ Visitor Pattern and double dispatch
- The Observer Pattern in C++: weak_ptr Subscribers, Typed Events and Signal/Slot Designs
- CRTP vs Virtual Functions: Static Polymorphism Without vtable Overhead
- Factories in C++: Simple Factory vs Factory Method vs Abstract Factory
- The Command Pattern in C++: Undo/Redo, Macros and Transactions