The Complete Guide to C++ CRTP | Static Polymorphism and Compile-Time Optimization

The Complete Guide to C++ CRTP | Static Polymorphism and Compile-Time Optimization

이 글의 핵심

CRTP: static polymorphism via base<Derived>, curiously recurring template—zero-cost alternatives to virtuals when types are known at compile time.

What is CRTP? Why Do We Need It?

Problem Scenario: Runtime Overhead of Virtual Functions

The Problem: Virtual functions provide runtime polymorphism, but they degrade performance due to vtable lookup costs and lack of inlining.

// Virtual function (runtime polymorphism)
class Shape {
public:
    virtual double area() const = 0;  // vtable lookup
};

class Circle : public Shape {
public:
    double area() const override {
        return 3.14159 * radius * radius;
    }
private:
    double radius;
};

void process(const Shape& s) {
    double a = s.area();  // vtable lookup at runtime
}

The Solution: CRTP (Curiously Recurring Template Pattern) provides compile-time polymorphism. By passing the derived class as a template parameter and using static_cast for calls, it eliminates the need for a vtable and enables inline optimization.

// CRTP (compile-time polymorphism)
template<typename Derived>
class Shape {
public:
    double area() const {
        return static_cast<const Derived*>(this)->areaImpl();
    }
};

class Circle : public Shape<Circle> {
public:
    double areaImpl() const {
        return 3.14159 * radius * radius;
    }
private:
    double radius;
};

void process(const auto& s) {
    double a = s.area();  // inlined at compile-time
}
flowchart TD
    subgraph virtual["Virtual Function (Runtime)"]
        v1["Shape* ptr"]
        v2["ptr->area()"]
        v3["vtable lookup"]
        v4["Circle::area() called"]
    end
    subgraph crtp["CRTP (Compile-Time)"]
        c1["Shape<Circle> obj"]
        c2["obj.area()"]
        c3["static_cast<Circle*>(this)"]
        c4["Circle::areaImpl() inlined"]
    end
    v1 --> v2 --> v3 --> v4
    c1 --> c2 --> c3 --> c4

Table of Contents

  1. Basic Structure
  2. Enforcing Interfaces
  3. Static Counter
  4. Mixin and CRTP
  5. Common Errors and Solutions
  6. Production Patterns
  7. Complete Example: Mathematical Operators
  8. Performance Comparison

1. Basic Structure

Minimal CRTP

#include <iostream>

template<typename Derived>
class Base {
public:
    void interface() {
        static_cast<Derived*>(this)->implementation();
    }
};

class Derived : public Base<Derived> {
public:
    void implementation() {
        std::cout << "Derived implementation\n";
    }
};

int main() {
    Derived d;
    d.interface();  // "Derived implementation"
}

Key Point: Base takes Derived as a template parameter and calls the derived class method using static_cast<Derived*>(this).

How static_cast Works in CRTP

The mechanism: When you write Base<Derived>, the compiler knows at compile-time that this (which is Base*) actually points to a Derived object. The static_cast<Derived*>(this) is a compile-time cast with zero runtime overhead.

template<typename Derived>
class Base {
public:
    void interface() {
        // At compile-time, compiler knows:
        // - this is Base<Derived>*
        // - Derived inherits from Base<Derived>
        // - Therefore, this actually points to a Derived object
        // - static_cast is safe and has zero cost
        static_cast<Derived*>(this)->implementation();
    }
};

Assembly comparison:

// Virtual function
class VirtualBase {
public:
    virtual void func() = 0;
};

class VirtualDerived : public VirtualBase {
public:
    void func() override { /* ... */ }
};

void call_virtual(VirtualBase* b) {
    b->func();  // mov rax, [rdi]      ; Load vtable pointer
                // call [rax + offset]  ; Indirect call through vtable
}

// CRTP
template<typename Derived>
class CRTPBase {
public:
    void func() {
        static_cast<Derived*>(this)->funcImpl();
    }
};

class CRTPDerived : public CRTPBase<CRTPDerived> {
public:
    void funcImpl() { /* ... */ }
};

void call_crtp(CRTPDerived* d) {
    d->func();  // call CRTPDerived::funcImpl  ; Direct call, inlined
}

Performance impact:

  • Virtual function: 2-3 CPU cycles for vtable lookup + indirect jump (prevents inlining)
  • CRTP: 0 cycles (direct call, can be inlined)

When to use each:

FeatureVirtual FunctionsCRTP
PolymorphismRuntimeCompile-time
PerformanceSlower (vtable)Faster (inlined)
FlexibilityHeterogeneous containersHomogeneous only
Use caseDifferent types in same containerPerformance-critical code

2. Enforcing Interfaces

