Post

C++ Mistakes

Common mistakes in C++ programming.

C++ Mistakes

CppQuiz #31 — Most vexing parse

Initialization, function declaration ambiguity, temporary object, most vexing parse.

1
2
Y y(X());
y.f();

Mistake:

I thought Y y(X()); constructs an object y.

Correct:

It declares a function y returning Y, taking one parameter of type “function taking no arguments and returning X”.

Function parameter types are adjusted, so the parameter becomes pointer-to-function:

1
2
Y y(X());       // function declaration
Y y(X (*)());   // adjusted form

So y.f(); fails because y is a function, not an object.

Why:

In a parameter list, X() can mean: function taking no arguments and returning X

C++ allows function declarations inside a function body. The function cannot be defined locally, but it can be declared there and defined later at namespace scope.

Function parameter adjustment:

1
2
int f();      // f is a function returning int
int (*f)();   // f is a pointer to function returning int

These are different as normal declarations.
But as a function parameter, function type is adjusted to pointer-to-function type.

C++ also allows extra parentheses around declarators, so:

1
int i(int(x));

is a function declaration equivalent to:

1
int i(int x);

Choosing rule

When a statement is syntactically ambiguous between:

  • expression statement
  • declaration statement

C++ chooses the declaration.

If the whole statement cannot syntactically be a declaration, it is an expression.

1
2
3
4
5
6
7
T(a);        // declaration: T a;
T(c) = 7;    // declaration: T c = 7;
T(e)[5];     // declaration: T e[5];

T(a)++;        // expression
T(a)->m = 7;   // expression
T(a, 5) << c;  // expression

Inside a declaration, function declaration can beat object construction:

1
Y y(X());  // function declaration, not object construction

This is the classic most vexing parse.

Fix

Modern C++:

1
Y y{X{}};

Old-school:

1
Y y((X()));

Appendix: extra cursed examples

1
T(*g)(double(3));

This is a declaration, but not a function pointer declaration.

It is parsed like:

1
T* g(double(3));

So g is a pointer to T, initialized by double(3).

This may be semantically invalid, but parsing happens before semantic checking.

For nested function declarations:

1
T1 (*(*b)(T2(c)))(int(d));

Parse from the name outward:

  • b is a pointer to function - (*b)
    • taking parameter c of type T2 - (*b)(T2(c))
    • returning pointer to function - (*(*b)(T2(c)))
      • taking parameter d of type int - (*(*b)(T2(c)))(int(d))
      • returning T1 - T1 (*(*b)(T2(c)))(int(d))

Equivalent using aliases:

1
2
3
4
using Inner = T1 (*)(int);
using Outer = Inner (*)(T2);

Outer b;

But alone:

1
(*(*b)(T2(c)))(int(d));

this is an expression statement, because it does not start with a declaration specifier like T1.

Extreme standard example

1
2
3
T1(a) = 3,
T2(4),
(*(*b)(T2(c)))(int(d));

C++ first tries to parse this as a declaration starting with type T1:

1
2
3
T1 a = 3,
   T2(4),
   ...;

Here: T2(4) is treated as declaring a variable named T2 of type T1.

Then the final part fails, because it needs T2 to be a type name, but T2 has already been treated as a variable name.

C++ does not first ask “does this make sense?”
If it can be parsed as a declaration, it is parsed as a declaration, even if the declaration is later ill-formed.

read more standard wording

CppQuiz #??? — std::future::get() twice

std::future, std::promise, shared state, one-shot result, undefined behavior.

1
2
3
4
5
6
7
std::promise<int> p;
std::future<int> f = p.get_future();

p.set_value(1);

std::cout << f.get();
std::cout << f.get();

Mistake:

I thought f.get() can be called multiple times.

Correct:

The first call: f.get(); waits until the shared state is ready, retrieves the value, and releases the shared state.

So it prints: 1.

After that: f.valid() == false

Calling get() again on this std::future is undefined behavior.

Many implementations throw std::future_error, but the standard says UB.

Rule to remember

1
std::future<T>  // get once only

If I need to read the result multiple times, use:

1
std::shared_future<T>

Extra future / promise rules

future::get() is one-shot

1
2
auto x = f.get(); // OK
auto y = f.get(); // UB

get() consumes the future’s shared state.

After get():

1
f.valid() == false

Calling most member functions except destructor, move assignment, share(), and valid() on an invalid future is UB.

future::wait() does not consume

