all_lessons/C++/09 · Classes & zero overheadlesson 10 / 20

Classes and the cost of abstraction

Lesson 08 showed that performance is mostly decided by layout — where your bytes sit relative to the cache line. But a raw struct with loose functions is not how you actually build software; you build classes — data bundled with the operations on it, behind a wall of access control. The natural fear, carried from high-level languages, is that this bundling costs something: that calling account.deposit(10) is slower than calling a plain function, that every object drags a hidden table of methods, that "abstraction" is a synonym for "indirection." This lesson dismantles that fear with the central claim of C++, the zero-overhead principle: a class with no virtual in it compiles to exactly the same machine code as a free function passed the object's address. The abstraction is genuinely free. We will prove it by showing what the method compiles to.

The thesis, here
This is the cost-model lens turned on the most common construct you will ever write. Asking "what does this method call compile to?" has a precise, reassuring answer for a non-virtual method: a plain function call with one hidden argument. There is no per-object overhead, no runtime lookup, nothing the optimizer can't see through — and across the call it can often erase the call entirely. The single keyword that breaks this, virtual, is the subject of Lesson 10; here we earn the right to say abstraction in C++ is, by default, free.
Linear position
Prerequisite: Lesson 06 (copy/move and value semantics — a class is the thing those rules govern), leaning on Lesson 02 (an object is typed storage with a layout), Lesson 05 (constructors acquire, destructors release), and Lesson 08 (layout decides cost).
New capability: read a non-virtual method as the function-with-a-hidden-this it really is, explain the implicit this pointer, and demonstrate — by the "compiles to" picture — that encapsulation adds zero runtime cost, so you reach for classes for clarity without paying a performance tax.
The plan
Five moves. (1) What a class actually is: data + operations + an access wall, and why bundling is for humans, not the machine. (2) The implicit this pointer — the one piece of hidden machinery, and it is not hidden cost. (3) The worked proof: a small class beside the free-function-with-this form it compiles to, argued identical. (4) inline and what the optimizer does across a call — it can delete the call. (5) The one place this breaks: a forward look at the virtual call you do pay for (Lesson 10).

1 · What a class is, and who it's for

