Published on 2025-12-27
Discussions: /r/programming.
Years ago, I maintained a big C++ codebase at my day job. This product was the bread winner for the company and offered a public HTTP API for online payments. We are talking billions of euros of processed payments a year.
I was not a seasoned C++ developer yet. I knew about undefined behavior of course, but it was an abstract concept, something only beginners fall into. Oh boy was I wrong.
Please note that I am not and never was a C++ expert, and it's been a few years since I have been writing C++ for a living, so hopefully I got the wording and details right, but please tell me if I did not.
In this article I always say 'struct' when I mean 'struct or class'.
So, one day I receive a bug report. There is this HTTP endpoint that returns a simple response to inform the client that the operation either succeeded or had an error:
{
"error": false,
"succeeded": true,
}
or
{
"error": true,
"succeeded": false,
}
The actual format was probably not JSON, it was probably form encoded, I cannot exactly remember, but that does not matter for this bug.
This data model is not ideal but that's what the software did. Obviously, either error or succeeded is set but not both or neither (it's a XOR).
Anyway, the bug report says that the client received this reply:
{
"error": true,
"succeeded": true
}
Hmm ok. That should not be possible, it's a bug indeed.
I now look at the code. It's all in one big function, and it's doing lots of database operations, but the shape of the code is very simple:
struct Response {
bool error;
bool succeeded;
std::string data;
};
void handle() {
Response response;
try {
// [..] Lots of database operations *not* touching `response`.
response.succeeded = true;
} catch(...) {
response.error = true;
}
response.write();
}
Here is a godbolt link with roughly this code.
There's only one place that sets the succeeded field. And only one that sets the error field. No other place in the code touches these two fields.
So now I am flabbergasted. How is that possible that both fields are true? The code is straightforward. Each field is only set once and exclusively. It should be impossible to have both fields with the value true.
At this point, my C developer spider senses are tingling: is Response response; the culprit? It has to be, right? In C, that's clear undefined behavior to read fields from response: The C struct is not initialized.
But right after, I stumble upon official C++ examples that use this syntax. So now I am confused. C++ initialization rules are different from C, after all.
Cue a training montage with 80's music of me reading the C++ standard for hours. The short answer is: yes, the rules are different (enough to fill a book, and also they vary by C++ version) and in some conditions, Response response; is perfectly fine. In some other cases, this is undefined behavior.
In a nutshell: The default initialization rule applies when a variable is declared without an initializer. It's quite complex but I'll try to simplify it here.
Default initialization occurs under certain circumstances when using the syntax T object; :
T is a non struct, non array type, e.g. int a;, no initialization is performed at all. This is obvious undefined behavior.T is an array, e.g. std::string a[10];, this is fine: each element is default-initialized. But note that some types do not have default initialization, such as int: int a[10] would leave each element uninitialized.T is a POD (Plain Old Data, pre C++11. The wording in the standard changed with C++11 but the idea remains under the term Trivially Default Constructible) struct, e.g. Foo foo; no initialization is performed at all. This is akin to doing int a; and then reading a. This is obvious undefined behavior.T is a non-POD struct, e.g. Bar bar; the default constructor is called, and it is responsible for initializing all fields. It is easy to miss one, or even forget to implement a default constructor entirely, leading to undefined behavior.It's important to distinguish the first and last case: in the first case, no call to the default constructor is emitted by the compiler. In the last case, the default constructor is called. If no default constructor is declared in the struct, the compiler generates one for us, and calls it. This can be confirmed by inspecting the generated assembly.
With this bug, we are in the last case: the Response type is a non-POD struct (due to the std::string data field), so the default constructor is called. Response does not implement a default constructor. This means that the compiler generates a default constructor for us, and in this generated code, each struct field is default initialized. So, the std::string constructor is called for the data field and all is well. Except, the other two fields are not initialized in any way. Oops.
Here is a quick summary:
| Type | Example | Result (Default Init) |
|---|---|---|
| Primitive (int, bool, etc) | int x; | Indeterminate (Garbage value) |
| POD / Trivial Struct | Point p; | Indeterminate (All fields garbage) |
| Array of Objects | std::string x[10]; | Safe (All strings initialized) |
| Array of Primitives | int x[10]; | Indeterminate (All garbage) |
| Non-Trivial Struct | Response r; | Calls Default Constructor (Structs ok, primitives garbage) |
| Any Type (Braces) | T obj{}; | Value Initialized (Safe / Zeroed) |
Thus, the only way to fix the struct without having to fix all call sites is to implement a default constructor that properly initializes every field:
struct Response {
bool error;
bool succeeded;
std::string data;
Response(): error{false}, succeeded{false}, data{}
{
}
};
Here is a godbolt link with this code.
Of course, due to the rule of 6 (when I started to learn C++ it was 3), we now have to implement the default destructor, the default move constructor etc etc etc.
Alternatively, we can define default values for the fields in the struct definition and avoid defining a default constructor:
struct Response {
bool error = false;
bool succeeded = false;
std::string data;
}
This way, the default constructor generated by the compiler will initialize all the fields.
My fix at the time was to simply change the call site to:
Response response{};
Here is a godbolt link with this code.
That forces zero initialization of the error and succeeded fields as well as default initialization of the data field. And no need to change the struct definition.
This was my recommendation to my teammates at the time: do not tempt the devil, just always zero initialize when declaring a variable.
It is important to note that in some cases, the declaration syntax Response response; is perfectly correct, provided that:
Then, the default constructor of the struct is invoked, which invokes the default constructor of each field.
For example:
struct Bar {
std::string s;
std::vector<std::string> vec;
};
int main() {
Bar bar;
// Prints: s=`` v.len=0
// No undefined behavior.
printf("s=%s v.len=%zu\n", bar.s.c_str(), bar.vec.size());
}
But to know that, you need to inspect each field (recursively) of the struct, or assume that every default constructor initializes each field.
Finally, it's also worth noting that it is only undefined behavior to read an uninitialized value. Simply having uninitialized fields is not undefined behavior. If the fields are never read, or written to with a known value, before being read, there is no undefined behavior.
The compiler (clang) does not catch this issue even with all warnings enabled. This is frustrating because the compiler happily generates, and calls, a default constructor that does not initialize all the fields. So, the caller is expected to set all the uninitialized fields to some value manually? This is nonsense to me.
clang-tidy catches the issue. However at the time it was imperfect, quoting my notes from back then:
clang-tidyreports this issue when trying to pass such a variable as argument to a function, but that's all. We want to detect all problematic locations, even when the variable is not passed to a function. Also,clang-tidyonly reports one location and exits.
But now, it seems it has improved, and reports all problematic locations, and not only in function calls, which is great.
I also wrote in my notes at the time that cppcheck 'spots this without issues', but when I try it today, it does not spot anything even with --enable=all. So, maybe it's a regression, or I am not using it correctly.
Most experienced C or C++ developers are probably screaming at their screen right now, thinking: just use Address Sanitizer (or ASan for short)!
Let's try it on the problematic code:
$ clang++ main.cpp -Weverything -std=c++11 -g -fsanitize=address,undefined -Wno-padded
$ ./a.out
a.out(46953,0x1f7f4a0c0) malloc: nano zone abandoned due to inability to reserve vm space.
main.cpp:21:41: runtime error: load of value 8, which is not a valid value for type 'bool'
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior main.cpp:21:41
error=0 success=1
Great, the undefined behavior is spotted! Even if the error message is not super clear. This is ASan's way of saying: "I expected a 0 or 1 for this boolean, but I found a random 8 in that memory slot."
We alternatively could also have used Valgrind to the same effect.
But: it means that we now need to have 100% test coverage to be certain that our code does not have undefined behavior. That's a big ask.
Also, in my testing, Address Sanitizer did not always report the issue. That's the nature of the tool: it is meant to be conservative and avoid false positives, to avoid alerting fatigue, but that means it won't catch all issues.
Additionally, these tools have a performance cost and can make the build process a bit more complex.
I wrote a libclang plugin at the time to catch other instances of this problem in the codebase at build time: https://github.com/gaultier/c/tree/master/libclang-plugin .
Amazingly, there was only one other case in the whole codebase, and it was a false positive because by chance, the caller set the uninitialized fields right after, like this:
Response response;
response.error = false;
response.success = true;
I have no idea if this libclang plugin still works today because I have heard that the libclang API often has breaking changes.
Remember all these rules we have just gone through? You want more? What if we added some sweet special cases to them?
Some types, when the value is not initialized, do not trigger undefined behavior, if they are used in certain ways:
std::byteunsigned charchar if the underlying representation is unsignedFor example, this code is perfectly valid and free of undefined behavior:
unsigned char c; // ācā has an indeterminate/erroneous value
unsigned char d = c; // no undefined/erroneous behavior,
// but ādā has an indeterminate/erroneous value
assert(c == d); // holds, but both integral promotions have
// undefined/erroneous behavior
And this runs perfectly fine under ASan. Clang throws some warnings but compiles fine, and this is valid (in terms of the C++ standard) code.
Now, if we use bool (for example) instead:
bool c;
bool d = c;
assert(c == d);
This is undefined behavior and immediately triggers ASan errors! Even if the code is the same in terms of type sizes and stack layout!
I do not know why the C++ standard felt the need to muddy the water even more, but they surely had a reason. Right?
Some quick research seems to indicate that these types are special cases to allow code to manipulate raw bytes like memcpy or buffer management without the compiler freaking out. Which...maybe makes sense?
In my opinion, this bug is C++ in a nutshell:
data field) makes the compiler generate completely different code at the call sites.In contrast I really, really like the 'POD' approach that many languages have taken, from C, to Go, to Rust: a struct is just plain data. Either the compiler forces you to set each field in the struct when creating it, or it does not force you, and in this case, it zero-initializes all unmentioned fields. This is so simple it is obviously correct (but let's not talk about uninitialized padding between fields in C :/ ).
In the end I am thankful for this bug, because it made me aware for the first time that undefined behavior is real and dangerous, for one simple reason: it makes your program behave completely differently than the code. By reading the code, you cannot predict the behavior of the program in any way. The code stopped being the source of truth. Impossible values appear in the program, as if a cosmic ray hit your machine and flipped some bits. And you can very easily, and invisibly, trigger undefined behavior.
We programmers are only humans, and we only internalize that something (data corruption, undefined behavior, data races, etc) is a big real issue when we have been bitten by it and it ruined our day.
Post-Scriptum: This is not a hit piece on C++: C++ paid my bills for 10 years. I have been able to take a mortgage and build a house thanks to C++. But it is also a deeply flawed language, and I would not start a new professional project in C++ today without a very good reason. If you like C++, all the power to you. I just want to raise awareness on this (perhaps) little-known rule in the language that might trip you up.
If you enjoy what you're reading, you want to support me, and can afford it: Support me. That allows me to write more cool articles!
This blog is open-source! If you find a problem, please open a Github issue. The content of this blog as well as the code snippets are under the BSD-3 License which I also usually use for all my personal projects. It's basically free for every use but you have to mention me as the original author.