Shape Example

#include <iostream>
#include <cmath>

template<typename Derived>
class Shape {
public:
    double area() const {
        return static_cast<const Derived*>(this)->areaImpl();
    }
    
    void draw() const {
        static_cast<const Derived*>(this)->drawImpl();
    }
};

class Circle : public Shape<Circle> {
public:
    Circle(double r) : radius(r) {}
    
    double areaImpl() const {
        return M_PI * radius * radius;
    }
    
    void drawImpl() const {
        std::cout << "Drawing circle with radius " << radius << '\n';
    }
    
private:
    double radius;
};

class Rectangle : public Shape<Rectangle> {
public:
    Rectangle(double w, double h) : width(w), height(h) {}
    
    double areaImpl() const {
        return width * height;
    }
    
    void drawImpl() const {
        std::cout << "Drawing rectangle " << width << "x" << height << '\n';
    }
    
private:
    double width, height;
};

template<typename T>
void processShape(const Shape<T>& s) {
    std::cout << "Area: " << s.area() << '\n';
    s.draw();
}

int main() {
    Circle c(5.0);
    Rectangle r(4.0, 6.0);
    
    processShape(c);  // Area: 78.5398, Drawing circle...
    processShape(r);  // Area: 24, Drawing rectangle...
}

Advantage: If Circle or Rectangle fails to implement areaImpl() or drawImpl(), a compile-time error will occur, enforcing the interface.


3. Static Counter

Tracking Object Count

#include <iostream>

template<typename Derived>
class Counter {
public:
    Counter() { ++count; }
    Counter(const Counter&) { ++count; }
    ~Counter() { --count; }
    
    static int getCount() { return count; }
    
private:
    static inline int count = 0;
};

class Widget : public Counter<Widget> {};
class Gadget : public Counter<Gadget> {};

int main() {
    {
        Widget w1, w2;
        Gadget g1;
        std::cout << "Widgets: " << Widget::getCount() << '\n';  // 2
        std::cout << "Gadgets: " << Gadget::getCount() << '\n';  // 1
    }
    std::cout << "Widgets: " << Widget::getCount() << '\n';  // 0
    std::cout << "Gadgets: " << Gadget::getCount() << '\n';  // 0
}

Key Point: Each derived class has its own independent count variable due to template instantiation.


4. Mixin and CRTP

Combining Features

#include <iostream>
#include <string>

template<typename Derived>
class Printable {
public:
    void print() const {
        std::cout << static_cast<const Derived*>(this)->toString() << '\n';
    }
};

template<typename Derived>
class Comparable {
public:
    bool operator<(const Derived& other) const {
        return static_cast<const Derived*>(this)->compare(other) < 0;
    }
    
    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) && 
               !(other < *static_cast<const Derived*>(this));
    }
};

class Person : public Printable<Person>, public Comparable<Person> {
public:
    Person(std::string n, int a) : name(n), age(a) {}
    
    std::string toString() const {
        return name + " (" + std::to_string(age) + ")";
    }
    
    int compare(const Person& other) const {
        return age - other.age;
    }
    
private:
    std::string name;
    int age;
};

int main() {
    Person p1("Alice", 30);
    Person p2("Bob", 25);
    
    p1.print();  // "Alice (30)"
    
    if (p1 > p2) {
        std::cout << "Alice is older\n";
    }
}

Advantage: Printable and Comparable can be independently combined.


5. Common Errors and Solutions

Error 1: Forgetting to Implement Required Methods

template<typename Derived>
class Base {
public:
    void interface() {
        static_cast<Derived*>(this)->implementation();
    }
};

class Derived : public Base<Derived> {
    // ❌ Forgot to implement implementation()
};

int main() {
    Derived d;
    d.interface();  // Compile error: no member named 'implementation'
}

Solution: Use static_assert to enforce interface at compile-time:

template<typename Derived>
class Base {
public:
    void interface() {
        static_assert(requires(Derived d) { d.implementation(); },
                     "Derived must implement implementation()");
        static_cast<Derived*>(this)->implementation();
    }
};

Error 2: Wrong Template Parameter

// ❌ Wrong: passing Base instead of Derived
class Derived : public Base<Base> {  // Wrong!
    void implementation() { /* ... */ }
};

// ✅ Correct: passing Derived
class Derived : public Base<Derived> {
    void implementation() { /* ... */ }
};

Error 3: Slicing

template<typename Derived>
class Base {
public:
    void interface() {
        static_cast<Derived*>(this)->implementation();
    }
};

class Derived : public Base<Derived> {
public:
    void implementation() { std::cout << "Derived\n"; }
};

void process(Base<Derived> b) {  // ❌ Pass by value = slicing!
    b.interface();  // UB! 'this' is not actually a Derived*
}

