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:

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

6. Rules of Thumb