C++ Expression Templates — Complete Guide
이 글의 핵심
Expression templates eliminate temporary allocations in chained math operations by building a lazy expression tree evaluated in a single pass. This guide implements a working vector library step by step.
The Problem: Temporary Allocations in Chained Math
Naive vector arithmetic allocates a temporary for every operation:
std::vector<double> a = {1, 2, 3, 4};
std::vector<double> b = {5, 6, 7, 8};
std::vector<double> c = {9, 10, 11, 12};
// Each + allocates a temporary vector
auto temp1 = a + b; // allocates: {6, 8, 10, 12}
auto temp2 = temp1 + c; // allocates: {15, 18, 21, 24}
auto result = temp2; // copy
For a 1 million element vector, that’s 3 heap allocations and 3 full passes over the data for a single expression. Cache traffic triples compared to a single fused loop.
Expression templates build a lazy expression tree. The + operator returns a lightweight description of the operation — no computation happens until you assign the result:
a + b + c → Add(Add(a, b), c) — no allocation
result = ... → for i: result[i] = a[i] + b[i] + c[i] — single pass
The Core Pattern: Expression Nodes
The trick is that operator+ returns an expression type, not a Vector:
#include <cassert>
#include <cstddef>
#include <vector>
#include <iostream>
// Base template for any vector expression
// T is the actual expression type (CRTP)
template<typename T>
struct VecExpr {
double operator[](size_t i) const {
// Delegate to the derived type
return static_cast<const T&>(*this)[i];
}
size_t size() const {
return static_cast<const T&>(*this).size();
}
};
The concrete Vector class holds actual data and inherits from VecExpr:
class Vector : public VecExpr<Vector> {
std::vector<double> data_;
public:
explicit Vector(size_t n, double val = 0.0) : data_(n, val) {}
double operator[](size_t i) const { return data_[i]; }
double& operator[](size_t i) { return data_[i]; }
size_t size() const { return data_.size(); }
// Assignment from any expression — this is where evaluation happens
template<typename E>
Vector& operator=(const VecExpr<E>& expr) {
const E& e = static_cast<const E&>(expr);
assert(data_.size() == e.size());
for (size_t i = 0; i < data_.size(); ++i) {
data_[i] = e[i]; // evaluate one element at a time
}
return *this;
}
// Constructor from expression
template<typename E>
Vector(const VecExpr<E>& expr)
: data_(static_cast<const E&>(expr).size())
{
*this = expr;
}
};
The Add Expression Node
VecAdd holds references to two sub-expressions. It computes element values on demand:
template<typename L, typename R>
class VecAdd : public VecExpr<VecAdd<L, R>> {
const L& lhs_;
const R& rhs_;
public:
VecAdd(const L& l, const R& r) : lhs_(l), rhs_(r) {
assert(l.size() == r.size());
}
// Lazy: compute one element at a time
double operator[](size_t i) const {
return lhs_[i] + rhs_[i];
}
size_t size() const { return lhs_.size(); }
};
// operator+ returns VecAdd — no computation
template<typename L, typename R>
VecAdd<L, R> operator+(const VecExpr<L>& l, const VecExpr<R>& r) {
return VecAdd<L, R>(
static_cast<const L&>(l),
static_cast<const R&>(r));
}
Putting It Together: Zero Temporaries
int main() {
Vector a(4), b(4), c(4), result(4);
a[0]=1; a[1]=2; a[2]=3; a[3]=4;
b[0]=5; b[1]=6; b[2]=7; b[3]=8;
c[0]=9; c[1]=10; c[2]=11; c[3]=12;
// a + b returns VecAdd<Vector, Vector> — no allocation
// (a+b) + c returns VecAdd<VecAdd<Vector,Vector>, Vector> — no allocation
// result = ... evaluates all elements in one pass
result = a + b + c;
for (size_t i = 0; i < result.size(); ++i) {
std::cout << result[i] << ' '; // 15 18 21 24
}
std::cout << '\n';
}
The type of a + b + c is VecAdd<VecAdd<Vector, Vector>, Vector>. When operator= iterates and calls e[i], that recursively calls:
VecAdd<VecAdd<V,V>,V>::operator[](i)
→ VecAdd<V,V>::operator[](i) + c[i]
→ a[i] + b[i] + c[i]
Everything inlines. The compiler sees a single loop over three array reads — equivalent to what you’d write by hand.
Extending: Subtraction and Scalar Multiplication
template<typename L, typename R>
class VecSub : public VecExpr<VecSub<L, R>> {
const L& lhs_;
const R& rhs_;
public:
VecSub(const L& l, const R& r) : lhs_(l), rhs_(r) {}
double operator[](size_t i) const { return lhs_[i] - rhs_[i]; }
size_t size() const { return lhs_.size(); }
};
template<typename L, typename R>
VecSub<L, R> operator-(const VecExpr<L>& l, const VecExpr<R>& r) {
return {static_cast<const L&>(l), static_cast<const R&>(r)};
}
// Scalar * vector — one side is a double, not a VecExpr
template<typename E>
class VecScale : public VecExpr<VecScale<E>> {
double scalar_;
const E& expr_;
public:
VecScale(double s, const E& e) : scalar_(s), expr_(e) {}
double operator[](size_t i) const { return scalar_ * expr_[i]; }
size_t size() const { return expr_.size(); }
};
template<typename E>
VecScale<E> operator*(double s, const VecExpr<E>& e) {
return {s, static_cast<const E&>(e)};
}
// Full expression: result = 2.0 * a + b - c — one pass, zero temporaries
int main() {
Vector a(4), b(4), c(4), result(4);
// ... initialize a, b, c ...
result = 2.0 * a + b - c; // VecAdd<VecScale<V>, VecSub<V,V>>
}
Common Pitfalls
Dangling References
Expression nodes hold references to their operands. If an operand is a temporary that’s destroyed before the expression is evaluated, you have a dangling reference:
// WRONG — dangerous
auto expr = a + (b + Vector(4, 1.0)); // temporary Vector(4,1.0) destroyed after this line
result = expr; // reads dangling reference — undefined behavior
// CORRECT — evaluate immediately or store the temporary
Vector temp(4, 1.0);
result = a + (b + temp); // temp lives long enough
Aliasing: a = a + b
When you write a = a + b, the assignment operator reads and writes a simultaneously. With NRVO-style naive vectors this may work; with expression templates it works correctly because elements are read before being written:
// This is actually safe with our implementation because:
// result = a + b evaluates element-by-element
// data_[i] = e[i] reads a[i] first, then writes data_[i]
a = a + b; // OK
But for matrix multiply (M = M * N), the aliasing is real — M * N evaluates a row-column dot product that reads a row of M while that row is being overwritten. Use a temporary:
Matrix temp = M * N; // evaluate first
M = temp; // then assign
Compile Time Cost
Deep expression templates create deeply nested types. Very long expressions hurt compile time. Break long expressions with named intermediates:
// Slow to compile — 10-deep nested type
result = a + b + c + d + e + f + g + h + i + j;
// Better — named intermediate breaks the type depth
Vector mid = a + b + c + d + e;
result = mid + f + g + h + i + j;
Matrix Expression Templates
The same pattern applies to matrices. Here’s a minimal MatMul that avoids allocating a temporary result:
template<typename T>
struct MatExpr {
double at(size_t r, size_t c) const {
return static_cast<const T&>(*this).at(r, c);
}
};
class Matrix : public MatExpr<Matrix> {
std::vector<double> data_;
size_t rows_, cols_;
public:
Matrix(size_t r, size_t c, double v = 0.0)
: data_(r*c, v), rows_(r), cols_(c) {}
double at(size_t r, size_t c) const { return data_[r*cols_ + c]; }
double& at(size_t r, size_t c) { return data_[r*cols_ + c]; }
size_t rows() const { return rows_; }
size_t cols() const { return cols_; }
template<typename E>
Matrix& operator=(const MatExpr<E>& expr) {
const E& e = static_cast<const E&>(expr);
for (size_t r = 0; r < rows_; ++r)
for (size_t c = 0; c < cols_; ++c)
at(r,c) = e.at(r,c);
return *this;
}
};
How Eigen Uses This Pattern
Eigen’s design is built on expression templates. When you write:
Eigen::VectorXd result = a + 2.0 * b - c;
No intermediate vectors are allocated. The expression evaluates element-by-element in a single loop, and Eigen’s backend can use SIMD instructions on the loop body.
This is why Eigen can be faster than hand-written loops in many cases — it fuses operations in a way the compiler can better optimize.
Key Takeaways
- Expression templates defer computation by returning lightweight expression nodes from operators
- Assignment triggers evaluation — the expression tree evaluates each element exactly once in a single loop
- The technique eliminates intermediate allocations in chained expressions over large arrays
- Dangling references are the main hazard — expression nodes hold references; evaluate before operands are destroyed
- Aliasing (like
a = a + b) is usually safe for element-wise ops but dangerous for operations like matrix multiply - Deep expression chains hurt compile time — split long chains with named intermediates
- In production, use Eigen or Blaze rather than rolling your own — but understanding the pattern helps you use them correctly
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. Deep dive into expression templates: VecExpr trees, vector/matrix ops, aliasing and dangling references, SIMD/parallel.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 또는 관련 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.
Q. 더 깊이 공부하려면?
A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- Cache-Friendly C++: Data-Oriented Design and AoS vs SoA
- C++ Concepts and Constraints | Type Requirements in C++20
- C++20 Modules
이 글에서 다루는 키워드 (관련 검색어)
C++, Expression Template, Templates, Optimization, Lazy, Eigen 등으로 검색하시면 이 글이 도움이 됩니다.