Constructors, Destructors, and RAII
Constructors and destructors are function calls you never wrote, and RAII is the compiler placing destructor calls on every exit path.
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 inrdi, constructor arguments inrsionward.- 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 asrbxacross the destructor call, because the call could overwriteeax. - 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) andC2(base object) variants, destructors inD1andD2; for simple classes they are identical and often aliased.
Create an account to track your progress and access interactive exercises. Already have one? Sign in.
Constructors, Destructors, and RAII - Quiz
Test your understanding of the lesson.
Lesson Discussion
Share your thoughts and questions
No comments yet. Be the first to share your thoughts!