The Problems with Global Mutable State
Recognize the dangers of mutable global state and learn safer alternatives.
Why Non-Const Global Variables Are Evil
Ask an experienced C++ programmer for a single piece of advice and "avoid global variables" is a strong contender. The advice is nearly always about non-const globals specifically. A constexpr constant at namespace scope is fine, and this course uses them freely. A global that any code can write to is the problem.
The temptation is real, especially early on. Threading a value through five function calls is tedious, and a global skips all of it. What that convenience buys you is a program whose state can change behind your back.
Any Function Can Change Them
This is the whole objection in one program:
#include <iostream>
int g_masterVolume;
void applyPreset()
{
g_masterVolume = 90;
}
int main()
{
g_masterVolume = 20;
applyPreset();
if (g_masterVolume == 20)
std::cout << "Volume is where we left it\n";
else
std::cout << "Something moved the volume to " << g_masterVolume << '\n';
return 0;
}
Output:
Something moved the volume to 90
main sets the volume to 20 and never touches it again, yet the check fails. Unless you already knew that applyPreset writes to g_masterVolume, nothing at the call site suggests it. That is the danger in general form: every call becomes a call that might change your state, and the signature tells you nothing about which ones do.
Local variables have no such problem, since no other function can reach them.
What That Costs You
Debugging turns into a search. Suppose a branch that should run when g_masterVolume is 90 is not running, and you discover the value is 35. Where did 35 come from? Every assignment anywhere in the program is a suspect, including code you consider unrelated. A local variable confines that search to one function; a global expands it to the whole codebase.
Reading turns into a survey. Part of why locals are declared close to first use is that it limits how much code you must read to understand them. Globals are the opposite extreme. If g_masterVolume appears 500 times, understanding what values are legal and what the variable really means can mean reading all 500.
Modularity drops. A function that works only from its parameters and produces no side effects is trivially reusable and testable. A function that reads a global carries a hidden dependency that its signature does not mention, and it cannot be reused in a context where that global means something different.
The severity depends on what the global controls. A global holding something informational, like the current user's display name, is unlikely to break logic if it changes. A global used in conditionals is a decision point, and those are the ones that turn into real bugs.
Prefer local variables to global variables wherever you can.
The Initialization Order Problem
Globals have static duration, so they are initialized before main runs, in two phases.
Static initialization comes first. Globals with constexpr initializers get their values, which is called constant initialization, and globals with no initializer are zero-initialized. Zero counts as static initialization because 0 is itself a constant expression.
Dynamic initialization follows, handling globals whose initializers are not constant expressions, such as a value returned from a function call.
Within one file, each phase runs roughly in order of definition. That is enough to bite you if one global's initializer depends on another defined later:
#include <iostream>
int readChannelCount();
int readDeviceCount();
int g_channelCount{ readChannelCount() };
int g_deviceCount{ readDeviceCount() };
int readChannelCount()
{
return g_deviceCount;
}
int readDeviceCount()
{
return 8;
}
int main()
{
std::cout << g_channelCount << ' ' << g_deviceCount << '\n';
return 0;
}
Output:
0 8
g_channelCount is initialized first, and it reads g_deviceCount before that variable has run its own initializer, so it sees the zero left by static initialization rather than 8.
Across translation units it is worse, because the order is not defined at all. Given mixer.cpp and devices.cpp, either file's globals may go first. If a global in one is initialized from a global in the other, whether it works is a coin toss that can flip when you change compiler, linker, or build order.
That cross-file ambiguity is known as the static initialization order fiasco.
Never initialize an object with static duration using another object with static duration from a different translation unit. Dynamic initialization of globals carries the same class of ordering hazard and is best avoided.
When One Is Actually Justified
The cases are narrow. A non-const global is defensible when both of these hold:
- There is genuinely only ever one of the thing in the program
- It is used pervasively, so passing it everywhere would add noise rather than clarity
A log destination fits. So does a program-wide random number generator. std::cout and std::cin are themselves globals living in namespace std, which is a fair indication that the pattern has legitimate uses.
The trap is the first criterion. "There is only one right now" is not the same as "there can only ever be one". A single-player game needs one player object until the day someone asks for split-screen, and by then the assumption is spread across the whole codebase.
Three Ways to Limit the Damage
Put it in a namespace. A bare global at file scope invites collisions and reads like a local at the point of use:
#include <iostream>
constexpr int sampleRate{ 48000 };
int main()
{
std::cout << sampleRate << '\n';
return 0;
}
Wrapping it names the owner and removes the ambiguity:
#include <iostream>
namespace audio
{
constexpr int sampleRate{ 48000 };
}
int main()
{
std::cout << audio::sampleRate << '\n';
return 0;
}
Output:
48000
Failing that, prefix with g_ so a reader can at least tell what they are looking at.
Encapsulate it behind an access function. Rather than exposing the variable across files, give it internal linkage and publish a function:
mixer.cpp:
namespace audio
{
constexpr int bufferFrames{ 256 };
}
int currentBufferFrames()
{
return audio::bufferFrames;
}
main.cpp:
#include <iostream>
int currentBufferFrames();
int main()
{
std::cout << currentBufferFrames() << '\n';
return 0;
}
The function is a seam. Validation, range checks, or an entirely different backing implementation can appear later without touching a single call site.
Global
const and constexpr variables already have internal linkage, so bufferFrames needs no static.
Pass it as a parameter. A function that reaches out to a global works for exactly one value:
#include <iostream>
namespace audio
{
constexpr int sampleRate{ 48000 };
}
int framesForSeconds(int seconds)
{
return audio::sampleRate * seconds;
}
int main()
{
std::cout << framesForSeconds(2) << '\n';
return 0;
}
Taking it as a parameter makes the same function work for any value, and makes the dependency visible in the signature:
#include <iostream>
namespace audio
{
constexpr int sampleRate{ 48000 };
}
int framesForSeconds(int seconds, int sampleRate)
{
return sampleRate * seconds;
}
int main()
{
std::cout << framesForSeconds(2, audio::sampleRate) << '\n';
std::cout << framesForSeconds(2, 44100) << '\n';
return 0;
}
Output:
96000
88200
Summary
The core danger: a non-const global can be modified by any function, so program state changes without anything at the call site indicating it might.
Debugging cost: an unexpected value means searching every assignment in the program, not just one function.
Modularity cost: reading a global is a hidden dependency absent from the signature, which makes a function harder to reuse and to test.
Decision points: globals that feed conditionals are the dangerous ones; informational globals are comparatively harmless.
Initialization: static initialization (constant and zero initialization) runs before dynamic initialization. Within a file order follows definitions, and across translation units the order is unspecified, which is the static initialization order fiasco.
Acceptable uses: only when there is truly one of the thing and its use is pervasive, such as a log or a program-wide random number generator.
Damage control: put globals in a namespace or prefix them with g_, hide them behind access functions, and pass values into functions as parameters rather than reading globals inside function bodies.
How did you find this lesson?
Your rating helps us improve the content.
Create an account to track your progress and access interactive exercises. Already have one? Sign in.
The Problems with Global Mutable State - Quiz
Test your understanding of the lesson.
Practice Exercises
Refactoring Global Variables
Refactor code that uses problematic global variables into safer alternatives using function parameters.
Lesson Discussion
Share your thoughts and questions
No comments yet. Be the first to share your thoughts!