지난 글에서 본 가상 함수는 호출마다 vtable을 거치는 간접 호출이라 인라인되지 않는다. 하지만 어떤 파생 타입을 쓸지 컴파일 시점에 이미 정해져 있다면 실행 시점 디스패치는 필요 없다. CRTP(Curiously Recurring Template Pattern)는 파생 클래스가 자기 자신을 기반 클래스 템플릿의 인수로 넘기는 패턴이다. 기반 클래스는 static_cast<Derived*>(this)로 파생 클래스의 함수를 컴파일 시점에 호출한다(정적 다형성). 이 글에서는 g++ 13.3으로 CRTP의 기본 형태, 가상 함수와의 성능 차이, 믹스인 활용, 잘못된 파생을 막는 방법을 확인한다.
이 시리즈의 다른 글
- C++ 고급 문법 (18) — 업캐스팅(Upcasting)
- C++ 고급 문법 (19) — 함수 바인딩(Function Binding)
- C++ 고급 문법 (21) — 가상 소멸자(Virtual Destructor)
- C++ 고급 문법 (22) — 추상 클래스(Abstract Class)
기본 형태
#include <cstdio>
template <typename Derived>
struct Shape {
double area() const
{
return static_cast<const Derived*>(this)->area_impl(); // 컴파일 타임에 Derived로 바인딩
}
void describe() const { std::printf("%s area=%.2f\n", Derived::name, area()); }
};
struct Circle : Shape<Circle> { // 자기 자신을 템플릿 인수로 넘긴다
static constexpr const char* name = "circle";
double r;
explicit Circle(double v) : r(v) {}
double area_impl() const { return 3.14159 * r * r; }
};
struct Square : Shape<Square> {
static constexpr const char* name = "square";
double s;
explicit Square(double v) : s(v) {}
double area_impl() const { return s * s; }
};
template <typename T>
void report(const Shape<T>& shape) { shape.describe(); } // 공통 인터페이스로 받기
int main()
{
report(Circle(1.0));
report(Square(3.0));
std::printf("sizeof(Circle)=%zu (vptr 없음)\n", sizeof(Circle));
}$ g++ -std=c++20 -Wall -o b basic.cpp
$ ./b
circle area=3.14
square area=9.00
sizeof(Circle)=8 (vptr 없음)
공통 인터페이스는 Shape<T>가 제공하고 구현은 파생 클래스의 area_impl()이 맡는다. 가상 함수가 없어 vptr도 없으므로 Circle은 double 하나 크기다.
가상 함수와의 성능 비교
#include <chrono>
#include <cstdio>
#include <memory>
#include <vector>
struct VShape { virtual ~VShape() = default; virtual double area() const = 0; };
struct VCircle : VShape { double r; explicit VCircle(double v) : r(v) {} double area() const override { return 3.14159 * r * r; } };
template <typename D> struct CShape { double area() const { return static_cast<const D*>(this)->area_impl(); } };
struct CCircle : CShape<CCircle> { double r; explicit CCircle(double v) : r(v) {} double area_impl() const { return 3.14159 * r * r; } };
template <typename F>
void bench(const char* name, F f)
{
auto t0 = std::chrono::steady_clock::now();
double s = 0;
for (int rep = 0; rep < 50; ++rep) s += f();
auto ms = std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - t0).count();
std::printf("%-24s %7.1f ms (sum=%.0f)\n", name, ms, s);
}
int main()
{
constexpr int N = 1'000'000;
std::vector<std::unique_ptr<VShape>> vs;
std::vector<CCircle> cs;
std::vector<VCircle> vflat; // 가상 함수 객체를 연속 메모리에
std::vector<const VShape*> vptrs;
for (int i = 0; i < N; ++i) { vs.push_back(std::make_unique<VCircle>(i % 10)); cs.emplace_back(i % 10); vflat.emplace_back(i % 10); }
for (auto& v : vflat) vptrs.push_back(&v);
bench("virtual (heap objects)", [&] { double s = 0; for (auto& p : vs) s += p->area(); return s; });
bench("virtual (contiguous)", [&] { double s = 0; for (auto* p : vptrs) s += p->area(); return s; });
bench("CRTP (contiguous)", [&] { double s = 0; for (auto& c : cs) s += c.area(); return s; });
}$ g++ -std=c++20 -Wall -O2 -o bn bench.cpp
$ ./bn
virtual (heap objects) 133.1 ms (sum=4476765750)
virtual (contiguous) 131.9 ms (sum=4476765750)
CRTP (contiguous) 31.8 ms (sum=4476765750)
가상 함수 쪽은 객체를 힙에 두든 연속 메모리에 두든 130ms 안팎으로 같아서, 차이는 메모리 배치가 아니라 디스패치 방식에서 나온다. CRTP는 area_impl()이 루프 안에 인라인되어 약 4배 빠르다.
| 항목 | 가상 함수 | CRTP |
|---|---|---|
| 바인딩 시점 | 실행 시점 | 컴파일 시점 |
| 서로 다른 타입을 한 컨테이너에 | 가능 (vector<unique_ptr<Base>>) | 불가 (Shape<Circle>과 Shape<Square>는 다른 타입) |
| 인라인 | 어려움 | 가능 |
| 객체 크기 | vptr 8바이트 추가 | 추가 없음 |
| 코드 크기 | 함수 하나 | 타입마다 인스턴스화 |
| 새 타입 추가 | 재컴파일 없이 플러그인 가능 | 사용하는 코드 재컴파일 필요 |
믹스인
CRTP 기반 클래스는 파생 타입을 알기 때문에 파생 타입에 맞춘 기능을 덧붙이는 믹스인(mixin)으로도 쓴다.
#include <cstdio>
// 믹스인 1: < 하나만 구현하면 나머지 비교 연산자를 붙여준다
template <typename D>
struct Ordered {
friend bool operator>(const D& a, const D& b) { return b < a; }
friend bool operator<=(const D& a, const D& b) { return !(b < a); }
friend bool operator>=(const D& a, const D& b) { return !(a < b); }
};
// 믹스인 2: 파생 타입마다 별도의 인스턴스 카운터
template <typename D>
struct Counted {
static inline int alive = 0;
Counted() { ++alive; }
Counted(const Counted&) { ++alive; }
~Counted() { --alive; }
};
struct Version : Ordered<Version>, Counted<Version> {
int major, minor;
Version(int a, int b) : major(a), minor(b) {}
friend bool operator<(const Version& x, const Version& y)
{
return x.major != y.major ? x.major < y.major : x.minor < y.minor;
}
};
struct Ticket : Counted<Ticket> {};
int main()
{
Version a{1, 2}, b{1, 10};
std::printf("a>b=%d a<=b=%d a>=b=%d\n", a > b, a <= b, a >= b);
Ticket t1, t2, t3;
std::printf("Version alive=%d Ticket alive=%d\n", Counted<Version>::alive, Counted<Ticket>::alive);
}$ g++ -std=c++20 -Wall -o mx mixin.cpp
$ ./mx
a>b=0 a<=b=1 a>=b=0
Version alive=2 Ticket alive=3
Counted<Version>과 Counted<Ticket>은 다른 타입이라 정적 카운터도 따로 있다. 비교 연산자 믹스인은 C++20에서는 <=>로 대체할 수 있다(연산자 오버로딩 편 참고).
잘못된 파생 막기
#include <cstdio>
template <typename D>
struct Shape {
double area() const { return static_cast<const D*>(this)->area_impl(); }
};
struct Circle : Shape<Circle> { double r = 1; double area_impl() const { return 3.14159 * r * r; } };
struct Square : Shape<Circle> { double s = 3; double area_impl() const { return s * s; } }; // 복붙 실수
int main()
{
Square sq;
std::printf("square area = %.2f\n", sq.area()); // Square를 Circle로 해석
}$ g++ -std=c++20 -Wall -o wr wrong.cpp
$ ./wr
square area = 28.27
Square가 Shape<Circle>을 상속해 static_cast가 Square를 Circle로 읽었다(s=3을 반지름으로 계산한 3.14159×9). 컴파일은 경고 없이 통과한다.
template <typename D>
class Shape {
Shape() = default; // private 생성자
friend D; // D만 생성할 수 있다
public:
double area() const { return static_cast<const D*>(this)->area_impl(); }
};
struct Circle : Shape<Circle> { double r = 1; double area_impl() const { return 3.14159 * r * r; } };
struct Square : Shape<Circle> { double s = 3; double area_impl() const { return s * s; } };
int main()
{
Square sq;
return sq.area() > 0;
}$ g++ -std=c++20 -Wall -c wrong_fix.cpp 2>&1 | grep error
wrong_fix.cpp:14:12: error: use of deleted function ‘Square::Square()’
wrong_fix.cpp:10:8: error: ‘constexpr Shape<D>::Shape() [with D = Circle]’ is private within this context
기반 생성자를 private으로 두고 D만 friend로 지정하면, 템플릿 인수와 실제 파생 클래스가 다를 때 생성자 접근 에러가 난다.
C++23 deducing this
struct Shape {
template <typename Self>
double area(this const Self& self) { return self.area_impl(); } // C++23 deducing this
};
struct Circle : Shape { double r = 1; double area_impl() const { return 3.14159 * r * r; } };
int main() { return Circle{}.area() > 3; }$ g++ -std=c++23 -Wall -c deducing.cpp 2>&1 | grep -E 'error' | head -2
deducing.cpp:3:17: error: expected identifier before ‘this’
deducing.cpp:3:17: error: expected ‘,’ or ‘...’ before ‘this’
C++23의 명시적 객체 매개변수(deducing this)를 쓰면 템플릿 인수 없이 self의 실제 타입으로 호출할 수 있어 CRTP의 상당 부분을 대체한다. g++ 13은 아직 지원하지 않아 위처럼 문법 에러가 나며, GCC 14부터 지원된다.
주의사항
| 상황 | 문제 | 대응 |
|---|---|---|
| 템플릿 인수에 다른 파생 클래스를 넘김 | 잘못된 static_cast, 미정의 동작 | private 생성자 + friend D |
| 파생 클래스가 구현 함수를 빠뜨림 | 기반의 같은 이름 함수를 재귀 호출하거나 긴 템플릿 에러 | 구현 함수 이름을 *_impl로 구분, concept으로 요구 조건 명시 |
| 여러 파생 타입을 한 컨테이너에 보관해야 함 | CRTP로는 불가 | 가상 함수 또는 std::variant |
| 기반 생성자·소멸자에서 파생 함수 호출 | 파생 부분이 아직 없거나 이미 파괴됨 | 생성·소멸 중에는 호출하지 않는다 |
기반 포인터로 delete | 가상 소멸자가 없어 미정의 동작 | 기반 소멸자를 protected로 |
마무리
- CRTP는 파생 타입을 템플릿 인수로 받아 컴파일 시점에 디스패치한다.
- 100만 개 루프에서 가상 함수 대비 약 4배 빨랐고, 차이는 인라인 가능 여부에서 나왔다.
- 서로 다른 타입을 한 컨테이너에 담을 수 없다는 제약이 있으며, 잘못된 파생은 private 생성자로 막는다.