Member Functions and the this Pointer
A member function is an ordinary function whose first, hidden argument is the object's address in rdi.
A Member Function Is Just a Function
In Chapter 5 you read p->y as a load at a fixed displacement: the struct's address arrived in rdi, and each member lived at a constant offset from it. That raises an obvious question. What changes when you stop writing free functions that take a Point* and start writing classes with member functions? The answer, and the idea this whole chapter builds on, is: almost nothing. A member function compiles to an ordinary function whose hidden first argument is the address of the object it was called on. That hidden argument is the this pointer, and it travels exactly where any first pointer argument travels: in rdi.
So when you write obj.getValue(), the compiler generates the same code it would generate for a free function call getValue(&obj). Any explicit arguments shift over by one slot: the first explicit argument goes in rsi, the second in rdx, and so on down the familiar System V sequence.
A Worked Example
Here is a small class with one data member, a setter, and a getter:
#include <iostream>
class Counter
{
public:
void setValue(int v);
void increment();
int getValue() const;
private:
int m_value{0};
};
void Counter::setValue(int v)
{
m_value = v;
}
void Counter::increment()
{
m_value = m_value + 1;
}
int Counter::getValue() const
{
return m_value;
}
int main()
{
Counter c{};
c.setValue(10);
c.increment();
std::cout << c.getValue() << '\n';
return 0;
}
Look at setValue at -O0. In the source it takes one parameter. In the assembly it receives two arguments:
Counter::setValue(int):
push rbp
mov rbp, rsp
mov QWORD PTR -8[rbp], rdi
mov DWORD PTR -12[rbp], esi
mov rax, QWORD PTR -8[rbp]
mov edx, DWORD PTR -12[rbp]
mov DWORD PTR [rax], edx
pop rbp
ret
This is the two-argument spill pattern from Chapter 4, unchanged. The first spill parks rdi, an 8-byte pointer, in -8[rbp]: that is this, the address of the Counter object. The second spill parks esi in -12[rbp]: that is v, the explicit parameter, bumped from the first argument register to the second because this claimed rdi. The last three instructions reload both and perform mov DWORD PTR [rax], edx, a store at offset 0 from this, because m_value is the first (and only) member of the class.
increment follows the same script with no explicit arguments at all, only the hidden one:
Counter::increment():
push rbp
mov rbp, rsp
mov QWORD PTR -8[rbp], rdi
mov rax, QWORD PTR -8[rbp]
mov eax, DWORD PTR [rax]
lea edx, 1[rax]
mov rax, QWORD PTR -8[rbp]
mov DWORD PTR [rax], edx
pop rbp
ret
It spills this, loads m_value from [rax], adds 1 with lea, and stores the result back to [rax]. Every mention of m_value in the source became a memory access through the this pointer.
The Call Site
Now flip to the caller. In main, c is a local at -4[rbp]. Here is the interesting part at -O0 (a call to Counter's compiler-generated constructor precedes these lines; constructors are the subject of the next lesson):
lea rax, -4[rbp]
mov esi, 10
mov rdi, rax
call Counter::setValue(int)
lea rax, -4[rbp]
mov rdi, rax
call Counter::increment()
lea rax, -4[rbp]
mov rdi, rax
call Counter::getValue() const
mov esi, eax
...
You wrote c.setValue(10) with one argument, but the caller loads two registers: esi gets 10, and rdi gets the result of lea rax, -4[rbp], the address of c. That lea/mov rdi pair before every member call is the signature of the hidden argument. c.increment() and c.getValue() take no explicit arguments, yet rdi is still loaded before each call, because the object's address always has to get there somehow. And getValue returns its result in eax, exactly like any int-returning function.
Member Access Is an Offset From this
Inside a member function, writing m_value really means this->m_value, so it compiles to the fixed-displacement access you learned in the struct lesson: [this + offsetOfMember]. Counter has a single member at offset 0, which is why the accesses above are plain [rax]. If the class had a second int member, accesses to it would read 4[rax] instead. The object layout of a class follows the same rules as a struct: members in declaration order, padding for alignment, offsets fixed at compile time. public: and private: change what the compiler lets you write, not where anything lives.
At -O2 the spilling disappears and the equivalence becomes impossible to miss:
Counter::getValue() const:
mov eax, DWORD PTR [rdi]
ret
Compare that with getY from Chapter 5, mov eax, DWORD PTR 4[rdi]. A member function reading a member and a free function reading through a struct pointer compile to the same instruction. Only the offset differs.
Name Mangling
One thing does look different in raw compiler output. Compiler Explorer and the assembly panel demangles labels into readable forms like Counter::increment(), but the actual symbol the compiler emits is:
_ZN7Counter9incrementEv:
This is a mangled name, and you can learn to skim it. _Z marks a mangled C++ symbol, N...E bracket a nested name, 7Counter is the class name preceded by its length, 9increment is the function name preceded by its length, and the trailing v says the parameter list is empty (void). setValue mangles to _ZN7Counter8setValueEi, where the trailing i encodes one int parameter. Mangled names encode the class, the function, and the parameter types, which is how the linker keeps overloads apart: setValue(int) and a hypothetical setValue(long) would get different symbols. The command-line tool c++filt translates them back; echo _ZN7Counter8setValueEi | c++filt prints Counter::setValue(int).
One special case: a static member function belongs to the class but not to any object, so it has no this pointer. Its first explicit argument goes in rdi like a free function's, and only the mangled name reveals its class membership.
There Is No Object-Oriented Machine Code
Step back and look at what survived compilation. The data members became a struct layout: bytes at fixed offsets. The member functions became ordinary functions taking an address in rdi. The access specifiers vanished entirely, since they are compile-time rules with no run-time representation. The CPU has no concept of a class, an object, or a method; it moves bytes and calls functions. Everything object-oriented in your source dissolved into structs plus functions, which means everything you learned in Chapters 4 and 5 already covers it. The lessons ahead, on constructors, virtual functions, and templates, are all variations on this same disappearing act.
Key Takeaways
- A member function compiles to an ordinary function with a hidden first argument: the
thispointer, passed inrdi. - Explicit arguments shift one slot right: the first goes in
rsi, the second inrdx, and so on. - At the call site,
obj.method()loads&objintordi(often vialea) before thecall, even when you wrote no arguments. - Inside a member function,
m_membermeansthis->m_member: a fixed-displacement access like[rax]or4[rax], using the struct-layout offsets from Chapter 5. - Raw symbols are mangled names like
_ZN7Counter9incrementEv, encoding class, function, and parameter types;c++filtdemangles them. - A static member function has no
thisargument; its first explicit parameter takesrdi. - There is no object-oriented machine code: classes compile away into structs plus functions.
Create an account to track your progress and access interactive exercises. Already have one? Sign in.
Member Functions and the this Pointer - Quiz
Test your understanding of the lesson.
Practice Exercises
Fix the deposit Member Function
The BankAccount class has a bug: deposit overwrites the balance instead of adding to it, so the program prints 25 and 125 instead of the running totals. Fix deposit so each call adds the amount to m_balance. Once the output is correct, read the assembly panel and find the hidden this argument: at each call site the account's address is loaded into rdi before the call, and inside deposit the member is accessed at offset 0 from this.
Lesson Discussion
Share your thoughts and questions
No comments yet. Be the first to share your thoughts!