Skip to main content

📝 Lesson 20: Design Patterns in Modern C++

Apply proven architectural blueprints to write elegant, maintainable, and scalable C++ code.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Understand and implement classic Creational, Structural, and Behavioral design patterns.
  • Leverage modern C++ features (smart pointers, lambdas, templates) to implement patterns more concisely and safely.
  • Apply the Strategy and Observer patterns using modern functional C++ techniques.
  • Use CRTP and std::variant to implement compile-time polymorphism and visitors.

Estimated Time: 90–120 minutes

Project: Build a flexible, plugin-based system using Factory and Strategy patterns.

In This Lesson

What Are Design Patterns?

Design patterns are like architectural blueprints for software. Just as architects reuse proven building designs (like the ranch house or Victorian style), programmers use design patterns to solve common problems elegantly.

Singleton Pattern: One Instance to Rule Them All

The Singleton ensures only one instance of a class exists, like having only one president of a country at a time.

Modern Thread-Safe Singleton

// Modern C++ Singleton using static local variable
class Logger {
private:
    Logger() {
        // Private constructor
        std::cout << "Logger created\n";
    }

public:
    // Delete copy/move operations
    Logger(const Logger&) = delete;
    Logger& operator=(const Logger&) = delete;
    Logger(Logger&&) = delete;
    Logger& operator=(Logger&&) = delete;

    // Thread-safe in C++11 and later
    static Logger& getInstance() {
        static Logger instance;  // Created once, destroyed at program end
        return instance;
    }

    void log(const std::string& message) {
        // Thread-safe logging would require additional synchronization
        std::cout << "[LOG] " << message << std::endl;
    }
};

// Usage
Logger::getInstance().log("Application started");
// Logger::getInstance() always returns the same instance

Trade-off: A Singleton is a global in disguise, so it makes code harder to test — anything that calls Logger::getInstance() has a hidden dependency you can't easily swap for a mock or reset between test cases. When you need to test collaborators in isolation, prefer passing the dependency in explicitly (dependency injection) rather than reaching for a Singleton.

graph TD A[First Call to getInstance] --> B[Create Static Instance] B --> C[Return Reference] D[Subsequent Calls] --> C E[Program End] --> F[Automatic Destruction]

Factory Pattern: Object Creation Made Flexible

Factory pattern is like a restaurant menu - you order by name ("pizza") without knowing the exact preparation details.

// Abstract Product
class Vehicle {
public:
    virtual ~Vehicle() = default;
    virtual void drive() const = 0;
    virtual std::string getType() const = 0;
};

// Concrete Products
class Car : public Vehicle {
public:
    void drive() const override {
        std::cout << "Driving a car on the road\n";
    }
    std::string getType() const override { return "Car"; }
};

class Motorcycle : public Vehicle {
public:
    void drive() const override {
        std::cout << "Riding a motorcycle\n";
    }
    std::string getType() const override { return "Motorcycle"; }
};

// Modern Factory using unique_ptr and variadic templates
class VehicleFactory {
public:
    // Template factory method
    template<typename T, typename... Args>
    static std::unique_ptr<Vehicle> create(Args&&... args) {
        return std::make_unique<T>(std::forward<Args>(args)...);
    }

    // String-based factory
    static std::unique_ptr<Vehicle> create(const std::string& type) {
        if (type == "car") {
            return std::make_unique<Car>();
        } else if (type == "motorcycle") {
            return std::make_unique<Motorcycle>();
        }
        return nullptr;
    }
};

// Usage
auto car = VehicleFactory::create<Car>();
auto moto = VehicleFactory::create("motorcycle");
car->drive();

Observer Pattern: Event-Driven Communication

Observer pattern is like a newsletter subscription - subscribers get notified when new content is published.

// Modern Observer using function objects and weak_ptr
template<typename EventType>
class EventEmitter {
private:
    using Handler = std::function<void(const EventType&)>;
    using HandlerPtr = std::shared_ptr<Handler>;
    mutable std::mutex mutex_;
    mutable std::vector<std::weak_ptr<Handler>> handlers_;

public:
    class Subscription {
        friend class EventEmitter;
        std::shared_ptr<Handler> handler_;  // owns the handler; emitter keeps a weak_ptr
        EventEmitter* emitter_;

