In this article, the author looks at why C++ feels so complicated, and why its flaws are inseparable from its long-term success.
We published and translated this article with the copyright holder’s permission. The author is Sergey Kushnirenko
There's a video on YouTube—a little over fifty minutes long, with a rather bold title: "The worst programming language of all time." I wouldn't be surprised if you assumed it was about C++. You'd be right. I first watched it about six months ago—or rather, skimmed it at 2x speed with plenty of skipping. I figured it was just another rant from a frustrated junior developer. Recently, though, a fellow community member brought it up again, so this time I watched the whole thing. And you know what the worst part is? If you focus purely on the substance and set aside the tone of an offended junior dev, about 70% of the arguments turn out to be valid. Not debatable, not "it depends on the context." Just plain facts that almost any developer who's spent a couple of years with C++ would confirm without hesitation.
The paradox is that the video takes aim at the language behind half the software around us. That includes the browser you're reading this in, the engine that powered the game you played yesterday, the firmware running on the hardware underneath, and even the compiler that built all of it.
The "C++ is terrible" narrative has been a cliché forever. You've probably read dozens of articles about the initialization-order fiasco, the overloaded static, the poorly named vector, the fact that std::move doesn't actually move anything, the painfully slow regex, and the underwhelming performance of unordered_map. None of these complaints are new. What I'd like to point out is that every issue and example I cover below stems from the same fundamental design decision. If you can't wait, open the spoilers—they explain the history behind each part of the language and why it turned out the way it did.
There's more than one way to initialize a variable
Let's start with the deepest circle of hell—the beginning of every program: creating a variable. In most languages, that's a single, straightforward operation. In C++, however, people write entire books about it. They publish research papers, give conference talks, and dissect every corner case as if the language didn't have enough other problems already. Did I miss anything? Please let me know.
int f; // automatic storage, garbage
static int f_static; // but the namespace/static scope already has zero
// (zero-init in the beginning)
std::string s; // a call to the default constructor
int* p = new int; // a heap, garbage
int e{}; // 0
int e2 = {}; // 0
int* p = new int{}; // 0
int* q = new int(); // it's 0 too
auto t = T{}; // temporary, value-init
int b(5);
std::string s("hello");
T obj(a1, a2);
auto p = new T(a1, a2);
int x = static_cast<int>(3.5); // a cast, it's also a direct-init
int y(int(5)); // functional notation
int a = 5;
std::string s = "hello";
T obj = other;
f(arg); // pass by value; the function's
// PARAMETER is initialized
// an invisible variable
f({1, 2, 3}); // same
return x; // return by value
return {1, 2, 3}; // same, if copy-elision doesn't work
throw x; // an exception object is initialized
int c{5}; // direct-list
int d = {5}; // copy-list
std::vector<int> v{1, 2, 3};
std::vector<int> v2 = {1, 2, 3};
std::vector<int> a(10); // 10 elements, all zeros
std::vector<int> b{10}; // ONE element has a value of 10
struct Point { int x, y; };
Point p{1, 2}; // you can do it this way
Point p2 = {1, 2}; // or you can do it that way
int arr[3] = {1, 2, 3}; // with or without zeros
int arr2[3] = {}; // all zeros
Point p3{.x = 1, .y = 2}; // designated initializers, C++20
Point p4{1}; // x=1, y=0 (value-init tail)
int& r = x;
const int& cr = 5; // linked to temporary + extending lifetime
int&& rr = 10;
constexpr int n = 42; // it must be constant-init
const int m = foo(); // it can be either constant or dynamic
constinit int g = bar(); // C++20: guarantees static-init,
// but DOESN'T make it const
static int s = compute(); // dynamic-init, with its Static Init Order Fiasco
struct S {
int x = 5; // default member initializer
int y{10};
S() : y{2}, x(1) {} // member initializer list
// (in the order of declaration, not by list)
};
auto [a, b] = std::pair{1, 2}; // structured bindings, C++17
for (int v : arr) { } // range-based for initializes v too
If we count the distinct initialization semantics, we end up with around ten different forms (= v, (v), {v}, = {v}, {}, = {}, () in new, and so on). These map to nine different semantic categories, and the relationship between syntax and semantics is far from one-to-one. Depending on the right type, the same {} syntax can mean value-init, list-init, or aggregate-init. There's even a dedicated 300-page book on C++ initialization—and no, this isn't a joke. A friend once recommended it to me. It really exists, and it really is 300 pages long. Enjoy.
Every initialization form behaves just a little differently. My favorite is the difference between e and f, where the presence or absence of a pair of braces determines whether your variable holds zero or whatever bits the previous function call left on the stack.
struct S {
int x = 5; // default member initializer
int y{10};
};
NSDMI stands for Non-Static Data Member Initializer—an initializer for a non-static data member written directly in the class definition. When we write int x = 5;, no object exists yet, so nothing is initialized. What we have instead is a default member initializer, a fallback the compiler uses during object construction ONLY IF the constructor doesn't initialize that member in its mem-init-list. In other words, it's a recipe for default initialization, not an actual initialization. Technically, it's a brace-or-equal-initializer for a member.
auto [a, b] = std::pair{1, 2};
Here the compiler initializes a hidden unnamed object (commonly called the hidden object or the decomposition object—let's call it e). The names a and b aren't independent variables; they're bindings to parts of that unnamed object. The initialization applies to e, not to a or b. It's a subtle distinction, but since we're being precise, it's worth pointing out again that they aren't real variables so much as structure members, and that e isn't directly accessible. The storage, however, looks roughly like this:
e
+---------+
| first | <--- a
| second | <--- b
+---------+
auto e = std::pair{1, 2};
auto& a = e.first;
auto& b = e.second;
Asm
int k, e;
int main() {
auto [a, b] = std::pair{k, e};
return a + b;
}
----------------------------------------
leaq -12(%rbp), %rdi
leaq k(%rip), %rsi
leaq e(%rip), %rdx
callq std::pair<int, int>::pair<int&, int&, true>(int&, int&)
This is where a hidden structured binding object is created. The rdi argument is the first argument to the constructor—that is, the address of where pair is constructed.
main:
pushq %rbp
movq %rsp, %rbp
subq $32, %rsp
movl $0, -4(%rbp)
leaq -12(%rbp), %rdi <<<<<
leaq k(%rip), %rsi <<<<<
leaq e(%rip), %rdx <<<<<
callq std::pair<int, int>::pair<int&, int&, true>(int&, int&) <<<<<
leaq -12(%rbp), %rdi
callq tuple_element<0ul, std::pair<int, int>>::type &&
std::get<0ul, int, int>(std::pair<int, int>&&)
movq %rax, -24(%rbp)
leaq -12(%rbp), %rdi
callq tuple_element<1ul, std::pair<int, int>>::type &&
std::get<1ul, int, int>(std::pair<int, int>&&)
movq %rax, -32(%rbp)
movq -24(%rbp), %rax
movl (%rax), %eax
movq -32(%rbp), %rcx
addl (%rcx), %eax
addq $32, %rsp
popq %rbp
retq
k:
.long 0
e:
.long 0
And just when we think we've got it, we find out that braces can mean aggregate initialization in one context and std::initializer_list in another, and that the same line of code may yield a different type depending on which version of the standard you compile against. Not a different result—a different type. Welcome to C++.
auto i = 1; // always int
auto j = {1}; // initializer_list<int> is always (copy-list-init)
auto k{1}; // C++11: initializer_list<int>; C++17+: int
The origin of the zoo
The initialization zoo grew out of an attempt to eliminate... the initialization zoo! In 2011, the committee genuinely tried to unify everything under a single syntax. Instead, it added one more enclosure to the zoo.
From C, C++ inherited initialization for declarations (int a = 5;) and braces for aggregates such as arrays and structs (int arr[] = {1,2,3};). In C, braces simply distributed values across the members of a POD aggregate. They were never meant to do anything else.
Then C++ introduced constructors. Once they existed, they needed a way to run when an object was created. Parentheses felt like the most natural choice, since a constructor call looks a lot like a function call—hence Widget w(args);. Makes sense? Absolutely.
Right up until the day you write Widget w(); and realize you've declared a function returning Widget. That's the infamous most vexing parse. It isn't an implementation bug but a direct consequence of the grammar inherited from C, where declarations are designed to resemble usage. That design also makes a function declaration syntactically indistinguishable from constructing an object with empty parentheses.
Then came C++11 and its great invention: uniform initialization. The goal was admirable—introduce a single syntax, {}, that works everywhere, whether you're initializing aggregates, classes with constructors, scalars, or containers.
As a bonus, it would eliminate the most vexing parse, because Widget w{}; can never be parsed as a function. It would also reject narrowing conversions: int x{3.5}; is a compilation error, whereas int x = 3.5; silently truncates the fractional part.
In other words, {} wasn't meant to become yet another initialization syntax. It was supposed to be the initialization syntax—the one true way that would eventually replace all the others.
This is where we run into the issue that explains so much about C++. To make {} the only syntax, the language would have had to drop = and (). But those appear in billions of lines of code—engines, libraries, and third-party APIs all rely on them.
So the old syntax couldn't be removed, and the new universal syntax didn't replace the older ones; it simply found a niche of its own, and now we have even more options. The feature meant to shut down the zoo became its most famous exhibit.
To make things even more entertaining, braces later picked up a second meaning through std::initializer_list. If a type has a constructor taking an initializer list, braces select it, and it takes precedence in overload resolution.
The difference between int e{}; (zero) and int f; (garbage) is a separate story. That behavior comes from C, where automatic variables were never zeroed by default. This wasn't an oversight but a deliberate design choice: zero-initialization costs CPU cycles, and C's philosophy has always been "you don't pay for what you don't use." If you wanted zero, you were expected to write it yourself.
That's why int f; leaves behind bits from the previous function call's stack frame. It's by design—a feature dating back to C, around 1972. Later, Stroustrup wanted to offer a safe, zero-initialized default, but he couldn't make it the language default without breaking both backward compatibility and C's core philosophy of "don't pay for what you don't use." So the safe option exists, but it isn't the default, because the default was fixed half a century ago in the name of performance.
At this point, unifying everything under a single initialization syntax is impossible. Every form has accumulated its own code, semantics, and rules. So when yet another 300-page book on C++ initialization appears—or someone presents a flowchart that takes up half the screen—that isn't the authors showing off. It's the price of everything that's been put into the language over the past forty years, all because of its most defining feature.
Simple things are the hardest to do
A textbook example of needlessly complicated simplicity is generating a random number. In Java or Python, you'd just write something like random.randint(1, 100) and get on with your code. Not in C++, though. That would be far too simple.
std::random_device rd;
std::mt19937 gen(rd()); // what's mt19937?
std::uniform_int_distribution<int> dist(1, 100); // why is it separated?
int value = dist(gen); // finally
The code is readable enough, but I still found myself checking CppReference every other time I needed to. Then I had to Google what mt19937 actually is. Turns out it's the Mersenne Twister. Congratulations—you now know the name of a pseudorandom number generator. Apparently, that's knowledge you're expected to have just to roll a die in C++.
Simple things
Before C++11, the language relied on rand(), inherited from C. It returns a number in a range bounded by RAND_MAX—a value the standard guarantees to be at least 32,767. On some platforms, that makes it impossible to get a uniformly distributed number across a wide range.
The familiar rand() % 100 introduces modulo bias, because 100 doesn't evenly divide the number of possible outputs. Some values come up more often than others, and the resulting sequence isn't even reproducible across compilers. In other words, rand() was a wonderfully simple way to shoot yourself in the foot.
That's why C++11 adopted an entirely new design borrowed from Boost.Random—one that deliberately pulled apart everything rand() had lumped together. Now there's the engine itself, the source of raw random bits (mt19937, a.k.a. the Mersenne Twister, designed by Matsumoto and Nishimura in 1997). Then there's a distribution that turns those raw bits into uniformly distributed values over a given range without introducing bias. And finally there's a separate seed source.
This design came from people who needed reproducible Monte Carlo simulations for scientific computing, and for that use case it worked brilliantly. The problem is that they built a magnificent cathedral and never bothered to add a small side door for "roll a number between 1 and 100." To this day, the C++ standard library offers no one-line randint().
Even the "correct" incantation isn't actually correct, though. First, mt19937 carries nearly 20,000 bits of internal state, yet we usually seed it with a single 32-bit value from std::random_device. In other words, we're severely undersowing the generator.
Then there's std::random_device itself. The standard explicitly permits implementations to make it deterministic, and for years older MinGW versions returned the same sequence on every program start. So the long, supposedly correct spell is indeed long, and technically correct but it still doesn't work reliably everywhere. Meanwhile, rand() % 100 is shorter and actually works.
Casting problems
Casting is a whole different story. In Java, we just put the target type in parentheses and move on. In C++, we get an entire collection of casts, each built for a specific purpose: static_cast, dynamic_cast, reinterpret_cast, const_cast, bit_cast—every one with a full name you have to spell out each time. There's also the hidden rvalue_cast, but we'll get to that in a moment.
double d = 3.9;
int i = static_cast<int>(d); // 3, the decimal part is omitted
Base* b = new Derived;
auto* der = static_cast<Derived*>(b); // downcast WITHOUT checking
// you guarantee that it's Derived
Base* b = get_something();
if (Derived* d = dynamic_cast<Derived*>(b)) {
d->derived_only(); // you can get here only if it's Derived
} // otherwise d == nullptr
// an address as a number
int x = 42;
std::uintptr_t addr = reinterpret_cast<std::uintptr_t>(&x);
void legacy_api(char* s); // It didn't modify the line but missed const
const std::string str = "hello";
legacy_api(const_cast<char*>(str.c_str())); // it's OK,
// since the API isn't modified
float f = 1.0f;
auto bits = std::bit_cast<std::uint32_t>(f); // 0x3F800000, no UB
// how they did it before C++20:
std::uint32_t old;
std::memcpy(&old, &f, sizeof f); // the same thing but manually
int i = (int)d; // it compiles and looks familiar
Beginners often find this so frustrating that they end up creating a short alias. This is considered poor practice because the code is written in a personal C++ dialect that only the creator understands.
// the "I'm tired of writing static_cast" anti-pattern
template <class T, class U>
constexpr T sc(U&& u) { return static_cast<T>(std::forward<U>(u)); }
int i = sc<int>(3.9); // is it short? yep.
// is it correct? nope.
// it may be even worse:
#define CAST(T, x) static_cast<T>(x)
This is where the language really starts to mess with you. Correct C++ often looks incorrect, because the short, elegant solution is usually buggy or subtly flawed, while the correct one looks like the author went out of their way to make life harder for everyone on the team. Developing an intuition for what good C++ code looks like takes years. Until then, it feels like you're constantly doing something wrong. Spoiler: you probably are—that's just how the language works.
Why so chonky?
C has the (T)expr C-style cast, and it does everything. It can change values, reinterpret pointers, strip const, narrow types, and more—all with exactly the same syntax. The result is that nowhere in a codebase can you tell where someone cast away const, because those casts look identical to any other conversion.
C++ split this "Swiss Army knife" into four named operations—static_cast, dynamic_cast, const_cast, and reinterpret_cast—precisely because in C they were indistinguishable.
The point was to make the intent explicit and to ensure that const_cast, which undermines const, would catch a reviewer's eye. The idea was to make casts easy to grep, so a programmer could find and inspect every reinterpret_cast in a codebase.
Casts are treated as a necessary evil, so the language designers deliberately made them ugly. As Andrei Alexandrescu famously put it, a cast is a code smell. Stroustrup intentionally chose a verbose syntax so that explicit casts would be painful to type and would stand out immediately in code review. The old C-style cast was too lightweight, and that's exactly what made it so dangerous.
That's why the correct solution in C++ so often looks wrong—not by accident, but by design. The short, elegant version is usually broken legacy that can't be removed because of the language's most important feature: backward compatibility. The correct one is deliberately wordy and awkward. Its purpose is to make you stop and think about every explicit conversion you write.
Keywords that lie
In a sane world, a keyword describes what it does. In C++, that's more of an optional feature. Take static, for example.
How many meanings does it have again? First, it creates a variable that survives between function calls. Second, it makes a variable or member function belong to all instances of a class. Third, placing static before a function in a .cpp file makes that function invisible from the outside, so it behaves like private but is named static. Why not internal, private, or file_local? Well, that's just how things are, which answers about half of the questions in this article.
void counter() {
static int calls = 0; // it's initialized ONCE, on the first entry
int local = 0; // a normal local; each call is made anew
++calls;
++local;
std::cout << "static: " << calls << ", local: " << local << '\n';
}
The static keyword, which was introduced after C++11, now implies that static also means light mutex-guarded. Did you know that?
Logger& logger() {
static Logger instance; // a one line
return instance;
}
Logger& logger() {
// the QUICK way: if the least significant byte of guard != 0,
// it's already been initialized
if ((reinterpret_cast<volatile char&>(__guard_for_instance)) == 0) {
// the SLOW way: we only go this way the first time,
// and only when we're in sync
if (__cxa_guard_acquire(&__guard_for_instance)) {
// `acquire` returned 1,
// so this thread is responsible for initializing it
// the other threads are currently SLEEPING inside __cxa_guard_acquire
try {
::new(&__instance_storage) Logger(); //the call to the constructor
__cxa_guard_release(&__guard_for_instance); // we set the flag
// and wake the sleepers
__cxa_atexit(&destroy_logger, ...); // we register the destruction
} catch (...) {
__cxa_guard_abort(&__guard_for_instance);
throw;
}
}
// the threads that lost the race left the acquire block after release
}
return reinterpret_cast<Logger&>(__instance_storage);
}
Why static does four unrelated things
In C, static already had two distinct meanings. The first and more familiar one controls storage duration—the variable exists for the entire lifetime of the program.
The second controls internal linkage, meaning the name isn't visible outside the translation unit. Semantically, these two ideas have almost nothing in common: one is about object lifetime, the other about symbol visibility. Yet K&R folded them into a single keyword. Their reasoning was simple: C should have as few keywords as possible, and static was close enough to "statically allocated."
C++ inherited both meanings unchanged. Dropping either one would have meant breaking compatibility with C—exactly the outcome the language was trying to avoid. Later, when Stroustrup introduced class members shared by all instances, he could have added a new keyword such as shared or classwide, or something descriptive in the spirit of private and protected. But by then it was already clear that every new keyword added to the language was a potential landmine for developers.
By that point, someone somewhere had almost certainly named a variable shared, internal, or classwide. The moment one of those words became a keyword, their code would stop compiling. That's why the language designers tried to reuse existing keywords wherever possible instead of inventing new ones. And since static was already in the language, its meaning was already somewhat overloaded—one more interpretation didn't risk breaking anyone's production code. So static picked up a third meaning, not because it was the best semantic fit, but because the keyword was already there, and reusing it preserved backward compatibility.
A fourth behavior arrived with C++11, when initialization of function-local static variables was made thread-safe. To pull that off, compilers began emitting a hidden guard that ensures initialization happens exactly once.
There was one attempt to clean things up. In C++98, using file-scope static for internal linkage was officially discouraged in favor of unnamed namespaces. But C++11 walked that recommendation back, because breaking developers' long-standing habits proved more costly than changing the language. In other words, C++ couldn't get rid of a meaning it had already declared obsolete.
Realistically, none of the four meanings can be removed now. Each has code depending on it—a local counter, a private helper function, or a class member that's part of a public API used by hundreds of thousands of developers.
The inline keyword once prompted the compiler to inline a function. Today, compilers are smart enough to decide on their own, and inline has mostly become a tool for dealing with linkage and the One Definition Rule. To make things stranger, inline means almost the opposite depending on what you apply it to: for a function, it allows code duplication, while for a variable, it prohibits data duplication.
// math.h
inline int square(int x) { return x * x; }
// a.cpp
#include "math.h"
int use_a() { return square(3); }
// b.cpp
#include "math.h"
int use_b() { return square(4); }
// counter.h
inline int g_calls = 0; // there's only one instance for the entire program
// a.cpp
#include "counter.h"
void hit_a() { ++g_calls; }
// b.cpp
#include "counter.h"
void hit_b() { ++g_calls; }
// config.h
struct Config {
static inline int instances = 0; // there's one counter for all objects in .h
};
inline constexpr double kPi = 3.14159265358979; // its a header-only constant,
// a single object
What inline was designed for
In early C++, inline was a type-safe replacement for a #define macro. The idea was simple: instead of a function call, substitute the function body directly at the call site. It was literally an optimization based on inline expansion. That's where the name comes from, and that's why many developers still believe inline means "make this faster." That hasn't been true for at least the past fifteen years.
Today its primary purpose has little to do with optimization. Instead, inline is about the ODR and linkage. An inline entity may have definitions in multiple translation units—for example, when you put the definition in a header and include it everywhere. The linker then has to merge those definitions into a single entity rather than failing with a multiple definition error.
So what does inline have to do with actual inlining these days? Very little. As an optimization hint, it's purely advisory. Modern compilers routinely ignore it because they rely on sophisticated cost models. They'll happily inline functions that aren't marked inline and refuse to inline ones that are. With LTO enabled, they can even inline across translation-unit boundaries whether the keyword is there or not.
These days inline is only indirectly related to real inlining. Its actual job is to let you put a definition in a header without triggering linker errors—and the optimizer can keep the visible body in a separate TU, since LTO is impossible without it.
Member functions defined directly in a class body are implicitly inline. So are all constexpr functions. Yet developers still write the keyword just in case, even though it changes nothing.
Now we force inlining with __forceinline (MSVC) and __attribute__((always_inline)) or [[gnu::always_inline]]—and even those often fail in the presence of recursion, varargs, or taking a function's address. The name is a relic of a time when it really did work that way; now it's a message to the linker.
The const keyword is supposed to mean immutability, yet you can write it on either side of a type, and both forms mean exactly the same thing—just to keep you on your toes. It gets better: mutable const is sometimes valid code. You can also manually remove the constness. It's not ideal but technically possible.
The official advice for determining whether const in a pointer declaration applies to the pointer or to its value is to read it from right to left, but most people read from left to right.
const int a = 5; // "west const"
int const b = 5; // "east const" is exactly the same; both are constants
const int* p = &a; // the pointer to const int
int const* q = &a; // this is the same thing
struct Cache {
mutable int hits = 0; // can be changed even for a const object
int value = 0;
};
const Cache c;
c.hits++; // OK, mutable
// c.value++; // this is an error; a regular member of a const object
struct S {
mutable const int* p = nullptr; // VALID
};
struct Bad {
mutable const int x = 5; // INVALID: mutable can't be used
// on a const member
};
// Case 1: The object is ACTUALLY not const, OK
int x = 5;
const int& cref = x;
const_cast<int&>(cref) = 10; // correct: x indeed changed, x == 10
// Case 2: the object is ACTUALLY const, Undefined Behavior
const int y = 5;
const_cast<int&>(y) = 10; // it compiles, but it's UB
// y could stay at 5, drop, or anything else
int x = 0, y = 0;
const int* p1 = &x; // p1: pointer to const int
int* const p2 = &x; // p2: const pointer to int
const int* const p3 = &x; // p3: const pointer to const int
*p1 = 5; // error: const value
p1 = &y; // OK: the pointer can be redirected
*p2 = 5; // OK: the value can be changed
p2 = &y; // error: const pointer
*p3 = 5; // error
p3 = &y; // error
Why use const if memory isn't const?
The const keyword originated in C++ and was later adopted by C, not the other way around. Stroustrup introduced it back in "C with Classes"—initially under the working name readonly. From there it made its way into the C89 standard and took on a life of its own. Ironically, the keyword created to bring order immediately inherited the quirks of the grammar it was dropped into.
First, const int and int const are literally the same thing: const is a type qualifier living in the decl-specifier-seq, the sequence of declaration specifiers.
That sequence was unordered long before const came along, for the same reason unsigned long and long unsigned name the same type. So const inherited an old quirk: the order of specifiers already didn't matter. The more consistent form is actually int const, because const conceptually applies to whatever sits immediately to its left. The alternative, const int, exists largely as a grammatical concession, since early compilers already allowed it.
That's why East const exists. John Kalb popularized it, building on earlier work by Dan Saks, who wrote extensively about untangling C++ declarations. Nobody deliberately created two equivalent spellings. They fell out of the grammar because it was convenient and wouldn't break existing code.
const int* p; // pointer to const int; change the pointer, not the value
int* const p; // const pointer to int; change the value, not the pointer
The position of const relative to the asterisk matters: to the left of the asterisk, it becomes part of the specifier sequence and qualifies what the pointer points to.
To the right of the asterisk, it becomes part of the declarator and qualifies the pointer itself. This follows the original design principle that declarations should resemble the expressions that will later use the variable. That works nicely for int* p; for int (*f)(int), it's just a cruel joke. Adding const only makes an already complicated grammar harder to read.
And once again, there's no way to fix it. C++ inherited the parsing rules for const from C, and they're effectively set in stone. Changing them would break both C and four decades of code written in both languages. So const looks like a lock on a door, but it's really more like a "Please do not enter" sign with the text facing the door.
The zoo of integer types
How many integer types does C++ have? Around fifty, if you count the fixed-width aliases. And to make things more interesting, not all of them have a fixed size—the actual size depends on the compiler and the target platform.
bool // yes, `bool` is also an integer (integral) type
char // a SEPARATE type
signed char // a SEPARATE type
unsigned char // a SEPARATE type, there are three different types, not two
char8_t // C++20
char16_t // C++11
char32_t // C++11
wchar_t
short
unsigned short
int
unsigned int
long
unsigned long
long long // C++11
unsigned long long // C++11
short, short int, signed short, signed short int // 4 spellings → 1 type
int, signed, signed int // 3 spellings → 1 type
long, long int, signed long, signed long int // 4 → 1
long long, long long int, signed long long, signed long long int // 4 → 1
unsigned, unsigned int // 2 → 1
int8_t int16_t int32_t int64_t // exact width (optional)
uint8_t uint16_t uint32_t uint64_t // 8 of them
int_least8_t ... int_least64_t // minimum width
uint_least8_t ... uint_least64_t // 8 of them (required)
int_fast8_t ... int_fast64_t // fast width
uint_fast8_t ... uint_fast64_t // 8 of them
intmax_t uintmax_t // the widest one
intptr_t uintptr_t // under the pointer (optional)
std::size_t // <cstddef>
std::ptrdiff_t // <cstddef>
std::sig_atomic_t // <csignal>
std::wint_t // <cwchar>
std::streamsize, std::streamoff // <ios>
int doesn't mean 32 bits. It means at least 16 bits, though it may well be 32. The only guarantee the standard gives is the chain short <= int <= long <= long long. On 64-bit Linux, long is 64 bits. On 64-bit Windows, long is still just 32 bits—all because of backward compatibility (remember that phrase). Use int64_t if you need a guaranteed 64-bit integer. I still don't understand why it carries the _t suffix—it isn't the 1990s anymore. Well, that's one more thing that can't be changed; more on that in the spoiler.
Character types deserve a special mention. There are seven of them, and sooner or later you'll find yourself wondering why a character can be signed or unsigned like an integer, how char differs from signed char and unsigned char (three distinct types, not two), what wchar_t actually is, how std::string differs from std::wstring, and why that difference can suddenly wreak havoc on your encoding. But that's a story for another day.
A history of the language
The type zoo is essentially a paleontological record of every machine C has ever run on. To understand why int isn't 32 bits, it helps to recall the hardware C grew up on.
C emerged in the early '70s, when computer architectures varied wildly. The PDP-11 used 16-bit words, for instance, while the Honeywell systems C was ported to early on used 36-bit words. Some modes even worked with 6- or 9-bit characters. Historically, a C byte was never guaranteed to be 8 bits. In the C standard, CHAR_BIT is the number of bits in the smallest addressable unit of memory, so a machine with CHAR_BIT == 9 is perfectly legal by the language rules.
CDC machines used 60-bit words; some architectures used two's complement, others one's complement. Given all that, Ritchie made the only sensible decision available: don't fix sizes at all. Instead, int simply meant "the machine's natural word type—the one the processor handles most efficiently—but at least 16 bits."
The language guaranteed only minimum ranges and the ordering short <= int <= long <= long long. Actual sizes were left to the target platform. Thanks to that decision, the same source code could compile efficiently on a 16-bit PDP-11, a 36-bit Honeywell, and a dozen other architectures. The fact that int had no fixed size was exactly what made C so portable.
Then 32-bit machines became the norm and int settled at 32 bits. When 64-bit systems arrived, int stayed put. By then the world was already full of code with sizeof(int) == 4 hard-coded into it. Widening int to 64 bits would have broken all of it, to say nothing of countless ABIs.
Microsoft even kept long at 32 bits. Why? A massive existing codebase, an enormous product ecosystem, and millions of users. Or, to put it simply: backward compatibility.
Untold lines of code and the Win32 API itself treated long as a four-byte type, used it interchangeably with int and DWORD, and serialized it to disk and across the network as four bytes. Fixing that was deemed too expensive. So long means different things on different platforms simply because in the late 1990s two ecosystems made different compatibility trade-offs.
int64_t and its _t suffix are essentially an admission that the language failed to make its fundamental integer types predictable. Decades later, a second family of fixed-width types was added in a separate header, <stdint.h>. The _t suffix isn't a relic of the 1990s; it's a relic of the 1970s. It comes from the Unix convention for typedef names, size_t, time_t, wchar_t where names ending in _t are reserved for implementation use. That lets the standard introduce new types without risking collisions with user-defined identifiers.
Non-standard standard library
If my goal were to confuse beginners as much as possible, I'd name things exactly the way the STL does. The most commonly used container is called vector, even though it's really a dynamic array. In the usual sense, a vector is a quantity with a direction. Even Alexander Stepanov, the creator of the STL, later admitted the name was a mistake.
If you need a hash table, you might reach for std::map. But that isn't a hash table—it's a balanced tree with logarithmic lookup. The actual hash table is std::unordered_map. And, spoiler alert, you're better off avoiding that one too, because it's slow. This isn't just a case of a lazy implementation; it's baked into the standard. The guarantees std::unordered_map has to provide leave libstdc++ and libc++ developers little choice but to make it slow.
std::map<std::string, int> ordered;
ordered["banana"] = 1;
ordered["apple"] = 2;
ordered["cherry"] = 3;
for (auto& [k, v] : ordered)
std::cout << k << ' '; // ALWAYS: apple banana cherry,
// in ascending order by key
std::unordered_map<std::string, int> hashed;
hashed["banana"] = 1;
hashed["apple"] = 2;
hashed["cherry"] = 3;
for (auto& [k, v] : hashed)
std::cout << k << ' '; // arbitrary order;
// it depends on the hash and buckets
std::unordered_map<std::string, int> m;
m.insert({"key", 1});
m.insert({"key", 2}); // It DIDN'T overwrite it but returned {iterator, false}
std::cout << m["key"]; // 1, not 2
m.insert_or_assign("key", 2); // Starting with C++17, this will be overwritten
std::cout << m["key"]; // 2
m["key"] = 2; // or just for the sake of it
The standard mandates separate chaining with nodes, and unordered_map must guarantee that references and pointers to elements stay valid across insert and erase, the erased element aside. That means not even a rehash may invalidate a pointer to an existing element.
This is only possible if each element is allocated separately on the heap (std::pair<const Key, Value> plus a pointer to the next element) and each bucket is implemented as a linked list of such elements. In other words, what the standard describes isn't a "hash table in an array" but an "array of pointers to lists scattered across the heap." And that's not the only surprise lurking in the standard library. Here's another:
std::unordered_map<std::string, int> m;
if (m["key"] == 0) { } // if the key did NOT exist, it was just added
// with a default value of 0.
// The "if the key exists" check created it.
The operator[] silently inserts the default value for a missing key, so check whether it exists using find/contains, not []. It's a small detail, but beginners stumble over this pitfall all the time.
Then there's empty() that sounds like it should clear the container, but it simply tells you whether the container is empty. You'd expect it to be called is_empty(), but it's not. Meanwhile, remove() doesn't actually remove anything; it simply moves matching elements to the end and returns an iterator. We still need to erase them using the erase-remove idiom. There are also std::stoi, std::stol, std::stoll, std::stof, std::stod, and std::stold, and devs just need to know what they are. Do you know?
Is it really standard?
To understand why the standard library sometimes seems designed to work against developers, it helps to remember that it was created by a mathematician. Alexander Stepanov spent decades developing the concept of generic programming, built on the idea that algorithms should depend on abstract type requirements rather than concrete data structures.
Before C++, he explored the approach in Scheme, then in Ada with David Musser. He eventually moved to C++, where he found that templates—originally designed for an entirely different purpose—were powerful enough to express all of his ideas. In 1993–1994 he brought the result of that work to the language's committee, and in a remarkably rare feat, he got nearly all of it into C++98 virtually unchanged.
That's where vector comes from. In Scheme's computing model, a vector was a one-dimensional contiguous array, so the name made perfect sense to Stepanov. People later realized array would have been a better fit, but by then vector had already made its way into the draft standard and countless codebases. Renaming it was no longer an option—the same story we keep running into throughout C++.
map has a similar history. The name accurately describes the abstraction: a mapping from keys to values. The trouble is that many developers come from Java or Python, where map or dict normally implies a hash table, so they instinctively expect the same behavior. When C++11 finally introduced a real hash table, the obvious name, hash_map, was already off the table—incompatible vendor-specific implementations from SGI, Microsoft, Dinkumware, and others had claimed it years earlier. Rather than break that entire ecosystem, the committee settled on the only unclaimed, if rather awkward, name available: unordered_map. The ugly name isn't so much a design mistake as another scar left by C++'s commitment to backward compatibility.
Don't forget about ...value
And to cover all the bases, there are lvalue, rvalue, glvalue, prvalue, and xvalue. Ring any bells?
In some cases std::move makes a copy, and what you actually need is std::move_if_noexcept. While we're on the subject of move semantics, std::move has been badly named from the very start. It doesn't move anything at all! All it does is cast its argument to an rvalue reference, making it eligible to bind to a move constructor or a move assignment operator. It really should have been called rvalue_cast. Then again, come to think of it, C++ probably doesn't need yet another cast.
Not every lvalue is an xvalue
C had just two value categories: lvalues and rvalues. C++11 introduced move semantics, and two were no longer enough. The language had to distinguish between "a named object you shouldn't touch" and "a temporary whose value you may take." Those are really two independent properties, and their combinations yield five value categories. That's where xvalue—short for eXpiring value, an object that has identity but whose value you may safely move from... comes in.
To mark an object as an xvalue, the language needed a dedicated operation. rvalue_cast would have been the obvious name, but the committee didn't want to introduce yet another cast. Instead, Howard Hinnant and the other designers picked a name that reflects intent rather than mechanics. So whenever you write std::move, read it as "I'm done with this object—you can take its value now."
The standard library also has a second std::move—one that actually moves elements. So, don't confuse the std::move that moves things with the std::move that doesn't. Someone once even showed me a 70-page PDF explaining when and how to use each one correctly. Too bad I don't remember its title.
That's without even getting into names of C++ idioms. Do you know what RAII is? It stands for Resource Acquisition Is Initialization, but the name points to the least important part of the idiom. RAII is really about the opposite: automatically releasing a resource in the destructor when an object goes out of scope. If anything, it should have been called CADR (Constructor Acquires, Destructor Releases) because that's what the idiom is actually about, not the fact that a constructor acquires a resource.
void process() {
FileHandle file("data.txt"); // we opened it
if (nothing_to_do())
return; // early exit, the file is closed automatically
might_throw(); // an exception was thrown, he file is STILL closed
} // regular scope end, the destructor closed the file
Take CRTP as another example. Have you ever stopped to think about what the name actually means? CRTP stands for Curiously Recurring Template Pattern, and the name doesn't describe what the pattern does at all. Instead, it reflects the author's reaction: they kept running into this same template pattern in different codebases and found it curious enough to give it that name.
Modern C++
Have you heard that learning modern C++ is a must? However, a simple Google search for "what is modern C++" yields results from over two decades ago.
Modern C++ Design: Generic Programming and Design Patterns Applied
Modern C++: Efficient and Scalable Application Development
C++11 was the first release people called modern C++. It brought smart pointers, lambdas, and move semantics. Then came C++14, C++17, C++20, and C++23, each claiming to be the latest—and therefore the new "modern C++." The result is that even the bestselling Effective Modern C++ is dated and, in places, no longer effective. Meanwhile, much of the industry still lives in the C++17 era, because nobody is eager to rewrite millions of lines of production code just to make it "even more modern." The risk simply isn't worth it.
So developers have to be fluent in every major version of the standard—and trust me, they differ. The legacy code at your job targets one dialect, the new codebase another, the textbook a third, and the YouTube tutorial a fourth. And none of them count as "modern" anymore.
Errors that don't fit on the screen
When you finally get around to compiling something, you're greeted by the error messages. C++ can turn a single misplaced character into a thousand lines of nearly unreadable garbage, with the real cause buried somewhere in the middle. By the time you dig it out, your brain has already given up, because half the output is just the standard library's internals spilling across the screen.
Template instantiation errors read like an unreadable dialect of Chinese written entirely in angle brackets. They stretch so far horizontally that they won't fit on a 4K monitor. I'm not joking—I only came to appreciate ultrawide displays after staring at those errors. At some point I even turned off line wrapping, because it just made them harder to read. It's no wonder so many C++ developers end up with ultrawide monitors, glasses, and chronic neck pain.
Why errors are so wordy
A template isn't code. When you supply particular types, the compiler instantiates it by substituting those types throughout the definition. Only then does it check the resulting code for errors. It instantiates the entire template, replacing every occurrence with your type.
Now imagine the real problem lies deep inside an algorithm, because your type doesn't satisfy sort's requirements. By the time the compiler spots the error, it has already expanded ten layers of templates, and the only route back to the root cause is to dump the whole instantiation chain onto your screen. In a sense, this is duck typing at compile time. The template simply works if the operations inside it are valid. The downside is that duck typing brings the same style of error reporting: the problem doesn't surface where you made the mistake, but somewhere deep inside someone else's library, where the operation is actually used. The template has no idea what it's calling there—it isn't code yet; it only checks whether what it's handed fits.
For thirty years, templates had no way to express their requirements. You simply couldn't write "this template requires a comparable type," because under duck typing the requirements are always implicit.
The compiler had no way to say "you've violated requirement X," because no such requirement existed anywhere. The best it could do was drag you to line 4212 somewhere deep inside <algorithm> and point out that your type doesn't define operator<. And to convince you that's really where the trouble started, it had to dump the entire template instantiation stack on you.
Concepts... the feature that finally lets you name those requirements—trace their roots back to Alexander Stepanov's informal concepts from the late 1980s. They were originally slated for C++0x, but the committee pulled them from C++11 because the design was judged too complex. They finally landed in C++20, which is why we now write requires.
Zero-maybe abstractions
For the past twenty years, C++ has marketed itself as the language of zero-cost abstractions, but every abstraction has a cost. Take std::unique_ptr: it's slower than a raw pointer, because it has a non-trivial destructor, and under the Itanium ABI that means it can't be passed in a register. It can only go on the stack, whereas a raw pointer would land in a register.
And this isn't just about smart pointers. The same applies to any type with a non-trivial destructor: shared_ptr, string, vector—they all get passed by value through memory for the same reason. Move semantics aren't free either. The real cost comes from the destructor. Because C++ uses non-destructive moves, the moved-from object still has to run its destructor, and carefully written manual code can outperform the equivalent move-based implementation.
The regular expressions in the standard library are notorious as one of the worst implementations around. In some cases they run tens or even hundreds of times slower than the alternatives, and merely including <regex> can easily add about a second of compile time to every translation unit.
unordered_map isn't especially fast either: it's cache-unfriendly, and when performance really matters, developers often reach for flat_hash_map from Google's Abseil or F14 from Facebook's Folly instead. Spot the pattern? Plenty of C++ features could be substantially faster, but they probably never will be. Making them faster would break ABI compatibility—and that brings us to the main point.
Compatibility at a cost of everything else
All the complaints I've mentioned—from awkward names and slow containers to the static zoo and invisible copies—ultimately lead to the same conclusion. People think C++ puts performance above everything else, but in reality, its highest priority is backward compatibility. Performance comes second; developer ergonomics come second; the overall programming experience comes second. Everything comes second.
That commitment to compatibility is exactly what turned the language into the abomination it is today. You can't rename vector because it would break billions of code lines. You can't make unordered_map significantly faster because it would change the ABI. And you certainly can't introduce destructive move semantics—the opportunity passed more than a decade ago, and breaking existing code is simply off the table. Every wart in the language is a fossilized consequence of some long-forgotten design decision. You can't remove it because something already depends on it: someone's library, game engine, application, or entire build pipeline.
Ironically, the very thing people criticize C++ for is also the reason it powers such a large part of software over the world. Backward compatibility is both the language's greatest flaw and the key to its survival. Code you wrote twenty years ago will often still compile today—with a bit of tweaking and the occasional ritual sacrifice. Even an abandoned library from 2008 can link to a modern application. For industries like game development, where rewriting a codebase could cost dozens of person-years, this isn't a drawback—it's the load-bearing wall.
What about Rust, though?
People have been asking me in the comments to share my thoughts on Rust. Hopefully we can avoid turning this into another language war—it's simply a different language.
From a design standpoint, Rust is far better. It ships with a standard compiler, a standard build system, and a standard package manager, and there are no header files. The best compiler error messages I've ever seen, sensible defaults, no implicit conversions, proper UTF-8 support, sum types, and compile-time memory safety. Many of the problems C++ has yet to solve—and likely won't solve even in the next decade—have already been handled in Rust.
Being better designed doesn't automatically make it the right choice, though. Game development thrives on rapid iteration, creative chaos, and "let's see what happens if we try this." Rust doesn't really accommodate that style—and, to be fair, neither does modern C++, no matter how many features each new standard piles on. That's one reason game development has shifted more and more toward scripting languages, DSLs, and declarative programming.
At its core, Rust prioritizes correctness and discipline, and that creates a fundamental tension. A growing number of game projects have started in Rust only to move away from it for exactly that reason. Then there's the ecosystem. However you look at it, CUDA, game engines, tooling, and billions upon billions of lines of existing code all revolve around C++. And that isn't going to change anytime soon.
The worst/best programming language of all times?
C++ is a terrible language. It's verbose where it shouldn't be and silent where it shouldn't be. It comes with a zoo of types, misleading keywords, invisible copies, unreadable error messages, and a build system straight out of hell.
Writing good C++ takes an encyclopedic knowledge of the exceptions to the rules—and once you've memorized those, you still have to learn the exceptions to the exceptions. Yet C++ is very much alive, and it's likely to stay that way for decades. It has tremendous momentum, and it's one of the few languages that puts backward compatibility first, possibly at the expense of everything else. That single decision made C++ both unbearable and irreplaceable. Half the software world runs on it today, and it will keep running on it for a long time, whether I like it or not.
I write in this terrible language, romanticizing its complexity to prove to myself that I'm smart. And I'll keep writing in it, because in my field it's still the only language that delivers the performance I need. It's become a load-bearing wall. Replacing that wall in an occupied, constantly renovated building isn't as simple as redesigning a few rooms in some fashionable language. You'd have to tear the whole building down and rebuild it from scratch while living in a shed for the next couple of years.
So yes, C++ is a terrible language. Now fire up your terrible IDE and get back to coding in the worst programming language in the entire world!
Want to learn more?
The PVS-Studio team values the C++ community and doesn't miss an opportunity to talk more about how to improve workflows using static code analyzers. Here are some useful resources:
- How catch-block selection works in exception handling
- Silent foe or quiet ally: Brief guide to alignment in C++ (part 1, part 2, part 3)
- How far does lookup see in C++?
Top comments (0)