C++ • Software Craft and Communication

Minimizing Global State & Const Correctness — Minimize global state and use const correctness

Taming mutable shared state and enforcing immutability contracts to build robust, maintainable C++ software.

Programming braces with a command terminal and binary digits.
Inside this lesson 10 sections 5 questions 5 figures

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

Dennis Ritchie's C language uses file-scope variables extensively. Without namespaces or access control, any translation unit can reach any external symbol, making large-scale programs fragile.

1979 • Bjarne Stroustrup Begins 'C with Classes'

Stroustrup introduces classes to C, enabling encapsulation. Data can now be hidden behind private interfaces, reducing reliance on global state. The idea of controlling access to data at compile time takes root.

1985 • C++ 1.0 and the const Keyword

The first commercial release of C++ includes the 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

The C++11 standard extends const correctness with 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

C++20 introduces 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.

1

Principle of Least Authority (POLA)

Every component should receive only the data and permissions it needs — no more. A function that only reads a vector should receive it by const&, never by non-const reference or pointer to a global.
2

Referential Transparency

A function exhibits referential transparency when its return value depends solely on its arguments. Global mutable state breaks this property because the same call can yield different results depending on hidden program state.
3

Const as a Contract

Marking a parameter, member function, or variable as 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.
4

Static Initialization Order Fiasco

Global objects in different translation units have an unspecified initialization order. If one global depends on another, your program may exhibit undefined behavior at startup — a notorious C++ pitfall.
5

Thread Safety by Design

Shared mutable state requires synchronization (mutexes, atomics). Const data and local state are inherently thread-safe, eliminating entire categories of concurrency bugs without runtime overhead.
✦

KEY TAKEAWAY

Think of global mutable state like a shared whiteboard in a busy office. Anyone can erase and rewrite it at any time, so you can never be sure the information you read is the information you expect. Const correctness is like laminating the whiteboard — people can still read it, but nobody can alter it without going through an explicit, auditable process. Minimizing global state means replacing that shared whiteboard with individual notepads handed directly to the people who need them.

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.

❌ Global State Architecture✅ Explicit Parameter Passingg_configg_dbg_loggerinitSystem()process()validate()report()Every function can read/write every globalmain()initSystem(cfg)process(cfg, db, log)validate(const cfg&)report(const log&)Each function receives only what it needsLegend:Ownership / creationconst reference (read-only)
Left: global variables 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

DeclarationWhat Is Const?Example
const int x = 42;The variable itself — cannot be reassignedx = 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 stateCan 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

The 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.

Strategies for Replacing Global State (Preference Order)1Dependency Injection via Constructor ParametersPass dependencies as const references or interfaces. The caller owns lifetime;the callee borrows read-only access. Fully testable with mock objects.2Local Objects with Explicit LifetimeCreate objects in main() or a top-level scope, then pass them down the callgraph. RAII ensures deterministic cleanup. No static-initialization surprises.3Namespace-Scoped Constants (constexpr / const)Immutable configuration or mathematical constants can live at namespace scopesafely. constexpr guarantees compile-time evaluation; no initialization-order risk.4Meyer's Singleton (Function-Local Static)Return a reference to a function-local static. Initialized on first use (C++11guarantees thread-safe init). Still global state — use only when injection is infeasible.5File-Scope Mutable Global (Last Resort)If unavoidable, restrict visibility with an unnamed namespace or static linkage.Protect with a mutex. Document heavily. Consider constinit (C++20) for safe init.↑ Most preferred (testable, explicit)Least preferred (implicit, fragile) ↓
Five strategies for replacing or controlling global state, ordered from most preferred (dependency injection) to last resort (file-scope mutable global). Each successive strategy trades explicitness for convenience.

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. 1

    Step 1 — Identify Global State

    The original code declares std::vector<double> g_readings; and double g_threshold = 5.0; at file scope. Three functions — addReading(double), filterReadings(), and averageFiltered() — all read and write these globals implicitly. Our audit reveals that g_threshold is never modified after startup, while g_readings is mutated by addReading and filterReadings.
    Identified: 2 globals, 3 functions with hidden dependencies
  2. 2

    Step 2 — Make the Immutable Global constexpr

    Since g_threshold never changes, we replace it with constexpr 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. 3

    Step 3 — Convert the Mutable Global to a Local with Explicit Passing

    We move the std::vector<double> into main() and pass it to functions that need it. addReading takes 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. 4

    Step 4 — Apply Const Correctness to Read-Only Consumers

    averageFiltered only reads the vector, so its signature becomes double averageFiltered(const std::vector<double>& readings). If we later accidentally add a push_back call inside this function, the compiler will reject it. The filterReadings function 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. 5

    Step 5 — Verify in main()

    The final main() function creates a local std::vector<double> readings;, populates it via addReading(readings, val), computes auto filtered = filterReadings(readings, sensor::kThreshold), and obtains const 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.

AspectBenefitCost / Limitation
ReadabilityFunction 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.
TestabilityPure 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 SafetyConst 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.
Performanceconstexpr 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 MigrationGradual 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

The phenomenon sometimes called 'const poisoning' — where adding const to one function forces changes throughout the call graph — is actually the type system revealing hidden mutation contracts that were always there. It is better to surface these contracts at compile time than to discover them as runtime bugs in production. Think of it like a structural engineer X-raying a building: the cracks were already there — the X-ray just made them visible.

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 LessonAdvanced Extension
const qualifier on variables and parametersconstexpr and consteval for compile-time computation; template metaprogramming operates on types (which are inherently immutable)
Passing dependencies explicitly via parametersFormal dependency injection frameworks (e.g., Boost.DI); the Dependency Inversion Principle (the 'D' in SOLID)
Shallow vs. deep conststd::experimental::propagate_const<T>; Rust's ownership model enforces deep immutability by default with &T references
Eliminating shared mutable state for thread safetyLock-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

Explain why a 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
A 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

Given the function signature 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

A colleague writes a class 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
The compiler rejects the call because 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

You are writing a multi-threaded server. A 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
Create the 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

A purist colleague argues that all globals should be eliminated, including Meyer's singletons. Another colleague argues that Meyer's singleton is fine because function-local statics have well-defined initialization semantics in C++11. Evaluate both positions. Under what conditions would you accept a Meyer's singleton over full dependency injection, and how would you apply const correctness to mitigate the remaining risks?
Show answer
Both positions have merit. The purist is correct that any global state — including Meyer's singletons — introduces hidden dependencies that complicate testing (you cannot easily substitute a mock), increase coupling (callers implicitly depend on a specific concrete type), and resist reasoning about program behavior. The pragmatist is correct that Meyer's singleton avoids the static initialization order fiasco (C++11 guarantees thread-safe, on-first-use initialization of function-local statics) and is sometimes the least disruptive way to manage truly program-wide infrastructure like a logging backend or a hardware abstraction layer. A reasonable middle ground: prefer dependency injection for all application logic, but accept a Meyer's singleton for cross-cutting infrastructure where (a) there is genuinely one instance per process, (b) it is impractical to thread the dependency through every call site, and (c) the returned reference is const wherever possible, e.g., 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.

Varsity Tutors • C++ • Minimizing Global State & Const Correctness