📝 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.
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) { /* ... */ }
};
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
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
staticinstance gives you a thread-safe, lazily-initialized single instance since C++11 - Factory —
std::make_uniquewith perfect forwarding (std::forward) creates objects safely without leaking ownership - Observer —
std::weak_ptrplusstd::functionlets subscribers be notified without keeping them alive artificially - Strategy —
std::functionand lambdas let you swap algorithms at runtime without an inheritance hierarchy - Decorator — templates and
unique_ptrchains let you compose behavior layer by layer - RAII — constructors acquire resources, destructors release them, and move semantics transfer ownership safely
- CRTP —
static_cast<Derived*>(this)gives compile-time polymorphism with none of the virtual-call overhead - Visitor —
std::variant+std::visitis 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.