void process_correct(Base<Derived>& b) {  // ✅ Pass by reference
    b.interface();  // OK
}

6. Production Patterns

Pattern 1: Operator Overloading

template<typename Derived>
class Comparable {
public:
    friend bool operator<(const Derived& lhs, const Derived& rhs) {
        return lhs.compare(rhs) < 0;
    }
    
    friend bool operator>(const Derived& lhs, const Derived& rhs) {
        return rhs < lhs;
    }
    
    friend bool operator<=(const Derived& lhs, const Derived& rhs) {
        return !(lhs > rhs);
    }
    
    friend bool operator>=(const Derived& lhs, const Derived& rhs) {
        return !(lhs < rhs);
    }
    
    friend bool operator==(const Derived& lhs, const Derived& rhs) {
        return lhs.compare(rhs) == 0;
    }
    
    friend bool operator!=(const Derived& lhs, const Derived& rhs) {
        return !(lhs == rhs);
    }
};

class Number : public Comparable<Number> {
public:
    explicit Number(int v) : value(v) {}
    
    int compare(const Number& other) const {
        return value - other.value;
    }
    
private:
    int value;
};

int main() {
    Number a(10), b(20);
    std::cout << (a < b) << '\n';   // true
    std::cout << (a == b) << '\n';  // false
    std::cout << (a >= b) << '\n';  // false
}

Pattern 2: Method Chaining

template<typename Derived>
class Chainable {
public:
    Derived& self() {
        return static_cast<Derived&>(*this);
    }
    
    const Derived& self() const {
        return static_cast<const Derived&>(*this);
    }
};

class Builder : public Chainable<Builder> {
public:
    Builder& setName(std::string n) {
        name = n;
        return self();
    }
    
    Builder& setAge(int a) {
        age = a;
        return self();
    }
    
    void build() {
        std::cout << name << ", " << age << '\n';
    }
    
private:
    std::string name;
    int age;
};

int main() {
    Builder()
        .setName("Alice")
        .setAge(30)
        .build();  // "Alice, 30"
}

Pattern 3: Static Interface Enforcement

template<typename Derived>
class Interface {
public:
    void execute() {
        // Compile-time check
        static_assert(requires(Derived d) {
            { d.step1() } -> std::same_as<void>;
            { d.step2() } -> std::same_as<int>;
        }, "Derived must implement step1() and step2()");
        
        auto& derived = static_cast<Derived&>(*this);
        derived.step1();
        int result = derived.step2();
        std::cout << "Result: " << result << '\n';
    }
};

class Implementation : public Interface<Implementation> {
public:
    void step1() { std::cout << "Step 1\n"; }
    int step2() { return 42; }
};

7. Complete Example: Mathematical Operators

#include <iostream>
#include <cmath>

template<typename Derived>
class Numeric {
public:
    Derived& operator+=(const Derived& other) {
        auto& self = static_cast<Derived&>(*this);
        self.add(other);
        return self;
    }
    
    Derived& operator-=(const Derived& other) {
        auto& self = static_cast<Derived&>(*this);
        self.subtract(other);
        return self;
    }
    
    friend Derived operator+(Derived lhs, const Derived& rhs) {
        lhs += rhs;
        return lhs;
    }
    
    friend Derived operator-(Derived lhs, const Derived& rhs) {
        lhs -= rhs;
        return lhs;
    }
};

class Vector2D : public Numeric<Vector2D> {
public:
    Vector2D(double x, double y) : x_(x), y_(y) {}
    
    void add(const Vector2D& other) {
        x_ += other.x_;
        y_ += other.y_;
    }
    
    void subtract(const Vector2D& other) {
        x_ -= other.x_;
        y_ -= other.y_;
    }
    
    double magnitude() const {
        return std::sqrt(x_ * x_ + y_ * y_);
    }
    
    void print() const {
        std::cout << "(" << x_ << ", " << y_ << ")\n";
    }
    
private:
    double x_, y_;
};

int main() {
    Vector2D v1(3, 4);
    Vector2D v2(1, 2);
    
    auto v3 = v1 + v2;
    v3.print();  // (4, 6)
    
    std::cout << "Magnitude: " << v3.magnitude() << '\n';  // 7.211...
}

8. Performance Comparison

Benchmark: Virtual vs CRTP

#include <chrono>
#include <iostream>
#include <vector>

// Virtual function approach
class VirtualShape {
public:
    virtual double area() const = 0;
    virtual ~VirtualShape() = default;
};

class VirtualCircle : public VirtualShape {
public:
    VirtualCircle(double r) : radius(r) {}
    double area() const override {
        return 3.14159 * radius * radius;
    }
private:
    double radius;
};