A class (the keyword class, or equivalently struct — the only difference is the default access level) bundles three things: data members (the bytes that make up each object — exactly the layout from Lesson 08), member functions or methods (operations that act on that data), and access control (a compile-time wall: public members are usable by anyone, private members only by the class's own methods). Bundling data with its operations is encapsulation; hiding the representation behind private is what lets you change it later without breaking every caller.

class Account {
public:
    Account(long cents) : balance_{cents} {}   // constructor: acquires/initializes (Lesson 05)
    void deposit(long cents) { balance_ += cents; }   // a method
    long balance() const { return balance_; }         // a const method — promises not to mutate
private:
    long balance_;        // a data member — this is the object's only storage
};

Three terms to pin down, because each is a place a high-level language would have charged you. The constructor runs when an object is created and establishes its invariants; the destructor (none needed here — there's no resource to release, only a long) runs deterministically at scope exit (Lesson 05). const after a method, as on balance(), is a compile-time promise that the method won't modify the object; it is checked by the compiler and costs nothing at runtime (Lesson 16 develops const-correctness fully). And here is the load-bearing fact for this whole lesson: the access wall and the bundling are entirely a compile-time, source-level idea. They organize your code; they do not exist in the running program. An Account object in memory is just one long — eight bytes — the same as a bare long would be. private is not a runtime guard that gets checked on each access; it is a rule the compiler enforces while compiling and then forgets.

The size proof
The class above carries one long member and three methods. sizeof(Account) is 8 — the same as sizeof(long). The methods add nothing to each object: there is no pointer-to-code, no method table, no header. A million Account objects in a vector are a million contiguous 8-byte longs (Lesson 08's contiguity, intact). Methods live once in the program's code segment (Lesson 01), shared by every object. This is the first half of zero-overhead — you don't pay for what you don't use — made physical: you didn't ask for runtime dispatch, so no per-object machinery appears.

2 · The implicit this pointer — a method is a function with a hidden argument

If methods aren't stored in the object, how does account.deposit(10) know which object's balance_ to change? The answer is the single piece of machinery a class introduces, and it is the key to everything that follows. A non-static method is an ordinary function with one extra, hidden first parameter: a pointer to the object it was called on, named this. Inside deposit, the bare name balance_ is shorthand for this->balance_ — "the balance_ member of the object this points to." (A pointer holds an address; -> dereferences it to reach a member — Lesson 03.) this has type Account* (or const Account* inside a const method — that's what the const promise means mechanically: this points to something you can't modify).

So the call account.deposit(10) is, underneath, nothing more exotic than: call the deposit code, passing the address of account as the hidden first argument and 10 as the second. The method name was resolved at compile time — the compiler knew the static type of account was Account, looked up Account::deposit, and emitted a direct call to that one function. No table was consulted at runtime. No pointer was chased to find the code. This is identical in cost to calling a free function and handing it the same address.

source you write: what the compiler treats it as: account.deposit(10); --> Account_deposit(&account, 10); ^hidden 'this' = address of account inside deposit: balance_ += cents; --> this->balance_ += cents;

3 · The proof: the method and its free-function twin compile to the same code

Here is the worked example the whole lesson turns on. On the left, the encapsulated method. On the right, the same logic written as a free function that takes the object by pointer — exactly the form the compiler lowers the method into. The claim is that these produce identical machine code.

The class method (abstraction)
struct Account {
    long balance_;
    void deposit(long c) {
        balance_ += c;
    }
};

// call site:
Account a{100};
a.deposit(50);
The free function (the "compiles to" form)
struct Account {
    long balance_;
};
void deposit(Account* self, long c) {
    self->balance_ += c;
}

// call site:
Account a{100};
deposit(&a, 50);

Compile both at -O2 and inspect the assembly (the godbolt.org Compiler Explorer is the standard way to do this — paste code, read the emitted instructions). The body of deposit is the same handful of instructions in both: take the pointer in the first argument register, load the long at offset 0, add the second argument, store it back, return. The function-call sequence is the same: put &a in the first argument register, put 50 in the second, call the one function. There is no third version of deposit hiding behind the method syntax — the dot, the this, and the access control all evaporated during compilation. Byte-for-byte, the encapsulated version costs what the hand-written pointer version costs: nothing extra.

What the "compiles to" trace shows
The method form gave you encapsulation, a clean call site (a.deposit(50)), const-checking, and a private representation you can change later. It charged you zero machine instructions for any of that over the raw deposit(&a, 50). The abstraction is not "fast enough" or "close to free" — at the instruction level it is the same program. This is the second half of zero-overhead made concrete: what you do use, you couldn't reasonably hand-code any better, because the hand-coded version is what it already became.

4 · inline, and what the optimizer does across the call

We can go one step further than "the call is cheap": the optimizer can often make the call disappear. A small function body can be inlined — the compiler copies the function's instructions directly into the call site instead of emitting a call/return at all. Once the body of deposit is spliced into the caller, the compiler sees the whole computation in one place and can fold it: for Account a{100}; a.deposit(50); followed by reading the balance, an optimizer may compute 150 at compile time and emit no deposit at all. The call was abstraction for your benefit; in the binary it is gone.

Two things to keep straight. First, the keyword inline is mostly not about this. Its standardized job is to tell the linker "this definition may appear in several translation units — don't complain about duplicates" (it relaxes the one-definition rule from Lesson 01), which is exactly why methods defined inside the class body and functions in headers are implicitly inline. The optimizer's decision to actually splice in the body is a separate, heuristic call it makes on its own, based on size and call frequency — it inlines plenty of functions you never marked, and may decline ones you did. Second, inlining is the reason non-virtual methods are so cheap in aggregate: because the target is known at compile time (§2), the optimizer can see through the call and optimize the caller and callee together. The keyword that hides the target — virtual — is precisely what robs the optimizer of this, which is the cost we turn to next.

ConstructHow the target is foundRuntime cost vs. a free function
Non-virtual method (this lesson)At compile time, from the static typeNone — identical machine code; often inlined away entirely
Free function with explicit this pointerAt compile time, by nameThe baseline we compare against
virtual method (Lesson 10)At runtime, via a per-object table pointerAn extra indirection, and the call usually can't be inlined

5 · The one thing you do pay for — a look ahead

Everything above assumed the compiler knows, at the call site, exactly which function runs. That assumption holds for every method until you write one word: virtual. A virtual method asks for runtime dispatch — the actual function chosen by the object's true type when the program runs, not its static type at compile time. To do that, each such object must carry a hidden pointer to a per-class table of function addresses (the vtable), and each call must load that pointer, index the table, and jump. That is real, itemized cost: an extra indirection (a potential cache miss — Lesson 08), per-object storage for the table pointer, and — because the target isn't known at compile time — the optimizer usually cannot inline it. This is not a flaw; it is the price of a feature, charged only to the objects that ask for it. Lesson 10 is devoted to it: when that dispatch earns its keep, and when it's pure waste. For now, hold the contrast — the default is free; the dynamic version is the exception you opt into.

6 · Common mistakes and failure modes

"Methods make objects bigger"
No. Non-virtual methods add nothing to sizeof; the code lives once in the program, not once per object (§1). The first member you add that does grow each object is a vtable pointer — and only if you add virtual (§5, Lesson 10).
"A method call is slower than a function call"
A non-virtual call is a function call with one hidden argument (§2–3), resolved at compile time. Same machine code; frequently inlined to nothing. The slowdown people remember belongs to virtual.
Forgetting const on read-only methods
const costs zero at runtime but earns compile-time safety and lets callers use the method on const objects. Omitting it silently narrows where your class is usable (Lesson 16).
Adding virtual "just in case"
A single virtual method gives every object a vtable pointer and blocks inlining of those calls forever (§5). Don't pay for dynamic dispatch you don't use — the whole point of zero-overhead is that you needn't.

Checkpoint exercise

Try it
Open godbolt.org, select a recent GCC or Clang with -O2. Paste the two-column example from §3 — the Account with a deposit method, and the version with a free deposit(Account*, long) — each in its own short program that constructs an account, calls deposit, and returns the balance. Predict first: will the emitted assembly differ? Then compare the two functions' instructions. You should find them identical (and, because the bodies are tiny, you may find deposit inlined away entirely so the function just returns a constant). Now add virtual to the method and recompile: watch sizeof(Account) grow by a pointer, a vtable appear in the output, and the call turn into a load-and-indirect-jump. You have just measured the exact boundary between free abstraction and paid dispatch.

Where this points next

We've established the comfortable half of the cost of abstraction: encapsulation, methods, and access control are organizational tools for humans that the compiler erases, leaving machine code no worse than hand-written. That is what makes designing with classes safe to do liberally. Lesson 10 takes up the uncomfortable half — virtual and the vtable — and draws the per-object pointer and the runtime indirection explicitly, so you can see exactly what dynamic dispatch costs, when its flexibility is worth that cost, and what slicing and override have to do with it. Then Lesson 12 closes the arc by handing polymorphism back to you with zero runtime cost through templates — the compile-time alternative to the vtable.

Takeaway
A class bundles data, methods, and access control, but only the data exists at runtime: private and the bundling are compile-time rules the compiler enforces and forgets, so sizeof counts only the data members and methods cost nothing per object. A non-virtual method is an ordinary function with one hidden first argument, the implicit this pointer, and the compiler resolves which function to call from the static type at compile time — so a.deposit(50) compiles to byte-identical machine code as a free deposit(&a, 50), and because the target is known the optimizer can inline the body and often erase the call entirely. That is the zero-overhead principle made concrete: encapsulation is genuinely free. The one construct that breaks it is virtual (Lesson 10), which adds a per-object table pointer and a runtime indirection and defeats inlining — the abstraction you actually pay for, charged only when you ask.

Interview prompts