        Subscription(std::shared_ptr<Handler> h, EventEmitter* e)
            : handler_(std::move(h)), emitter_(e) {}

    public:
        void unsubscribe() {
            if (handler_) {
                emitter_->remove(handler_);
                handler_.reset();
            }
        }
    };

    Subscription subscribe(Handler handler) {
        std::lock_guard<std::mutex> lock(mutex_);
        auto ptr = std::make_shared<Handler>(std::move(handler));
        handlers_.push_back(ptr);
        return Subscription(ptr, this);
    }

    void emit(const EventType& event) const {
        std::lock_guard<std::mutex> lock(mutex_);
        // Clean up expired handlers
        auto it = std::remove_if(handlers_.begin(), handlers_.end(),
            [](const std::weak_ptr<Handler>& h) { return h.expired(); });
        handlers_.erase(it, handlers_.end());

        // Notify active handlers
        for (const auto& weakHandler : handlers_) {
            if (auto handler = weakHandler.lock()) {
                (*handler)(event);
            }
        }
    }

private:
    void remove(const HandlerPtr& target) {
        std::lock_guard<std::mutex> lock(mutex_);
        handlers_.erase(
            std::remove_if(handlers_.begin(), handlers_.end(),
                [&](const std::weak_ptr<Handler>& h) {
                    auto ptr = h.lock();
                    return !ptr || ptr == target;
                }),
            handlers_.end()
        );
    }
};

// Usage Example
struct PriceChangeEvent {
    std::string symbol;
    double oldPrice;
    double newPrice;
};

class StockMarket {
    EventEmitter<PriceChangeEvent> priceChanged;

public:
    auto onPriceChange(std::function<void(const PriceChangeEvent&)> handler) {
        return priceChanged.subscribe(std::move(handler));
    }

    void updatePrice(const std::string& symbol, double newPrice) {
        double oldPrice = getPrice(symbol);
        setPrice(symbol, newPrice);
        priceChanged.emit({symbol, oldPrice, newPrice});
    }

private:
    std::map<std::string, double> prices_;
    double getPrice(const std::string& symbol) { /* ... */ }
    void setPrice(const std::string& symbol, double price) { /* ... */ }
};
graph LR A[Subject<br/>StockMarket] --> B[Event:<br/>Price Changed] B --> C[Observer 1<br/>Logger] B --> D[Observer 2<br/>Trader] B --> E[Observer 3<br/>Display]

Strategy Pattern: Algorithms as Objects

Strategy pattern is like choosing a route to work - you can walk, drive, or take the bus, each with different characteristics.

// Modern Strategy using std::function and lambdas
class DataProcessor {
private:
    using ProcessingStrategy = std::function<std::vector<int>(const std::vector<int>&)>;
    ProcessingStrategy strategy_;

public:
    void setStrategy(ProcessingStrategy strategy) {
        strategy_ = std::move(strategy);
    }

    std::vector<int> process(const std::vector<int>& data) {
        if (!strategy_) {
            throw std::logic_error("No strategy set");
        }
        return strategy_(data);
    }
};

// Predefined strategies
namespace Strategies {
    auto sortAscending = [](const std::vector<int>& data) {
        auto result = data;
        std::sort(result.begin(), result.end());
        return result;
    };

    auto sortDescending = [](const std::vector<int>& data) {
        auto result = data;
        std::sort(result.rbegin(), result.rend());
        return result;
    };

    auto removeDuplicates = [](const std::vector<int>& data) {
        auto result = data;
        std::sort(result.begin(), result.end());
        result.erase(std::unique(result.begin(), result.end()), result.end());
        return result;
    };

    auto filterEven = [](const std::vector<int>& data) {
        std::vector<int> result;
        std::copy_if(data.begin(), data.end(), std::back_inserter(result),
                     [](int n) { return n % 2 == 0; });
        return result;
    };
}

// Usage
DataProcessor processor;
std::vector<int> data = {3, 1, 4, 1, 5, 9, 2, 6};

