references/compile-time.md
Compile-time pipelines
A compile-time pipeline is a chain of stages whose structure is fixed by the compiler, so at runtime there is no dispatch, no indirection, and often no intermediate materialisation. The stages are types. Composing them builds a type. Running it is one inlined call tree.
build phase (compile time) : compose stages into one big type
execute phase (run time) : one entry point, everything inlined
The contrast is std::vector<std::function<X(X)>>: flexible, and every stage is an indirect call the optimiser cannot see through.
The five techniques
Ranked by how often you actually reach for them.
1. Pipe-composed adaptors
The ranges and senders idiom. Each adaptor is a small object and operator| folds the left operand into the right.
auto v = rows | std::views::filter(pred) | std::views::transform(to_minor);
// type is transform_view<filter_view<R, F>, G>
Zero runtime overhead after inlining. The cost is in debug builds and compile time, charged per stage, because each adaptor needs two overloads: one for partial application (views::filter(pred)) and one for application (rng | that).
Reach for this first. It is the idiom the standard library already speaks, so the next reader does not have to learn yours.
2. Expression templates
Operators return lightweight nodes describing the expression rather than computing it. Evaluation happens once, at assignment. Eigen, Blaze, xtensor.
The payoff is temporary elimination: a = b + c + d becomes one fused loop instead of three loops and two temporaries.
The hazards are specific:
auto x = b + c;captures a node holding references. Ifborcdies,
x dangles. This is the single most common expression-template bug. Assign to the concrete type, or call .eval().
- Error messages name the node type, not your expression.
- Compile time and binary size grow with expression complexity.
Use a library that does this. Writing your own is a large amount of machinery for a payoff you can usually get from a loop the compiler already vectorises.
3. Variadic inheritance with a fold
The boring one, and the right first reach for ordinary application code. No dependencies, no cleverness, the whole chain inlines.
template <typename... Stages>
struct Pipeline : Stages... {
template <typename T>
auto run(T v) const {
((v = static_cast<const Stages&>(*this).apply(std::move(v))), ...);
return v;
}
};
struct Trim { template <class T> auto apply(T) const -> T; };
struct Normalise { template <class T> auto apply(T) const -> T; };
struct Validate { template <class T> auto apply(T) const -> T; };
using DefaultPipeline = Pipeline<Trim, Normalise, Validate>;
Inheriting from every stage pulls each apply into scope; the fold threads the value through them in order.
Cost: changing the pipeline means recompiling. If stages must be selectable per build, select the typedef behind a build flag rather than reaching for runtime dispatch and losing the whole benefit.
4. constexpr and consteval
Move the computation itself to compile time.
constexprmeans may run at compile time.constevalmeans must. Aconstevalfunction that cannot be constant
evaluated is a compile error.
Use consteval when running at runtime would be a bug rather than a slowdown: a compile-time parser, a table generator, a format-string check.
The modern baseline:
constexprallocation,constexpr std::vectorandstd::string(C++20)if consteval(C++23), which replacesif (std::is_constant_evaluated()),
needs no header, and unlike the old form lets you call consteval functions in the true branch
static constexprlocals insideconstexprfunctions (C++23)#embedfor pulling binary data in at compile time
The showcase is CTRE, compile-time regular expressions. A PCRE-compatible pattern is parsed at compile time into a type, converted to a finite automaton, and minimised, all before codegen. The user pays nothing at runtime for pattern compilation.
Test this layer with static_assert. A test over a constexpr function runs during the build, cannot be skipped, and costs nothing at runtime.
5. Reflection-generated pipelines (C++26)
The reflection operator ^^, the splice operator [: :], and std::meta::info, all consteval. Voted into C++26 in June 2025.
This derives stages from a type's members rather than hand-listing them: serialisers, ORM mappers, struct-of-arrays transforms. Unlike Java or C# reflection nothing survives into the binary; std::meta::info exists only during compilation, so the generated code is identical to the hand-written equivalent.
Compiler support is early. Worth knowing it exists; not worth building on yet.
The costs
Runtime overhead is roughly zero. Nothing else is. This is static overhead: compile time, write time, read time, and debug time, as distinct from run time.
| Cost | Detail |
|---|---|
| Compile time | Each stage adds overload resolution and instantiation. Deep chains multiply. |
| Debug builds | Without inlining every adaptor layer is a real call. A ranges pipeline at -O0 can be an order of magnitude slower than a hand loop. |
| Debuggability | Stepping through means step-in and step-out through adaptor boilerplate, and the types in the debugger are unreadable. |
| Error messages | A constraint violation is reported inside the adaptor, not at your call site. |
| Binary size | One instantiation per unique type combination. |
That table is the argument for keeping pipelines shallow and local, not for avoiding them.
Mitigations that hold up
Measure before guessing. clang -ftime-trace emits a .json next to each .o; ClangBuildAnalyzer aggregates those across a build and reports which templates cost the most to instantiate and which headers are included most. A common finding is 60 to 70 percent of a translation unit's time sitting in InstantiateFunction and PerformPendingInstantiations for one over-generic template.
Constrain templates with concepts so overload sets stay small. The usual fix for the case above is adding constraints, which cuts the candidates considered at every call.
Type-erase at component boundaries. A pipeline should be compile-time inside a component and hidden behind a stable signature at its edge, so both the instantiation cost and the rebuild blast radius stay contained. stdexec ships exec::any_sender_of and stdexec::task_scheduler for exactly this. Same argument as the maintainability skill's blast radius.
Explicit instantiation (extern template) for hot generic types, so each translation unit does not re-instantiate them.
Modules and precompiled headers cut header re-parsing.
Name intermediate stages. Three named views beat one fifteen-stage expression, for the compiler and for the next reader.
Worth knowing: P2011 |>
A proposed pipeline-rewrite operator: x |> f(y) rewrites to f(x, y) at parse time, with no adaptor object created at all. It would remove both the opt-in boilerplate adaptors need today and the debug-build cost, and it would work with any function rather than only ones that opted in. Not in C++26.
When not to
A pipeline is the wrong shape when the stage list is genuinely dynamic (loaded from config, chosen per request from an open set), when the stages are large enough that call overhead is noise, or when the code is cold. There, a vector<function> or a virtual interface is the honest answer and costs nothing you will measure.