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 this pointer, passed in rdi.
  • Explicit arguments shift one slot right: the first goes in rsi, the second in rdx, and so on.
  • At the call site, obj.method() loads &obj into rdi (often via lea) before the call, even when you wrote no arguments.
  • Inside a member function, m_member means this->m_member: a fixed-displacement access like [rax] or 4[rax], using the struct-layout offsets from Chapter 5.
  • Raw symbols are mangled names like _ZN7Counter9incrementEv, encoding class, function, and parameter types; c++filt demangles them.
  • A static member function has no this argument; its first explicit parameter takes rdi.
  • There is no object-oriented machine code: classes compile away into structs plus functions.