A Function Call You Never Wrote

The previous lesson established that a member function is an ordinary function with this in rdi. Constructors and destructors are member functions too. What makes them special is not how they compile but when they are called: the compiler inserts the calls for you, at points you never wrote a call in the source. This lesson is about learning to spot those inserted calls, because they are the machinery behind one of the most important ideas in C++: RAII.

Start with the smallest possible example, a class with a user-defined constructor and destructor:

class Logger
{
public:
    Logger();
    ~Logger();
};

void run()
{
    Logger log{};
}

The body of run contains no function calls, just a variable definition. At -O0:

run():
    push rbp
    mov  rbp, rsp
    sub  rsp, 16
    lea  rax, -1[rbp]
    mov  rdi, rax
    call Logger::Logger()
    lea  rax, -1[rbp]
    mov  rdi, rax
    call Logger::~Logger()
    leave
    ret

There they are: two calls you never wrote. The line Logger log{}; compiled into lea rax, -1[rbp] followed by mov rdi, rax and call Logger::Logger(). That is the member-function calling pattern from the previous lesson, unchanged. A constructor is a function that receives the address of raw, uninitialized stack memory in rdi and turns it into a valid object. (An empty class occupies one byte, which is why log sits at the odd-looking offset -1[rbp].) If the constructor took arguments, they would follow in rsi, rdx, and so on: Logger log{3}; would load esi with 3 before the call.

The second inserted call is the destructor, Logger::~Logger(), with the same address in rdi. Notice where it sits: after everything else in the function, just before the epilogue. The compiler placed it at the point where log goes out of scope.

Destructors on Every Exit Path

That placement rule has a consequence worth seeing in full. The destructor must run when the object's scope ends, however the scope ends. If a function has several return statements, the compiler duplicates the destructor call onto every path:

int process(int value)
{
    Logger log{};

    if (value < 0)
    {
        return -1;
    }

    return value * 2;
}

At -O0:

process(int):
    push rbp
    mov  rbp, rsp
    push rbx
    sub  rsp, 24
    mov  DWORD PTR -20[rbp], edi
    lea  rax, -17[rbp]
    mov  rdi, rax
    call Logger::Logger()
    cmp  DWORD PTR -20[rbp], 0
    jns  .L2
    mov  ebx, -1
    lea  rax, -17[rbp]
    mov  rdi, rax
    call Logger::~Logger()
    mov  eax, ebx
    jmp  .L3
.L2:
    mov  eax, DWORD PTR -20[rbp]
    add  eax, eax
    mov  ebx, eax
    lea  rax, -17[rbp]
    mov  rdi, rax
    call Logger::~Logger()
    mov  eax, ebx
.L3:
    mov  rbx, QWORD PTR -8[rbp]
    leave
    ret

Read it with Chapter 3 eyes. One constructor call at the top, then cmp and jns split the function into the two return paths, and each path contains its own call Logger::~Logger(). You wrote the destructor once; the listing calls it twice, because there are two ways out of the scope.

There is a second detail here that rewards a close look. On each path the return value is computed before the destructor call, but it cannot sit in eax while the destructor runs, because a call may overwrite eax (it is the return register, and caller-saved). So the compiler parks the value in ebx, a callee-saved register, makes the destructor call, and only then moves ebx into eax for the return. That is also why this function's prologue gained a push rbx and its epilogue restores it. The calling convention from Chapter 4 explains every line.

RAII, Visible in the Listing

Now the payoff. RAII (Resource Acquisition Is Initialization) is the C++ idiom of tying a resource to an object's lifetime: acquire in the constructor, release in the destructor. In source code, RAII is a discipline you trust. In assembly, it is a pattern you can see. Consider a scope guard around a shared counter:

class Mutex;

class LockGuard
{
public:
    LockGuard(Mutex& m);
    ~LockGuard();

private:
    Mutex& m_mutex;
};