processor.setStrategy(Strategies::sortAscending);
auto sorted = processor.process(data);  // [1, 1, 2, 3, 4, 5, 6, 9]

processor.setStrategy(Strategies::removeDuplicates);
auto unique = processor.process(data);  // [1, 2, 3, 4, 5, 6, 9]

// Custom strategy on the fly
processor.setStrategy([](const auto& data) {
    std::vector<int> result;
    for (int n : data) {
        result.push_back(n * n);  // Square each element
    }
    return result;
});
auto squared = processor.process(data);

Decorator Pattern: Adding Features Dynamically

Decorator pattern is like adding toppings to a pizza - each topping adds to the base without changing its fundamental nature.

// Modern Decorator using templates and perfect forwarding
template<typename Component>
class TextProcessor {
public:
    virtual ~TextProcessor() = default;
    virtual std::string process(const std::string& text) const = 0;
};

// Base component
class PlainText : public TextProcessor<PlainText> {
public:
    std::string process(const std::string& text) const override {
        return text;
    }
};

// Decorator base class
template<typename Component>
class TextDecorator : public TextProcessor<Component> {
protected:
    std::unique_ptr<TextProcessor<Component>> component_;

public:
    explicit TextDecorator(std::unique_ptr<TextProcessor<Component>> component)
        : component_(std::move(component)) {}
};

// Concrete decorators
template<typename Component>
class UpperCaseDecorator : public TextDecorator<Component> {
public:
    using TextDecorator<Component>::TextDecorator;

    std::string process(const std::string& text) const override {
        auto result = this->component_->process(text);
        std::transform(result.begin(), result.end(), result.begin(), ::toupper);
        return result;
    }
};

template<typename Component>
class TrimDecorator : public TextDecorator<Component> {
public:
    using TextDecorator<Component>::TextDecorator;

    std::string process(const std::string& text) const override {
        auto result = this->component_->process(text);
        // Trim leading/trailing whitespace
        result.erase(0, result.find_first_not_of(" \t\n\r"));
        result.erase(result.find_last_not_of(" \t\n\r") + 1);
        return result;
    }
};

// Fluent builder for easy decoration
template<typename T>
class TextProcessorBuilder {
    std::unique_ptr<TextProcessor<T>> processor_;

public:
    TextProcessorBuilder() : processor_(std::make_unique<PlainText>()) {}

    TextProcessorBuilder& upperCase() {
        processor_ = std::make_unique<UpperCaseDecorator<T>>(std::move(processor_));
        return *this;
    }

    TextProcessorBuilder& trim() {
        processor_ = std::make_unique<TrimDecorator<T>>(std::move(processor_));
        return *this;
    }

    std::unique_ptr<TextProcessor<T>> build() {
        return std::move(processor_);
    }
};

// Usage
auto processor = TextProcessorBuilder<PlainText>()
    .trim()
    .upperCase()
    .build();

std::string result = processor->process("  hello world  ");
// Result: "HELLO WORLD"

RAII and Smart Pointer Patterns

RAII (Resource Acquisition Is Initialization) is like hiring a cleaning service that automatically cleans when they leave - resources are automatically managed.

// Custom RAII wrapper for file handles
class FileHandle {
private:
    FILE* file_;

public:
    explicit FileHandle(const std::string& filename, const char* mode)
        : file_(std::fopen(filename.c_str(), mode)) {
        if (!file_) {
            throw std::runtime_error("Failed to open file: " + filename);
        }
    }

    ~FileHandle() {
        if (file_) {
            std::fclose(file_);
        }
    }

    // Delete copy operations
    FileHandle(const FileHandle&) = delete;
    FileHandle& operator=(const FileHandle&) = delete;

    // Move operations
    FileHandle(FileHandle&& other) noexcept : file_(other.file_) {
        other.file_ = nullptr;
    }

    FileHandle& operator=(FileHandle&& other) noexcept {
        if (this != &other) {
            if (file_) std::fclose(file_);
            file_ = other.file_;
            other.file_ = nullptr;
        }
        return *this;
    }

