Chapter 30: Patterns, Idioms, and Modern Style
This chapter collects structural techniques that appear across modern C++ codebases. These patterns help you separate interfaces from implementations, build flexible hierarchies, express alternatives cleanly, avoid accidental ownership, and lean on the Guideline Support Library. Treat them as tools that improve clarity when used in the right context rather than rules to follow blindly.
Pimpl, type erasure, and value semantics
Pimpl (pointer to implementation) hides internal details behind a stable interface. Type erasure lifts concrete types into a uniform value like std::function. Both approaches preserve value semantics so users can pass and return objects freely without exposing internal structure.
Build a Pimpl for stable ABI and fast builds
A Pimpl shields header files from heavy implementation details. The public class holds a pointer to a forward declared struct. This reduces rebuild time and gives you freedom to change internals without altering the public interface. Manage lifetime carefully so copy and move operations behave naturally.
// In widget.h
class Widget {
struct Impl;
std::unique_ptr<Impl> p_;
public:
Widget();
Widget(Widget&&) noexcept;
Widget& operator=(Widget&&) noexcept;
Widget(const Widget&);
Widget& operator=(const Widget&);
~Widget();
void do_stuff();
};
// In widget.cpp
struct Widget::Impl { int x; void work(); };
std::make_unique inside constructors and write move members to avoid double allocation when transferring ownership.
Apply type erasure with small value objects
Type erasure wraps any type that satisfies a concept behind a stable interface. std::function, std::any, std::optional, and many iterator wrappers follow this pattern. You get a uniform value type that can hold different concrete implementations while exposing consistent operations.
// A sketch of erased callable
struct callable {
struct base { virtual ~base(){} virtual int call(int)=0; };
template<class F> struct model : base {
F f;
model(F x): f(x) {}
int call(int x) override { return f(x); }
};
std::unique_ptr<base> ptr;
template<class F> callable(F f): ptr(std::make_unique<model<F>>(f)) {}
int operator()(int x) { return ptr->call(x); }
};
Preserve value semantics in public APIs
When designing types, strive for copyable and movable values. Hidden pointers and reference counting can still support value semantics as long as operations behave like independent values. Clear semantics encourage clean interfaces across modules.
CRTP and mixins
The Curiously Recurring Template Pattern lets a base class use the derived class type as a template parameter. You get compile time polymorphism without virtual dispatch. Mixins layer reusable behavior that the compiler can inline and optimize aggressively.
Shape compile time polymorphism
CRTP enables the base to call derived functions statically. This skips virtual tables and opens the door to inlining. It fits well for numeric kernels, policy classes, and static interfaces.
template<class Derived>
struct Base {
void run(){ static_cast<Derived*>(this)->step(); }
};
struct Algo : Base<Algo> {
void step(){ /* work */ }
};
Layer behaviors with mixins
Mixins let you compose features without heavy inheritance trees. Each mixin adds a slice of behavior, and your final type assembles them. Keep mixins small so they remain understandable and avoid ambiguous name lookups.
template<class T> struct Logging { void log(const char* m){ /* … */ } };
template<class T> struct Timing { void tic(){ /* … */ } void toc(){ /* … */ } };
struct Task : Logging<Task>, Timing<Task> {
void run(){ tic(); log("start"); /* ... */ log("end"); toc(); }
};
Policies and static customization points
Policy classes passed as template parameters let you change behavior without runtime overhead. Common examples include allocators, comparison strategies, and threading policies. Keep policy interfaces minimal so substitutions remain easy.
Visitor, variant, and pattern matching idioms
Variants and visitors express alternatives cleanly. With std::variant you can hold one of several types, then use std::visit to pattern match. This replaces scattered conditionals with structured dispatch and keeps state machines tidy.
Use std::variant for tagged unions
std::variant stores a choice among types and tracks which one is active. It replaces manual tag enums and unions, and integrates with move semantics and exception safety.
using Node = std::variant<int, std::string>;
Node n = 42;
n = std::string("hi");
Pattern match with std::visit
std::visit lets you apply a callable to the active alternative. With lambdas you can write simple handlers for each case. This keeps logic grouped and avoids long switch statements on tag values.
std::visit([](auto& v){
using T = std::decay_t<decltype(v)>;
if constexpr (std::is_same_v<T,int>) { /* handle int */ }
else { /* handle string */ }
}, n);
Model sum types and recursive structures
Variants can model recursive data like expression trees. Use std::unique_ptr for recursive members and keep visitors small. This mirrors sum types in functional languages and makes transformations explicit.
struct Expr;
using ExprPtr = std::unique_ptr<Expr>;
struct Expr {
std::variant<int, std::pair<char, std::array<ExprPtr,2>>> node;
};
Dependency inversion and non owning views
Dependency inversion pushes concrete resources to the edges and depends on abstract interfaces in the middle. Non owning views like std::span or std::string_view express read only access without taking ownership. This reduces coupling and clarifies responsibilities through clean boundaries.
Inject dependencies through constructors
Pass services or resources into objects from the outside. This avoids hidden singletons and makes behavior testable. Store references or pointers only when ownership stays elsewhere, and document expected lifetimes clearly.
struct Logger { void write(const std::string&); };
struct Service {
Logger& log;
Service(Logger& l): log(l) {}
void run(){ log.write("start"); /* … */ }
};
Use non owning views for read only access
std::span and std::string_view describe ranges without owning them. They clarify intent and avoid expensive copies. Ensure the referenced data remains valid while the view is in use.
void process(std::span<const int> s){
for (int v : s) { /* ... */ }
}
void show(std::string_view sv){ /* … */ }
Abstract interfaces and plug in implementations
Define narrow interfaces and provide concrete implementations at the boundary. For runtime polymorphism use virtual functions or type erased wrappers. For compile time flexibility use policy classes or CRTP patterns.
Guideline support library notes
The Guideline Support Library (GSL) provides tools that encode common safety idioms. It includes types for non null pointers, spans, narrow conversions, and contracts. Many of these ideas shaped C++20 and C++23 features.
Use gsl::span, gsl::not_null, and gsl::index
gsl::span mirrors std::span on older compilers. gsl::not_null encodes non null pointers for safer APIs. gsl::index expresses sizes and indexes with intent. These helpers reduce accidental misuse and improve clarity.
#include <gsl/gsl>
void draw(gsl::span<const float> xs);
void use(gsl::not_null<Widget*> w);
Narrowing and contract hints
gsl::narrow and gsl::narrow_cast offer explicit conversions between integer sizes. Use them when range constraints matter. The GSL also provides optional contract macros that document preconditions and postconditions, though full contracts remain a future C++ feature.
int small = gsl::narrow<int>(big_value); // checks range
int wide = gsl::narrow_cast<int>(big_value); // unchecked
Adopt GSL ideas while remaining idiomatic
Many GSL patterns are reflected in C++20 core features. Use GSL where older compilers need support or where explicit safety gains are clear, but otherwise prefer standard library types. Treat the GSL as a reference of good practices rather than a required dependency in all projects.