Skip to main content
C++ · lesson 20 of 24
C++ advanced Lesson 20 of 24

Also part of: Low-Level Design

Design Patterns in C++

C++ design patterns run for real: CRTP, mixins, policy-based design, a PIMPL build and its destructor trap, Observer, Factory, thread-safe Singleton.

By SkillByExample · Updated

C++ supports the classic object-oriented patterns, and adds compile-time ones — CRTP, policies — that resolve the “which implementation?” question during compilation instead of at runtime.

CRTP: Curiously Recurring Template Pattern

In CRTP a class derives from a base template instantiated with itself. The base can then call derived methods through a static_cast, with no virtual:

#include <iostream>

template <typename Derived>
class Shape {
public:
    double area() const { return static_cast<const Derived*>(this)->area_impl(); }
    void print() const { std::cout << Derived::name << " area: " << area() << "\n"; }
};

class Circle : public Shape<Circle> {
    double r_;
public:
    static constexpr const char* name = "circle";
    explicit Circle(double r) : r_(r) {}
    double area_impl() const { return 3.14159 * r_ * r_; }
};

class Square : public Shape<Square> {
    double s_;
public:
    static constexpr const char* name = "square";
    explicit Square(double s) : s_(s) {}
    double area_impl() const { return s_ * s_; }
};

struct VirtualCircle {                          // the runtime-polymorphic alternative
    double r;
    virtual ~VirtualCircle() = default;
    virtual double area() const { return 3.14159 * r * r; }
};

template <typename T> void describe(const Shape<T>& s) { s.print(); }   // static dispatch

int main() {
    describe(Circle(5));
    describe(Square(3));
    std::cout << "sizeof(Circle) = " << sizeof(Circle)
              << ", sizeof(VirtualCircle) = " << sizeof(VirtualCircle) << "\n";
}
$ g++ -std=c++20 -Wall -Wextra -o crtp crtp.cpp
$ ./crtp
circle area: 78.5397
square area: 9
sizeof(Circle) = 8, sizeof(VirtualCircle) = 16

Shape<Circle>::area() knows at compile time that this is really a Circle, so the call to area_impl() is direct and can be inlined. The size difference is the vtable pointer: Circle holds only its double (8 bytes), while the equivalent class with a virtual function carries a hidden pointer too (16). The cost of CRTP is that Shape<Circle> and Shape<Square> are unrelated types — you can’t put them in one std::vector — which is exactly when you reach for virtual functions instead.

CRTP also works for mixins, injecting behaviour into a class:

#include <iostream>

template <typename Derived>
struct Comparable {                               // derives != > <= >= from == and <
    const Derived& self() const { return static_cast<const Derived&>(*this); }
    bool operator!=(const Derived& o) const { return !(self() == o); }
    bool operator>(const Derived& o) const  { return o < self(); }
    bool operator<=(const Derived& o) const { return !(o < self()); }
    bool operator>=(const Derived& o) const { return !(self() < o); }
};

struct Point : Comparable<Point> {
    int x, y;
    Point(int x, int y) : x(x), y(y) {}
    bool operator==(const Point& o) const { return x == o.x && y == o.y; }
    bool operator<(const Point& o) const { return x < o.x || (x == o.x && y < o.y); }
};

int main() {
    Point a{1, 2}, b{1, 5};
    std::cout << std::boolalpha << "a < b " << (a < b) << ", a > b " << (a > b)
              << ", a <= b " << (a <= b) << ", a != b " << (a != b) << "\n";
}
$ g++ -std=c++20 -Wall -Wextra -o mixin mixin.cpp
$ ./mixin
a < b true, a > b false, a <= b true, a != b true

Point writes only == and <; Comparable<Point> derives the other four from them. In C++20, a defaulted operator<=> does this job without a base class (see the operators lesson), but the mixin technique still applies to anything else you want to bolt on.

Policy-Based Design

A policy is a template parameter that supplies one behaviour. Here, the locking strategy of a buffer:

#include <cstddef>
#include <iostream>
#include <mutex>

struct NoLock {                                   // policy 1: zero-cost no-op
    struct Guard { explicit Guard(NoLock&) {} };
};
struct MutexLock {                                // policy 2: a real mutex
    std::mutex m;
    struct Guard { std::lock_guard<std::mutex> g; explicit Guard(MutexLock& p) : g(p.m) {} };
};
struct LoggingLock {                              // policy 3: for debugging
    struct Guard {
        explicit Guard(LoggingLock&) { std::cout << "  [lock]"; }
        ~Guard() { std::cout << " [unlock]\n"; }
    };
};