1
2
3
f.wait(); // OK
f.wait(); // OK
f.get();  // consumes

wait() only waits until the result is ready.

get() is the consuming operation.

shared_future::get() can be called multiple times

1
2
3
4
std::shared_future<int> sf = f.share();

sf.get(); // OK
sf.get(); // OK

Use shared_future when multiple reads are needed.

future::share() transfers the state

1
std::shared_future<int> sf = f.share();

After share():

1
f.valid() == false

The shared state moves from future into shared_future.

promise::get_future() is one-shot

1
2
auto f1 = p.get_future(); // OK
auto f2 = p.get_future(); // throws std::future_error

A promise can create only one future for its shared state.

Calling get_future() again throws std::future_error.

promise::set_value() is one-shot

1
2
p.set_value(1); // OK
p.set_value(2); // throws std::future_error

A shared state can store only one result.

The result is either value or exception not both, and not multiple times.

promise::set_exception() stores an exception

1
2
3
4
5
try {
    throw std::runtime_error("oops");
} catch (...) {
    p.set_exception(std::current_exception());
}

Then:

1
f.get();

throws that stored exception.

Destroying an unset promise gives broken promise

1
2
3
4
5
6
7
8
std::future<int> f;

{
    std::promise<int> p;
    f = p.get_future();
} // promise destroyed without set_value / set_exception

f.get(); // throws std::future_error

If the promise dies before setting a value or exception, the future receives broken_promise.

CppQuiz #235 — copying std::initializer_list

std::initializer_list, backing array, shallow copy.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class C {
public:
    C() = default;
    C(const C&) { std::cout << 1; }
};

void f(std::initializer_list<C> i) {}

int main() {
    C c;
    std::initializer_list<C> i{c};
    f(i);
    f(i);
}

Creating:

1
std::initializer_list<C> i{c};

creates a hidden temporary array of const C.

The element is copy-initialized from c, so C is copied once and prints 1.

But copying an initializer_list does not copy its elements.

So:

1
2
f(i);
f(i);

only copies the lightweight initializer_list object (with a pointer to the hidden array), not the underlying C.

CppQuiz #44 — object slicing vs reference polymorphism

Inheritance, virtual function, reference, object slicing.

1
2
3
4
5
6
7
X arr[1];
Y y1;

arr[0] = y1;

print(y1);
print(arr[0]);

Mistake:

I thought Y is always converted to X, so output is XX.

Correct output: YX

Why:

1
arr[0] = y1;

slices, because arr[0] is a real X object. Only the X part of y1 is copied; the Y part is lost.

So:

1
print(arr[0]); // X

But:

1
print(y1);

does not slice, because print takes a reference:

1
void print(const X& x)

A base reference binds to the original Y object without copying.

Inside print(y1):

  • static type: const X&
  • dynamic type: Y

Since f() is virtual:

1
print(y1); // Y

Rule

1
2
3
4
5
X x = y;        // slicing
arr[0] = y;     // slicing

const X& r = y; // no slicing
const X* p = &y; // no slicing

Base object copies slice.
Base references/pointers preserve dynamic type.

CppQuiz #4 — floating-point literal type

Overload resolution, literal type, float, double.

1
2
3
4
5
void f(float)  { std::cout << 1; }
void f(double) { std::cout << 2; }

f(2.5);
f(2.5f);

Mistake:

I forgot that 2.5 has type double, not float.

Correct output:

21

Rule to remember

1
2
3
2.5   // double
2.5f  // float
2.5L  // long double

No suffix = double by default.

CppQuiz #259 — char + char integral promotion

Integral promotion, usual arithmetic conversions, overload resolution.

1
2
3
4
5
6
7
8
void f(unsigned int) { std::cout << "u"; }
void f(int)          { std::cout << "i"; }
void f(char)         { std::cout << "c"; }

char x = 1;
char y = 2;

f(x + y);

x + y is not char. Before +, both chars go through integral promotion.

Usually: char -> int so most systems print: i

But the standard allows a rare case where: char -> unsigned int so this may also print: u

Both can be standard-conforming. But it will not print: c

Why

For arithmetic operators like +, C++ applies usual arithmetic conversions.

Small integer types are promoted before arithmetic:

1
2
3
4
5
6
char
signed char
unsigned char
short
unsigned short
bool

usually become int.

So: x + y is not computed as char + char. It is computed after promotion.

