C++ 고급 문법 (25) — RTTI 직접 구현(Implementing RTTI)

지난 글에서 dynamic_cast는 상속 그래프를 탐색하느라 typeid 비교보다 10배 넘게 느렸고, -fno-rtti로 빌드하면 아예 쓸 수 없었다. 그래서 LLVM/Clang처럼 타입 판정이 잦고 -fno-rtti로 빌드하는 프로젝트는 RTTI를 직접 구현한다. 각 객체에 정수 타입 태그를 하나 두고, 클래스마다 “이 태그면 나다”를 판정하는 함수만 있으면 isa/dyn_cast를 만들 수 있다. 이 글에서는 g++ 13.3(-fno-rtti)으로 LLVM 스타일 kind 기반 RTTI를 구현해 dynamic_cast와 성능을 비교하고, 흔한 실수와 중앙 enum이 필요 없는 대안 구현을 확인한다.

이 시리즈의 다른 글

LLVM 스타일 kind 기반 RTTI

판정 함수는 대상 클래스의 정적 함수 classof()에 맡기고, 공통 템플릿 세 개가 이를 호출한다.

#pragma once
#include <cassert>

// LLVM 스타일: To::classof(from)으로 판정하는 isa / dyn_cast / cast
template <typename To, typename From>
bool isa(const From* p) { return p && To::classof(p); }

template <typename To, typename From>
To* dyn_cast(From* p) { return isa<To>(p) ? static_cast<To*>(p) : nullptr; }

template <typename To, typename From>
const To* dyn_cast(const From* p) { return isa<To>(p) ? static_cast<const To*>(p) : nullptr; }

template <typename To, typename From>
To* cast(From* p) { assert(isa<To>(p) && "cast<Ty>() argument of incompatible type!"); return static_cast<To*>(p); }

기반 클래스는 생성 시 정해지는 Kind를 저장한다. enum을 상속 트리의 깊이 우선 순서로 나열하면, 중간 클래스는 “자기 계열의 첫 값 ~ 마지막 값” 범위 비교 한 번으로 판정할 수 있다.

#pragma once
#include "casting.h"

class Node {
public:
    enum Kind {                     // 상속 트리를 깊이 우선 순서로 나열한다
        K_Binary,                   // Binary 계열 시작
          K_Add,
          K_Mul,
          K_LastBinary = K_Mul,     // Binary 계열 끝
        K_Leaf,                     // Leaf 계열 시작
          K_Num,
          K_Var,
          K_LastLeaf = K_Var,       // Leaf 계열 끝
    };
    Kind kind() const { return kind_; }
    virtual ~Node() = default;
protected:
    explicit Node(Kind k) : kind_(k) {}
private:
    const Kind kind_;               // 생성 시 한 번 정해진다
};

struct Binary : Node {
    Node *lhs = nullptr, *rhs = nullptr;
    static bool classof(const Node* n) { return n->kind() >= K_Binary && n->kind() <= K_LastBinary; }  // 범위로 판정
protected:
    using Node::Node;
};
struct Add : Binary { Add() : Binary(K_Add) {} static bool classof(const Node* n) { return n->kind() == K_Add; } };
struct Mul : Binary { Mul() : Binary(K_Mul) {} static bool classof(const Node* n) { return n->kind() == K_Mul; } };

struct Leaf : Node {
    static bool classof(const Node* n) { return n->kind() >= K_Leaf && n->kind() <= K_LastLeaf; }
protected:
    using Node::Node;
};
struct Num : Leaf { int v; explicit Num(int x) : Leaf(K_Num), v(x) {} static bool classof(const Node* n) { return n->kind() == K_Num; } };
struct Var : Leaf { char name; explicit Var(char c) : Leaf(K_Var), name(c) {} static bool classof(const Node* n) { return n->kind() == K_Var; } };
#include <cstdio>
#include "nodes.h"

int eval(const Node* n)
{
    if (auto* num = dyn_cast<Num>(n)) return num->v;
    if (auto* var = dyn_cast<Var>(n)) return var->name == 'x' ? 10 : 0;
    if (auto* add = dyn_cast<Add>(n)) return eval(add->lhs) + eval(add->rhs);
    if (auto* mul = dyn_cast<Mul>(n)) return eval(mul->lhs) * eval(mul->rhs);
    return 0;
}

int main()
{
    Num two(2), three(3);
    Var x('x');
    Mul m; m.lhs = &two; m.rhs = &x;          // 2 * x
    Add a; a.lhs = &m;   a.rhs = &three;      // 2 * x + 3
    std::printf("eval = %d\n", eval(&a));

    const Node* n = &x;
    std::printf("isa<Leaf>=%d isa<Var>=%d isa<Binary>=%d isa<Num>=%d\n",
                isa<Leaf>(n), isa<Var>(n), isa<Binary>(n), isa<Num>(n));
    std::printf("sizeof(Num)=%zu\n", sizeof(Num));
}
$ g++ -std=c++20 -Wall -fno-rtti -o use use.cpp
$ ./use
eval = 23
isa<Leaf>=1 isa<Var>=1 isa<Binary>=0 isa<Num>=0
sizeof(Num)=16