template <typename T, std::size_t N, typename LockPolicy = NoLock>
class Buffer : private LockPolicy {
    T data_[N]{};
public:
    void write(std::size_t i, T v) {
        typename LockPolicy::Guard guard(*this);  // no-op, mutex or log
        data_[i] = v;
    }
    T read(std::size_t i) const { return data_[i]; }
};

int main() {
    Buffer<int, 4> fast;
    Buffer<int, 4, MutexLock> safe;
    Buffer<int, 4, LoggingLock> traced;
    fast.write(0, 1);
    safe.write(0, 2);
    traced.write(0, 3);
    std::cout << "reads: " << fast.read(0) << " " << safe.read(0) << " " << traced.read(0) << "\n";
    std::cout << "sizeof: NoLock " << sizeof(fast) << ", MutexLock " << sizeof(safe)
              << ", LoggingLock " << sizeof(traced) << "\n";
}
$ g++ -std=c++20 -Wall -Wextra -o policy policy.cpp
$ ./policy
  [lock] [unlock]
reads: 1 2 3
sizeof: NoLock 16, MutexLock 56, LoggingLock 16

The three buffers share one implementation of write, but each constructs a different Guard. With NoLock, the guard is an empty object the optimizer removes entirely, so the single-threaded buffer pays nothing — and because Buffer inherits the empty policy privately, the empty-base optimization keeps it at 16 bytes, the size of four ints. MutexLock adds a real 40-byte std::mutex (56 total). LoggingLock shows the guard running around the write. The standard library uses the same idea for allocators and comparators.

The PIMPL Idiom

PIMPL (“pointer to implementation”) hides a class’s private members in a struct that only the .cpp file defines.

// widget.h — what users include
#pragma once
#include <memory>
#include <string>

class Widget {
public:
    explicit Widget(std::string name);
    ~Widget();                            // defined in widget.cpp, where Impl is complete
    Widget(Widget&&) noexcept;
    Widget& operator=(Widget&&) noexcept;
    void set_color(int r, int g, int b);
    void render() const;
private:
    struct Impl;                          // declared, never defined here
    std::unique_ptr<Impl> impl_;
};
// widget.cpp — the only file that sees Impl
#include "widget.h"
#include <iostream>

struct Widget::Impl {
    std::string name;
    int r = 0, g = 0, b = 0;
};

Widget::Widget(std::string name) : impl_(std::make_unique<Impl>(Impl{std::move(name)})) {}
Widget::~Widget() = default;
Widget::Widget(Widget&&) noexcept = default;
Widget& Widget::operator=(Widget&&) noexcept = default;
void Widget::set_color(int r, int g, int b) { impl_->r = r; impl_->g = g; impl_->b = b; }
void Widget::render() const {
    std::cout << "render " << impl_->name << " rgb(" << impl_->r << "," << impl_->g << "," << impl_->b << ")\n";
}
// pimpl_main.cpp — knows only widget.h
#include <iostream>
#include "widget.h"

int main() {
    Widget w("button");
    w.set_color(255, 128, 0);
    w.render();
    Widget moved = std::move(w);
    moved.render();
    std::cout << "sizeof(Widget) = " << sizeof(Widget) << "\n";
}
$ g++ -std=c++20 -Wall -Wextra -o app pimpl_main.cpp widget.cpp
$ ./app
render button rgb(255,128,0)
render button rgb(255,128,0)
sizeof(Widget) = 8

pimpl_main.cpp compiled without ever seeing Impl’s members, and Widget itself is one pointer — 8 bytes — whatever Impl grows into. Adding a field to Impl recompiles only widget.cpp. The fiddly part is the destructor: it is declared in the header but defined (as = default) in widget.cpp. Move the = default into the header and see what happens:

$ sed -i 's|~Widget();.*|~Widget() = default;|' widget.h
$ sed -i '/^Widget::~Widget() = default;$/d' widget.cpp
$ g++ -std=c++20 -Wall -Wextra -o app pimpl_main.cpp widget.cpp 2>&1 | grep error
/usr/include/c++/13/bits/unique_ptr.h:97:23: error: invalid application of 'sizeof' to incomplete type 'Widget::Impl'

The destructor is now generated in pimpl_main.cpp, where Impl is only forward-declared, and std::unique_ptr’s deleter needs sizeof(Impl) to delete it. The same applies to the move assignment operator, which must destroy the old Impl.

Observer

Observer decouples an event source from its listeners. std::function lets any callable subscribe, with no listener base class:

