C++ Expression Templates: 벡터 연산의 임시 객체를 없애는 지연 평가 구현
이 글의 핵심
C++ Expression Templates는 CRTP를 이용해 a + b + c 같은 연산 체인의 중간 임시 객체를 제거하고 단일 루프로 지연 평가하는 기법입니다. 벡터·행렬 연산, Eigen/Blaze 같은 실무 라이브러리 사례, 댕글링 참조 등 실전 함정까지 정리합니다.
문제 상황
operator+가 매번 Vector result(...)라는 새 객체를 값으로 반환하도록 구현되어 있다는 점이 이 코드의 병목입니다. a + b + c + d는 왼쪽부터 결합되므로 실제로는 ((a + b) + c) + d로 평가되고, 각 + 호출마다 힙에 할당된 vector<double>을 새로 만들고 결과를 복사해 반환하는 임시 객체가 하나씩 생깁니다 — 네 개를 더하는 이 식 하나가 실제로는 원소 개수에 비례하는 메모리 할당을 세 번, 그리고 그만큼의 불필요한 값 복사를 발생시킵니다. 벡터 크기가 작으면 무시할 수준이지만, 수치 계산 라이브러리처럼 수백만 개 원소를 가진 벡터를 다룰 때는 이 “숨겨진” 임시 객체 할당이 실제 계산량보다 더 큰 비용을 차지하는 경우가 흔합니다.
// 단순 벡터 구현
class Vector {
vector<double> data;
public:
Vector(size_t n) : data(n) {}
Vector operator+(const Vector& other) const {
Vector result(data.size());
for (size_t i = 0; i < data.size(); i++) {
result.data[i] = data[i] + other.data[i];
}
return result; // 임시 객체 생성
}
};
// 문제: 임시 객체 3개 생성
Vector a, b, c, d;
Vector result = a + b + c + d;
// temp1 = a + b
// temp2 = temp1 + c
// temp3 = temp2 + d
// result = temp3
“C++11의 이동 의미론이 이미 해결하지 않았나?”라는 질문이 자연스럽게 나옵니다. 부분적으로만 그렇습니다. Vector operator+(Vector&& lhs, const Vector& rhs) 오버로드를 추가해 임시 객체의 버퍼를 재사용하면 할당은 한 번으로 줄일 수 있습니다. 하지만 여전히 + 하나마다 전체 배열을 한 번씩 훑는 별도의 루프가 돌기 때문에, 원소 100만 개짜리 벡터 네 개를 더하면 메모리를 세 번 왕복합니다. 데이터가 캐시에 들어가지 않는 크기라면 이 왕복이 실제 비용의 대부분입니다. 표현식 템플릿은 할당뿐 아니라 이 루프들까지 하나로 합친다는 점(loop fusion)에서 이동 의미론과 다른 차원의 최적화입니다.
Expression Templates 해결
표현식 템플릿의 핵심 아이디어는 “a + b가 즉시 계산된 결과를 반환하는 대신, 나중에 계산하는 방법을 아는 작은 객체를 반환하게 만들자”는 것입니다. VecAdd<E1, E2>는 실제 덧셈을 수행하지 않고 u와 v에 대한 참조만 들고 있다가, operator[]가 호출되는 순간에야 u[i] + v[i]를 계산합니다. Vector result = a + b + c를 평가하면 a + b는 VecAdd<Vector, Vector> 타입의 값(참조만 담은 가벼운 객체)을 반환하고, 그 결과에 + c를 더하면 VecAdd<VecAdd<Vector, Vector>, Vector>라는 더 깊이 중첩된 타입이 만들어지며 — 이 중첩된 표현식 트리 전체는 Vector의 생성자가 expr[i]를 호출해 실제로 결과를 채워 넣는 단 한 번의 루프에서야 비로소 “실행”됩니다. 즉 세 개의 임시 Vector 대신, 컴파일 타임에만 존재하는 타입 트리 하나와 런타임의 단일 루프로 대체된 것입니다 — CRTP(VecExpression<E>의 static_cast<const E&>(*this))가 이 모든 표현식 타입에 대해 가상 함수 없이 정적으로 operator[]/size()를 호출할 수 있게 해 주는 기반입니다.
// 표현식 템플릿
template<typename E>
class VecExpression {
public:
double operator[](size_t i) const {
return static_cast<const E&>(*this)[i];
}
size_t size() const {
return static_cast<const E&>(*this).size();
}
};
// 실제 벡터
class Vector : public VecExpression<Vector> {
vector<double> data;
public:
Vector(size_t n) : data(n) {}
double operator[](size_t i) const { return data[i]; }
double& operator[](size_t i) { return data[i]; }
size_t size() const { return data.size(); }
// 표현식으로부터 생성
template<typename E>
Vector(const VecExpression<E>& expr) : data(expr.size()) {
for (size_t i = 0; i < expr.size(); i++) {
data[i] = expr[i]; // 지연 평가
}
}
};
// 덧셈 표현식
template<typename E1, typename E2>
class VecAdd : public VecExpression<VecAdd<E1, E2>> {
const E1& u;
const E2& v;
public:
VecAdd(const E1& u, const E2& v) : u(u), v(v) {}
double operator[](size_t i) const {
return u[i] + v[i];
}
size_t size() const { return u.size(); }
};
// 연산자 오버로딩
template<typename E1, typename E2>
VecAdd<E1, E2> operator+(const VecExpression<E1>& u, const VecExpression<E2>& v) {
return VecAdd<E1, E2>(static_cast<const E1&>(u), static_cast<const E2&>(v));
}
int main() {
Vector a(3), b(3), c(3);
// 임시 객체 없음!
Vector result = a + b + c; // 한 번의 루프로 계산
}
이 최소 구현에는 실제로 쓰기 전에 채워야 할 빈칸이 몇 개 있습니다. 첫째, 크기 검사가 없습니다. VecAdd::size()는 u.size()만 돌려주므로 길이가 다른 벡터를 더하면 짧은 쪽을 범위 밖까지 읽습니다. 크기는 런타임 값이라 static_assert로는 잡을 수 없고, VecAdd 생성자에 assert(u.size() == v.size())를 두는 것이 보통입니다. 둘째, 표현식을 받는 대입 연산자가 없습니다. 이미 존재하는 result에 result = a + b;를 하면 컴파일은 되지만, 변환 생성자로 임시 Vector를 하나 만든 뒤 복사(또는 이동) 대입하므로 없애려던 할당이 다시 생깁니다. template<class E> Vector& operator=(const VecExpression<E>& e)를 추가해 기존 버퍼에 바로 쓰도록 해야 합니다. 셋째, 최적화가 전제입니다. -O0 빌드에서는 operator[] 호출이 중첩 깊이만큼 인라인되지 않고 실제 함수 호출로 남아서, 표현식 템플릿 버전이 단순 구현보다 더 느린 경우가 흔합니다. 디버그 빌드에서 느리다고 기법이 실패한 것은 아닙니다.
실전 예시
예시 1: 벡터 연산
VecScale을 추가하면 (a + b) * 2.0 + a * 3.0처럼 여러 연산자가 섞인 식도 같은 원리로 하나의 표현식 트리로 합쳐지고, 여전히 단 한 번의 루프에서 평가됩니다. dot 함수가 VecExpression<E1>&를 매개변수로 받는 것도 같은 이유입니다 — 구체적인 Vector 타입이 아니라 표현식 인터페이스를 받으므로, dot(a, b)뿐 아니라 dot(a + b, c * 2.0)처럼 아직 평가되지 않은 표현식을 직접 넘겨도 그 안에서 즉시 []로 값을 읽어 임시 벡터를 만들지 않고 내적을 계산할 수 있습니다.
// 곱셈 표현식
template<typename E>
class VecScale : public VecExpression<VecScale<E>> {
const E& v;
double scalar;
public:
VecScale(const E& v, double s) : v(v), scalar(s) {}
double operator[](size_t i) const {
return v[i] * scalar;
}
size_t size() const { return v.size(); }
};
template<typename E>
VecScale<E> operator*(const VecExpression<E>& v, double scalar) {
return VecScale<E>(static_cast<const E&>(v), scalar);
}
// 내적
template<typename E1, typename E2>
double dot(const VecExpression<E1>& u, const VecExpression<E2>& v) {
double result = 0;
for (size_t i = 0; i < u.size(); i++) {
result += u[i] * v[i];
}
return result;
}
int main() {
Vector a(3), b(3);
// 복잡한 표현식도 최적화
Vector result = (a + b) * 2.0 + a * 3.0;
double d = dot(a, b);
}
예시 2: 행렬 연산
행렬로 확장할 때 유일하게 달라지는 부분은 인덱싱 방식(operator[](i) 대신 2차원 operator()(i, j))뿐이고, CRTP 기반 표현식 트리 구조 자체는 벡터와 완전히 동일합니다. 이것이 표현식 템플릿 기법의 실질적 강점입니다 — 한 번 구축한 지연 평가 프레임워크(베이스 표현식 클래스, 연산자 오버로딩 패턴)를 벡터, 행렬, 텐서 등 인덱싱 가능한 어떤 수치 자료구조에도 거의 그대로 재사용할 수 있습니다. 실제로 Eigen 같은 라이브러리가 벡터·행렬·희소 행렬을 모두 하나의 일관된 표현식 템플릿 프레임워크로 다루는 것이 이 확장성 덕분입니다.
template<typename E>
class MatExpression {
public:
double operator()(size_t i, size_t j) const {
return static_cast<const E&>(*this)(i, j);
}
size_t rows() const {
return static_cast<const E&>(*this).rows();
}
size_t cols() const {
return static_cast<const E&>(*this).cols();
}
};
class Matrix : public MatExpression<Matrix> {
vector<double> data;
size_t nrows, ncols;
public:
Matrix(size_t r, size_t c) : data(r * c), nrows(r), ncols(c) {}
double operator()(size_t i, size_t j) const {
return data[i * ncols + j];
}
double& operator()(size_t i, size_t j) {
return data[i * ncols + j];
}
size_t rows() const { return nrows; }
size_t cols() const { return ncols; }
template<typename E>
Matrix(const MatExpression<E>& expr)
: data(expr.rows() * expr.cols()),
nrows(expr.rows()),
ncols(expr.cols()) {
for (size_t i = 0; i < nrows; i++) {
for (size_t j = 0; j < ncols; j++) {
(*this)(i, j) = expr(i, j);
}
}
}
};
template<typename E1, typename E2>
class MatAdd : public MatExpression<MatAdd<E1, E2>> {
const E1& u;
const E2& v;
public:
MatAdd(const E1& u, const E2& v) : u(u), v(v) {}
double operator()(size_t i, size_t j) const {
return u(i, j) + v(i, j);
}
size_t rows() const { return u.rows(); }
size_t cols() const { return u.cols(); }
};
template<typename E1, typename E2>
MatAdd<E1, E2> operator+(const MatExpression<E1>& u, const MatExpression<E2>& v) {
return MatAdd<E1, E2>(static_cast<const E1&>(u), static_cast<const E2&>(v));
}
예시 3: 지연 평가 리스트
ListMap은 표현식 템플릿이 산술 연산을 넘어 임의의 변환 함수에도 적용될 수 있음을 보여줍니다. auto expr = map(a, [](int x) { return x * 2; }) 시점에는 a의 원소를 하나도 건드리지 않고 “나중에 각 원소에 2를 곱하겠다”는 계획만 담긴 ListMap<List> 객체가 만들어지며, List result = expr로 대입하는 순간에야 List의 생성자가 expr[i]를 호출해 실제 곱셈이 일어납니다. 이 지연성 덕분에 여러 map을 연쇄하거나 filter 같은 다른 연산과 결합해도 중간 컨테이너를 전혀 만들지 않고 최종적으로 필요한 순간에만 한 번에 계산할 수 있는데, 이는 함수형 언어의 지연 시퀀스(lazy sequence)나 최근 C++의 std::ranges 뷰가 채택한 것과 본질적으로 같은 발상입니다.
template<typename E>
class ListExpression {
public:
int operator[](size_t i) const {
return static_cast<const E&>(*this)[i];
}
size_t size() const {
return static_cast<const E&>(*this).size();
}
};
class List : public ListExpression<List> {
vector<int> data;
public:
List(initializer_list<int> init) : data(init) {}
int operator[](size_t i) const { return data[i]; }
size_t size() const { return data.size(); }
template<typename E>
List(const ListExpression<E>& expr) : data(expr.size()) {
for (size_t i = 0; i < expr.size(); i++) {
data[i] = expr[i];
}
}
};
template<typename E>
class ListMap : public ListExpression<ListMap<E>> {
const E& list;
function<int(int)> func;
public:
ListMap(const E& l, function<int(int)> f) : list(l), func(f) {}
int operator[](size_t i) const {
return func(list[i]);
}
size_t size() const { return list.size(); }
};
template<typename E>
ListMap<E> map(const ListExpression<E>& list, function<int(int)> func) {
return ListMap<E>(static_cast<const E&>(list), func);
}
int main() {
List a = {1, 2, 3, 4, 5};
// 지연 평가
auto expr = map(a, [](int x) { return x * 2; });
// 여기서 실제 계산
List result = expr;
for (size_t i = 0; i < result.size(); i++) {
cout << result[i] << " "; // 2 4 6 8 10
}
}
이 예제는 설명을 단순하게 하려고 변환 함수를 std::function<int(int)>에 담았는데, 성능 면에서는 앞의 산술 예제와 성격이 다릅니다. std::function은 타입 소거를 위해 호출할 때마다 간접 호출을 거치고, 컴파일러가 람다 본문을 루프 안으로 인라인하기 어려워 벡터화도 막힙니다. 표현식 템플릿의 이점을 살리려면 template<class E, class F> class ListMap처럼 함수 객체 타입을 템플릿 인자로 받아 값으로 저장해야 합니다. 그러면 ListMap<List, 람다타입>이라는 고유 타입이 생기고 람다 호출이 완전히 인라인됩니다.
성능 비교
이 벤치마크가 측정하는 차이는 CPU 연산량이 아니라 메모리 트래픽입니다 — normalAdd는 원소 100만 개짜리 vector<double>을 두 번(temp1, temp2) 새로 할당하고, 그 결과를 다시 읽고 쓰는 과정에서 캐시를 반복적으로 오염시킵니다. etAdd는 단 하나의 결과 벡터만 할당하고, 그 벡터를 채우는 단일 루프 안에서 a[i] + b[i] + c[i]를 한 번에 계산하므로 메모리 접근 패턴이 훨씬 캐시 친화적입니다. 실제로 이런 벤치마크를 돌려 보면 표현식 템플릿 버전이 일반 버전보다 몇 배 빠른 경우가 흔한데, 그 차이는 대체로 “덜 계산해서”가 아니라 “덜 할당하고, 메모리를 덜 오가서” 생깁니다 — 이 점을 이해하면 언제 표현식 템플릿이 실제로 이득이 되는지(연산 체인이 길고 원소 수가 많을 때) 가늠하기 쉬워집니다.
#include <chrono>
// 일반 방식
Vector normalAdd(const Vector& a, const Vector& b, const Vector& c) {
Vector temp1 = a + b; // 임시 객체 1
Vector temp2 = temp1 + c; // 임시 객체 2
return temp2;
}
// Expression Templates
Vector etAdd(const Vector& a, const Vector& b, const Vector& c) {
return a + b + c; // 임시 객체 없음
}
int main() {
Vector a(1000000), b(1000000), c(1000000);
auto start = chrono::high_resolution_clock::now();
Vector r1 = normalAdd(a, b, c);
auto end = chrono::high_resolution_clock::now();
cout << "일반: " << chrono::duration_cast<chrono::milliseconds>(end - start).count() << "ms" << endl;
start = chrono::high_resolution_clock::now();
Vector r2 = etAdd(a, b, c);
end = chrono::high_resolution_clock::now();
cout << "ET: " << chrono::duration_cast<chrono::milliseconds>(end - start).count() << "ms" << endl;
}
자주 발생하는 문제
문제 1: 댕글링 참조
표현식 템플릿의 가장 위험한 함정입니다. VecAdd나 VecScale 같은 표현식 노드는 성능을 위해 피연산자를 값이 아니라 const E& 참조로 저장합니다 — 이는 표현식을 즉시 평가하지 않고 나중까지 지연시키는 이 기법의 핵심 전제이지만, 동시에 표현식 객체의 수명이 참조 대상보다 오래 살아남으면 안 된다는 뜻이기도 합니다. auto expr = a + b;처럼 auto로 표현식 자체를 저장해 두면 expr은 a와 b에 대한 참조만 들고 있는 상태로 존재하게 되고, 이후 a나 b가 스코프를 벗어나 소멸하면 expr은 이미 사라진 객체를 참조하는 댕글링 참조가 됩니다. 이 문제는 표현식 템플릿을 라이브러리로 제공할 때 실제로 사용자가 저지르기 쉬운 실수이므로(Eigen 문서에도 auto로 표현식을 저장하지 말라는 경고가 명시적으로 있습니다), 표현식은 반드시 그 자리에서 구체 타입(Vector)으로 즉시 평가해 저장해야 안전합니다.
// ❌ 위험
auto expr = a + b; // 표현식 저장
// a, b 소멸
// expr 사용 → 댕글링 참조!
// ✅ 즉시 평가
Vector result = a + b;
더 까다로운 경우는 a와 b가 멀쩡히 살아 있는데도 터지는 경우입니다. auto expr = (a + b) + c;에서 바깥 VecAdd는 안쪽 a + b가 만든 임시 VecAdd 객체를 참조로 들고 있는데, 그 임시 객체는 이 문장이 끝나는 순간 소멸합니다. 그래서 다음 줄에서 expr[0]을 읽는 것만으로 이미 정의되지 않은 동작이고, 최적화 수준에 따라 맞는 값이 나오기도 해서 발견이 늦어집니다. 실무 라이브러리는 이 문제를 피하려고 잎 노드(실제 벡터)는 참조로, 중간 표현식 노드는 값으로 저장합니다. Eigen의 nested 트레이트가 바로 이 선택을 하는 장치입니다. 직접 구현한다면 같은 규칙을 따르거나, 표현식 타입을 auto로 받지 못하게 문서화하는 것이 최소한의 방어입니다.
문제 2: 복잡한 에러 메시지
표현식이 깊게 중첩될수록 그 표현식의 실제 타입도 VecAdd<VecAdd<VecScale<Vector>, Vector>, VecAdd<Vector, Vector>>처럼 기하급수적으로 길고 중첩된 템플릿 인스턴스가 됩니다. 타입이 맞지 않는 실수(예: 크기가 다른 벡터를 더하려는 시도)를 하면, 컴파일러는 이 깊이 중첩된 타입 전체를 에러 메시지에 그대로 펼쳐내는 경우가 많아 실제 원인(맨 안쪽의 타입 불일치)을 찾기까지 수십 줄의 템플릿 인스턴스화 이력을 읽어야 할 수 있습니다. static_assert를 연산자 오버로딩 지점에 미리 넣어 두면, 실패 조건을 표현식이 완전히 인스턴스화되기 전에 짧고 읽기 쉬운 메시지로 미리 잡아낼 수 있어 이런 “템플릿 에러 지옥”을 상당 부분 피할 수 있습니다.
// 에러 발생 시 긴 템플릿 에러
Vector result = a + b + c + d;
// static_assert로 명확한 에러 제공
// (각 표현식 타입에 using value_type = double; 같은 별칭이 정의되어 있다고 가정)
template<typename E1, typename E2>
VecAdd<E1, E2> operator+(const VecExpression<E1>& u, const VecExpression<E2>& v) {
static_assert(is_same_v<typename E1::value_type, typename E2::value_type>,
"타입이 일치해야 합니다");
return VecAdd<E1, E2>(static_cast<const E1&>(u), static_cast<const E2&>(v));
}
C++20을 쓸 수 있다면 static_assert 대신 콘셉트로 operator+의 인자를 제약하는 편이 더 낫습니다. static_assert는 이미 그 오버로드가 선택된 뒤에 실패하지만, 콘셉트는 조건을 만족하지 않는 후보를 오버로드 집합에서 빼 버리므로 “이 타입 조합에 맞는 operator+가 없다”는 짧은 에러와 함께 어떤 제약이 실패했는지를 알려 줍니다. 제가 이런 코드를 디버깅할 때 효과를 본 또 다른 방법은, 에러 메시지에 나온 긴 타입 이름을 복사해 VecAdd<, VecScale< 단위로 줄바꿈해 보는 것입니다. 중첩 구조가 눈에 보이면 어느 노드에서 타입이 어긋났는지 금방 드러납니다.
문제 3: 컴파일 시간
표현식이 깊어질수록 컴파일러는 그만큼 더 깊이 중첩된 새 템플릿을 매번 새로 인스턴스화해야 하고, 각 인스턴스화는 타입 추론·오버로드 해석·인라이닝 시도 등 컴파일러 내부 작업을 수반합니다. 문제 2에서 본 것처럼 표현식 타입 자체가 기하급수적으로 커지므로, 표현식 하나가 길어질수록 컴파일 시간도 선형이 아니라 훨씬 가파르게 늘어날 수 있습니다. 여러 개의 표현식 템플릿 라이브러리 호출이 한 함수 안에 몰려 있고 빌드 시간이 실제로 문제가 된다면, 중간 결과를 구체 타입 변수에 담아 표현식 트리를 의도적으로 “끊어 주는” 것이 실무적인 해법입니다 — 런타임 성능은 약간 손해 볼 수 있지만(중간 임시 객체가 다시 생기므로) 컴파일 시간과의 트레이드오프를 컨트롤할 수 있습니다.
// 복잡한 표현식은 컴파일 시간 증가
Vector result = (a + b) * 2.0 + (c - d) * 3.0 + e;
// 필요하면 중간 결과 저장
Vector temp1 = (a + b) * 2.0;
Vector temp2 = (c - d) * 3.0;
Vector result = temp1 + temp2 + e;
문제 4: 앨리어싱
지연 평가는 “결과를 쓰는 중에도 입력을 계속 읽는다”는 뜻이기도 합니다. a = a + b처럼 원소별 연산이면 문제가 없습니다. a[i]를 쓰기 전에 같은 a[i]만 읽으므로 순서가 꼬이지 않습니다. 문제는 결과의 한 원소가 입력의 여러 원소에 의존하는 연산입니다.
// 행렬 곱: C(i,j)를 계산하려면 A의 i행 전체를 읽어야 함
A = A * B; // 지연 평가하면 A의 앞쪽 원소를 이미 덮어쓴 뒤에 다시 읽음 → 틀린 결과
A = A.transpose(); // 전치도 마찬가지: A(0,1)을 쓰면 나중에 읽을 A(1,0) 자리가 이미 바뀜
해결책은 이런 연산에 한해 먼저 임시 객체에 평가한 뒤 복사하는 것입니다. Eigen은 행렬 곱을 기본적으로 임시 객체에 평가해 안전하게 만들고, 사용자가 겹치지 않는다고 확신할 때만 C.noalias() = A * B;로 그 임시 객체를 생략하게 합니다. 전치처럼 기본적으로 임시 객체를 만들지 않는 연산에는 A.transposeInPlace() 같은 별도 함수를 둡니다. 직접 구현한다면 원소별 연산 노드와 “전체를 봐야 하는” 노드를 구분하고, 후자가 대입 우변에 있으면 임시 객체로 평가하는 규칙을 대입 연산자에 넣어야 합니다. 대입 대상과 입력이 같은 객체인지 주소로 비교하는 방법은 표현식 트리 깊숙한 곳의 잎 노드까지 확인해야 해서 생각보다 복잡합니다.
실무 라이브러리
이 글에서 처음부터 구현한 VecExpression/VecAdd/VecScale은 사실 Eigen이나 Blaze 같은 실무 선형대수 라이브러리가 내부적으로 쓰는 기법을 최소한으로 축소한 것입니다. 두 라이브러리를 쓰는 코드가 위의 손수 구현 예제와 똑같이 a + b + c라는 자연스러운 문법을 쓸 수 있는 것도, 물밑에서 정확히 같은 CRTP 기반 표현식 트리가 동작하고 있기 때문입니다. 다만 실무 라이브러리는 여기서 다루지 않은 SIMD 벡터화, 캐시 블로킹, 희소 행렬 특수화 같은 수십 년치 최적화가 그 위에 더해져 있으므로, 직접 구현한 버전보다 훨씬 정교한 성능을 냅니다.
Eigen
#include <Eigen/Dense>
Eigen::VectorXd a(3), b(3), c(3);
// Expression Templates 자동 적용
Eigen::VectorXd result = a + b + c;
Blaze
#include <blaze/Math.h>
blaze::DynamicVector<double> a(3), b(3), c(3);
// 구체 타입으로 받아야 그 자리에서 평가됨
// (auto로 받으면 평가되지 않은 표현식 객체가 되어 앞의 댕글링 함정이 그대로 적용)
blaze::DynamicVector<double> result = a + b + c;
Eigen과 Blaze 모두 “표현식 결과를 auto로 받지 말라”는 경고를 문서에 명시하고 있습니다. auto가 편리한 모던 C++ 습관과 정면으로 충돌하는 부분이라, 이런 라이브러리를 팀에 도입할 때는 코드 리뷰 규칙으로 따로 정해 두는 편이 좋습니다. Eigen에서는 의도적으로 평가를 강제하고 싶을 때 .eval()을 붙이는 관용구도 있습니다.
FAQ
Q1: Expression Templates는 언제 사용하나요?
A:
- 수치 계산 라이브러리
- 행렬/벡터 연산
- DSL 구현
Q2: 성능 이점은?
A: 이득의 크기는 연산 체인의 길이, 원소 수, 데이터가 캐시에 들어가는지에 따라 크게 달라집니다. 작은 벡터에서는 차이가 거의 없고, 캐시보다 큰 벡터에 긴 연산 체인을 적용할 때 할당과 메모리 왕복이 줄어드는 만큼 효과가 커집니다. 반드시 최적화 빌드에서 실제 데이터 크기로 측정하십시오.
Q3: 단점은?
A:
- 구현 복잡
- 컴파일 시간 증가
- 에러 메시지 복잡
Q4: 직접 구현해야 하나요?
A: 아니요. Eigen, Blaze 같은 라이브러리를 사용하세요.
Q5: 디버깅은?
A:
- 간단한 케이스부터 테스트
- static_assert 활용
- 컴파일러 최적화 끄고 테스트
Q. C++20 Ranges의 지연 평가와는 무엇이 다른가요?
A: 둘 다 “연산을 쌓아 두고 나중에 한 번에 평가한다”는 점은 같지만 목적이 다릅니다. Ranges의 뷰(views::transform, views::filter)는 원소를 하나씩 끌어오는 파이프라인이라 필터처럼 원소 수가 바뀌는 연산도 표현할 수 있고, 대신 크기·임의 접근 같은 정보를 잃기 쉽습니다. 표현식 템플릿은 고정 크기 수치 연산에 특화되어 크기와 임의 접근을 유지하므로 SIMD 벡터화나 블록 단위 행렬 곱 같은 최적화를 붙이기 좋습니다. 원소별 변환 체인이라면 Ranges로도 임시 객체 없이 처리되므로, 수치 라이브러리를 만드는 게 아니라면 Ranges가 먼저 고려할 도구입니다.
Q6: 이미 존재하는 벡터에 결과를 대입하면 임시 객체가 생기나요?
A: 이 글의 최소 구현에서는 그렇습니다. 변환 생성자만 있으면 result = a + b;가 임시 Vector를 만든 뒤 대입합니다. 표현식을 받는 템플릿 대입 연산자를 두어 기존 버퍼에 직접 쓰게 해야 하며, 이때 앨리어싱(문제 4)을 함께 고려해야 합니다. 이 기법을 더 깊이 공부하려면 “C++ Templates: The Complete Guide”의 표현식 템플릿 장과 Eigen 소스의 CwiseBinaryOp, nested 트레이트를 보는 것이 좋습니다.