// CRTP approach
template<typename Derived>
class CRTPShape {
public:
    double area() const {
        return static_cast<const Derived*>(this)->areaImpl();
    }
};

class CRTPCircle : public CRTPShape<CRTPCircle> {
public:
    CRTPCircle(double r) : radius(r) {}
    double areaImpl() const {
        return 3.14159 * radius * radius;
    }
private:
    double radius;
};

// Benchmark
int main() {
    constexpr int N = 10000000;
    
    // Virtual function
    std::vector<VirtualShape*> virtual_shapes;
    for (int i = 0; i < 100; ++i) {
        virtual_shapes.push_back(new VirtualCircle(i));
    }
    
    auto start = std::chrono::steady_clock::now();
    double sum = 0;
    for (int i = 0; i < N; ++i) {
        sum += virtual_shapes[i % 100]->area();
    }
    auto elapsed_virtual = std::chrono::steady_clock::now() - start;
    
    // CRTP
    std::vector<CRTPCircle> crtp_shapes;
    for (int i = 0; i < 100; ++i) {
        crtp_shapes.emplace_back(i);
    }
    
    start = std::chrono::steady_clock::now();
    sum = 0;
    for (int i = 0; i < N; ++i) {
        sum += crtp_shapes[i % 100].area();
    }
    auto elapsed_crtp = std::chrono::steady_clock::now() - start;
    
    std::cout << "Virtual: " << std::chrono::duration<double, std::milli>(elapsed_virtual).count() << " ms\n";
    std::cout << "CRTP:    " << std::chrono::duration<double, std::milli>(elapsed_crtp).count() << " ms\n";
    std::cout << "Speedup: " << (elapsed_virtual.count() / (double)elapsed_crtp.count()) << "x\n";
    
    // Cleanup
    for (auto* s : virtual_shapes) delete s;
}

Typical results (varies by compiler and optimization level):

  • Virtual: ~150ms
  • CRTP: ~50ms
  • Speedup: ~3x

Why CRTP is faster:

  1. No vtable lookup: Direct function call
  2. Inlining: Compiler can inline the function
  3. Better cache locality: No pointer indirection

Summary

ItemDescription
PatternBase class takes Derived as template parameter
Mechanismstatic_cast<Derived*>(this) for compile-time dispatch
AdvantagesZero overhead, inlining, compile-time interface enforcement
DisadvantagesNo heterogeneous containers, more complex code
Use casesPerformance-critical code, static polymorphism, mixins

FAQ

Q: When should I use CRTP instead of virtual functions?

A: Use CRTP when:

  • Performance is critical (tight loops, hot paths)
  • You don’t need heterogeneous containers
  • All types are known at compile-time

Use virtual functions when:

  • You need runtime polymorphism
  • You need heterogeneous containers (std::vector<Base*>)
  • Types are determined at runtime

Q: Is static_cast safe in CRTP?

A: Yes, it’s safe because the compiler guarantees that this points to a Derived object. The cast is checked at compile-time and has zero runtime cost.

Q: Can I combine CRTP with virtual functions?

A: Yes, you can use both. CRTP for compile-time polymorphism and virtual functions for runtime polymorphism.

template<typename Derived>
class CRTPBase {
public:
    virtual void virtualMethod() = 0;  // Runtime polymorphism
    
    void crptMethod() {  // Compile-time polymorphism
        static_cast<Derived*>(this)->implementation();
    }
};

Q: What’s the overhead of CRTP?

A: CRTP has zero runtime overhead compared to direct calls. The only cost is increased compile time and code size due to template instantiation.

Q: Can I use CRTP with multiple inheritance?

A: Yes, CRTP works well with multiple inheritance for mixins:

class MyClass : public Printable<MyClass>, 
                public Comparable<MyClass>,
                public Serializable<MyClass> {
    // Implement required methods
};

Related posts: C++ Templates Basics, Virtual Functions, PIMPL Pattern.

One-line summary: CRTP enables compile-time polymorphism with zero overhead by passing the derived class as a template parameter and using static_cast for dispatch.

## 같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.

- [C++ 템플릿 | "제네릭 프로그래밍" 초보자 가이드](/blog/cpp-template-basics/)
- [C++ 가상 함수 | "Virtual Functions" 가이드](/blog/cpp-virtual-functions/)
- [C++ Pimpl Idiom 완벽 가이드 | 구현 은닉과 컴파일 시간 단축](/blog/cpp-pimpl/)

---

---

## 이 글에서 다루는 키워드 (관련 검색어)

C++, crtp, template, polymorphism, pattern, static 등으로 검색하시면 이 글이 도움이 됩니다.