C++ CRTP, 기반 클래스가 파생 클래스를 컴파일 타임에 아는 방법
CRTP가 컴파일되는 원리와 정적 다형성, 믹스인 같은 쓰임새, 함정과 가상 함수와의 비교, C++23 deducing this를 정리했습니다.
개요
CRTP는 Curiously Recurring Template Pattern, 우리말로 “신기하게 되풀이되는 템플릿 패턴”이다.
1
2
3
4
template <typename Derived>
class Base { /* ... */ };
class D : public Base<D> { /* ... */ };
D가 자기 자신을 템플릿 인자로 넘겨 기반 클래스를 만든다. 상속 관계가 자기 자신을 한 바퀴 돌아 들어오니 “되풀이”다.
왜 컴파일되는가
처음 보면 닭과 알 문제처럼 보인다. Base<D>를 만들려면 D를 알아야 하는데, public Base<D>를 쓰는 그 지점에서 D는 아직 정의가 끝나지 않았다. 즉 D는 불완전 타입(incomplete type)이다.
이게 되는 이유는 클래스 템플릿의 인스턴스화가 두 단계로 나뉘기 때문이다.
| 시점 | 인스턴스화되는 것 |
|---|---|
Base<D>가 기반 클래스로 쓰이는 순간 | 멤버 선언(이름, 반환 타입, 매개변수 타입), 데이터 멤버, 기반 클래스 목록 |
| 그 멤버 함수가 실제로 호출되는 순간 | 멤버 함수의 본문 |
멤버 함수 본문은 호출 시점까지 미뤄진다. 그때는 D의 정의가 이미 끝나 완전한 타입이다. 그래서 본문 안에서는 D의 멤버를 마음껏 쓸 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
template <typename Derived>
class Base
{
public:
void interface()
{
static_cast<Derived*>(this)->impl(); // 호출될 때 인스턴스화된다. 이때 Derived는 완전 타입
}
};
class D : public Base<D>
{
public:
void impl() { /* ... */ }
};
D d;
d.interface(); // 여기서 Base<D>::interface의 본문이 인스턴스화되고 D::impl을 찾는다
반대로 클래스 본문에서 즉시 Derived의 완전성을 요구하면 실패한다.
1
2
3
4
5
6
template <typename Derived>
class Base
{
Derived member; // 에러: 불완전 타입의 객체를 멤버로 둘 수 없다
static_assert(sizeof(Derived) > 0); // 에러: 클래스 인스턴스화 시점에 평가된다
};
두 줄 모두 클래스가 인스턴스화되는 시점에 Derived의 크기를 요구한다. 그때 Derived는 아직 정의가 끝나지 않았으니 크기를 알 수 없다. 검사가 필요하면 멤버 함수 본문으로 옮기면 된다. 흔히 소멸자를 쓴다.
1
2
3
4
5
6
template <typename Derived>
class Base
{
protected:
~Base() { static_assert(std::is_base_of_v<Base, Derived>); } // 본문이라 늦게 평가된다
};
무엇에 쓰는가
CRTP를 쓰는 이유는 기반 클래스가 컴파일 타임에 파생 클래스의 타입을 알게 하기 위해서다. 이걸로 얻는 것은 두 가지다.
하나는 성능이다. 기반 클래스는 static_cast<Derived*>(this)로 파생 클래스의 멤버에 직접 접근한다. 어떤 함수를 부를지가 컴파일 타임에 정해지므로, 가상 함수처럼 실행 중에 vtable을 거쳐 호출할 함수를 찾을 필요가 없고, 컴파일러가 호출을 인라인할 수도 있다.
다른 하나는 표현력이다. 가상 함수는 동작은 파생 클래스마다 바꿀 수 있지만, 반환 타입, 매개변수 타입, 정적 멤버처럼 타입에 묶인 것은 마음대로 바꿀 수 없다. CRTP에서는 기반 클래스가 파생 타입을 알고 있으니, 이런 것들에 파생 타입을 그대로 쓸 수 있다.
정적 다형성
가상 함수와 같은 일을 런타임 비용 없이 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 가상 함수: 런타임 디스패치
struct Shape
{
virtual double area() const = 0;
};
struct Circle : Shape
{
double area() const override { return 3.14 * r * r; }
double r;
};
// CRTP: 컴파일 타임 디스패치
template <typename D>
struct Shape
{
double area() const { return static_cast<const D*>(this)->areaImpl(); }
};
struct Circle : Shape<Circle>
{
double areaImpl() const { return 3.14 * r * r; }
double r;
};
CRTP 쪽은 vtable 포인터가 없고, 가상 호출이 없고, 인라인이 된다. 추상화는 유지하면서 비용은 0이다. 대신 어떤 구현을 쓸지 컴파일 타임에 확정되어야 한다.
믹스인
믹스인은 다른 클래스에 기능을 섞어 넣기 위해 만든 클래스이다. 혼자서는 쓰지 않고, 상속을 통해 다른 클래스에 기능을 덧붙이는 용도로만 쓴다. CRTP의 가장 실용적인 쓰임이라고 한다. 파생 클래스가 최소한의 연산만 제공하면 나머지를 기반 클래스가 만들어 준다.
보통의 상속이 is-a 관계를 나타내면 믹스인은 can-do 관계를 나타낸다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
template <typename D>
struct Comparable
{
friend bool operator!=(const D& a, const D& b) { return !(a == b); }
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); }
};
struct Version : Comparable<Version>
{
int major, minor;
friend bool operator==(const Version& a, const Version& b)
{
return a.major == b.major && a.minor == b.minor;
}
friend bool operator<(const Version& a, const Version& b)
{
return a.major != b.major ? a.major < b.major : a.minor < b.minor;
}
};
==와 < 두 개만 쓰면 여섯 개 비교 연산자가 모두 생긴다. C++20의 operator<=>가 이 특정 용도를 대체했지만, 믹스인이라는 기법 자체는 여전히 쓰인다.
파생 클래스마다 별도의 정적 멤버
1
2
3
4
5
6
7
8
9
10
template <typename D>
struct Counted
{
static inline int count = 0;
Counted() { ++count; }
~Counted() { --count; }
};
struct Widget : Counted<Widget> {};
struct Gadget : Counted<Gadget> {};
Counted<Widget>과 Counted<Gadget>은 서로 다른 타입이므로 count도 따로 존재한다. 템플릿이 아닌 기반 클래스를 단순 상속하면 카운터 하나를 모든 파생 클래스가 공유해 버린다. 파생 클래스마다 별도의 정적 멤버를 갖게 만드는 것도 가능하다.
기반 클래스에 타입 전달
기반 클래스가 파생 타입을 알아야만 구현할 수 있는 기능이 있다. 대표적인 예가 this에서 shared_ptr<T>를 얻게 해 주는 std::enable_shared_from_this<T>다. 이 클래스는 weak_ptr<T>를 멤버로 들고 shared_ptr<T>를 돌려줘야 하는데, 기반 클래스가 그 T를 알 방법이 CRTP밖에 없다.
조심해야 되는 부분
템플릿 인자를 잘못 쓰면 조용히 정의되지 않은 동작
1
2
struct A : Base<A> {};
struct B : Base<A> {}; // 컴파일은 된다. 하지만 static_cast<A*>(this)가 거짓말이 된다
B 객체를 A*로 캐스팅해 쓰니 정의되지 않은 동작이다. 막는 방법은 기반 클래스의 생성자를 private으로 두고 파생 클래스만 friend로 삼는 것이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
template <typename D>
class Base
{
private:
Base() = default;
friend D; // D만 Base<D>를 생성할 수 있다
public:
void interface() { static_cast<D*>(this)->impl(); }
};
struct A : Base<A> { void impl() {} };
struct B : Base<A> { void impl() {} };
A a; // OK
B b; // 에러: B의 기본 생성자가 삭제된다
B는 Base<A>의 friend가 아니라서 Base<A>의 private 생성자를 부를 수 없다. 그래서 컴파일러가 B의 기본 생성자를 삭제된 것으로 정의하고, B b;가 삭제된 함수를 호출하는 에러가 된다.
이름이 겹치면 무한 재귀
기반 클래스에 “기본 구현”을 같은 이름으로 두고, 파생 클래스가 그걸 재정의하는 걸 잊으면 영원히 자기 자신을 부른다.
1
2
3
4
5
6
7
8
9
10
template <typename D>
struct Base
{
void impl() { static_cast<D*>(this)->impl(); } // 기본 구현이라고 둔 것
};
struct D : Base<D> {}; // impl을 깜빡했다
D d;
d.impl(); // Base::impl → Base::impl → ... 스택 오버플로
컴파일러에 따라 무한 재귀 경고를 띄워 주기도 하지만, 에러가 아니라서 빌드는 된다. 가상 함수라면 순수 가상으로 두어 컴파일 에러를 받겠지만, CRTP에는 그런 장치가 없다. 그래서 인터페이스 이름(interface)과 구현 이름(impl)을 반드시 다르게 두는 것이 관례다. 이름이 다르면 impl이 없을 때 컴파일 에러가 난다.
공통 기반 타입이 없다
Shape<Circle>과 Shape<Square>는 아무 관계도 없는 별개의 타입이다. 가상 함수 버전처럼 std::vector<Shape*>에 섞어 담을 수 없고, 런타임에 타입이 결정되는 상황(플러그인, 파일에서 읽은 객체 종류)에는 쓸 수 없다. 둘 다 필요하면 추상 인터페이스를 따로 두고 CRTP를 그 아래에 깔아 섞어 쓴다.
코드 팽창
파생 클래스마다 기반 클래스가 따로 인스턴스화된다. 멤버 함수가 많으면 바이너리가 커지고 컴파일이 느려진다. 가상 함수는 구현이 하나뿐이다.
가상 함수와의 비교
| 가상 함수 | CRTP | |
|---|---|---|
| 디스패치 시점 | 런타임 | 컴파일 타임 |
| 호출 비용 | 간접 호출, 인라인 어려움 | 0, 인라인 가능 |
| 객체 크기 | vptr만큼 증가 | 증가 없음 |
| 공통 기반 타입 | 있음, 컨테이너에 섞어 담기 가능 | 없음 |
| 런타임에 구현 교체 | 가능 | 불가능 |
| 바이너리 크기 | 구현당 하나 | 파생 클래스마다 복제될 수 있다 |
| 구현 누락 감지 | 순수 가상으로 컴파일 에러 | 이름 설계에 의존 |
C++23 deducing this
C++23의 명시적 객체 매개변수(deducing this)가 CRTP의 정적 다형성 용도를 더 단순하게 대체한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
struct Base
{
template <typename Self>
void interface(this Self&& self) { self.impl(); }
};
struct D : Base
{
void impl() { /* ... */ }
};
D d;
d.interface(); // Self가 D&로 추론된다
기반 클래스가 템플릿이 아니고, 파생 클래스가 자기 이름을 넘길 필요도 없고, static_cast도 없다. this Self&& self가 호출한 표현식의 타입을 컴파일 타임에 추론한다. 잘못된 템플릿 인자로 생기는 정의되지 않은 동작 자체가 사라진다.
다만 추론되는 것은 실행 중의 실제 타입이 아니라 표현식의 정적 타입이다. 객체를 Base&로 가리켜 호출하면 Self는 Base&가 되고, Base에는 impl이 없으니 컴파일 에러가 난다. 가상 함수처럼 실행 중에 타입을 따라가지는 않는다.
| 용도 | 대안 |
|---|---|
| 정적 다형성 | deducing this (C++23) |
| 멤버 함수의 반환 타입, 매개변수 타입에 파생 타입 쓰기 | deducing this (C++23) |
| 인터페이스 제약 | concepts (C++20) |
| 비교 연산자 생성 | operator<=> (C++20) |
기반 클래스에 타입 전달 (enable_shared_from_this 같은 것) | 여전히 CRTP |
정리
CRTP는 파생 클래스가 자기 타입을 기반 클래스 템플릿에 넘겨, 기반 클래스가 컴파일 타임에 파생 클래스를 알게 하는 기법이다. 멤버 함수 본문이 늦게 인스턴스화되기 때문에 성립한다.
이걸로 얻는 것은 두 가지다. 하나는 성능으로, 파생 클래스의 멤버를 vtable 없이 직접 호출한다. 다른 하나는 표현력으로, 반환 타입, 매개변수 타입, 정적 멤버처럼 가상 함수로는 바꿀 수 없는 것에 파생 타입을 쓸 수 있다. 대가로 공통 기반 타입을 잃고, 잘못된 템플릿 인자나 구현 누락을 언어가 막아 주지 않는다.
C++23의 deducing this가 정적 다형성과, 멤버 함수의 반환 타입이나 매개변수 타입에 파생 타입을 쓰는 용도까지 대신하게 되었지만, 정적 멤버나 데이터 멤버처럼 클래스 수준에서 파생 타입이 필요한 곳에는 여전히 CRTP가 쓰인다.
참고