    FILE* get() const { return file_; }
    operator FILE*() const { return file_; }
};

// Custom deleter for unique_ptr
struct SocketDeleter {
    void operator()(int* socket) const {
        if (socket && *socket >= 0) {
            close(*socket);
            delete socket;
        }
    }
};

using SocketPtr = std::unique_ptr<int, SocketDeleter>;

// Shared resource with custom deleter
auto createSharedBuffer(size_t size) {
    return std::shared_ptr<uint8_t[]>(
        new uint8_t[size],
        [](uint8_t* p) {
            std::cout << "Deallocating buffer\n";
            delete[] p;
        }
    );
}

Template Method Pattern with CRTP

The Curiously Recurring Template Pattern (CRTP) provides compile-time polymorphism, like a recipe template where specific steps are filled in by the chef.

// CRTP base class for game entities
template<typename Derived>
class GameObject {
public:
    void update(float deltaTime) {
        // Common pre-update logic
        if (!isActive()) return;

        // Derived-specific update
        static_cast<Derived*>(this)->onUpdate(deltaTime);

        // Common post-update logic
        updateAnimation(deltaTime);
    }

    void render() {
        if (!isVisible()) return;

        beginRender();
        static_cast<Derived*>(this)->onRender();
        endRender();
    }

protected:
    bool isActive() const { return active_; }
    bool isVisible() const { return visible_; }

private:
    bool active_ = true;
    bool visible_ = true;

    void updateAnimation(float deltaTime) { /* ... */ }
    void beginRender() { /* ... */ }
    void endRender() { /* ... */ }
};

// Concrete implementations
class Player : public GameObject<Player> {
    friend class GameObject<Player>;

    void onUpdate(float deltaTime) {
        // Player-specific update logic
        handleInput();
        movePlayer(deltaTime);
    }

    void onRender() {
        // Player-specific rendering
        // drawSprite(sprite_, position_);  // your rendering code here
    }

    void handleInput() { /* ... */ }
    void movePlayer(float deltaTime) { /* ... */ }
};

class Enemy : public GameObject<Enemy> {
    friend class GameObject<Enemy>;

    void onUpdate(float deltaTime) {
        // Enemy-specific update logic
        updateAI(deltaTime);
        moveTowardsPlayer(deltaTime);
    }

    void onRender() {
        // Enemy-specific rendering
        // drawAnimatedSprite(animation_, position_);  // your rendering code here
    }

    void updateAI(float deltaTime) { /* ... */ }
    void moveTowardsPlayer(float deltaTime) { /* ... */ }
};

Modern Visitor Pattern with std::variant

The visitor pattern allows operations on objects without modifying their classes. With std::variant, it's like having a universal remote that works differently for each device.

// Define shape types
struct Circle {
    double radius;
};

struct Rectangle {
    double width, height;
};

struct Triangle {
    double base, height;
};

// Shape variant
using Shape = std::variant<Circle, Rectangle, Triangle>;

// Visitor for calculating area
struct AreaCalculator {
    double operator()(const Circle& c) const {
        return std::numbers::pi * c.radius * c.radius;  // <numbers>, C++20
    }

    double operator()(const Rectangle& r) const {
        return r.width * r.height;
    }

    double operator()(const Triangle& t) const {
        return 0.5 * t.base * t.height;
    }
};

// Visitor for drawing
struct ShapeDrawer {
    void operator()(const Circle& c) const {
        std::cout << "Drawing circle with radius " << c.radius << "\n";
    }

    void operator()(const Rectangle& r) const {
        std::cout << "Drawing rectangle " << r.width << "x" << r.height << "\n";
    }

    void operator()(const Triangle& t) const {
        std::cout << "Drawing triangle with base " << t.base << "\n";
    }
};

// Usage
std::vector<Shape> shapes = {
    Circle{5.0},
    Rectangle{10.0, 20.0},
    Triangle{8.0, 6.0}
};

// Calculate total area
double totalArea = 0;
for (const auto& shape : shapes) {
    totalArea += std::visit(AreaCalculator{}, shape);
}

// Draw all shapes
for (const auto& shape : shapes) {
    std::visit(ShapeDrawer{}, shape);
}

