Skip to content

9. Design Patterns, the C++ Way

What & why

The classic "Gang of Four" patterns were written with C++ in mind — but 1994 C++. Modern C++ has lambdas, templates, std::variant, std::function, and RAII, so many patterns shrink to a few lines or disappear, while C++ also has idioms of its own that other languages don't need. This lesson covers the patterns and idioms that matter in modern C++ code.

RAII — the pattern behind everything

You've used it since the beginner track: every resource owned by an object whose destructor releases it. It replaces finally, most "cleanup" code, and many uses of the Command pattern (undo scopes). When you design any class that acquires something, ask: who releases it, and what happens on exception?

Strategy: pass a callable

The GoF Strategy is an interface with one virtual method. In C++, pass a function:

// Compile-time strategy: zero overhead, inlinable
template <std::invocable<double> Pricing>
double checkout(const Cart& cart, Pricing price_of) {
    double total = 0;
    for (const auto& item : cart.items) total += price_of(item.price);
    return total;
}

checkout(cart, [](double p) { return p * 0.9; });   // 10% off

// Runtime strategy: swappable at runtime, stored in a member
class Checkout {
public:
    explicit Checkout(std::function<double(double)> pricing) : pricing_{std::move(pricing)} {}
    void set_pricing(std::function<double(double)> p) { pricing_ = std::move(p); }
private:
    std::function<double(double)> pricing_;
};

Observer: callbacks with lifetime care

#include <functional>
#include <vector>

template <typename... Args>
class Signal {
public:
    using Slot = std::function<void(Args...)>;

    std::size_t connect(Slot slot) {
        slots_.push_back({next_id_, std::move(slot)});
        return next_id_++;
    }

    void disconnect(std::size_t id) {
        std::erase_if(slots_, [id](const auto& s) { return s.id == id; });
    }

    void emit(Args... args) const {
        for (const auto& s : slots_) s.fn(args...);
    }

private:
    struct Entry { std::size_t id; Slot fn; };
    std::vector<Entry> slots_;
    std::size_t next_id_ = 0;
};

Signal<int> on_score;
auto id = on_score.connect([](int s) { std::cout << "score: " << s << '\n'; });
on_score.emit(42);
on_score.disconnect(id);

The C++-specific danger: a slot capturing this of an object that's been destroyed. Solutions: return an RAII connection object that disconnects in its destructor, or capture a std::weak_ptr and lock() it in the callback. (Libraries: Boost.Signals2, sigslot.)

Factory and registry

#include <functional>
#include <map>
#include <memory>
#include <string>

class Shape { public: virtual ~Shape() = default; virtual double area() const = 0; };

class ShapeFactory {
public:
    using Creator = std::function<std::unique_ptr<Shape>()>;

    static ShapeFactory& instance() {
        static ShapeFactory f;       // thread-safe initialization (Meyers singleton)
        return f;
    }

    void register_type(std::string name, Creator c) { creators_[std::move(name)] = std::move(c); }

    std::unique_ptr<Shape> create(const std::string& name) const {
        auto it = creators_.find(name);
        return it == creators_.end() ? nullptr : it->second();
    }

private:
    std::map<std::string, Creator> creators_;
};

Factories return std::unique_ptr — ownership goes to the caller.

Singletons: mostly don't

A singleton is a global variable in disguise: hidden dependencies, hard to test, initialization-order issues. If you truly need one (a logger, a registry), the Meyers singleton above (function-local static) is the correct implementation: lazy, thread-safe. Prefer passing dependencies explicitly (constructor injection) so tests can substitute fakes.

Pimpl: compilation firewall

Hide a class's private members behind a pointer to an incomplete type. Changing the implementation no longer recompiles every user, and headers stop leaking heavy includes. Also key to stable ABIs (lesson 11).

// widget.hpp
#pragma once
#include <memory>
#include <string>

class Widget {
public:
    explicit Widget(std::string name);
    ~Widget();                                  // must be defined where Impl is complete
    Widget(Widget&&) noexcept;
    Widget& operator=(Widget&&) noexcept;

    void draw() const;

private:
    struct Impl;                                // forward declaration only
    std::unique_ptr<Impl> impl_;
};
// widget.cpp
#include "widget.hpp"
#include <iostream>
#include <vector>
#include "heavy_graphics_library.hpp"           // users of widget.hpp never see this

struct Widget::Impl {
    std::string name;
    std::vector<Pixel> buffer;
};

Widget::Widget(std::string name) : impl_{std::make_unique<Impl>(std::move(name))} {}
Widget::~Widget() = default;                     // Impl is complete here
Widget::Widget(Widget&&) noexcept = default;
Widget& Widget::operator=(Widget&&) noexcept = default;

void Widget::draw() const { std::cout << "drawing " << impl_->name << '\n'; }

Cost: one heap allocation and one indirection per object.

Type erasure: runtime polymorphism without inheritance

std::function stores any callable without a common base class. You can build the same thing for your own interfaces — value semantics, no inheritance required from users:

#include <memory>
#include <string>
#include <vector>

namespace detail {
template <typename T>
void draw_dispatch(const T& obj) { draw(obj); }   // unqualified: found by ADL at instantiation
}

class Drawable {
public:
    template <typename T>
    Drawable(T obj) : self_{std::make_unique<Model<T>>(std::move(obj))} {}

    Drawable(const Drawable& o) : self_{o.self_->clone()} {}
    Drawable& operator=(const Drawable& o) { self_ = o.self_->clone(); return *this; }
    Drawable(Drawable&&) noexcept = default;
    Drawable& operator=(Drawable&&) noexcept = default;

