SECTION 1
Historical Context & Motivation
The tension between convenient global state and disciplined locality of data has shaped the evolution of programming languages since the earliest days of computing. In the 1950s and 1960s, programs written in assembly and early FORTRAN routinely communicated through shared memory locations — what we now call global variables. As programs grew from hundreds to millions of lines, the hidden dependencies introduced by globals became a primary source of defects, making reasoning about program behavior exceedingly difficult. The quest to constrain mutability led to the introduction of const correctness in C++ — a compile-time mechanism that encodes immutability directly into the type system.
1972 • C Language & Global by Default
1979 • Bjarne Stroustrup Begins 'C with Classes'
1985 • C++ 1.0 and the const Keyword
const qualifier, allowing programmers to declare that a variable or parameter should not be modified. This is a significant departure from C's weaker #define constants.2011 • C++11: constexpr and Move Semantics
constexpr, enabling compile-time evaluation of functions and variables. Move semantics further reduce the need for shared mutable state by enabling efficient value transfers.2020 • C++20: consteval, constinit, and Modules
consteval (immediate functions) and constinit (guaranteeing constant initialization of globals), directly addressing the static initialization order fiasco that plagues global state.Despite decades of language evolution, global mutable state persists in production codebases. The fundamental question this lesson addresses is: how can a C++ programmer systematically minimize global state and leverage the type system's const qualifiers to communicate and enforce immutability contracts? Answering this question is central to writing software that is testable, thread-safe, and maintainable.
SECTION 2
Core Principles & Definitions
Before diving into implementation strategies, it is essential to establish the foundational ideas that underpin both minimizing global state and applying const correctness. These principles are not merely stylistic preferences; they arise from the formal desire to restrict the set of states a program can occupy at any point in its execution, thereby simplifying reasoning, testing, and concurrent execution.
Principle of Least Authority (POLA)
const&, never by non-const reference or pointer to a global.Referential Transparency
Const as a Contract
const is a promise enforced by the compiler: this entity will not be mutated through this path. It documents intent and catches violations at compile time.Static Initialization Order Fiasco
Thread Safety by Design
KEY TAKEAWAY
SECTION 3
Visual Explanation — Data Flow Architectures
The following diagram contrasts two architectural patterns: a program that routes data through global variables versus one that passes data explicitly through function parameters. The left side shows the tangled dependency web created by global state, while the right side demonstrates clean, explicit data flow with const-qualified interfaces.
g_config, g_db, and g_logger create hidden coupling between all functions. Right: main() owns the data and passes it explicitly — read-only access is enforced via const& parameters.In the left-hand architecture, every function implicitly depends on every global variable, creating an N × M coupling surface. When you modify g_config, you must audit every function in the program to understand the impact. In the right-hand architecture, the function signature itself documents what data flows in, and whether it can be modified. This explicitness is the cornerstone of maintainable C++ design. Notice that validate takes a const Config& — the compiler will reject any attempt to mutate the configuration inside that function, providing a machine-checked guarantee rather than a comment or convention.
SECTION 4
How Const Correctness Works in the Type System
C++ treats const as a type qualifier — int and const int are distinct types. This distinction propagates through the type system: a const int* (pointer to const int) cannot be implicitly converted to an int*. The compiler enforces these rules at every assignment, function call, and member access, creating a web of compile-time checks that prevent accidental mutation.
The Four Faces of Const
| Declaration | What Is Const? | Example |
|---|---|---|
const int x = 42; | The variable itself — cannot be reassigned | x = 10; // ERROR |
const int* p; | The pointed-to data — pointer can be reseated | *p = 10; // ERROR, p = &y; // OK |
int* const p = &x; | The pointer itself — cannot be reseated | *p = 10; // OK, p = &y; // ERROR |
void foo() const; | Member function — promises not to modify the object's observable state | Can be called on const objects and const references |
The four common const declarations in C++
Const Propagation Rules
When a member function is declared const, the implicit this pointer becomes const T*. This means every data member accessed through this is treated as const, and only other const member functions may be called on the object. This is known as shallow const propagation — if a class holds a raw pointer member, the pointee is not automatically const. For deep const semantics, the programmer must use std::experimental::propagate_const or design wrapper types that propagate constness to owned resources.
The mutable Escape Hatch
mutable keyword allows a data member to be modified even within a const member function. This is legitimate for caches, mutexes, and lazy-evaluation bookkeeping — it preserves logical constness while permitting internal optimization. Use it sparingly, and document the invariant it protects.SECTION 5
Strategies for Minimizing Global State
Eliminating global state entirely is sometimes impractical — logging infrastructure, hardware register maps, and certain singleton services may demand program-wide accessibility. The goal is not zero globals but minimal, controlled, and well-encapsulated global state. The following strategies, listed from most aggressive to most pragmatic, provide a toolkit for achieving this goal.
Strategy 1 — dependency injection — deserves particular emphasis because it simultaneously eliminates global state and naturally invites const correctness. When a class receives its dependencies through a constructor taking const ILogger& or const Config&, the compiler enforces that the class cannot modify those resources. Furthermore, unit tests can inject mock objects without manipulating any global state, dramatically improving testability.
Strategy 3 — namespace-scoped constants — is worth highlighting because constexpr constants are not global state at all; they are compile-time values baked into the binary. A constexpr double pi = 3.14159265358979; at namespace scope poses no threading risk, no initialization-order risk, and no testability problem. The distinction between global data (safe if const) and global mutable state (dangerous) is crucial.
SECTION 6
Worked Example — Refactoring a Global-Heavy Module
Consider a small module that processes sensor readings. The original version uses two global variables and lacks const correctness. We will refactor it step by step, demonstrating how to apply the principles discussed earlier.
Refactoring Sensor Processing Module
- 1
Step 1 — Identify Global State
The original code declaresstd::vector<double> g_readings;anddouble g_threshold = 5.0;at file scope. Three functions —addReading(double),filterReadings(), andaverageFiltered()— all read and write these globals implicitly. Our audit reveals thatg_thresholdis never modified after startup, whileg_readingsis mutated byaddReadingandfilterReadings.Identified: 2 globals, 3 functions with hidden dependencies - 2
Step 2 — Make the Immutable Global constexpr
Sinceg_thresholdnever changes, we replace it withconstexpr double kThreshold = 5.0;inside an appropriate namespace. The 'k' prefix follows the Google C++ Style Guide convention for constants. This value is now a compile-time constant: it has no address at runtime (unless ODR-used), poses no initialization-order risk, and the compiler may inline it at every use site.namespace sensor { constexpr double kThreshold = 5.0; } - 3
Step 3 — Convert the Mutable Global to a Local with Explicit Passing
We move thestd::vector<double>intomain()and pass it to functions that need it.addReadingtakes a non-const reference because it must mutate the vector:void addReading(std::vector<double>& readings, double value). The function signature now advertises that it modifies the vector.void addReading(std::vector<double>& readings, double value); - 4
Step 4 — Apply Const Correctness to Read-Only Consumers
averageFilteredonly reads the vector, so its signature becomesdouble averageFiltered(const std::vector<double>& readings). If we later accidentally add apush_backcall inside this function, the compiler will reject it. ThefilterReadingsfunction returns a new filtered vector rather than mutating the original, allowing us to pass the original by const reference:std::vector<double> filterReadings(const std::vector<double>& readings, double threshold).double averageFiltered(const std::vector<double>& readings); - 5
Step 5 — Verify in main()
The finalmain()function creates a localstd::vector<double> readings;, populates it viaaddReading(readings, val), computesauto filtered = filterReadings(readings, sensor::kThreshold), and obtainsconst double avg = averageFiltered(filtered). There are now zero global mutable variables. Every function's dependencies are visible in its signature. The code is trivially unit-testable: pass a known vector, assert the result.Zero global mutable state. All dependencies explicit. Fully unit-testable.
SECTION 7
Trade-offs — Benefits and Costs
Like most engineering practices, minimizing global state and applying const correctness involve trade-offs. While the benefits overwhelmingly favor disciplined use in production systems, it is important to understand the costs honestly to make informed design decisions.
| Aspect | Benefit | Cost / Limitation |
|---|---|---|
| Readability | Function signatures document exactly what data flows in and whether it is mutable — self-documenting code. | Functions may develop longer parameter lists when dependencies are injected explicitly instead of accessed globally. |
| Testability | Pure functions with no global dependencies can be tested in isolation with deterministic inputs and outputs. | Requires designing interfaces (abstract classes / templates) for dependency injection, adding some upfront design effort. |
| Thread Safety | Const data is inherently safe for concurrent reads. Eliminating shared mutable state removes data races by construction. | Shallow const on pointer members does not guarantee deep immutability; extra wrapper types may be needed. |
| Performance | constexpr enables compile-time computation. Optimizer can reason more aggressively about const references. | Passing objects through deep call stacks adds pointer indirections; in practice, the cost is negligible on modern hardware. |
| Codebase Migration | Gradual adoption is possible — start by adding const to leaf functions and working upward through the call graph. | Retrofitting const into a large legacy codebase can trigger cascading changes ('const poisoning'), requiring substantial refactoring. |
Benefits and costs of minimizing global state and enforcing const correctness
KEY TAKEAWAY
SECTION 8
Connection to Advanced Techniques
The principles of minimizing global state and const correctness are foundational stepping stones toward more advanced C++ and software engineering concepts. Understanding these connections helps situate today's lesson within the broader trajectory of a computer science education.
| This Lesson | Advanced Extension |
|---|---|
const qualifier on variables and parameters | constexpr and consteval for compile-time computation; template metaprogramming operates on types (which are inherently immutable) |
| Passing dependencies explicitly via parameters | Formal dependency injection frameworks (e.g., Boost.DI); the Dependency Inversion Principle (the 'D' in SOLID) |
| Shallow vs. deep const | std::experimental::propagate_const<T>; Rust's ownership model enforces deep immutability by default with &T references |
| Eliminating shared mutable state for thread safety | Lock-free programming; std::shared_mutex with reader/writer semantics; actor model (message passing replaces shared state) |
| Meyer's Singleton (function-local static) | Service locator pattern; compile-time dependency resolution via policy-based design (Alexandrescu's Modern C++ Design) |
Mapping today's concepts to advanced C++ and software engineering topics
As you progress to courses in concurrent systems, compiler construction, or formal verification, you will see these ideas recur in increasingly powerful forms. Languages like Rust have made immutability the default — variables are immutable unless explicitly declared mut. Functional programming languages like Haskell enforce referential transparency at the language level. In C++, these guarantees are opt-in via const, which places the responsibility squarely on the programmer — making the discipline of applying const by default and removing it only when mutation is genuinely needed all the more important.
SECTION 9
Practice Problems
Question 1: CONCEPTUAL
constexpr variable at namespace scope is fundamentally different from a mutable global variable in terms of thread safety and initialization ordering. Under what circumstances, if any, could a constexpr global still pose a design problem?Show answer
constexpr variable is evaluated at compile time and its value is baked into the binary, so there is no runtime initialization to order and no mutable state to race on. Multiple threads can read it concurrently without synchronization. In contrast, a mutable global requires runtime initialization (which may depend on other globals in different translation units — the static initialization order fiasco) and concurrent writes require explicit synchronization. The only design concern with constexpr globals is excessive coupling: if many translation units include a header defining constexpr auto kBufferSize = 1024;, changing that value forces recompilation of every dependent unit. This is a build-system concern, not a correctness concern, and can be mitigated by isolating constants in dedicated configuration headers.Question 2: BASIC CALCULATION
void process(std::vector<int>& data, const Config& cfg, Logger* log), identify which parameters can be mutated by process and which cannot. Then rewrite the signature to also make the Logger a non-owning, read-only dependency, assuming logging has const-correct methods.Show answer
data is passed by non-const reference, so process can mutate it. cfg is passed by const reference, so it cannot be mutated. log is passed as a non-const raw pointer: process can both call non-const methods on it and potentially reassign the pointer locally. If Logger provides const-correct logging methods (e.g., void log(std::string_view msg) const), we can rewrite the signature as: void process(std::vector<int>& data, const Config& cfg, const Logger& log). This makes the Logger non-owning (reference), read-only (const), and non-nullable (reference instead of pointer).Question 3: INTERMEDIATE
Cache with a method std::string lookup(const std::string& key) that internally updates a std::unordered_map<std::string, std::string> to store recently accessed entries. They want to call lookup on a const Cache&, but the compiler rejects it. Explain why, and propose two different solutions.Show answer
lookup is not declared const, and non-const member functions cannot be called on a const reference. The underlying issue is that lookup mutates the cache's internal map, violating bitwise constness. Solution 1: Declare the internal map as mutable std::unordered_map<std::string, std::string> cache_; and mark lookup as const. This is appropriate because the cache is an implementation detail that does not affect the logical state of the object — the same key always maps to the same value. Solution 2: Separate the interface into a const lookup(key) const that returns std::optional<std::string> (cache hit or miss without mutation) and a non-const insert(key, value) method. This avoids mutable at the cost of the caller managing cache population.Question 4: APPLIED
ConnectionPool object is created in main() and shared among request-handler threads. Currently it is a global variable. Refactor the design so that: (a) the ConnectionPool is no longer global, (b) handler threads receive a reference to it, and (c) you maximize const correctness. Sketch the key type signatures and explain your thread-safety strategy.Show answer
ConnectionPool as a local variable in main(): ConnectionPool pool(config);. Pass it to the server initialization function: void runServer(ConnectionPool& pool, const Config& cfg). Each request handler receives the pool by reference: void handleRequest(ConnectionPool& pool, const Request& req). The pool is not const because acquiring and releasing connections mutates internal state. However, the Config is const because handlers should not modify server configuration. Thread safety: ConnectionPool's acquire() and release() methods internally lock a std::mutex protecting the internal connection list. The mutex can be declared mutable if you want a const method like size_t activeConnections() const. The key insight is that eliminating the global does not change thread-safety requirements — but it does make the shared dependency explicit in every function signature, making the codebase easier to audit.Question 5: CRITICAL THINKING
Show answer
const Logger& getLogger(). Making the returned reference const ensures that callers cannot mutate the singleton's configuration after initialization. Additionally, the singleton's internal state should be protected by a mutex (the mutable keyword on the mutex, if const methods need synchronization). This approach limits the damage while acknowledging engineering reality.SUMMARY
Lesson Summary
Summary
Global mutable state creates hidden dependencies, breaks referential transparency, introduces thread-safety hazards, and triggers the static initialization order fiasco. Minimizing global state involves preferring dependency injection and local objects with explicit lifetime over file-scope variables, while reserving namespace-scoped constexpr constants for truly immutable values and Meyer's singleton for the rare cases where program-wide infrastructure demands a single instance.
Const correctness leverages C++'s type system to enforce immutability contracts at compile time. The four key applications are: const variables, pointer-to-const vs. const-pointer, const member functions (which make this a const pointer), and const reference parameters that document read-only intent. The mutable keyword provides a disciplined escape hatch for caches and synchronization primitives. Together, these practices produce code that is self-documenting, testable, and inherently safer in concurrent environments.