Skip to content

05 · Design Patterns in C++

Design patterns are named solutions to recurring design problems — a shared vocabulary, not a library you import. What makes them worth a dedicated module here is that C++ has idiomatic ways to implement the classics that differ from the textbook Java/C# versions: smart pointers replace manual memory management, and RAII (from Module 4) removes whole categories of cleanup bugs the original pattern descriptions had to worry about. This module covers three of the most common patterns — Singleton, Factory, and Observer — the modern C++ way.

Singleton — exactly one instance, safely

The intent is simple: exactly one instance of a class exists, reachable from anywhere. The classic danger is a hand-rolled version that's either not thread-safe (two threads both see "not created yet" and both construct it) or leaks the instance. C++11 solved this in the language itself:

#include <iostream>

class Logger {
public:
    static Logger& instance() {
        static Logger instance;   // constructed on first call, guaranteed thread-safe by the standard
        return instance;
    }

    void log(const std::string& message) { std::cout << "[LOG] " << message << std::endl; }

    Logger(const Logger&) = delete;
    Logger& operator=(const Logger&) = delete;

private:
    Logger() { std::cout << "Logger constructed" << std::endl; }
};

int main() {
    std::cout << "before first log call" << std::endl;
    Logger::instance().log("starting up");
    Logger::instance().log("still the same instance");
}
// before first log call
// Logger constructed
// [LOG] starting up
// [LOG] still the same instance

This is called a Meyer's Singleton. Since C++11, the standard guarantees that initialization of a function-local static is thread-safe: if two threads call instance() for the first time simultaneously, the runtime inserts locking so the constructor runs exactly once and every caller waits for it, with no extra code from you. It's also lazy (nothing is built until first use) and leak-free at a predictable time (destroyed in reverse order of construction at program exit, like any static).

Prefer this over a Singleton reachable through a raw global pointer or a manually-locked "check a bool, then construct" pattern — both are strictly worse versions of what static already gives you for free.

Factory Method — decouple "what to build" from "how to use it"

A factory hides the concrete type behind an interface, so calling code depends only on a base class, not on which derived class gets constructed:

#include <iostream>
#include <memory>
#include <string>

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

class Circle : public Shape {
public:
    explicit Circle(double radius) : radius_(radius) {}
    double area() const override { return 3.14159 * radius_ * radius_; }
    std::string name() const override { return "Circle"; }
private:
    double radius_;
};

class Square : public Shape {
public:
    explicit Square(double side) : side_(side) {}
    double area() const override { return side_ * side_; }
    std::string name() const override { return "Square"; }
private:
    double side_;
};

// The factory function: callers ask for a kind of shape by name and get
// back an owning pointer to the interface -- they never name Circle or
// Square directly.
std::unique_ptr<Shape> makeShape(const std::string& kind, double size) {
    if (kind == "circle") return std::make_unique<Circle>(size);
    if (kind == "square") return std::make_unique<Square>(size);
    throw std::invalid_argument("unknown shape kind: " + kind);
}

int main() {
    for (const auto& kind : {"circle", "square"}) {
        std::unique_ptr<Shape> shape = makeShape(kind, 2.0);
        std::cout << shape->name() << " area = " << shape->area() << std::endl;
    }
}
// Circle area = 12.5664
// Square area = 4

Returning std::unique_ptr<Shape> instead of a raw Shape* is what makes this modern-C++-idiomatic: ownership of the new object transfers to the caller unambiguously, and there's no way to forget to delete it — the factory's job is choosing which type to build, not managing its lifetime, and unique_ptr handles the lifetime automatically. Adding a new shape means adding one if branch (or, for a large family, a std::unordered_map<std::string, std::function<...>> registry) — call sites never change.

Observer — notify a set of listeners without knowing who they are

The Observer pattern lets a "subject" broadcast events to any number of "observers" that registered interest, without the subject holding type-specific knowledge of them:

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

class Observer {
public:
    virtual ~Observer() = default;
    virtual void onTemperatureChanged(double celsius) = 0;
};

class TemperatureSensor {
public:
    void addObserver(std::shared_ptr<Observer> obs) { observers_.push_back(std::move(obs)); }

    void setTemperature(double celsius) {
        current_ = celsius;
        for (const auto& obs : observers_) obs->onTemperatureChanged(current_);
    }

private:
    double current_ = 0.0;
    std::vector<std::shared_ptr<Observer>> observers_;
};

