Rust 112 🦀 Building Zero-Cost Abstractions for Domain-Specific Engines
A domain-specific engine is a program that interprets or executes a narrow language: a query planner, a rules engine, a state machine interpreter, a calculation pipeline. The abstraction that makes it expressive is usually a trait or an enum representing the language’s operations, and the abstraction that makes it fast is monomorphization. The goal is to write the engine in terms of composable, type-safe operations and have the compiler reduce the whole thing to the same machine code a hand-written implementation would produce.
This chapter covers the techniques for building domain-specific engines where the abstraction disappears at codegen. It begins with the mechanism that makes zero-cost abstraction possible—monomorphization and inlining—and the specific proof that an iterator chain or a trait-based expression tree compiles to a tight loop with no call instructions. It then addresses the two structural choices that determine whether the abstraction is free: static dispatch through generics and impl Trait, versus dynamic dispatch through dyn Trait and Box. The final sections cover the enum-based interpreter pattern, the newtype wrapper that costs nothing at runtime, and the verification workflow that proves the abstraction is actually gone.
Key point: Zero-cost is a property of the generated code, not of the source code. The abstraction is free only if the compiler can see through it. Generics and impl Trait give the optimizer that visibility. dyn Trait and Box take it away.
Why zero-cost abstraction works
Monomorphization is the first mechanism. When a generic function is called with a concrete type, Rust generates a separate specialized copy of the function for that type. The T in fn process<T: Trait>(x: T) does not exist at runtime; it is replaced by the concrete type at compile time. An iterator adapter like map is a generic struct, and the closure passed to it is a unique anonymous type. The compiler creates a concrete version of the adapter chain for that specific closure, with the closure’s body baked into the generated code .
Inlining is the second mechanism. Once the generic function is monomorphized, its body is small enough for the optimizer to inline into the caller. The filter closure, the map closure, and the sum fold disappear into a single loop. The next() calls that the iterator chain describes at the source level are flattened into direct arithmetic. The cargo-show-asm tool proves this: both a hand-written loop and an iterator chain compile to the same tight loop with no call instructions to any closure or adapter .
The newtype is the third mechanism. A tuple struct with a single field, like struct Meters(f64), is a distinct type at compile time and a bare f64 at runtime. The #[repr(transparent)] attribute guarantees that the wrapper has the same layout as the inner type. The type system prevents adding Meters to Seconds, but the generated code adds two f64 values with no boxing or indirection .
The cost that remains. Monomorphization increases compile time and binary size because each concrete type produces a separate copy. The runtime cost is zero, but the build cost is not. The dyn Trait alternative avoids the code duplication but introduces a vtable lookup and prevents inlining, which is often a larger runtime cost than the code size it saves .
a. Static dispatch and impl Trait
The choice between static and dynamic dispatch is the primary decision for a domain-specific engine. Static dispatch uses generics or impl Trait, and the compiler sees the concrete type at the call site. Dynamic dispatch uses dyn Trait or Box<dyn Trait>, and the concrete type is erased behind a vtable pointer.
// Static dispatch: the engine is generic over the operations.
// Each concrete type produces a monomorphized copy.
trait Operation {
fn apply(&self, input: i64) -> i64;
}
fn run_engine<O: Operation>(ops: &[O], input: i64) -> i64 {
ops.iter().fold(input, |acc, op| op.apply(acc))
}
// The compiler sees the concrete type and can inline apply().
The impl Trait form is the same mechanism with less ceremony. In argument position, fn run_engine(ops: &[impl Operation]) is equivalent to the generic version. In return position, fn make_engine() -> impl Fn(i64) -> i64 returns a concrete closure type that the caller can inline .
// impl Trait in return position: the concrete type is hidden but static.
fn make_multiplier(factor: i64) -> impl Fn(i64) -> i64 {
move |x| x * factor
}
// The caller gets a concrete closure type, not a trait object.
// The closure is monomorphized and inlined at the call site.
The rule is: use impl Trait or generics for the hot path of the engine, where the operations are known at compile time and the call frequency justifies the code duplication. Use dyn Trait only when the set of operations is open (a plugin system) or when the binary size is a hard constraint .
b. The enum interpreter and the GADT-style encoding
An enum-based interpreter is the alternative to trait objects for a closed set of operations. The enum variants represent the language’s constructs, and the interpreter is a match on the variant. The compiler sees the full set of variants and can optimize the dispatch into a jump table or a series of branches.
// A closed set of operations: the enum is the language.
enum Expr {
Const(i64),
Add(Box<Expr>, Box<Expr>),
Mul(Box<Expr>, Box<Expr>),
}
// The interpreter is a recursive match.
// The Box is heap allocation; the dispatch is static.
fn eval(expr: &Expr) -> i64 {
match expr {
Expr::Const(n) => *n,
Expr::Add(a, b) => eval(a) + eval(b),
Expr::Mul(a, b) => eval(a) * eval(b),
}
}
The enum approach is closed: adding a new operation requires editing the enum and every match. The trait approach is open: adding a new operation is a new impl. The trade-off is extensibility versus the optimizer’s visibility. For a domain-specific engine where the language is fixed and the performance matters, the enum is often the better choice because the match is a single dispatch point that LLVM can optimize .
The GADT-style encoding is a more advanced variant of the enum pattern. It uses the type system to ensure that only one variant is constructible at a time, and the interpreter is a trait method that the compiler erases entirely. The inferara blog post demonstrates that a complex expression tree with lambdas and applications compiles to a series of arithmetic instructions (leaq, addq) with no interpreter loop, no dynamic dispatch, and no allocation . The technique is niche, but it proves the principle: if the type system can encode the language, the compiler can erase the interpreter.
c. Verification and the cargo-show-asm workflow
The claim that an abstraction is zero-cost is not verifiable by reading the source. It is verifiable by reading the assembly. cargo-show-asm dumps the release-mode assembly for a single function, with the source lines interleaved.
# Install the tool.
cargo install cargo-show-asm
# Show the release assembly for a function.
cargo asm --lib sum_sq_evens_loop --rust
cargo asm --lib sum_sq_evens_iter --rust
The two functions in the cargo-show-asm example express the same computation: sum the squares of the even numbers in a slice. The loop version uses an index and an if. The iterator version uses filter and map. The assembly for both, on x86-64 with release optimization, is a tight loop with no call instructions to any closure or adapter. The optimizer has inlined the closures and fused the chain into a single loop, often with SIMD instructions (paddq, pmuludq) that process multiple values per instruction .
The verification checklist has two items. First, no call instructions to the functions that implement the abstraction. If the abstraction is not zero-cost, there will be a call into a generic function or a vtable lookup. Second, the loop body is the same arithmetic in both versions. If the iterator version is doing extra work, the assembly will show it.
The #[inline] attribute is relevant only at crate boundaries. Within a crate, the optimizer inlines small functions at opt-level = 3 without the attribute. The attribute is necessary when a function in one crate is called from another crate, because the optimizer cannot see the body across the boundary without it. Over-inlining bloats the instruction cache and can make the program slower, so the attribute should be applied only when the profiler shows a hot function is not being inlined .
Complete Example Session
// ============================================
// PART 1: THE DOMAIN OPERATIONS
// ============================================
// A trait representing a transformation on an integer.
trait Transform {
fn apply(&self, x: i64) -> i64;
}
struct Add(i64);
impl Transform for Add {
fn apply(&self, x: i64) -> i64 { x + self.0 }
}
struct Mul(i64);
impl Transform for Mul {
fn apply(&self, x: i64) -> i64 { x * self.0 }
}
// ============================================
// PART 2: THE STATIC ENGINE
// ============================================
// Generic over the operation, monomorphized at the call site.
fn run_engine<T: Transform>(transforms: &[T], input: i64) -> i64 {
transforms.iter().fold(input, |acc, t| t.apply(acc))
}
// ============================================
// PART 3: THE DYNAMIC ENGINE
// ============================================
// Trait object: the concrete type is erased.
fn run_engine_dyn(transforms: &[Box<dyn Transform>], input: i64) -> i64 {
transforms.iter().fold(input, |acc, t| t.apply(acc))
}
// ============================================
// PART 4: THE ENUM ENGINE
// ============================================
// Closed set, match dispatch, no vtable.
enum Op {
Add(i64),
Mul(i64),
}
fn run_engine_enum(ops: &[Op], input: i64) -> i64 {
ops.iter().fold(input, |acc, op| match op {
Op::Add(n) => acc + n,
Op::Mul(n) => acc * n,
})
}
// ============================================
// PART 5: THE NEWTYPE
// ============================================
// Compile-time safety, runtime identity.
#[repr(transparent)]
struct Meters(f64);
#[repr(transparent)]
struct Seconds(f64);
fn speed(distance: Meters, time: Seconds) -> f64 {
distance.0 / time.0
}
// ============================================
// PART 6: THE ITERATOR CHAIN
// ============================================
// The canonical zero-cost example.
fn sum_even_squares_iter(values: &[i64]) -> i64 {
values.iter()
.filter(|&&x| x % 2 == 0)
.map(|&x| x * x)
.sum()
}
fn sum_even_squares_loop(values: &[i64]) -> i64 {
let mut sum = 0;
for &x in values {
if x % 2 == 0 {
sum += x * x;
}
}
sum
}
// ============================================
// PART 7: THE VERIFICATION
// ============================================
// cargo asm --lib run_engine --rust
// Look for: no call instructions, tight loop.
//
// cargo asm --lib sum_even_squares_iter --rust
// cargo asm --lib sum_even_squares_loop --rust
// Both should produce the same loop body.
// ============================================
// PART 8: THE INLINE BOUNDARY
// ============================================
// Cross-crate: #[inline] exposes the body.
#[inline]
pub fn fast_apply<T: Transform>(t: &T, x: i64) -> i64 {
t.apply(x)
}
// ============================================
// PART 9: THE DISPATCH DECISION
// ============================================
// Static: run_engine, run_engine_enum
// Dynamic: run_engine_dyn
// Newtype: Meters, Seconds
// ============================================
// PART 10: THE FULL PICTURE
// ============================================
// The engine is the abstraction.
// The assembly is the proof.
fn main() {
let transforms = vec![Add(1), Mul(2)];
let input = 5;
// Static: monomorphized.
let result = run_engine(&transforms, input);
// Dynamic: vtable.
let boxed: Vec<Box<dyn Transform>> = vec![Box::new(Add(1)), Box::new(Mul(2))];
let result_dyn = run_engine_dyn(&boxed, input);
// Enum: match dispatch.
let ops = vec![Op::Add(1), Op::Mul(2)];
let result_enum = run_engine_enum(&ops, input);
assert_eq!(result, result_dyn);
assert_eq!(result, result_enum);
// Newtype: same bits, different type.
let distance = Meters(100.0);
let time = Seconds(9.58);
let speed = speed(distance, time);
assert_eq!(std::mem::size_of::<Meters>(), std::mem::size_of::<f64>());
}
The ten parts build a comparison of the three dispatch strategies for a domain engine: generic (static), trait object (dynamic), and enum (closed set), plus the newtype and the iterator chain as separate zero-cost patterns. The verification step is the assembly inspection.
Quick Reference
Dispatch Strategies
| Strategy | Syntax | Runtime Cost | Code Size |
|---|---|---|---|
| Static generic | fn f<T: Trait>(x: T) | Zero | Grows per type |
impl Trait | fn f(x: impl Trait) | Zero | Grows per type |
| Dynamic | fn f(x: &dyn Trait) | Vtable lookup | Minimal |
| Boxed dynamic | fn f(x: Box<dyn Trait>) | Vtable + heap | Minimal |
| Enum | match on variants | Jump/branch | One copy |
Zero-Cost Mechanisms
| Mechanism | What It Does | Proof |
|---|---|---|
| Monomorphization | Specialized copy per type | No call to generic |
| Inlining | Flattens the body into caller | Tight loop, no call |
#[repr(transparent)] | Newtype has same layout | size_of equal |
| Iterator fusion | Chain becomes one loop | Same assembly as loop |
Verification Commands
| Command | Purpose |
|---|---|
cargo asm --lib fn --rust | Assembly with source lines |
cargo asm --lib fn | Assembly only |
Inline Rules
| Context | Needs #[inline]? |
|---|---|
| Same crate | No, optimizer inlines |
| Cross-crate | Yes, exposes body |
| Hot small function | Yes, if profiler shows not inlined |
Best Practices
✅ Do This:
// Use generics for the hot path of the engine.
fn run<T: Transform>(ops: &[T]) { ... } // ✅
// Use enum for a closed operation set.
enum Op { Add, Mul } // ✅
// Use #[repr(transparent)] for newtypes.
#[repr(transparent)] struct Meters(f64); // ✅
// Verify with cargo asm before claiming zero-cost.
cargo asm --lib run --rust // ✅
// Use #[inline] only at crate boundaries or when profiled.
#[inline] pub fn hot<T: Transform>(...); // ✅
❌ Don’t Do This:
// Don't use Box<dyn Trait> in a hot loop without profiling.
for _ in 0..1_000_000 { obj.apply(x); } // ❌
// Don't assume the abstraction is free without checking.
// The source says nothing about the generated code. // ❌
// Don't annotate everything #[inline].
// Over-inlining bloats the instruction cache. // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Iterator chain slower than loop | Debug build, or hidden allocation in collect | Check --release, remove collect |
| Vtable call in hot loop | dyn Trait used for closed set | Use enum or generics |
| Newtype has overhead | No #[repr(transparent)] | Add the attribute |
| Cross-crate no inlining | Function body not exposed | #[inline] |
| Binary too large | Too many monomorphizations | dyn for cold paths, or enum |
Real-World Examples
1. Generic Engine
fn run<T: Transform>(ops: &[T], x: i64) -> i64 { ops.iter().fold(x, |a, t| t.apply(a)) }
2. Dynamic Engine
fn run_dyn(ops: &[Box<dyn Transform>], x: i64) -> i64 { ... }
3. Enum Engine
fn run_enum(ops: &[Op], x: i64) -> i64 { ... }
4. Newtype
#[repr(transparent)] struct Meters(f64);
5. Iterator Fusion
values.iter().filter(...).map(...).sum()
6. Assembly Check
cargo asm --lib sum_even_squares_iter --rust
7. Inline Boundary
#[inline] pub fn fast<T: Trait>(t: &T) -> i64 { t.apply(0) }
8. impl Trait Return
fn make() -> impl Fn(i64) -> i64 { |x| x * 2 }
9. Size Check
assert_eq!(std::mem::size_of::<Meters>(), 8);
10. GADT-Style
// Expression tree erased to arithmetic
Visual
The Monomorphization Pipeline
┌─────────────────────────────────────────────────────────┐
│ SOURCE │
│ fn run<T: Transform>(ops: &[T]) { ... } │
│ │ │
│ ▼ monomorphization │
│ run<Add> run<Mul> run<AddMul> │
│ │ │
│ ▼ inlining │
│ A single loop per concrete type │
│ No call instructions, no vtable │
│ │ │
│ ▼ │
│ MACHINE CODE │
│ Same as a hand-written loop │
└─────────────────────────────────────────────────────────┘
Static vs Dynamic
┌─────────────────────────────────────────────────────────┐
│ STATIC (generic / impl Trait) │
│ - Concrete type known at compile time │
│ - Direct call, inlinable │
│ - Zero runtime cost │
│ - Binary grows with type count │
│ │
│ ───────────────────────────────────────────────────── │
│ │
│ DYNAMIC (dyn Trait / Box) │
│ - Concrete type erased │
│ - Vtable lookup, indirect call │
│ - Prevents inlining │
│ - Binary stays small │
└─────────────────────────────────────────────────────────┘
The Enum Interpreter
┌─────────────────────────────────────────────────────────┐
│ enum Op { Add(i64), Mul(i64) } │
│ │ │
│ ▼ match op │
│ Op::Add(n) => acc + n │
│ Op::Mul(n) => acc * n │
│ │
│ One dispatch point. │
│ LLVM can turn the match into a jump table. │
│ No vtable, no heap indirection. │
└─────────────────────────────────────────────────────────┘
The Assembly Proof
┌─────────────────────────────────────────────────────────┐
│ sum_sq_evens_loop sum_sq_evens_iter │
│ ───────────────────── ──────────────────────────── │
│ for &x in values { values.iter() │
│ if x % 2 == 0 { .filter(|x| x % 2 == 0) │
│ sum += x * x; .map(|x| x * x) │
│ } .sum() │
│ } │
│ │
│ BOTH COMPILE TO: │
│ ┌───────────────────────────────────────────────────┐ │
│ │ loop: │ │
│ │ test %rax, 1 │ │
│ │ jne skip │ │
│ │ imul %rax, %rax │ │
│ │ add %rax, %sum │ │
│ │ skip: │ │
│ │ inc %index │ │
│ │ cmp %len, %index │ │
│ │ jl loop │ │
│ └───────────────────────────────────────────────────┘ │
│ │
│ No call instructions. The abstraction is gone. │
└─────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Monomorphization | Specialized copy per concrete type |
| Inlining | Flattens the body into the caller |
| Static dispatch | Generics, impl Trait, no runtime cost |
| Dynamic dispatch | dyn Trait, vtable, indirect call |
| Enum interpreter | Closed set, match dispatch, one copy |
| Newtype | #[repr(transparent)], zero runtime cost |
| Verification | cargo asm --lib fn --rust |
#[inline] | Cross-crate exposure, not blanket |
Key takeaways:
- Zero-cost is a property of the generated code. The source can be expressive and abstract; the proof is in the assembly.
cargo asmshows whether the abstraction disappeared . - Monomorphization and inlining are the two mechanisms. The generic function is specialized per type, and the specialized body is inlined into the caller. The iterator chain becomes a loop because the closures are monomorphized and inlined .
- Static dispatch is the default for the hot path. Generics and
impl Traitgive the optimizer visibility.dyn TraitandBoxtake it away . - The enum is the alternative for a closed set. A
matchon an enum is a single dispatch point that LLVM can optimize. It avoids the vtable and the code duplication of monomorphization . - The newtype is free with
#[repr(transparent)]. The type system enforces the distinction; the runtime sees the inner type . - Verify, do not assume. The abstraction that looks free may be hiding a
collect, aclone, or a vtable call. The assembly is the evidence .
Remember: A domain-specific engine is a language. The trait or enum is the grammar, the interpreter is the semantics, and the compiler is the optimizer. The zero-cost promise is the claim that the grammar and the semantics do not exist at runtime; only the computation remains. That claim is true when the abstraction is monomorphized and inlined, and false when it is boxed or dynamically dispatched. The engine is fast not because it is written with traits, but because the compiler can see through the traits to the arithmetic underneath.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!