본문으로 건너뛰기 C++ Expression Templates — Complete Guide

C++ Expression Templates — Complete Guide

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 등으로 검색하시면 이 글이 도움이 됩니다.