void addSample(Mutex& m, int& total, int sample)
{
    LockGuard guard{m};
    total = total + sample;
}

At -O0 the body of addSample is bracketed exactly the way the idiom promises:

addSample(Mutex&, int&, int):
    push rbp
    mov  rbp, rsp
    sub  rsp, 48
    mov  QWORD PTR -24[rbp], rdi
    mov  QWORD PTR -32[rbp], rsi
    mov  DWORD PTR -36[rbp], edx
    mov  rdx, QWORD PTR -24[rbp]
    lea  rax, -8[rbp]
    mov  rsi, rdx
    mov  rdi, rax
    call LockGuard::LockGuard(Mutex&)
    mov  rax, QWORD PTR -32[rbp]
    mov  edx, DWORD PTR [rax]
    mov  eax, DWORD PTR -36[rbp]
    add  edx, eax
    mov  rax, QWORD PTR -32[rbp]
    mov  DWORD PTR [rax], edx
    lea  rax, -8[rbp]
    mov  rdi, rax
    call LockGuard::~LockGuard()
    leave
    ret

The constructor call, which acquires the lock, comes first: guard's address goes in rdi and the Mutex& (an address, as Chapter 5 taught) goes in rsi. Then the update of total happens through its reference. Then the destructor call, which releases the lock. The acquire and release physically enclose the work, and the every-exit-path rule from the previous section guarantees the release survives early returns too. (With exceptions enabled, the compiler additionally emits cleanup paths so the destructor runs even if the code in between throws; the final lesson of this chapter looks at that machinery.) When someone says RAII "guarantees" cleanup, this listing is the guarantee.

When the Abstraction Is Free

Not every constructor becomes a call. If a type is trivial, meaning it has no user-defined constructor or destructor and its members are trivial too, there is simply nothing for a function to do:

struct Pair
{
    int first;
    int second;
};

int sumPair()
{
    Pair p{3, 4};
    return p.first + p.second;
}

At -O0 the "construction" of p is two plain stores, mov DWORD PTR -8[rbp], 3 and mov DWORD PTR -4[rbp], 4, with no call anywhere. And at -O2 the whole function collapses:

sumPair():
    mov eax, 7
    ret

The object never exists at all. This is the other half of the story: constructors and destructors cost exactly what their bodies cost. Non-trivial ones are real calls placed with perfect discipline; trivial ones are a few movs or nothing. The class abstraction itself adds zero overhead.

Two Names for Every Constructor

One last reading skill. In raw, undemangled output, Logger's constructor appears under mangled names like _ZN6LoggerC1Ev, and you will often find a _ZN6LoggerC2Ev as well; destructors likewise appear as _ZN6LoggerD1Ev and _ZN6LoggerD2Ev. The C1 variant is the complete-object constructor and C2 is the base-object constructor: when a class serves as a base of a derived class, constructing it as a base must skip work (such as virtual-base handling) that constructing it as a complete object performs. For a simple class like Logger the two are identical, and GCC typically emits one and makes the other an alias for it. c++filt demangles both to plain Logger::Logger(), so if you ever see the "same" constructor twice in a symbol table, nothing is wrong; you are looking at the C1/C2 pair.

Key Takeaways

  • Logger log{}; compiles to a constructor call you never wrote: the object's stack address in rdi, constructor arguments in rsi onward.
  • The compiler inserts a destructor call at scope exit, and duplicates it onto every exit path, including early returns.
  • At -O0, a return value is parked in a callee-saved register such as rbx across the destructor call, because the call could overwrite eax.
  • RAII is visible in the listing: the constructor (acquire) and destructor (release) calls physically bracket the code in the scope.
  • Trivial construction compiles to plain movs or, at -O2, often to nothing; the class abstraction itself is free.
  • Mangled constructors come in C1 (complete object) and C2 (base object) variants, destructors in D1 and D2; for simple classes they are identical and often aliased.