CRTP 패턴 분석 — vtable 없는 정적 다형성
#한 줄 요약
“CRTP는 template을 통한 다형성.” — virtual 함수 없이 컴파일 타임에 dispatch를 결정합니다.
#어떤 문제를 푸는가
런타임 다형성(virtual)에는 다음과 같은 비용이 따릅니다.
- vtable이 클래스당 4~N 바이트를 차지합니다.
- vptr이 객체당 4 바이트를 더합니다.
- 간접 호출이 발생해 branch prediction이 어렵고 inline이 거의 되지 않습니다.
- 가상 함수 코드와 vtable이 코드 크기에 더해집니다.
소규모 MCU에서는 다형성 객체 수십 개만으로도 수 KB의 부담이 됩니다. 이때의 대안이 CRTP(Curiously Recurring Template Pattern)입니다.
// 전통 — virtualclass Shape {public: virtual ~Shape() = default; virtual int area() const = 0;};
class Circle : public Shape { int r;public: int area() const override { return 3 * r * r; }};
// CRTP — virtual 없이template<typename Derived>class Shape {public: int area() const { return static_cast<const Derived*>(this)->area_impl(); }};
class Circle : public Shape<Circle> { int r;public: int area_impl() const { return 3 * r * r; }};CRTP의 핵심은 Shape<Circle>::area()가 static_cast<Circle*>로 컴파일 타임에 dispatch된다는 점입니다. virtual table 없이 다형성을 얻습니다.
#CRTP의 구조
핵심 idiom은 다음과 같습니다.
template<typename Derived>class Base {public: void interface_method() { // Derived의 method 호출 static_cast<Derived*>(this)->impl_method(); }};
class Concrete : public Base<Concrete> { // 자신을 template 매개변수로public: void impl_method() { // 실제 구현 }};Base<Derived>가 interface를 정의합니다.Derived는 자신을 template 인자로 넘기며Base<Derived>를 상속합니다.- Base가
static_cast로 Derived의 method를 호출합니다.
각 Concrete 인스턴스는 Base<Concrete>를 별도로 인스턴스화하며, dispatch가 컴파일 타임에 결정됩니다.
#임베디드 — Logger CRTP
template<typename Derived>class LoggerBase {public: void log(const char* msg) { derived()->log_impl(msg); }
void log_error(const char* msg) { derived()->log_impl("[ERROR] "); derived()->log_impl(msg); }
protected: Derived* derived() { return static_cast<Derived*>(this); }};
class UartLogger : public LoggerBase<UartLogger> {public: void log_impl(const char* msg) { while (*msg) { USART2->DR = *msg++; while (!(USART2->SR & USART_SR_TC)); } }};
class FileLogger : public LoggerBase<FileLogger> {public: void log_impl(const char* msg) { fwrite(msg, 1, strlen(msg), file_); }private: FILE* file_;};
UartLogger uart;uart.log("hello"); // 컴파일 타임에 UartLogger::log_impl 호출uart.log("hello")의 어셈블리는 다음과 같습니다.
# 전통 virtualldr r3, [r0] ; vptr 로드ldr r3, [r3] ; vtable에서 함수 주소blx r3 ; 간접 호출 — branch prediction 어려움
# CRTPbl UartLogger::log_impl ; 직접 호출 — 인라인 가능간접 호출이 제거되고 inline이 가능합니다. 함수가 작으면 완전히 인라인됩니다.
#CRTP vs virtual — 비교
호출 흐름의 차이부터 보면 다음과 같습니다. virtual은 vptr → vtable → 함수까지 두 단계의 메모리 indirection을 거치지만, CRTP는 컴파일 타임에 derived 함수로 바로 인라인됩니다.
| virtual | CRTP | |
|---|---|---|
| Dispatch | 런타임 | 컴파일 타임 |
| vptr | 객체당 1 (4 B) | 없음 |
| vtable | 클래스당 (N * 4 B) | 없음 |
| 간접 호출 | 있음 | 없음 |
| Inline 가능 | 거의 없음 | 자주 |
| Container 동질성 | OK (Shape*) | 제한 |
| 런타임 type 결정 | OK | 컴파일 타임만 |
CRTP에는 제약이 있습니다. 런타임에 type을 결정할 수 없고, 컴파일 타임에 type이 알려져 있어야 합니다.
// virtual — runtime polymorphismstd::vector<Shape*> shapes; // 다른 Shape 타입 섞임 OKshapes.push_back(new Circle);shapes.push_back(new Square);for (auto* s : shapes) { s->area(); // 각자의 area 호출}
// CRTP — compile-timeCircle c;Square s;c.area(); // OKs.area(); // OK// 한 컨테이너에 섞기 어려움 (다른 base type)CRTP는 컴파일 타임에 type set이 닫혀 있을 때 유리합니다.
#임베디드 — Peripheral CRTP
template<typename Derived>class PeripheralBase {public: void init() { derived()->init_impl(); }
void send(uint8_t b) { derived()->send_impl(b); }
void send_buffer(const uint8_t* data, size_t len) { for (size_t i = 0; i < len; ++i) { derived()->send_impl(data[i]); } }
private: Derived* derived() { return static_cast<Derived*>(this); }};
class Uart2 : public PeripheralBase<Uart2> {public: void init_impl() { /* */ } void send_impl(uint8_t b) { while (!(USART2->SR & USART_SR_TXE)); USART2->DR = b; }};
class Spi1 : public PeripheralBase<Spi1> {public: void init_impl() { /* */ } void send_impl(uint8_t b) { SPI1->DR = b; while (!(SPI1->SR & SPI_SR_TXE)); }};
Uart2 uart;uart.send_buffer(data, 100); // 인라인 가능send_buffer의 loop 안에서 send_impl이 인라인됩니다. 데이터 복사와 UART 쓰기가 작은 loop 하나로 압축됩니다.
#CRTP로 mixin 패턴
여러 독립 기능을 조합할 때 virtual base class 대신 각 mixin을 CRTP로 구현합니다.
template<typename Derived>struct Comparable { bool operator!=(const Derived& other) const { return !(static_cast<const Derived*>(this)->operator==(other)); } bool operator>(const Derived& other) const { return other < *static_cast<const Derived*>(this); } bool operator<=(const Derived& other) const { return !(*static_cast<const Derived*>(this) > other); } bool operator>=(const Derived& other) const { return !(*static_cast<const Derived*>(this) < other); }};
class Version : public Comparable<Version> {public: int major, minor, patch;
bool operator==(const Version& o) const { return major == o.major && minor == o.minor && patch == o.patch; } bool operator<(const Version& o) const { if (major != o.major) return major < o.major; if (minor != o.minor) return minor < o.minor; return patch < o.patch; }};
Version v1{1, 0, 0}, v2{1, 2, 0};bool b = v1 < v2; // operator< — 직접 구현bool c = v1 >= v2; // Comparable이 자동 제공 → v1 < v2 → notVersion은 operator==와 operator<만 구현하며, 나머지 비교 연산자는 Comparable이 자동으로 제공합니다.
C++20의 <=>(spaceship operator)가 같은 효과를 내지만, CRTP는 C++11부터 가능합니다.
#CRTP의 단점
#1. 같은 base의 다른 instantiation은 별개 type
template<typename D> struct Base {};class A : Base<A> {};class B : Base<B> {};
// Base<A>와 Base<B>는 다른 타입// "Base를 받는 함수"가 자연스럽지 않음해결책은 C++20의 concept이나 type erasure입니다.
// C++20 concepttemplate<typename T>concept LoggerLike = requires(T t, const char* msg) { t.log(msg);};
void log_all(LoggerLike auto& logger, ...) { // 어떤 Logger든 받음}#2. 컴파일 에러 메시지가 복잡함
template error의 전형적인 문제입니다. C++20 concepts가 훨씬 깔끔하게 만들어 줍니다.
#3. 런타임 type 결정이 불가능
plug-in 시스템이나 동적 객체 생성에는 virtual이 필요합니다. CRTP는 컴파일 타임에 정해진 type set만 다룹니다.
#CRTP가 잘 맞는 3가지 패턴
#1. Static interface enforcement
template<typename Derived>class Driver {public: void init() { static_assert(requires(Derived d) { d.init_impl(); }, "Derived must implement init_impl"); static_cast<Derived*>(this)->init_impl(); }};자식이 반드시 구현해야 하는 메서드를 컴파일 타임에 강제합니다.
#2. Code sharing without runtime cost
여러 device driver가 같은 utility를 공유하면서도 각자의 init/send/recv를 가질 때, CRTP가 공통 부분은 한 곳에, 특수 부분은 자식에 둡니다.
#3. Operator generation
비교 연산자나 arithmetic 연산자 등을 자동 생성해 boilerplate를 줄여 줍니다.
#CRTP 함정
#1. Base에서 Derived의 private member 접근
template<typename D>class Base {public: void foo() { static_cast<D*>(this)->private_method(); // ERROR }};friend class Base<Derived>;를 추가하거나 public method로 노출해 해결합니다.
#2. Derived의 destructor가 호출되지 않음
Base<Derived>* ptr = new Derived;delete ptr; // Base의 destructor만 호출 — Derived 자원 누수CRTP base는 보통 stack에서 쓰거나 derived로 직접 사용합니다. base pointer로 소유하는 패턴은 피해야 합니다.
#3. 복사 동작이 의도와 어긋남
template<typename D>class Base {public: void copy_from(const Base& other) { *static_cast<D*>(this) = *static_cast<const D*>(&other); }};operator=가 잘 정의된 D에서만 안전합니다.
#4. 과도한 CRTP layer
class Concrete : public BaseA<Concrete>, public BaseB<Concrete>, public BaseC<Concrete> {};다중 상속에서 diamond 문제나 이름 충돌이 발생할 수 있습니다. 한두 layer 정도로 제한합니다.
#C++20 concepts + CRTP
CRTP의 흐릿한 interface 정의를 concept으로 명확하게 만들 수 있습니다.
template<typename T>concept Logger = requires(T t, const char* msg) { { t.log_impl(msg) } -> std::same_as<void>;};
template<Logger Derived> // ← Derived는 Logger를 만족해야 함class LoggerBase {public: void log(const char* msg) { static_cast<Derived*>(this)->log_impl(msg); }};template error message가 한층 명확해집니다.
#자주 보는 함정과 안티패턴
#1. 공허한 CRTP
template<typename D>class Base {}; // 비어 있음
class Concrete : public Base<Concrete> {};기능이 없습니다. CRTP는 의도된 utility를 제공해야 의미가 있습니다.
#2. CRTP base에서 virtual 사용
template<typename D>class Base {public: virtual void method() { /* */ } // virtual + CRTP는 모순};CRTP는 virtual을 회피하는 패턴이므로 의도가 모호해집니다.
#3. Multiple CRTP base 충돌
template<typename D> struct A { void foo(); };template<typename D> struct B { void foo(); };class C : public A<C>, public B<C> {};C c;c.foo(); // ERROR — A::foo와 B::foo 충돌c.A<C>::foo()처럼 명시적으로 호출합니다.
#4. CRTP 외부 인터페이스 불일치
서로 다른 CRTP 인스턴스를 같은 함수에서 받기가 어렵습니다. concept이나 type erasure를 사용합니다.
#5. Sizeof 증가
CRTP base가 멤버를 가지면 Concrete가 그만큼 커집니다. Empty Base Optimization 덕분에 보통은 추가 크기가 0으로 처리됩니다.
#측정 — CRTP의 효과
같은 logger를 virtual과 CRTP로 비교합니다(ARM Cortex-M4, -O2).
# Virtualclass Logger { virtual void log_impl(const char*); };class UartLogger : public Logger { void log_impl(...) override; };
uart_logger.log("hello");# 어셈블리:ldr r3, [r0] ; vptr 로드ldr r3, [r3, #4] ; vtable에서 log_impl 주소blx r3 ; 간접 호출
# vtable 크기: 12 B (Logger)# vptr: 4 B (UartLogger 인스턴스마다)
# CRTPtemplate<typename D> class LoggerBase { void log(...); };class UartLogger : public LoggerBase<UartLogger> { void log_impl(...); };
uart_logger.log("hello");# 어셈블리:bl UartLogger::log_impl ; 직접 호출 — 인라인 가능
# vtable: 0# vptr: 0객체당 4 B, 클래스당 12 B를 절약합니다. 100 객체 50 클래스 기준이면 1000 B가 절약되며, 작은 차이지만 극소형 MCU에서는 의미가 있습니다.
#CRTP의 실용과 과용
다음과 같은 경우에 권장합니다.
- 공통 utility에 다양한 구현이 붙는 경우(driver, logger).
- 비교나 산술 같은 operator generation.
- 독립 기능을 조합하는 mixin.
다음과 같은 경우에는 피합니다.
- 간단한 함수 한두 개라면 그냥 함수로 둡니다.
- plug-in 시스템처럼 런타임 확장이 필요하면 virtual을 씁니다.
- 외부 라이브러리 인터페이스는 사용자 친화적이지 않으므로 피합니다.
#정리
- CRTP는 template 기반 다형성으로, virtual 없이 컴파일 타임에 dispatch합니다.
- vptr, vtable, 간접 호출이 모두 0이고 인라인도 가능합니다.
- 임베디드에서는 peripheral driver, logger, mixin 패턴에 적합합니다.
- 런타임에 type을 결정할 수는 없고 컴파일 타임에 닫힌 set만 다룹니다.
- C++20 concepts와 함께 쓰면 interface가 명확해집니다.
#관련 항목
- Part 2-06: Templates 기초
- Part 2-09: Type Traits — SFINAE 결합
- Part 2-10: Concepts (C++20) — CRTP 명확화
- Part 5-03: Peripheral 추상화 — CRTP peripheral
- GoF 21: Strategy — virtual vs CRTP
#다음 글
Part 2-09: Type Traits 활용 — std::is_*, std::enable_if, SFINAE로 컴파일 타임 type 분기를 다룹니다.
Embedded C++ for Real Systems · 17 of 41
- 1Embedded C++ for Real Systems — 임베디드 모던 C++ 시리즈 소개
- 2임베디드 C++ vs C — 런타임·코드 크기·ABI 관점 비교
- 3임베디드 C++ 컴파일러 플래그 분석 — -fno-rtti·-fno-exceptions·-Os
- 4임베디드 C++ 런타임 요구사항 — libstdc++·newlib·crt0 분석
- 5C++ 코드 크기 분석 — 가상 함수·템플릿·예외 비용 추적
- 6C++ ABI 호환성 — Itanium ABI·name mangling·vtable 레이아웃
- 7C++ 스타트업 코드 분석 — .init_array·전역 생성자 호출 순서
- 8임베디드 C++ 링커 스크립트 — vtable·정적 객체 배치 추적
- 9임베디드 C++ 표준 선택 가이드 — C++11/14/17/20/23 트레이드오프
- 10임베디드 RAII 기초 — 리소스 안전성과 결정적 소멸 보장
- 11임베디드 RAII 실전 패턴 — Lock·Pin·DMA·Power 관리
- 12constexpr 기초와 임베디드 적용 — 컴파일 타임 계산 활용
- 13constexpr 고급 활용 — 룩업 테이블·CRC·해시 컴파일 타임 생성
- 14consteval과 constinit 분석 — C++20 컴파일 타임 강제 메커니즘
- 15임베디드 Templates 기초 — 타입 안전과 코드 재사용 분석
- 16Template 비용 분석 — 코드 폭증·인스턴스화·디버그 정보 측정
- 17CRTP 패턴 분석 — vtable 없는 정적 다형성
- 18Type Traits 임베디드 활용 — SFINAE·is_pod·컴파일 타임 검사
- 19C++20 Concepts 활용 — 템플릿 제약과 가독성 개선
- 20동적 할당 없는 임베디드 C++ — placement new·정적 객체·풀
- 21Custom Allocator 기초 — std::allocator 인터페이스 분석
- 22Pool Allocator 구현 — Fixed-Size Block과 O(1) 보장
- 23std::pmr 임베디드 활용 — Polymorphic Memory Resource 분석
- 24No-Exception C++ 설계 — 코드 크기·결정성 트레이드오프
- 25임베디드 에러 처리 패턴 — Result·errno·optional 비교
- 26std::expected 분석 — C++23 결과 타입과 에러 전파
- 27No-RTTI C++ 설계 — dynamic_cast 제거와 정적 타입 분기
- 28임베디드 스마트 포인터 선택 — unique·shared·custom 비교
- 29임베디드 C++ 소유권 모델 — single·shared·borrow 패턴
- 30Intrusive Containers 분석 — 동적 할당 없는 컨테이너 설계
- 31ETL 라이브러리 분석 — Embedded Template Library의 STL 대체
- 32임베디드 Lock-free 기초 — atomic·memory ordering·CAS
- 33Lock-free Container 구현 — SPSC Queue·Ring Buffer
- 34Type-safe Flags 패턴 — Enum Class·Strong Typedef·Tag
- 35임베디드 State Machine 패턴 — Variant·Visitor·Table-driven 비교
- 36Compile-time FSM 구현 — 템플릿으로 상태 전이 검증
- 37Singleton 대안 패턴 — Service Locator·Static Init·Phantom
- 38MMIO Register 추상화 — 타입 안전한 비트 필드 접근
- 39GPIO 추상화 패턴 — Template·Concept으로 보드 독립성
- 40Peripheral 추상화 — UART·SPI·I2C 공통 인터페이스 설계
- 41임베디드 HAL 설계 패턴 — Static·Dynamic·Hybrid 비교
관련 글
이 글을 참조하는 글 (10)
- C++ in RTOS — RAII·std::thread·ETL·Coroutine— Practical RTOS Internals
- 임베디드 HAL 설계 패턴 — Static·Dynamic·Hybrid 비교— Embedded C++ for Real Systems
- Singleton 대안 패턴 — Service Locator·Static Init·Phantom— Embedded C++ for Real Systems
- No-RTTI C++ 설계 — dynamic_cast 제거와 정적 타입 분기— Embedded C++ for Real Systems
- C++20 Concepts 활용 — 템플릿 제약과 가독성 개선— Embedded C++ for Real Systems
- Type Traits 임베디드 활용 — SFINAE·is_pod·컴파일 타임 검사— Embedded C++ for Real Systems
- Template 비용 분석 — 코드 폭증·인스턴스화·디버그 정보 측정— Embedded C++ for Real Systems
- 임베디드 Templates 기초 — 타입 안전과 코드 재사용 분석— Embedded C++ for Real Systems
- C++ 코드 크기 분석 — 가상 함수·템플릿·예외 비용 추적— Embedded C++ for Real Systems
- 임베디드 C++ vs C — 런타임·코드 크기·ABI 관점 비교— Embedded C++ for Real Systems