CppQuiz #250 — C-style ellipsis vs variadic template

Templates, overload resolution, variadic function, parameter pack.

1
2
3
4
5
6
7
8
template<typename T>
void foo(T...) { std::cout << 'A'; }

template<typename... T>
void foo(T...) { std::cout << 'B'; }

foo(1);
foo(1, 2);

Mistake:

I thought both calls choose the first overload, so output is: AA

Correct output: AB

Key difference

This one:

1
2
template<typename T>
void foo(T...)

is not a variadic template. It means roughly:

1
void foo(T, ...)

One real parameter of type T, then old C-style ellipsis.

But this one:

1
2
template<typename... T>
void foo(T...)

is a real variadic template. It has a parameter pack.

foo(1)

Both overloads are viable:

1
2
foo<int>(int, ...) // first
foo<int>(int)      // second

The ... consumes zero arguments here, so both match 1 exactly.

Since conversion ranking ties, C++ uses template partial ordering.

The first template has a fixed first parameter:

1
2
template<typename T>
void foo(T, ...)

The second is a pure pack:

1
2
template<typename... T>
void foo(T...)

A fixed first parameter is considered more specialized than a pure pack, so:

1
foo(1); // A

foo(1, 2)

First overload:

1
foo<int>(int, ...)

Second argument 2 has to go into C-style ellipsis.

Second overload:

1
foo<int, int>(int, int)

Both arguments match normally.

Normal parameter match beats ellipsis match, so it prints: B

Rule to remember

1
2
3
4
5
template<typename T>
void f(T...);       // one T + C-style ellipsis

template<typename... T>
void f(T...);       // variadic template parameter pack

C-style ellipsis is a weak fallback.

Parameter pack gives real typed parameters.

CppQuiz #??? — std::flat_map, std::min, dangling reference

std::flat_map, operator[], reference invalidation, unspecified argument order.

1
2
std::flat_map<int, int> map;
std::cout << std::min(map[1], map[2]);

Mistake:

I thought this was about the default-initialized values of map[1] and map[2], so it should print 0.

Correct:

The trap is reference invalidation.

std::min takes arguments by const reference:

1
const T& min(const T& a, const T& b);

So:

1
std::min(map[1], map[2])

binds references to the results of map[1] and map[2].

But the evaluation order of function arguments is unspecified.

If map[2] is evaluated first, it inserts key 2 and returns a reference to its value.

Then map[1] inserts key 1.

std::flat_map stores data in vectors by default, so insertion can move elements and invalidate references.

Since 1 < 2, inserting 1 before 2 invalidates the reference returned by map[2].

Then std::min compares using a dangling reference → undefined behavior.

std::map = node-based, references stable on insert
std::flat_map = vector-based, insert may invalidate references

Short note: std::flat_map in C++23

std::flat_map is like std::map, but stored in sorted contiguous containers instead of tree nodes.

Comparison:

ContainerStorageLookupInsert/eraseReference stability
std::maptree nodesO(log n)O(log n)stable on insert
std::flat_mapsorted vectorsO(log n)O(n)can be invalidated

Use std::flat_map when:

  • data is mostly built once, then queried many times
  • map is small/medium sized
  • iteration speed and cache locality matter
  • memory overhead matters
  • sorted order is needed

Avoid std::flat_map when:

  • many random insert/erase operations
  • you need stable references/iterators
  • you store huge objects that are expensive to move
  • you need node-based operations like extract/merge

std::map — insert/erase while iterating

std::map is node-based.

Insertion does not invalidate existing iterators/references:

1
2
3
for (auto it = mp.begin(); it != mp.end(); ++it) {
    mp.insert({new_key, new_val}); // OK
}

But newly inserted elements may or may not be visited later, depending on key order.

Erasing current element: use returned iterator.

1
2
3
4
5
6
7
for (auto it = mp.begin(); it != mp.end(); ) {
    if (bad(it->first)) {
        it = mp.erase(it); // OK, moves to next
    } else {
        ++it;
    }
}

CppQuiz #222 — std::variant default initialization

std::variant, default constructor, first alternative, index().

1
2
std::variant<int, double, char> v;
std::cout << v.index();

Mistake / missing rule:

I got it right, but forgot that default-constructing a variant chooses the first alternative.

Correct output: 0

Why:

For:

1
std::variant<int, double, char> v;

the alternatives are:

index 0 -> int
index 1 -> double
index 2 -> char

