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.

PatternClassic formModern C++ formDeep dive
Singletonstatic pointer + new, maybe double-checked lockfunction-local static (or no singleton at all)below
Factoryswitch returning raw newstd::unique_ptr return, registry of creator callablesFactories in C++
Observervector<Observer*>weak_ptr subscribers or connection handlesObserver pattern
Strategyabstract class + subclassesstd::function, lambda, or template parameterStrategy pattern
Visitordouble dispatch via accept/visitstd::variant + std::visitVisitor pattern
Template Method / static interfacevirtual hooksCRTPCRTP vs virtual
Commandcommand class hierarchystd::function<void()> pairs for do/undoCommand 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 calls Config::instance(), and Config finished constructing after Logger did. Then Config is destroyed first, and the logger touches a dead object during shutdown. The mirror problem at startup, globals in different .cpp files 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:

  1. If an observer is destroyed without detaching, the next notify() calls through a dangling pointer.
  2. 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.