C++ CRTP: 가상 함수 없이 정적 다형성 구현하기와 C++23 deducing this
이 글의 핵심
CRTP로 가상 함수 없이 컴파일 타임 다형성을 구현하는 방법을 카운터 믹스인, 비교 연산자 자동 생성, 싱글톤, 메서드 체이닝 예제로 정리합니다. 잘못된 상속을 컴파일 에러로 막는 private 생성자 관용구와, C++23 deducing this로 CRTP를 대체할 수 있는 경우도 다룹니다.
CRTP란?
Curiously Recurring Template Pattern
- 파생 클래스를 기본 클래스의 템플릿 인자로 전달
- 정적 다형성 (컴파일 타임)
- 가상 함수 없이 다형성 구현
“기묘하게 재귀적인”이라는 이름이 붙은 이유는 class Derived : public Base<Derived>라는 선언이 처음 보면 논리적으로 이상하게 느껴지기 때문입니다 — 파생 클래스가 아직 완전히 정의되지도 않은 시점에 자기 자신을 부모 클래스의 템플릿 인자로 넘기는 것이 어떻게 가능한가 싶지만, C++ 템플릿은 실제로 그 코드가 쓰이는(인스턴스화되는) 시점까지 본문 해석을 지연시키므로 이것이 실제로 성립합니다. 이 트릭이 만들어내는 결과는 Base<Derived>가 컴파일 타임에 자신을 상속한 구체적인 파생 타입을 정확히 알게 된다는 것입니다 — static_cast<Derived*>(this)는 this(현재 Base<Derived>*)를 그 알려진 정확한 타입으로 변환해, 가상 함수 테이블을 거치지 않고도 파생 클래스의 함수를 직접 호출할 수 있게 해 줍니다. 이것이 “정적 다형성”이라 불리는 이유입니다 — 어떤 구현이 호출될지가 런타임이 아니라 컴파일 타임에 이미 확정되어 있습니다.
// 기본 클래스
template<typename Derived>
class Base {
public:
void interface() {
static_cast<Derived*>(this)->implementation();
}
};
// 파생 클래스
class Derived : public Base<Derived> {
public:
void implementation() {
cout << "Derived 구현" << endl;
}
};
int main() {
Derived d;
d.interface(); // "Derived 구현"
}
Base의 interface() 본문이 Derived::implementation()을 부를 수 있는 이유는, 멤버 함수 본문은 interface()가 실제로 호출될 때 인스턴스화되고 그 시점에는 Derived가 완전한 타입이기 때문입니다. 반대로 Base 클래스 정의 자체에서 Derived의 멤버를 쓰려 하면 실패합니다. 예를 들어 using value_type = typename Derived::value_type;을 Base 안에 두면 class Derived : public Base<Derived>를 읽는 순간 Base<Derived>가 인스턴스화되는데 Derived는 아직 불완전해서 “incomplete type” 에러가 납니다. CRTP 베이스에서 파생 클래스의 타입 정보가 필요하면 별도의 traits 템플릿을 두거나, 멤버 함수 안에서만 접근해야 합니다. 처음 CRTP 베이스를 일반화하다 보면 거의 반드시 한 번은 이 에러를 만납니다.
이 패턴은 정책 기반 설계나 디자인 패턴 정리의 Strategy 논의와도 맞닿아 있습니다. Strategy가 “알고리즘을 객체로 바꿔 끼운다”면, CRTP는 “공통 알고리즘은 베이스에 두고 바뀌는 단계만 파생 클래스가 채운다”는 템플릿 메서드 패턴을 컴파일 타임에 구현한 것에 가깝습니다.
가상 함수 vs CRTP
두 접근 모두 “베이스 클래스가 정의한 인터페이스를 파생 클래스가 각자 다르게 구현한다”는 같은 목표를 달성하지만, 그 목표에 도달하는 시점이 다릅니다 — 가상 함수는 어떤 구현을 호출할지 결정하는 시점을 런타임까지 늦춰 vtable을 통한 간접 호출을 대가로 치르지만, CRTP는 그 결정을 컴파일 타임에 이미 끝내 버려 런타임에는 아무 결정도 남아있지 않습니다. 이 차이가 실질적으로 의미하는 것은, 가상 함수는 Base*라는 공통 포인터 타입 하나로 서로 다른 파생 타입들을 균일하게 담고 순회할 수 있는(런타임 다형성) 반면, CRTP는 애초에 Base<Derived1>과 Base<Derived2>가 서로 다른 타입이므로 그런 균일한 컨테이너에 담을 수 없다는 것입니다 — 뒤에서 다룰 “CRTP의 단점”이 바로 이 지점에서 나옵니다.
가상 함수 (동적 다형성)
class Base {
public:
virtual void func() = 0;
virtual ~Base() {}
};
class Derived : public Base {
public:
void func() override {
cout << "Derived" << endl;
}
};
// 런타임 오버헤드 (vtable)
CRTP (정적 다형성)
template<typename Derived>
class Base {
public:
void func() {
static_cast<Derived*>(this)->funcImpl();
}
};
class Derived : public Base<Derived> {
public:
void funcImpl() {
cout << "Derived" << endl;
}
};
// 컴파일 타임, 오버헤드 없음
실전 예시
카운터 믹스인
static int count가 Countable<Derived> 안에 선언되어 있지만, Widget과 Gadget이 각각 별도의 카운터를 갖는 것처럼 동작하는 이유가 CRTP의 핵심 메커니즘을 보여줍니다 — Countable<Widget>과 Countable<Gadget>은 서로 다른 템플릿 인자를 가진 완전히 다른 클래스이므로, 각각이 독립적인 static int count 멤버를 갖습니다(정적 멤버는 클래스마다 하나씩이지, “모든 Countable 인스턴스”가 공유하는 것이 아닙니다). 만약 Countable이 템플릿이 아니라 평범한 베이스 클래스였다면 Widget과 Gadget이 상속을 통해 같은 count를 공유하게 되어 이런 타입별 독립 카운팅이 불가능했을 것입니다 — CRTP가 여기서 실질적으로 하는 일은 “각 파생 타입마다 서로 다른 베이스 클래스 인스턴스화를 만들어, 그 인스턴스화에 속한 static 상태를 타입별로 자동으로 분리한다”는 것입니다.
template<typename Derived>
class Countable {
private:
static int count;
public:
Countable() { count++; }
Countable(const Countable&) { count++; }
~Countable() { count--; }
static int getCount() { return count; }
};
template<typename Derived>
int Countable<Derived>::count = 0;
class Widget : public Countable<Widget> {
public:
Widget() { cout << "Widget 생성" << endl; }
};
class Gadget : public Countable<Gadget> {
public:
Gadget() { cout << "Gadget 생성" << endl; }
};
int main() {
Widget w1, w2;
Gadget g1;
cout << "Widget 개수: " << Widget::getCount() << endl; // 2
cout << "Gadget 개수: " << Gadget::getCount() << endl; // 1
}
Countable(const Countable&)를 따로 정의한 것도 이유가 있습니다. 이것을 빼면 컴파일러가 만든 복사 생성자는 아무것도 세지 않는데 소멸자는 count--를 하므로, 객체를 복사할 때마다 개수가 하나씩 모자라게 됩니다. 이동 생성자는 복사 생성자가 사용자 선언되어 암시적으로 만들어지지 않고, 이동 시에도 이 복사 생성자가 쓰이므로 개수가 맞습니다. 여러 스레드에서 객체를 만든다면 static int 대신 static std::atomic<int>를 써야 하고, C++17부터는 static inline std::atomic<int> count{0};으로 클래스 안에서 바로 정의해 클래스 밖 정의 줄을 없앨 수 있습니다.
비교 연산자 자동 생성
이 패턴이 절약해 주는 것은 “여섯 개의 비교 연산자를 매번 손으로 다 구현하는” 반복 작업입니다 — 수학적으로 ==와 < 단 두 개만 있으면 나머지 네 개(!=, >, <=, >=)는 논리적으로 유도 가능하므로(a != b는 !(a == b), a > b는 b < a), Comparable<Derived>가 그 유도 공식을 한 번만 작성해 두고 모든 파생 클래스가 재사용하게 합니다. Point는 오직 ==와 <만 정의했는데도 !=, >=가 즉시 동작하는 것이 그 증거입니다 — 이는 C++20의 <=>(3중 비교 연산자)가 표준화되기 전까지 비교 연산자 세트를 자동 생성하는 대표적인 방법이었으며, friend 함수로 정의된 것도 operator==(lhs, rhs)가 두 인자 모두 Derived 타입으로 대칭적인 문법(p1 == p2와 p2 == p1 양쪽 다 자연스럽게 작동)을 갖게 하기 위함입니다.
template<typename Derived>
class Comparable {
public:
friend bool operator!=(const Derived& lhs, const Derived& rhs) {
return !(lhs == rhs);
}
friend bool operator>(const Derived& lhs, const Derived& rhs) {
return rhs < lhs;
}
friend bool operator<=(const Derived& lhs, const Derived& rhs) {
return !(rhs < lhs);
}
friend bool operator>=(const Derived& lhs, const Derived& rhs) {
return !(lhs < rhs);
}
};
class Point : public Comparable<Point> {
public:
int x, y;
Point(int x, int y) : x(x), y(y) {}
// ==와 <만 구현하면 나머지는 자동
friend bool operator==(const Point& lhs, const Point& rhs) {
return lhs.x == rhs.x && lhs.y == rhs.y;
}
friend bool operator<(const Point& lhs, const Point& rhs) {
if (lhs.x != rhs.x) return lhs.x < rhs.x;
return lhs.y < rhs.y;
}
};
int main() {
Point p1(1, 2);
Point p2(3, 4);
cout << (p1 == p2) << endl; // 0
cout << (p1 != p2) << endl; // 1
cout << (p1 < p2) << endl; // 1
cout << (p1 >= p2) << endl; // 0
}
Comparable이 정의한 friend 함수들은 클래스 밖 어디에도 선언되지 않은 “숨은 친구(hidden friend)“라서, 일반 이름 탐색으로는 보이지 않고 인자 타입이 Point일 때 인자 의존 탐색(ADL)으로만 찾아집니다. Point의 연관 클래스에 베이스 Comparable<Point>가 포함되기 때문에 찾아지는 것이며, 덕분에 이 연산자들이 관련 없는 타입의 오버로드 후보를 늘리지 않아 컴파일 에러 메시지도 짧아집니다.
C++20 이후라면 이 믹스인은 대부분 필요 없습니다. friend auto operator<=>(const Point&, const Point&) = default;와 operator== 하나면 여섯 개 연산자가 모두 생기고, operator==만 정의해도 컴파일러가 a != b를 !(a == b)로 다시 써 줍니다. 레거시 코드에서 이 CRTP 믹스인을 C++20으로 옮길 때는 믹스인의 operator!=와 재작성된 후보가 함께 고려되므로, 믹스인을 지우고 <=>로 바꾸는 쪽이 헷갈림이 적습니다.
싱글톤 믹스인
Meyer's Singleton(함수 내부 static Derived instance)을 CRTP와 결합하면, 싱글톤 구현 로직(getInstance(), 복사 금지)을 단 한 곳에만 작성해 두고 여러 클래스(Config, Logger)가 코드 중복 없이 재사용할 수 있습니다. friend class Singleton<Config>가 필요한 이유는 Singleton<Derived>::getInstance()가 static Derived instance를 만들려면 Config의 private 생성자를 호출해야 하는데, friend 선언 없이는 베이스 클래스 템플릿이 파생 클래스의 private 멤버에 접근할 권한이 없기 때문입니다. C++11부터 함수 내부 static 변수의 초기화가 스레드 안전하게 표준으로 보장되므로, 이 패턴은 별도의 락이나 이중 검사 잠금(double-checked locking) 없이도 멀티스레드 환경에서 안전한 지연 초기화 싱글톤을 제공합니다.
template<typename Derived>
class Singleton {
protected:
Singleton() {}
public:
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
static Derived& getInstance() {
static Derived instance;
return instance;
}
};
class Config : public Singleton<Config> {
friend class Singleton<Config>;
private:
Config() { cout << "Config 생성" << endl; }
int value = 0;
public:
void setValue(int v) { value = v; }
int getValue() { return value; }
};
class Logger : public Singleton<Logger> {
friend class Singleton<Logger>;
private:
Logger() { cout << "Logger 생성" << endl; }
public:
void log(const string& msg) {
cout << "[LOG] " << msg << endl;
}
};
int main() {
Config::getInstance().setValue(100);
Logger::getInstance().log("시스템 시작");
cout << Config::getInstance().getValue() << endl; // 100
}
스레드 안전한 초기화와 별개로, 함수 지역 static은 프로그램 종료 시 생성의 역순으로 소멸한다는 점을 기억해야 합니다. 다른 전역 객체의 소멸자가 Logger::getInstance().log(...)를 부르는데 Logger가 먼저 소멸했다면, 이미 파괴된 객체를 쓰게 됩니다. 싱글톤끼리 서로를 참조하는 구조라면 종료 순서까지 설계에 포함해야 합니다. 또 CRTP 싱글톤은 테스트에서 인스턴스를 바꿔 끼울 방법이 없어 전역 상태를 테스트마다 초기화하기 어렵다는, 싱글톤 패턴 자체의 단점을 그대로 물려받습니다.
체이닝 인터페이스
self()가 static_cast<Derived&>(*this)로 항상 정확한 파생 타입의 참조를 반환하는 것이 이 패턴의 핵심입니다 — 만약 Chainable이 CRTP 없이 그냥 Chainable& self() { return *this; }로 구현되었다면, select()가 반환하는 것은 QueryBuilder&가 아니라 Chainable&가 되어 그 다음에 .from(...)을 체이닝하려 해도 Chainable에는 from이라는 멤버가 없어 컴파일되지 않았을 것입니다. CRTP로 “나 자신의 정확한 타입”을 베이스 클래스가 알게 만들면, 체이닝 메서드가 항상 파생 클래스 자신의 타입으로 반환되어 .select().from().where()처럼 파생 클래스에만 있는 메서드들을 끊김 없이 이어 호출할 수 있습니다 — 이것이 SQL 쿼리 빌더, 테스트 프레임워크의 어서션(assertion) 체인 등 유창한 인터페이스(fluent interface)에서 CRTP가 흔히 쓰이는 이유입니다.
template<typename Derived>
class Chainable {
protected:
Derived& self() {
return static_cast<Derived&>(*this);
}
};
class QueryBuilder : public Chainable<QueryBuilder> {
private:
string query;
public:
QueryBuilder& select(const string& fields) {
query = "SELECT " + fields;
return self();
}
QueryBuilder& from(const string& table) {
query += " FROM " + table;
return self();
}
QueryBuilder& where(const string& condition) {
query += " WHERE " + condition;
return self();
}
string build() {
return query;
}
};
int main() {
QueryBuilder qb;
string sql = qb.select("*")
.from("users")
.where("age > 18")
.build();
cout << sql << endl;
// SELECT * FROM users WHERE age > 18
}
이 예제는 Chainable에 체이닝 메서드가 없어서 CRTP의 이점이 잘 보이지 않습니다. 이점이 드러나는 것은 공통 체이닝 메서드를 베이스에 둘 때입니다. 예를 들어 Chainable에 Derived& limit(int n) { /* ... */ return self(); }를 두면, QueryBuilder는 limit을 직접 구현하지 않고도 qb.select("*").limit(10).from("users")처럼 베이스 메서드 뒤에 파생 클래스 메서드를 이어 붙일 수 있습니다. 베이스가 Chainable&를 반환했다면 limit(10) 뒤의 .from(...)에서 컴파일이 멈췄을 것입니다. 이 문제는 C++23의 deducing this로도 해결되며, 아래 절에서 다룹니다.
성능 비교
아래 벤치마크가 측정하는 것은 정확히 앞서 “가상 함수 vs CRTP” 절에서 설명한 “런타임 결정 vs 컴파일 타임 결정”의 실제 비용입니다 — vb->compute(i)는 매 호출마다 vb의 vtable을 읽고 그 안의 함수 포인터를 다시 읽어 호출하는 두 단계 간접 호출을 거치지만, cd.compute(i)는 컴파일 타임에 CRTPDerived::computeImpl이 정확히 결정되어 있으므로 컴파일러가 그 호출을 완전히 인라인해 버릴 수 있습니다(극단적인 경우 반복문 전체가 상수로 접혀 사라질 수도 있습니다). 1억 번 반복이라는 큰 숫자를 쓴 이유는 개별 호출의 차이가 나노초 단위로 작아 한두 번의 호출로는 그 차이가 측정 오차에 묻히기 때문이며, 이런 마이크로벤치마크로 확인해야 할 실질적인 교훈은 “가상 함수가 항상 느리다”가 아니라 “호출 빈도가 극단적으로 높은 핫패스에서는 이 차이가 누적되어 유의미해질 수 있다”는 것입니다 — 일반적인 애플리케이션 코드에서는 이 차이가 체감되지 않는 경우가 대부분입니다.
#include <chrono>
// 가상 함수
class VirtualBase {
public:
virtual int compute(int x) = 0;
virtual ~VirtualBase() {}
};
class VirtualDerived : public VirtualBase {
public:
int compute(int x) override {
return x * 2;
}
};
// CRTP
template<typename Derived>
class CRTPBase {
public:
int compute(int x) {
return static_cast<Derived*>(this)->computeImpl(x);
}
};
class CRTPDerived : public CRTPBase<CRTPDerived> {
public:
int computeImpl(int x) {
return x * 2;
}
};
int main() {
const int N = 100000000;
// 가상 함수
VirtualBase* vb = new VirtualDerived();
auto start = chrono::high_resolution_clock::now();
for (int i = 0; i < N; i++) {
vb->compute(i);
}
auto end = chrono::high_resolution_clock::now();
cout << "Virtual: " << chrono::duration_cast<chrono::milliseconds>(end - start).count() << "ms" << endl;
// CRTP
CRTPDerived cd;
start = chrono::high_resolution_clock::now();
for (int i = 0; i < N; i++) {
cd.compute(i);
}
end = chrono::high_resolution_clock::now();
cout << "CRTP: " << chrono::duration_cast<chrono::milliseconds>(end - start).count() << "ms" << endl;
}
이 코드를 -O2로 빌드해 돌려 보면 두 결과가 모두 0ms 근처로 나오는 경우가 흔한데, 이는 측정이 잘못되었다는 신호입니다. 반환값을 아무 데도 쓰지 않으니 CRTP 쪽 루프는 통째로 제거될 수 있고, 가상 함수 쪽도 컴파일러가 vb가 가리키는 것이 new VirtualDerived()임을 알 수 있어 탈가상화(devirtualization) 후 같은 방식으로 제거할 수 있습니다. 의미 있는 비교를 하려면 결과를 누적해 출력하거나 Google Benchmark의 benchmark::DoNotOptimize로 최적화를 막고, 가상 함수 쪽 객체는 컴파일러가 타입을 추론할 수 없도록 다른 번역 단위나 런타임 입력으로 선택해야 합니다.
측정이 제대로 되어도 차이의 대부분은 간접 호출 자체보다 인라이닝이 막히는 것에서 나옵니다. 가상 호출은 분기 예측이 잘 맞으면 한 번의 비용이 크지 않지만, 호출 대상을 모르니 호출된 함수의 본문을 루프 안으로 끌어와 벡터화하는 최적화가 불가능해집니다. 반대로 CRTP는 파생 타입마다 코드가 따로 인스턴스화되므로 타입이 많으면 바이너리가 커지고 명령어 캐시 압박이 늘어납니다. 그래서 “CRTP가 항상 빠르다”가 아니라, 작은 함수를 매우 자주 호출하는 핫 루프에서 인라이닝 이득이 클 때 유리하다고 보는 것이 정확합니다.
자주 발생하는 문제
잘못된 캐스팅
Wrong : public Base<OtherClass>가 위험한 이유는, 컴파일러가 이 실수를 잡아내지 못한 채(문법적으로는 완벽하게 유효한 템플릿 인스턴스화입니다) 그대로 컴파일을 통과시킨다는 데 있습니다. 런타임에 wrong.func()을 호출하면 static_cast<OtherClass*>(this)가 실행되는데, this는 실제로 Wrong*이므로 이는 완전히 관련 없는 타입으로의 잘못된 캐스팅이 되어 정의되지 않은 동작(대개 크래시, 혹은 더 나쁘게는 조용한 메모리 손상)으로 이어집니다. 이 실수를 컴파일 타임에 방지하는 한 가지 방법은 Base의 생성자 본문에서 static_assert(std::is_base_of_v<Base<Derived>, Derived>)처럼 “Derived가 실제로 BaseDerived가 아직 불완전해서 쓸 수 없고, 생성자 본문은 인스턴스화 시점이 늦어 가능합니다), 이런 방어적 static_assert를 CRTP 베이스 클래스에 습관적으로 추가해 두면 이런 종류의 실수가 런타임 크래시가 아니라 즉각적인 컴파일 에러로 드러납니다.
// ❌ 위험
template<typename Derived>
class Base {
public:
void func() {
static_cast<Derived*>(this)->impl();
}
};
class Wrong : public Base<OtherClass> { // 잘못된 타입!
public:
void impl() {}
};
// ✅ 올바른 사용
class Correct : public Base<Correct> {
public:
void impl() {}
};
is_base_of 검사에는 빈틈이 있습니다. 가장 흔한 실수는 복사·붙여넣기로 class B : public Base<A>를 만드는 경우인데, A가 제대로 된 CRTP 파생 클래스라면 is_base_of_v<Base<A>, A>는 참이라 검사를 통과합니다. 이 실수를 확실히 막는 관용구는 베이스 생성자를 private으로 두고 Derived만 friend로 여는 것입니다. Base<A>의 생성자는 A만 부를 수 있으므로, B가 Base<A>를 상속하면 바로 컴파일 에러가 납니다.
template <typename Derived>
class Base {
public:
void func() { static_cast<Derived*>(this)->impl(); }
private:
Base() = default;
friend Derived; // 오직 Derived만 Base<Derived>를 생성할 수 있음
};
class A : public Base<A> { public: void impl() {} };
// class B : public Base<A> {}; // ❌ 컴파일 에러: Base<A>::Base()는 private
순환 의존
class A : public Base<B>가 실패하는 이유는 이 시점에 B가 아직 어디에도 선언조차 되어 있지 않기 때문입니다 — CRTP는 “자기 자신”을 템플릿 인자로 넘기는 재귀적 구조에는 관대하지만(완전한 정의가 필요한 시점이 나중으로 미뤄지므로), 서로 다른 두 클래스가 서로를 템플릿 인자로 참조하려 하면 정의 순서 문제가 그대로 발생합니다 — A를 정의하는 시점에 B가 필요하고, B를 정의하는 시점에 A가 필요하니 어느 쪽도 먼저 완성될 수 없습니다. CRTP의 정상적인 사용 패턴은 항상 “각자가 자기 자신만을 템플릿 인자로 넘기는” 형태(class A : public Base<A>)이며, 서로 다른 두 타입이 서로를 필요로 하는 진짜 상호 의존 관계라면 이는 CRTP가 아니라 별도의 설계(전방 선언, 인터페이스 분리 등)로 풀어야 하는 문제입니다.
// ❌ 순환 의존
class A : public Base<B> {}; // B가 아직 정의 안 됨
class B : public Base<A> {};
// ✅ 각자 자신을 템플릿 인자로
class A : public Base<A> {};
class B : public Base<B> {};
가상 소멸자 누락
이는 CRTP 특유의 문제라기보다는, CRTP를 쓴다고 해서 일반적인 C++ 소멸자 규칙이 면제되지 않는다는 것을 보여주는 사례입니다 — Base<Derived>* 포인터로 delete를 호출할 가능성이 조금이라도 있다면, ~Base()가 가상이 아닌 한 파생 클래스의 소멸자가 호출되지 않아 파생 클래스가 소유한 리소스가 누수됩니다(이는 CRTP와 무관하게 모든 다형적 삭제에 적용되는 규칙입니다). 다만 CRTP를 쓰는 코드 대부분은 애초에 Base<Derived>*라는 공통 포인터로 객체를 다루지 않고(그런 균일한 처리가 필요 없는 것이 CRTP를 선택한 이유이기도 합니다) Derived 구체 타입을 직접 다루므로, 이 문제는 CRTP 코드에서 실제로는 상대적으로 드물게 나타나며, 오히려 CRTP 베이스에 불필요하게 가상 소멸자를 추가하면 그 자체로 vtable 포인터가 생겨 CRTP의 “오버헤드 없음”이라는 장점을 일부 상쇄한다는 점도 함께 고려해야 합니다.
// ❌ 메모리 누수 가능
template<typename Derived>
class Base {
// 가상 소멸자 없음
};
// ✅ 가상 소멸자 추가 (다형적 삭제 시)
template<typename Derived>
class Base {
public:
virtual ~Base() = default;
};
// ✅ 더 흔한 선택: 베이스 포인터로 delete 자체를 막기
template<typename Derived>
class Base {
protected:
~Base() = default; // 비가상 + protected → delete basePtr; 는 컴파일 에러
};
CRTP 베이스에서 권장되는 쪽은 두 번째입니다. 소멸자를 protected로 두면 파생 클래스의 소멸자는 여전히 베이스 소멸자를 호출할 수 있지만, 외부 코드가 Base<Derived>*로 delete하려 하면 접근 에러가 나므로 누수 가능성 자체가 컴파일 단계에서 사라집니다. vtable도 생기지 않으므로 CRTP를 고른 이유와도 어긋나지 않습니다.
C++23 deducing this: CRTP 없이 같은 효과
C++23의 명시적 객체 매개변수(deducing this)를 쓰면 베이스를 템플릿으로 만들지 않고도 “호출한 실제 타입”을 알 수 있습니다. static_cast<Derived*>(this)도, 위의 잘못된 상속 실수도 사라집니다.
struct Counter {
// self의 타입이 호출한 파생 클래스로 추론됨
template <typename Self>
void increment(this Self&& self) {
++self.count_;
self.on_changed(); // 파생 클래스의 멤버를 직접 호출
}
};
struct Clicks : Counter {
int count_ = 0;
void on_changed() { /* ... */ }
};
Clicks c;
c.increment(); // Self = Clicks&
GCC 14, Clang 18, MSVC 17.2 이후에서 지원됩니다. 새 코드에서 “파생 타입을 알고 싶어서 CRTP를 쓰는” 경우라면 먼저 이 방식을 검토하고, 연산자 자동 생성이나 정적 카운터처럼 베이스 타입 자체가 파생마다 달라야 하는 경우(정적 멤버를 파생마다 따로 두는 카운터 등)에는 여전히 CRTP가 필요합니다.
CRTP 사용 시나리오
성능이 중요한 핫 루프
// 게임 엔진, 고성능 계산
template<typename Derived>
class Entity {
public:
void update(float dt) {
static_cast<Derived*>(this)->updateImpl(dt);
}
};
게임 엔진 예제에서 주의할 점은, 서로 다른 엔티티 타입(Player, Enemy)을 하나의 vector<Entity*>로 순회할 수 없다는 것입니다. 실제 엔진에서 CRTP를 쓸 때는 타입별로 별도 배열을 두고 각각 순회하는 구조(ECS의 컴포넌트 배열처럼)와 함께 쓰는 경우가 많고, 이 구조는 같은 타입의 객체가 메모리에 연속으로 놓여 캐시 효율이 좋다는 이점도 함께 줍니다.
공통 기능 믹스인
// 공통 기능을 믹스인으로
template<typename Derived>
class Serializable {
public:
string serialize() const {
// 파생 클래스가 제공하는 필드 목록을 이용해 공통 형식으로 직렬화
return "{" + static_cast<const Derived&>(*this).fields() + "}";
}
};
믹스인은 여러 개를 동시에 상속해 기능을 조합할 수 있습니다. class User : public Serializable<User>, public Comparable<User>처럼 쓰면 두 기능이 독립적으로 붙습니다. 다만 믹스인이 늘어날수록 파생 클래스가 채워야 하는 “요구 사항”(여기서는 fields())이 흩어져 보이지 않게 되므로, C++20에서는 믹스인의 요구 사항을 concept로 적어 두면 누락 시 에러 메시지가 훨씬 읽기 쉬워집니다.
CRTP가 필요 없는 컴파일 타임 다형성
// 템플릿 인자로 다형성
template<typename T>
void process(T& obj) {
obj.compute(); // 컴파일 타임에 결정
}
마지막 예제는 CRTP가 아니라 평범한 함수 템플릿입니다. 일부러 넣은 이유는, “가상 함수 없이 타입별로 다른 동작”이 목적이라면 CRTP 없이 이것만으로 충분한 경우가 많기 때문입니다. CRTP가 따로 필요한 것은 베이스 클래스 쪽에 공통 구현을 두고 싶을 때, 즉 믹스인이나 템플릿 메서드처럼 베이스가 파생 타입의 메서드를 호출해야 할 때입니다. 호출하는 쪽만 여러 타입을 받아들이면 되는 상황이라면 함수 템플릿에 C++20 concept로 요구 사항(requires { obj.compute(); })을 적는 편이 상속 관계를 만들지 않아 더 단순합니다.