Default constructor makes v hold a value-initialized first alternative:

1
int{} // 0

So v.index() returns: 0

So v.index() == 0.

CppQuiz #295 — temporary lifetime until full-expression

Temporary object, destructor, c_str(), full-expression, dangling pointer.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream>

char a[2] = "0";

struct a_string {
    a_string() { *a='1'; }
    ~a_string() { *a='0'; }
    const char* c_str() const { return a; }
};

void print(const char* s) { std::cout << s; }
a_string make_string() { return a_string{}; }

int main() {
    a_string s1 = make_string();
    print(s1.c_str());

    const char* s2 = make_string().c_str();
    print(s2);

    print(make_string().c_str());
}

Mistake:

At first I missed the temporary and guessed 111.

Then I thought the temporary would be destroyed immediately after c_str() was used, so I guessed 100.

Correct output: 101

Why:

1
2
a_string s1 = make_string();
print(s1.c_str());

s1 is a real local object. It lives until the end of main, so this prints 1.


1
const char* s2 = make_string().c_str();

The temporary a_string lives until the end of this full-expression, meaning until the semicolon.

Then it is destroyed, setting a back to 0.

So:

1
print(s2); // 0

In real code, this is like storing std::string(...).c_str() → dangling pointer.

1
print(make_string().c_str());

Here the temporary is a function argument.

It lives until the whole function call finishes, not just until c_str() returns.

So during print(...), the temporary is still alive, and this prints 1.

Rule

Temporaries are destroyed at the end of the full-expression.

1
2
3
4
5
const char* p = make_string().c_str();
// temporary dies here, at semicolon

print(make_string().c_str());
// temporary dies after print returns

semicolon ends temporary lifetime
function argument temporary survives until function call ends

CppQuiz #226 — mutable, copy ctor, no implicit move

mutable, copy constructor, move constructor, overload resolution, destructor count.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct X {
    X() { std::cout << "1"; }
    X(X &) { std::cout << "2"; }
    X(const X &) { std::cout << "3"; }
    X(X &&) { std::cout << "4"; }
    ~X() { std::cout << "5"; }
};

struct Y {
    mutable X x;
    Y() = default;
    Y(const Y &) = default;
};

Y y1;
Y y2 = std::move(y1);

Mistake:

I forgot three things:

  1. mutable makes x non-const even through const Y&.
  2. Declaring Y(const Y&) means Y(Y&&) is not implicitly declared.
  3. Copying x creates another X, so two X objects are destroyed.

Correct output: 1255

Why:

1
Y y1;

default-constructs y1.x, so prints: 1.

Then:

1
Y y2 = std::move(y1);

does not move.

std::move(y1) makes y1 an rvalue, but Y has no move constructor.

So it calls:

1
Y(const Y&)

During memberwise copy, it copies:

1
x

The parameter is const Y&, but x is mutable, so other.x is treated as non-const.

Therefore this overload wins:

1
X(X&) // prints 2

not:

1
X(const X&) // prints 3

At the end of main, both objects are destroyed:

  • y2.x destroyed -> 5
  • y1.x destroyed -> 5

So total: 1255

Rule

1
mutable X x;

means x can be accessed as non-const even inside a const Y&.

1
Y(const Y&) = default;

suppresses implicit move constructor generation.

So:

1
std::move(y1)

does not guarantee moving. It only casts to rvalue. If no move constructor exists, copy constructor can still be called.

CppQuiz #125 — static local variable in function template

Function template specialization, static local variable.

1
2
3
4
5
6
7
8
9
template <class T>
void f(T) {
  static int i = 0;
  std::cout << ++i;
}

f(1);
f(1.0);
f(1);

Mistake:

I thought there is only one static int i, so output is 123.

Correct output: 112

Why:

Each template specialization gets its own static local variable.

So C++ creates:

1
2
f<int>     // has its own static i
f<double>  // has its own static i

Step by step:

f(1)   -> f<int>    -> int i becomes 1
f(1.0) -> f<double> -> double i becomes 1
f(1)   -> f<int>    -> int i becomes 2

Each static local variable is unique to its function template specialization.

CppQuiz #148 — volatile read is a side effect

volatile, unsequenced evaluation, side effect, undefined behavior.

1
2
3
4
5
volatile int a;

int main() {
  std::cout << (a + a);
}

Mistake:

I thought a is zero-initialized, so a + a prints 0.

Correct:

The program has undefined behavior.