-fno-rtti로 빌드했지만 dyn_cast로 타입별 분기가 동작하고, Var는 Leaf 범위에 들어가 isa<Leaf>도 참이다.

함수실패 시용도
isa<T>(p)false판정만
dyn_cast<T>(p)nullptr타입이 불확실할 때 (dynamic_cast 대응)
cast<T>(p)assert 실패타입이 확실할 때 (검사된 static_cast)
#include <cstdio>
#include "nodes.h"

int main()
{
    Var x('x');
    Node* n = &x;
    Num* bad = cast<Num>(n);       // 확신이 있을 때만 쓰는 cast: 틀리면 assert
    std::printf("%d\n", bad->v);
}
$ g++ -std=c++20 -Wall -fno-rtti -o cf castfail.cpp
$ ./cf; echo "exit=$?"
cf: casting.h:15: To* cast(From*) [with To = Num; From = Node]: Assertion `isa<To>(p) && "cast<Ty>() argument of incompatible type!"' failed.
bash: line 1: 10055 Aborted                 (core dumped) ./cf
exit=134

cast는 디버그 빌드에서 잘못된 가정을 즉시 잡고, -DNDEBUG 릴리스 빌드에서는 검사가 빠져 static_cast와 비용이 같다.

dynamic_cast와 성능 비교

#include <chrono>
#include <cstdio>
#include <memory>
#include <vector>
#include "nodes.h"

template <typename F>
void bench(const char* name, const std::vector<std::unique_ptr<Node>>& v, F pred)
{
    auto t0 = std::chrono::steady_clock::now();
    long hits = 0;
    for (int rep = 0; rep < 20; ++rep)
        for (const auto& n : v) hits += pred(n.get());
    auto ms = std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - t0).count();
    std::printf("%-26s %7.1f ms (hits=%ld)\n", name, ms, hits);
}

int main()
{
    std::vector<std::unique_ptr<Node>> v;
    for (int i = 0; i < 1'000'000; ++i) {
        if (i % 2) v.push_back(std::make_unique<Num>(i));
        else       v.push_back(std::make_unique<Add>());
    }
    bench("isa<Num> (kind ==)",      v, [](const Node* n) { return isa<Num>(n); });
    bench("isa<Leaf> (kind range)",  v, [](const Node* n) { return isa<Leaf>(n); });
#if __cpp_rtti
    bench("dynamic_cast<Num*>",      v, [](const Node* n) { return dynamic_cast<const Num*>(n) != nullptr; });
    bench("dynamic_cast<Leaf*>",     v, [](const Node* n) { return dynamic_cast<const Leaf*>(n) != nullptr; });
#endif
}
$ g++ -std=c++20 -Wall -O2 -o bn bench.cpp
$ ./bn
isa<Num> (kind ==)            23.8 ms (hits=10000000)
isa<Leaf> (kind range)        22.4 ms (hits=10000000)
dynamic_cast<Num*>           201.4 ms (hits=10000000)
dynamic_cast<Leaf*>          298.4 ms (hits=10000000)
$ g++ -std=c++20 -Wall -O2 -fno-rtti -o bn_nortti bench.cpp
$ ./bn_nortti
isa<Num> (kind ==)            23.2 ms (hits=10000000)
isa<Leaf> (kind range)        23.7 ms (hits=10000000)

isa는 정수 비교 한두 번이라 정확한 타입이든 중간 클래스든 약 23ms로 같고, dynamic_cast보다 8~13배 빠르다. -fno-rtti 빌드에서는 __cpp_rtti가 정의되지 않아 dynamic_cast 쪽이 빠졌다.

흔한 실수: enum 범위 밖에 추가

#include <cstdio>
#include "casting.h"

class Node {
public:
    enum Kind {
        K_Binary, K_Add, K_Mul, K_LastBinary = K_Mul,
        K_Leaf, K_Num, K_LastLeaf = K_Num,
        K_Sub,                                // 새 Binary 파생을 enum 끝에 추가 (실수)
    };
    Kind kind() const { return kind_; }
    virtual ~Node() = default;
protected:
    explicit Node(Kind k) : kind_(k) {}
private:
    const Kind kind_;
};
struct Binary : Node {
    static bool classof(const Node* n) { return n->kind() >= K_Binary && n->kind() <= K_LastBinary; }
protected:
    using Node::Node;
};
struct Sub : Binary { Sub() : Binary(K_Sub) {} static bool classof(const Node* n) { return n->kind() == K_Sub; } };

int main()
{
    Sub s;
    const Node* n = &s;
    std::printf("isa<Sub>=%d isa<Binary>=%d\n", isa<Sub>(n), isa<Binary>(n));   // Binary인데 0
}
$ g++ -std=c++20 -Wall -fno-rtti -o rb range_bug.cpp
$ ./rb
isa<Sub>=1 isa<Binary>=0

새 클래스의 kind를 enum 끝에 붙이면 Binary의 범위(K_Binary~K_LastBinary) 밖이라 isa<Binary>가 거짓이 된다. 새 kind는 반드시 부모 계열 범위 안에 넣고 K_Last*를 갱신해야 하며, 컴파일러는 이 실수를 잡지 못한다.

대안: 정적 객체 주소를 타입 ID로

중앙 enum은 모든 파생 클래스를 한 곳에서 알아야 해서 플러그인처럼 열린 계층에는 맞지 않는다. 클래스마다 정적 TypeInfo 객체를 하나 두고 그 주소를 ID로, 부모 포인터를 연결해 is-a를 판정하면 중앙 목록이 필요 없다.

#include <cstdio>

// 중앙 enum 없이: 클래스마다 고유한 정적 객체 주소를 타입 ID로 쓰고, 부모를 연결해 is-a를 판정
struct TypeInfo {
    const char*     name;
    const TypeInfo* parent;
};

#define DECLARE_TYPE(Self, Parent)                                                    \
    static const TypeInfo* static_type() { static const TypeInfo t{#Self, Parent}; return &t; } \
    const TypeInfo* dyn_type() const override { return static_type(); }

struct Object {
    virtual ~Object() = default;
    static const TypeInfo* static_type() { static const TypeInfo t{"Object", nullptr}; return &t; }
    virtual const TypeInfo* dyn_type() const { return static_type(); }

    template <typename T>
    bool is_a() const                                  // 부모 체인을 따라 올라간다
    {
        for (auto* t = dyn_type(); t; t = t->parent)
            if (t == T::static_type()) return true;
        return false;
    }
    template <typename T> T*       as()       { return is_a<T>() ? static_cast<T*>(this) : nullptr; }
    template <typename T> const T* as() const { return is_a<T>() ? static_cast<const T*>(this) : nullptr; }
};

struct Widget : Object { DECLARE_TYPE(Widget, Object::static_type()) };
struct Button : Widget { DECLARE_TYPE(Button, Widget::static_type()) int clicks = 3; };
struct Label  : Widget { DECLARE_TYPE(Label,  Widget::static_type()) };   // 다른 파일에서 자유롭게 추가 가능

int main()
{
    Button b;
    const Object& o = b;
    std::printf("dyn_type=%s\n", o.dyn_type()->name);
    std::printf("is_a<Button>=%d is_a<Widget>=%d is_a<Object>=%d is_a<Label>=%d\n",
                o.is_a<Button>(), o.is_a<Widget>(), o.is_a<Object>(), o.is_a<Label>());
    if (auto* btn = o.as<Button>()) std::printf("clicks=%d\n", btn->clicks);
}
$ g++ -std=c++20 -Wall -fno-rtti -o tt typetag.cpp
$ ./tt
dyn_type=Button
is_a<Button>=1 is_a<Widget>=1 is_a<Object>=1 is_a<Label>=0
clicks=3

판정 비용은 상속 깊이만큼 포인터를 따라가는 것이라 kind 범위 비교보다는 느리지만 상속 그래프 전체를 탐색하는 dynamic_cast보다는 단순하다. 단일 상속만 지원한다는 제약이 있다.

항목표준 RTTIkind enum (LLVM 스타일)정적 TypeInfo + 부모 체인
-fno-rtti에서 사용불가가능가능
판정 비용계층 탐색정수 비교 1~2회상속 깊이만큼 포인터 추적
새 클래스 추가자유중앙 enum 수정 필요자유 (매크로 한 줄)
다중 상속·교차 캐스트지원미지원미지원
객체당 추가 비용vptrkind 필드 (4바이트)vptr
실수 가능성낮음enum 순서·범위 실수매크로 부모 지정 실수

주의사항

상황문제대응
새 kind를 enum 끝에 추가중간 클래스 classof 판정 오류부모 범위 안에 넣고 K_Last* 갱신, 단위 테스트로 isa 확인
파생 클래스가 생성자에서 kind를 잘못 전달엉뚱한 타입으로 판정kind는 각 구체 클래스 생성자에서만 지정 (중간 클래스 생성자는 protected)
classof 누락템플릿 인스턴스화 시 기반 클래스 classof가 대신 쓰일 수 있음모든 클래스에 classof 정의
릴리스 빌드의 castassert가 빠져 잘못된 캐스트가 조용히 통과불확실하면 dyn_cast
공유 라이브러리 간 정적 TypeInfo심볼 가시성에 따라 같은 타입이 다른 주소를 가질 수 있음타입 정보를 정의하는 라이브러리를 하나로 고정하고 기본 가시성으로 내보냄

마무리

  • 객체에 kind 태그를 두고 클래스별 classof로 판정하면 -fno-rtti에서도 isa/dyn_cast/cast를 쓸 수 있다.
  • enum을 깊이 우선 순서로 두면 중간 클래스 판정도 범위 비교 한 번이며, dynamic_cast보다 8~13배 빨랐다.
  • 계층이 열려 있으면 정적 TypeInfo 주소와 부모 체인으로 중앙 목록 없이 구현할 수 있다.

참고

답글 남기기