Practical Examples

🏋️ Exercise: Command Pattern with Undo/Redo

// Implement a command pattern for a text editor
class TextEditor {
    std::string text_;
public:
    void insert(size_t pos, const std::string& str) { /* ... */ }
    void erase(size_t pos, size_t len) { /* ... */ }
    const std::string& getText() const { return text_; }
};

class Command {
public:
    virtual ~Command() = default;
    virtual void execute() = 0;
    virtual void undo() = 0;
};

// TODO: Implement InsertCommand and DeleteCommand
// TODO: Implement CommandHistory with undo/redo stacks

🏋️ Exercise: Builder Pattern for Complex Objects

// Create a fluent builder for HTTP requests
class HttpRequest {
    // Private members: method, url, headers, body, etc.
public:
    class Builder {
        // TODO: Implement builder methods
        // Builder& method(const std::string& m);
        // Builder& url(const std::string& u);
        // Builder& header(const std::string& key, const std::string& value);
        // Builder& body(const std::string& b);
        // HttpRequest build();
    };

    static Builder create() { return Builder{}; }
};

// Usage should look like:
// auto request = HttpRequest::create()
//     .method("POST")
//     .url("https://api.example.com/data")
//     .header("Content-Type", "application/json")
//     .body("{\"key\": \"value\"}")
//     .build();

Best Practices and Guidelines

graph TD A[Choosing a Pattern] --> B{What problem?} B --> C[Object Creation] B --> D[Object Structure] B --> E[Object Behavior] C --> F["Factory/Builder/Singleton"] D --> G["Adapter/Decorator/Composite"] E --> H["Strategy/Observer/Command"] I[Modern C++ Features] --> J[Prefer composition] I --> K[Use smart pointers] I --> L[Leverage templates] I --> M[Consider std::variant]

Modern C++ Pattern Guidelines

  • ✓ Use smart pointers instead of raw pointers
  • ✓ Prefer std::function over inheritance for strategies
  • ✓ Consider std::variant for visitor pattern
  • ✓ Use CRTP for compile-time polymorphism
  • ✓ Leverage perfect forwarding in factories
  • ✓ Apply RAII principles everywhere

🎯 Quick Quiz

Question 1: Why is the Meyer's Singleton in this lesson (static Logger& getInstance() { static Logger instance; return instance; }) considered thread-safe in C++11 and later?

Question 2: In VehicleFactory::create, what does std::make_unique<T>(std::forward<Args>(args)...) accomplish?

Question 3: In the CRTP example, GameObject<Derived>::update() calls static_cast<Derived*>(this)->onUpdate(deltaTime);. What is the main benefit of this over a virtual function call?

Summary

🎉 Key Takeaways

  • Singleton — a function-local static instance gives you a thread-safe, lazily-initialized single instance since C++11
  • Factorystd::make_unique with perfect forwarding (std::forward) creates objects safely without leaking ownership
  • Observerstd::weak_ptr plus std::function lets subscribers be notified without keeping them alive artificially
  • Strategystd::function and lambdas let you swap algorithms at runtime without an inheritance hierarchy
  • Decorator — templates and unique_ptr chains let you compose behavior layer by layer
  • RAII — constructors acquire resources, destructors release them, and move semantics transfer ownership safely
  • CRTPstatic_cast<Derived*>(this) gives compile-time polymorphism with none of the virtual-call overhead
  • Visitorstd::variant + std::visit is a type-safe, exhaustive alternative to the classic visitor hierarchy

📚 Additional Resources

🚀 What's Next?

You've seen how modern C++ features — smart pointers, lambdas, templates, and std::variant — supercharge classic design patterns, making them safer and more expressive than their textbook forms. Next, in Lesson 21: C++ Prerequisites for Unreal Engine, you'll bridge your standard C++ skills to the conventions, macros, and object-lifecycle patterns Unreal Engine expects before you start building in the engine itself.

🎉 Design patterns unlocked!

You've turned battle-tested architectural blueprints into idiomatic, modern C++ — a skill that will make every codebase you touch more maintainable.