Why:

a has static storage duration, so yes:

1
a == 0

But the real issue is volatile.

Reading a volatile object is a side effect.

So in:

1
a + a

there are two volatile reads of the same object.

For +, the left and right operands are unsequenced relative to each other.

So we have two unsequenced side effects on the same object → UB.

Rule

1
2
volatile int a;
a + a; // UB

because both reads are volatile side effects and are unsequenced.

Safer version

1
2
3
int x = a;
int y = a;
std::cout << x + y;

Now the volatile reads happen in separate full-expressions / statements, so they are sequenced.

Mental model

normal int read     = just value computation
volatile int read   = side effect

volatile means “the read/write itself matters”, usually for memory-mapped I/O, signals, hardware-ish stuff.

It is not a thread-safety tool. Use atomics for that.

CppQuiz #160 — virtual function + default argument

Virtual dispatch, default arguments, static type vs dynamic type.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct A {
    virtual void foo(int a = 1) {
        std::cout << "A" << a;
    }
};

struct B : A {
    void foo(int a = 2) override {
        std::cout << "B" << a;
    }
};

A* b = new B;
b->foo();

Mistake:

I knew virtual dispatch calls B::foo, but I thought the default argument also comes from B.

Correct output: B1

Why:

1
A* b = new B;

Inside:

  • static type: A*
  • dynamic type: B

The function body is chosen by dynamic type, because foo is virtual: B::foo(...)

But the default argument is chosen by static type: A* b

So the default argument comes from:

1
A::foo(int a = 1)

Therefore, b->foo(); means:

1
b->foo(1); // calls B::foo with a = 1

Rule

  • virtual function body -> dynamic type
  • default argument -> static type

So avoid different default arguments in overridden virtual functions. It’s cursed.

CppQuiz #3 — floating to signed/unsigned overload ambiguity

Overload resolution, floating-integral conversion, ambiguity.

1
2
3
4
void f(int)      { std::cout << 1; }
void f(unsigned) { std::cout << 2; }

f(-2.5);

Mistake:

I thought double -> unsigned is not accepted, so it must call:

1
f(int)

Correct:

The program is ill-formed because the overload call is ambiguous.

Why:

-2.5 has type:

1
double

Both overloads are viable:

1
2
double -> int
double -> unsigned

Both are floating-integral conversions.

They have the same conversion rank, so neither overload is better.

Therefore:

1
f(-2.5); // ambiguous

CppQuiz #284 — std::string::operator[] at size()

std::string, null terminator, operator[], bounds.

1
2
std::string out{"Hello world"};
std::cout << (out[out.size()] == '\0');

Mistake:

I might think:

1
out[out.size()]

is out of bounds / UB.

Correct output: 1

Why:

For std::string, operator[] is valid when:

1
pos <= size()

If:

1
pos == size()

it returns a reference to the null character:

1
char{} // value 0

And:

1
'\0'

also has value 0.

So:

1
out[out.size()] == '\0'

is true.

Rule

1
2
s[i]        // valid for i < s.size()
s[s.size()] // valid, gives '\0'

But do not modify it to anything except '\0'.

1
s[s.size()] = 'x'; // UB

CppQuiz #122 — unsigned ll typedef trap

Typedef, declaration parsing, defining type specifier.

1
2
3
4
5
6
typedef long long ll;

void foo(unsigned ll) { std::cout << "1"; }
void foo(unsigned long long) { std::cout << "2"; }

foo(2ull);

Mistake:

I thought unsigned ll means unsigned long long.

Correct output: 2

Exact rule:

While parsing a decl-specifier-seq, if a type-name appears, it is treated as a type-name only if there was no previous defining type specifier in that same specifier sequence, except cv-qualifiers.

Here:

1
unsigned ll

unsigned is already a defining type specifier and means unsigned int.

So ll is no longer parsed as the typedef name. It is parsed as the parameter name.

Therefore:

1
void foo(unsigned ll)

means:

1
void foo(unsigned int ll)

not:

1
void foo(unsigned long long)

So the overloads are:

1
2
foo(unsigned int)        // prints 1
foo(unsigned long long)  // prints 2

2ull has type unsigned long long, so it calls the second overload.

Rule to remember

1
2
3
typedef long long ll;

unsigned ll; // unsigned int variable named ll

Typedef aliases are not macros. unsigned does not modify the alias.

This post is licensed under CC BY 4.0 by the author.