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.
virtual, is the subject of Lesson 10; here we earn the right to say abstraction in C++ is, by default, free.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.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.
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.
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.
struct Account {
long balance_;
void deposit(long c) {
balance_ += c;
}
};
// call site:
Account a{100};
a.deposit(50);
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.
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.
| Construct | How the target is found | Runtime cost vs. a free function |
|---|---|---|
| Non-virtual method (this lesson) | At compile time, from the static type | None — identical machine code; often inlined away entirely |
Free function with explicit this pointer | At compile time, by name | The baseline we compare against |
virtual method (Lesson 10) | At runtime, via a per-object table pointer | An 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
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).virtual.const on read-only methodsconst 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).virtual "just in case"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
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.
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
- What is the implicit
thispointer, and what does it tell you about how a method call works? (§2 — every non-static method has a hidden first parameterthis, a pointer to the object; a member name likebalance_meansthis->balance_, so a method is just a function passed the object's address.) - Does adding methods to a class increase the size of its objects? Why or why not? (§1 — no; non-virtual methods live once in the code segment and add nothing to
sizeof, which counts only data members; only avirtualmethod adds a per-object vtable pointer.) - Argue that
a.deposit(50)costs the same as a free function call. (§2–3 — the method is lowered to a direct call passing&aas the hiddenthis, resolved at compile time from the static type; the assembly is identical todeposit(&a, 50), with no runtime lookup.) - How is the zero-overhead principle visible in non-virtual classes specifically? (§1, §3 — you don't pay for dispatch you didn't ask for (no per-object machinery), and the method you do call compiles to the same code you'd hand-write with an explicit pointer.)
- What does
inlineactually mean, and is it how the optimizer decides to inline? (§4 —inlineprimarily relaxes the one-definition rule for definitions appearing in multiple translation units; the optimizer's decision to splice a body in is a separate size/frequency heuristic it makes regardless of the keyword.) - Why can a non-virtual call be inlined but a virtual call usually can't? (§4–5 — a non-virtual target is known at compile time so the compiler can copy the body in and optimize across the call; a virtual target is chosen at runtime via the vtable, so the optimizer can't know which body to inline.)
- What does the single keyword
virtualchange about an object and a call? (§5 — it gives the object a hidden pointer to a per-class vtable (growingsizeof), turns the call into load-pointer-index-table-jump (an extra indirection and possible cache miss), and prevents inlining — the cost detailed in Lesson 10.)