class Display : public Observer {
public:
    explicit Display(std::string label) : label_(std::move(label)) {}
    void onTemperatureChanged(double celsius) override {
        std::cout << label_ << " now shows " << celsius << "C" << std::endl;
    }
private:
    std::string label_;
};

int main() {
    TemperatureSensor sensor;
    sensor.addObserver(std::make_shared<Display>("Kitchen display"));
    sensor.addObserver(std::make_shared<Display>("Phone app"));

    sensor.setTemperature(21.5);
    sensor.setTemperature(23.0);
}
// Kitchen display now shows 21.5C
// Phone app now shows 21.5C
// Kitchen display now shows 23C
// Phone app now shows 23C

Storing std::shared_ptr<Observer> rather than raw pointers means the subject can share ownership of observers with whoever else holds them, without either side worrying about who deletes what. If observers instead need to outlive the subject's interest in them without being kept alive purely by that registration (a common cause of surprising lifetime extension), store std::weak_ptr<Observer> instead and .lock() it before calling — skipping any observer whose lock() returns null because it's already been destroyed elsewhere.

Cheat sheet

Pattern Problem it solves Modern C++ idiom
Singleton Exactly one instance, globally reachable Function-local static (Meyer's Singleton)
Factory Method Decouple construction from usage Function returning std::unique_ptr<Base>
Observer Broadcast events to unknown listeners std::vector<std::shared_ptr<Observer>> + virtual callback

Traps

A Singleton is a global with extra ceremony. It's the right tool for a handful of truly-one-of-these things (a logger, a hardware register map) — reaching for it as a default way to avoid passing dependencies around makes code harder to test, since every user of the Singleton is now implicitly coupled to it.

A factory that returns a raw pointer pushes the "who deletes this?" question back onto every call site. Returning std::unique_ptr (or std::shared_ptr when shared ownership is genuinely needed) answers the question once, in the factory itself.

An Observer holding shared_ptrs to observers that also hold a shared_ptr back to the subject is a reference cycle — neither side's reference count ever reaches zero, and both leak. If a back-reference is needed, make it a weak_ptr, exactly as with the cycles discussed in Level 2's smart pointers module.

How It Actually Works

The C++-idiomatic versions of these patterns differ from the textbook forms specifically because C++ has RAII and value semantics that other languages lack, and the mechanism-level reasons matter:

  • A Factory returning std::unique_ptr<Base> relies on exactly the same polymorphic-through-pointer mechanism as Level 2's library project — the factory function's return type is Base*-shaped (wrapped for safety), but the vptr installed by whichever concrete constructor actually ran determines real behavior at every call site, with ownership transferred to the caller via a move (no reference counting needed, unlike factories in garbage-collected languages that hand out shared references implicitly).
  • Singleton implemented as a function-local static object relies on a specific, standard-guaranteed mechanism: the first call to the function triggers construction, and the compiler inserts a hidden thread-safe initialization check (a guard variable, checked with an atomic operation) around that first-time construction — this is mandated by the standard since C++11 specifically so concurrent first calls from multiple threads don't race and construct the object twice.
  • Observer implemented with std::function callbacks stores type-erased closures — std::function internally holds either a small inline buffer (small-object optimization, mirroring std::string's SSO) or a heap-allocated wrapper around whatever callable was assigned, plus a vtable-like set of function pointers for "call this," "copy this," "destroy this" — which is how one std::function<void(int)> member can hold a lambda, a function pointer, or a bound member function interchangeably at the cost of one indirect call per invocation.
  • RAII-based Strategy/Decorator compositions rely on the same destructor-ordering guarantees from Module 4 to guarantee wrapped behaviors unwind in the right order even under exceptions, something the Java/C# versions of these patterns don't get for free.

Exercise

Extend the shape factory with a Triangle (base and height) and register it in makeShape. Then give Shape a pure virtual describe() that returns a formatted string, and write a free function totalArea that takes a const std::vector<std::unique_ptr<Shape>>& and sums their areas using a range-based for loop. Finally, add a second observer type, Logger, that appends every temperature reading to a std::vector<double> instead of printing, and confirm both a Display and a Logger can watch the same TemperatureSensor at once.