#include <algorithm>
#include <cstddef>
#include <functional>
#include <iostream>
#include <utility>
#include <vector>

template <typename... Args>
class Event {
    std::vector<std::pair<std::size_t, std::function<void(Args...)>>> handlers_;
    std::size_t next_id_ = 0;
public:
    std::size_t subscribe(std::function<void(Args...)> h) {
        handlers_.emplace_back(next_id_, std::move(h));
        return next_id_++;
    }
    void unsubscribe(std::size_t id) {
        std::erase_if(handlers_, [id](const auto& h) { return h.first == id; });
    }
    void emit(Args... args) const { for (const auto& [id, h] : handlers_) h(args...); }
};

struct Button {
    Event<int, int> on_click;
    void click(int x, int y) { std::cout << "click(" << x << "," << y << ")\n"; on_click.emit(x, y); }
};

int main() {
    Button btn;
    int clicks = 0;
    auto logger = btn.on_click.subscribe([](int x, int y) { std::cout << "  log: " << x << "," << y << "\n"; });
    btn.on_click.subscribe([&clicks](int, int) { std::cout << "  counter: " << ++clicks << "\n"; });
    btn.click(10, 20);
    btn.on_click.unsubscribe(logger);
    btn.click(30, 40);
}
$ g++ -std=c++20 -Wall -Wextra -o observer observer.cpp
$ ./observer
click(10,20)
  log: 10,20
  counter: 1
click(30,40)
  counter: 2

Both handlers ran for the first click. subscribe returns an id, so the logger could remove itself; the second click reached only the counter. The counter lambda captures clicks by reference, which is fine here because clicks outlives btn — a handler that captures something shorter-lived is the classic Observer dangling bug.

Factory

A factory maps a runtime value — a config string, a file extension — to a concrete type, so callers never name the concrete classes.

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

struct Serializer {
    virtual ~Serializer() = default;
    virtual std::string serialize(const std::string& data) const = 0;
    static std::unique_ptr<Serializer> create(const std::string& format);
};
struct Json : Serializer {
    std::string serialize(const std::string& d) const override { return "{\"data\": \"" + d + "\"}"; }
};
struct Xml : Serializer {
    std::string serialize(const std::string& d) const override { return "<data>" + d + "</data>"; }
};

std::unique_ptr<Serializer> Serializer::create(const std::string& format) {
    static const std::map<std::string, std::function<std::unique_ptr<Serializer>()>> registry{
        {"json", [] { return std::make_unique<Json>(); }},
        {"xml",  [] { return std::make_unique<Xml>(); }},
    };
    auto it = registry.find(format);
    if (it == registry.end()) throw std::invalid_argument("unknown format: " + format);
    return it->second();
}

int main() {
    for (const char* fmt : {"json", "xml", "yaml"}) {
        try {
            auto s = Serializer::create(fmt);          // may throw before anything prints
            std::cout << fmt << " -> " << s->serialize("hello") << "\n";
        } catch (const std::invalid_argument& e) {
            std::cout << fmt << " -> error: " << e.what() << "\n";
        }
    }
}
$ g++ -std=c++20 -Wall -Wextra -o factory factory.cpp
$ ./factory
json -> {"data": "hello"}
xml -> <data>hello</data>
yaml -> error: unknown format: yaml

Adding a format means adding one registry entry; callers don’t change. The registry is a function-local static, built once on first use. Unknown formats throw instead of returning null, so a misspelled config value fails loudly. Note the create call is on its own line: in std::cout << fmt << " -> " << Serializer::create(fmt)..., C++17 evaluates the << chain left to right, so “yaml -> ” would already be printed when create throws.

Thread-Safe Singleton

Since C++11, a function-local static is initialized exactly once even when threads race to reach it — the “Meyers singleton”. Eight threads race here:

#include <atomic>
#include <iostream>
#include <string>
#include <thread>
#include <unordered_map>
#include <vector>

std::atomic<int> constructions{0};

class Config {
    std::unordered_map<std::string, std::string> settings_;
    Config() { ++constructions; settings_["version"] = "1.0"; }   // only instance() can call
public:
    static Config& instance() {
        static Config inst;           // initialized exactly once, thread-safe since C++11
        return inst;
    }
    Config(const Config&) = delete;
    Config& operator=(const Config&) = delete;
    const std::string& get(const std::string& k) const { return settings_.at(k); }
};