    void draw() const { self_->do_draw(); }

private:
    struct Concept {                                        // internal interface
        virtual ~Concept() = default;
        virtual void do_draw() const = 0;
        virtual std::unique_ptr<Concept> clone() const = 0;
    };

    template <typename T>
    struct Model final : Concept {                          // adapts any T with a free draw(const T&)
        explicit Model(T o) : obj{std::move(o)} {}
        void do_draw() const override { detail::draw_dispatch(obj); }
        std::unique_ptr<Concept> clone() const override { return std::make_unique<Model>(*this); }
        T obj;
    };

    std::unique_ptr<Concept> self_;
};

// Users' types: no base class, no virtual functions
struct Circle { double r; };
struct Text { std::string s; };
void draw(const Circle& c) { std::cout << "circle " << c.r << '\n'; }
void draw(const Text& t) { std::cout << "text " << t.s << '\n'; }

// Usage
std::vector<Drawable> scene{Circle{1}, Text{"hi"}, Circle{2}};
for (const auto& d : scene) d.draw();

The detail::draw_dispatch helper exists because inside Drawable, the member function draw would hide the users' free draw functions; calling from namespace scope lets argument-dependent lookup find them.

This is Sean Parent's "runtime polymorphism" idiom. Libraries like Boost.TypeErasure, Dyno, and Proxy automate it.

Visitor: std::variant + std::visit

For a closed set of types, std::variant replaces the double-dispatch Visitor pattern:

#include <variant>

template <typename... Fs>
struct overloaded : Fs... { using Fs::operator()...; };

struct Num { double value; };
struct Add;
struct Mul;
using Expr = std::variant<Num, std::unique_ptr<Add>, std::unique_ptr<Mul>>;
struct Add { Expr lhs, rhs; };
struct Mul { Expr lhs, rhs; };

double eval(const Expr& e) {
    return std::visit(overloaded{
        [](const Num& n) { return n.value; },
        [](const std::unique_ptr<Add>& a) { return eval(a->lhs) + eval(a->rhs); },
        [](const std::unique_ptr<Mul>& m) { return eval(m->lhs) * eval(m->rhs); },
    }, e);
}

Adding a new operation (print, simplify) is easy — write another visit. Adding a new type requires touching every visit — and the compiler tells you where.

CRTP mixins and policy-based design

Compose behavior at compile time with template parameters:

struct NoLock { void lock() {} void unlock() {} };
struct MutexLock {
    void lock() { m.lock(); }
    void unlock() { m.unlock(); }
    std::mutex m;
};

template <typename LockPolicy = NoLock>
class Counter {
public:
    void increment() {
        std::lock_guard guard{lock_};
        ++count_;
    }
private:
    LockPolicy lock_;
    long count_ = 0;
};

Counter<> fast;              // single-threaded, zero overhead
Counter<MutexLock> safe;     // thread-safe

The standard library does this: allocators, std::char_traits, hash and comparison parameters.

Builder and named parameters

C++20 designated initializers give you readable "named arguments" for option structs:

struct ServerOptions {
    std::string host = "0.0.0.0";
    int port = 8080;
    int threads = 4;
    bool tls = false;
};

Server s{ServerOptions{.port = 9000, .tls = true}};

Often simpler than a fluent Builder class.

Command and undo

Store actions as callables:

struct Command {
    std::function<void()> execute;
    std::function<void()> undo;
};

std::vector<Command> history;

void apply(Command c) {
    c.execute();
    history.push_back(std::move(c));
}

void undo_last() {
    if (history.empty()) return;
    history.back().undo();
    history.pop_back();
}

Choosing polymorphism

Mechanism Binding Types set Cost Use when
Templates / concepts compile time open code size performance-critical, types known at compile time
virtual + inheritance runtime open vtable call, heap plugin-style hierarchies you own
Type erasure runtime open heap + virtual value semantics, users' types without a base class
std::variant runtime closed size of largest type fixed set of alternatives, many operations

Common mistakes

  • Porting Java patterns literally — abstract factories of factories when a lambda would do.
  • Singletons as global state that makes testing impossible.
  • Observers holding dangling this.
  • Pimpl without defining the destructor in the .cpp — "invalid application of sizeof to incomplete type".
  • Deep inheritance hierarchies where composition or std::variant is clearer.
  • Over-templating — policy-based designs nobody can read.

Exercises

  1. Implement Signal<Args...> with an RAII Connection object that disconnects on destruction. Test that a destroyed observer is never called.
  2. Convert a class with heavy includes in its header to pimpl. Measure how many files recompile when you change a private member before and after.
  3. Implement the Drawable type-erasure class and add a second operation (bounding_box()).
  4. Build an expression evaluator with std::variant: numbers, +, *, and variables, with eval and to_string visitors.
  5. Write a Logger with policies for output (ConsoleSink, FileSink) and filtering (MinLevel<Level::Warn>), combined as template parameters.
  6. Replace a Strategy class hierarchy (interface + 3 implementations) with lambdas. What did you gain and lose?

Recap

  • RAII underlies almost every good C++ design.
  • Strategy/Command/Observer become callables — std::function for runtime, templates for compile time.
  • Factories return std::unique_ptr; avoid singletons, or use a function-local static.
  • Pimpl hides implementation details and stabilizes ABIs.
  • Type erasure gives value-semantic runtime polymorphism without inheritance.
  • std::variant + std::visit replaces Visitor for closed type sets.
  • Pick the polymorphism mechanism by binding time, openness, and cost.

Next: Ranges & views →