지난 글에서 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보다는 단순하다. 단일 상속만 지원한다는 제약이 있다.
| 항목 | 표준 RTTI | kind enum (LLVM 스타일) | 정적 TypeInfo + 부모 체인 |
|---|---|---|---|
-fno-rtti에서 사용 | 불가 | 가능 | 가능 |
| 판정 비용 | 계층 탐색 | 정수 비교 1~2회 | 상속 깊이만큼 포인터 추적 |
| 새 클래스 추가 | 자유 | 중앙 enum 수정 필요 | 자유 (매크로 한 줄) |
| 다중 상속·교차 캐스트 | 지원 | 미지원 | 미지원 |
| 객체당 추가 비용 | vptr | kind 필드 (4바이트) | vptr |
| 실수 가능성 | 낮음 | enum 순서·범위 실수 | 매크로 부모 지정 실수 |
주의사항
| 상황 | 문제 | 대응 |
|---|---|---|
| 새 kind를 enum 끝에 추가 | 중간 클래스 classof 판정 오류 | 부모 범위 안에 넣고 K_Last* 갱신, 단위 테스트로 isa 확인 |
| 파생 클래스가 생성자에서 kind를 잘못 전달 | 엉뚱한 타입으로 판정 | kind는 각 구체 클래스 생성자에서만 지정 (중간 클래스 생성자는 protected) |
classof 누락 | 템플릿 인스턴스화 시 기반 클래스 classof가 대신 쓰일 수 있음 | 모든 클래스에 classof 정의 |
릴리스 빌드의 cast | assert가 빠져 잘못된 캐스트가 조용히 통과 | 불확실하면 dyn_cast |
공유 라이브러리 간 정적 TypeInfo | 심볼 가시성에 따라 같은 타입이 다른 주소를 가질 수 있음 | 타입 정보를 정의하는 라이브러리를 하나로 고정하고 기본 가시성으로 내보냄 |
마무리
- 객체에 kind 태그를 두고 클래스별
classof로 판정하면-fno-rtti에서도isa/dyn_cast/cast를 쓸 수 있다. - enum을 깊이 우선 순서로 두면 중간 클래스 판정도 범위 비교 한 번이며,
dynamic_cast보다 8~13배 빨랐다. - 계층이 열려 있으면 정적
TypeInfo주소와 부모 체인으로 중앙 목록 없이 구현할 수 있다.