int main() {
    std::vector<std::thread> threads;
    std::vector<const Config*> seen(8);
    for (int i = 0; i < 8; ++i)
        threads.emplace_back([&seen, i] { seen[i] = &Config::instance(); });   // race to be first
    for (auto& t : threads) t.join();

    bool same = true;
    for (auto* p : seen) same = same && (p == seen[0]);
    std::cout << std::boolalpha << "constructed " << constructions << " time(s), all threads got the same object: "
              << same << ", version " << Config::instance().get("version") << "\n";
}
$ g++ -std=c++20 -Wall -Wextra -o singleton singleton.cpp
$ ./singleton
constructed 1 time(s), all threads got the same object: true, version 1.0

One construction, one address shared by all eight threads, with no mutex in sight — the compiler emits a guarded one-time initialization. That guarantee covers construction only; if Config had setters, concurrent calls would still need a lock. Singletons are global state, which makes testing harder, so prefer passing a Config& to the code that needs it and keep singletons for truly process-wide things like a logger.

Practice

  1. Add a Triangle to crtp.cpp and a function template total_area(const Shape<Ts>&... shapes) that sums any number of shapes.

    Solution
    class Triangle : public Shape<Triangle> {
        double b_, h_;
    public:
        static constexpr const char* name = "triangle";
        Triangle(double b, double h) : b_(b), h_(h) {}
        double area_impl() const { return 0.5 * b_ * h_; }
    };
    
    template <typename... Ts>
    double total_area(const Shape<Ts>&... shapes) { return (shapes.area() + ...); }
    // total_area(Circle(1), Square(2), Triangle(3, 4)) -> 13.1416

    A variadic template is how CRTP handles “several different shapes” without a common runtime base.

  2. Write a CountingLock policy for policy.cpp whose guard increments a counter, and print how many writes a buffer received.

    Solution
    struct CountingLock {
        int count = 0;
        struct Guard { explicit Guard(CountingLock& p) { ++p.count; } };
    };
    // In Buffer, add: int lock_count() const { return LockPolicy::count; }
    // (only valid for policies that have a count)

    Because the policy is a private base, Buffer can read its members; member functions of a class template are only instantiated when called, so lock_count() doesn’t break the other policies.

  3. Add a std::string label() const accessor to Widget without touching widget.h’s private section.

    Solution

    Declare std::string label() const; in the public section of widget.h and define it in widget.cpp as return impl_->name;. The private section — one unique_ptr — never changes, which is the point: sizeof(Widget) stays 8.

  4. Make an Event subscriber that unsubscribes itself after its first call.

    Solution
    std::size_t once_id = 0;
    once_id = btn.on_click.subscribe([&](int, int) {
        std::cout << "  once\n";
        btn.on_click.unsubscribe(once_id);   // modifies handlers_ during emit!
    });

    This is a trap: unsubscribe erases from the vector that emit is iterating, which is undefined behavior. A robust Event defers removals until emit finishes, or iterates over a copy of the handler list.

  5. Register a "csv" serializer in factory.cpp that returns data\r\nhello.

    Solution
    struct Csv : Serializer {
        std::string serialize(const std::string& d) const override { return "data\r\n" + d; }
    };
    // registry:
    {"csv", [] { return std::make_unique<Csv>(); }},

    main doesn’t change at all — only the registry grows.

Frequently Asked Questions

When should I use CRTP instead of virtual functions?
Use CRTP when the concrete type is known at compile time and you want the calls resolved statically and inlined, with no vtable pointer in each object. Use virtual functions when you need to hold different types in one container or pick the type at runtime; CRTP types don't share a common base type.
What problem does the PIMPL idiom solve?
PIMPL moves a class's private members into an implementation struct defined only in the .cpp file. Users of the header don't see those members or their includes, so changing them recompiles one file instead of every includer, and the public class's size and layout stay stable.
Why must a PIMPL class declare its destructor in the header but define it in the .cpp?
std::unique_ptr<Impl> needs Impl to be a complete type wherever its deleter is instantiated. If the destructor is defaulted in the header, it is generated in every file that includes it, where Impl is incomplete, and compilation fails with an incomplete-type error.
Is a function-local static singleton thread-safe?
Its initialization is: since C++11 the compiler guarantees a local static is constructed exactly once even if several threads reach it simultaneously, which is the Meyers singleton. Access to the singleton's data after construction still needs its own synchronization if it is mutable.
What is policy-based design?
Building a class from template parameters that each supply one orthogonal behaviour, such as locking or storage. The policy is chosen at compile time, so a no-op policy costs nothing, the same way std::map takes a comparator and containers take an allocator.