Zero-cost abstraction, examined
Which abstractions really are free, and which are not.
By the end of this chapter you can
- Show that a range pipeline compiles to the same code as a loop
- Identify an abstraction that does cost something
- Explain what zero-cost does and does not promise
The slogan comes from Stroustrup, and it is two claims, not one:
What you don’t use, you don’t pay for. And what you do use, you couldn’t hand code any better.
The first half is about absence: a C++ program that never throws pays nothing for exceptions existing. The second is about presence: an abstraction you do use should compile to what you would have written by hand.
Both claims are testable, and the honest answer is that the first is nearly always true and the second is usually true — with a set of well-known exceptions that are worth knowing by name. This chapter checks, rather than repeating the slogan.
#include <array>
#include <iostream>
#include <numeric>
#include <ranges>
#include <vector>
int by_index(const std::vector<int>& v) {
int total = 0;
for (std::size_t i = 0; i < v.size(); ++i) total += v[i];
return total;
}
int by_range_for(const std::vector<int>& v) {
int total = 0;
for (int x : v) total += x;
return total;
}
int by_accumulate(const std::vector<int>& v) {
return std::accumulate(v.begin(), v.end(), 0);
}
long long squares_by_loop(const std::vector<int>& v) {
long long total = 0;
for (int x : v) total += x * x;
return total;
}
long long squares_by_view(const std::vector<int>& v) {
long long total = 0;
for (int x : v | std::views::transform([](int n) { return n * n; })) total += x;
return total;
}
int main() {
std::vector<int> values{1, 2, 3, 4, 5};
std::cout << by_index(values) << ' ' << by_range_for(values) << ' '
<< by_accumulate(values) << ' '
<< squares_by_loop(values) << ' ' << squares_by_view(values) << '\n';
}Open the Assembly view at -O2 and compare _Z16squares_by_loop… with
_Z16squares_by_view…. They are instruction for instruction identical —
same registers, same order, same branches. A transform_view, an iterator
wrapping an iterator, a lambda called through a std::invoke chain, all of it
collapses to the same fifteen instructions the raw loop produces.
by_range_for and by_accumulate differ by two instructions swapped and one
comparison written the other way round — the same code, scheduled differently.
The interesting one is by_index, the version with no abstraction at all. It is
nineteen instructions to the range-for’s twelve: it computes the size by
subtracting the pointers and shifting, then indexes with a scaled address on
every iteration. The range-for just walks a pointer. Here the abstraction is not
merely free; it is what stopped you writing the slower thing.
Free, verified
Two more that hold up:
#include <array>
#include <iostream>
int sum_c_array(const int (&a)[8]) {
int total = 0;
for (int i = 0; i < 8; ++i) total += a[i];
return total;
}
int sum_std_array(const std::array<int, 8>& a) {
int total = 0;
for (int x : a) total += x;
return total;
}
int main() {
int raw[8]{1, 2, 3, 4, 5, 6, 7, 8};
std::array<int, 8> wrapped{1, 2, 3, 4, 5, 6, 7, 8};
std::cout << sum_c_array(raw) << ' ' << sum_std_array(wrapped) << '\n';
}Identical, twelve instructions each — and std::array knows its own size, can be
returned from a function, and does not decay to a pointer. That one is a
straight win.
The general pattern behind every case that works: the compiler can see the
whole thing. The lambda’s body, the iterator’s operator++, the view’s
begin() — all of them are in the header, all of them get inlined, and after
inlining the optimiser is looking at the same code you would have written. The
abstraction existed only at the level of the source text.
Not free: three you should recognise
std::views::filter
Change transform to filter in the first sample and the equivalence breaks.
#include <iostream>
#include <ranges>
#include <vector>
long long evens_by_loop(const std::vector<int>& v) {
long long total = 0;
for (int x : v)
if (x % 2 == 0) total += x;
return total;
}
long long evens_by_view(const std::vector<int>& v) {
long long total = 0;
for (int x : v | std::views::filter([](int n) { return n % 2 == 0; })) total += x;
return total;
}
int main() {
std::vector<int> values{1, 2, 3, 4, 5, 6};
std::cout << evens_by_loop(values) << ' ' << evens_by_view(values) << '\n';
}Sixteen instructions against thirty-four. Both are O(n) with one modulo
per element, and for most programs the difference is invisible — but “the same
code as the loop” is not what happened.
The reason is structural rather than a missed optimisation. filter_view must
present an iterator, and an iterator’s begin() has to point at the first
element that satisfies the predicate — so begin() runs a search, and the
standard requires it to be amortised constant time, which means filter_view
caches the result. Advancing has to skip to the next satisfying element and stop
at the end, so the loop body carries two exit conditions where the hand-written
version carries one. There is genuinely more to do.
std::function
std::function is 32 bytes on this implementation, against 8 for a raw
function pointer, and calling through one is an indirect call the compiler
cannot see past.
#include <functional>
#include <iostream>
// The compiler knows exactly which function this is.
template <class F>
int apply_template(F f, int x) { return f(x); }
// It does not know what is inside this one.
int apply_function(const std::function<int(int)>& f, int x) { return f(x); }
int main() {
std::cout << "sizeof(std::function<int(int)>) = " << sizeof(std::function<int(int)>)
<< ", sizeof(int(*)(int)) = " << sizeof(int (*)(int)) << '\n';
std::function<int(int)> triple = [](int n) { return n * 3; };
std::cout << apply_template([](int n) { return n * 3; }, 14) << ' '
<< apply_function(triple, 14) << '\n';
}Look at _Z14apply_function… in the assembly. Twenty instructions, and every
one of them is doing something the template version does not: spilling the
argument to the stack so its address can be passed, testing whether the
std::function is empty, an indirect call *24(%rdi) through the stored target
pointer, and a branch to __throw_bad_function_call for the empty case.
Now look at main. Neither call survives: there is no indirect call anywhere
in it, and the two results collapse into a single movl $42. In main the
compiler can see which lambda went into the std::function, so it devirtualises
the call, inlines the body, folds the arithmetic, and — since both expressions
come to the same value — emits the constant once. That is the rule stated exactly: the abstraction is free
where the compiler can see through it, and costs where it cannot. The same
rule that decided whether a virtual call survived in Chapter 5.8.
Use std::function when you need to store a callable of unknown type — that is
what it is for. Do not use it as a parameter type for a callback you are about
to call immediately; a template parameter costs nothing there.
std::shared_ptr
16 bytes rather than 8, and the reference count is atomic. Copying one compiles
to roughly sixty instructions including two lock-prefixed read-modify-writes,
which are the expensive kind — they synchronise across cores whether or not your
program has more than one thread.
std::unique_ptr is the opposite: 8 bytes, exactly a raw pointer, and moving
one is a register copy. Chapter 4.7 argued for unique_ptr by default on
ownership grounds; this is the same advice arriving from the other direction.
What zero-cost does not promise
Four things, none of which the slogan covers:
Compile time. Templates are instantiated per type, and the instantiation happens in every translation unit that uses them. A heavily generic header can cost seconds per file. This is a real cost, paid by every developer on every build, and it does not show up in any runtime measurement.
Binary size. The same per-type instantiation puts one copy of the code in the binary for each type used. Ten instantiations of a large template is ten copies. Virtual dispatch compiles once.
Debug builds. Everything in this chapter was measured at -O2. At -O0,
none of it holds: every layer of an abstraction is a real function call with a
real stack frame, and a range pipeline can be an order of magnitude slower than
the loop. If your tests run in a debug build, the abstraction is not free
there — which matters if a test suite’s runtime is what people complain about.
Exceptions, exactly. The “zero-cost” exception model means no instructions execute on the non-throwing path — the unwinding information lives in tables consulted only when a throw happens. What it costs instead is binary size (those tables are typically 10–15% of the text section) and some optimisation freedom, because the compiler must be able to unwind from anywhere a call might throw. Throwing itself is very slow, hundreds of times the cost of a return. That is fine, because exceptions are for exceptional cases, and stops being fine the moment one appears in a loop.