Ch18.4: Dead Virtual Function Bloat
1. The Rule the Compiler Cannot Break
The vtable must contain an address for every virtual function of the class. The vtable is emitted because the class is used polymorphically. Therefore every virtual function gets compiled into your binary, whether or not anything ever calls it.
This is not an optimisation failure. It is the ABI: some translation unit you have never
seen could load your object's address and call slot 3, so slot 3 must point at real code.
The compiler is not allowed to notice that your program only ever calls area()
and never calls heavy().
2. Measure It
One class, one virtual function that nothing calls. Build it twice: once with
virtual, once with the keyword removed and nothing else changed.
#include <fast_io.h>
struct shape
{
virtual double area() const noexcept = 0;
virtual ~shape() noexcept = default;
};
struct square final : shape
{
double side{};
explicit square(double s) noexcept : side{s} {}
double area() const noexcept override { return side * side; }
virtual void heavy(double x) noexcept
{
x = x * 1.0 + 1.0;
x = x * 1.2 + 2.0;
/* ... twenty-six more lines of the same shape ... */
x = x * 8.2 + 30.0;
side = x;
}
};
int main()
{
try
{
square sq{3.0};
shape &s = sq;
::fast_io::io::println(s.area());
}
catch throws (::std::error e)
{
::fast_io::io::perrln(e);
}
}
heavy() is never called. Build with -O2 and compare the
.text size of the two binaries:
base text=39799 heavy_sym=1 (virtual, one-line body)
virt text=40663 heavy_sym=1 (virtual, 30-line body, never called)
novirt text=39775 heavy_sym=0 (same 30-line body, NOT virtual, never called)
Read the three lines:
virt−novirt= 888 bytes of dead code that exist only because the wordvirtualis present. That is about 30 bytes per line of a function nobody calls.novirtis smaller thanbase, and the symbolsquare::heavy(double)is gone from the binary entirely: the compiler saw it was never called and deleted the definition.- Repeat the experiment with a 400-line body instead of 30 and the same difference becomes 12,448 bytes (39,799 → 52,247). It scales linearly with the code you write, not with the code you call.
In a plugin architecture with fifty classes and a dozen virtual functions each, this is
where a 300 KB binary becomes 3 MB. It is also why embedded projects ban
virtual in data-structure-shaped classes: you pay for the whole interface on
every object of every type, forever.
Two mitigations exist and both are partial: -ffunction-sections
-Wl,--gc-sections can drop a virtual function only if the whole vtable is
droppable, and -flto can devirtualise calls whose target it can prove. What
you cannot do is tell the compiler "this class has a vtable for area() but
not for heavy()". With a table you build yourself
(Ch18.6), you list exactly the entries
you use, and unused entries cost nothing because they do not exist.
3. __cxa_pure_virtual: the Hidden Runtime Dependency
A pure virtual function has no body, but the vtable still needs an address for its slot.
The Itanium ABI fills that slot with a function named
__cxa_pure_virtual, which the C++ runtime provides. So the moment you
declare one pure virtual, your object file starts asking for runtime symbols:
struct shape
{
virtual double area() const noexcept = 0;
virtual ~shape() noexcept = default;
};
struct square final : shape
{
double side{2.0};
double area() const noexcept override { return side * side; }
};
int use(shape &s) noexcept { return s.area() > 0.0; }
int main() { square q; return use(q); }
$ g++ -std=c++26 -fherbceptions -O0 -c pv.cc && nm -C -u pv0.o
w __cxa_pure_virtual
U operator delete(void*, unsigned long)
U vtable for __cxxabiv1::__class_type_info
U vtable for __cxxabiv1::__si_class_type_info
Four symbols, and only one of them is about destruction. The other three are the RTTI
runtime: declaring a pure virtual function makes your object file depend on
__cxxabiv1::__class_type_info and its single-inheritance sibling, even if
you never write typeid or dynamic_cast.
And __cxa_pure_virtual is not a tiny stub that calls abort
any more. Disassembled from libstdc++.so.6 in this book's toolchain:
00000000000b6ded <__cxa_pure_virtual@@CXXABI_1.3>:
b6ded: endbr64
b6df1: push %rax
b6df2: call b2570 <_ZSt9terminatev@plt>
It calls ::std::terminate, whose default handler is
__gnu_cxx::__verbose_terminate_handler, which calls — measured from
the same library:
cbbac: call __cxa_current_exception_type@plt
cbbed: call __cxa_demangle@plt
cbc11: call fwrite@plt
cbc25: call fputs@plt
cbc3f: call fwrite@plt
cbccb: call abort@plt
That is: a pure virtual function drags std::terminate, the C++
demangler, and stdio into your program — in a program that
never prints anything, never throws, and never uses RTTI. On a freestanding target, or a
embedded build where stdio does not exist, this is the link error nobody can
explain. On a hosted target, it is dead weight in your ROM.
There is no way to opt out. You cannot supply your own __cxa_pure_virtual
with a noexcept body, you cannot ask for a vtable with "no pure entries", and
-fno-rtti does not remove this dependency (it removes the typeinfo objects,
not the pure-virtual slot).
4. The ABI Is Not Yours
The C++ standard does not specify how virtual dispatch works. In practice there are two
incompatible answers, Itanium (GCC, Clang, most Unix) and MSVC, and they differ in vtable
layout, vptr placement, RTTI representation, thunking for multiple inheritance,
virtual bases, and the treatment of this-adjusting conversions. A class
with virtual functions therefore has no ABI outside one compiler family,
which is why every large C++ library that wants a stable interface exports a C API and
hides the classes.
The same is not true of a function-pointer table you define: its layout is whatever your
struct says it is, in every compiler, because nothing about it is special.
5. What To Do If You Inherit a Hierarchy
- Do not add virtual functions to it. Adding one is a permanent cost for every object of every derived type.
- Watch for "convenience" virtuals:
virtual void dump() const, logging hooks, debug printers. They are almost always unused in release and always paid for. - Prefer
finalon leaf classes so the compiler can at least drop the vtable for local objects (Ch18.3). - If a hierarchy exists only to hold data, kill it: it wants to be a
::std::variantor a plain struct with a tag. - If you need open-ended dispatch, do not grow the hierarchy — build the table Ch18.6 describes and let the hierarchy die where it is.
6. Rules of Thumb
- Every virtual function you write is compiled, forever, on every object of every derived type. Write fewer of them.
- Never add a pure virtual you do not call. It is the one construct that costs a runtime dependency (demangler + stdio) for zero functionality.
- If you must have pure virtuals, accept that your program now depends on
__cxa_pure_virtual, and do not be surprised when a freestanding or-nostdlibbuild fails to link. - Measure your own binaries.
size,nm -Candgrepon the symbol table are the only way to know what a hierarchy is actually costing you. - Never design a new abstraction around
virtual. The whole point of Ch18.6 is that you get the dispatch without any of this.