기반 클래스 포인터만 들고 있는데 실제 객체가 어떤 타입인지 알아야 할 때가 있다. 이벤트 큐에서 꺼낸 객체를 타입별 핸들러로 보내거나, 특정 파생 타입에만 있는 기능을 쓰는 경우다. C++은 실행 시점 타입 정보(RTTI, Run-Time Type Information)로 이를 지원한다. 연산자는 typeid와 dynamic_cast 두 가지이고, 둘 다 다형 클래스(가상 함수가 하나 이상 있는 클래스)의 vtable에 붙은 type_info를 이용한다. 이 글에서는 g++ 13.3으로 typeid와 type_index, 교차 캐스트, RTTI 연산의 실제 비용, -fno-rtti의 효과를 확인한다. RTTI를 직접 구현하는 방법은 다음 편에서 다룬다.
이 시리즈의 다른 글
- C++ 고급 문법 (22) — 추상 클래스(Abstract Class)
- C++ 고급 문법 (23) — 인터페이스(Interface)
- C++ 고급 문법 (25) — RTTI 직접 구현(Implementing RTTI)
typeid
#include <cstdio>
#include <cxxabi.h>
#include <cstdlib>
#include <typeinfo>
struct Plain { }; // 다형 클래스 아님
struct PlainChild : Plain { };
struct Poly { virtual ~Poly() = default; };
struct PolyChild : Poly { };
const char* demangle(const char* mangled)
{
static char buf[128];
int status = 0;
char* s = abi::__cxa_demangle(mangled, nullptr, nullptr, &status);
std::snprintf(buf, sizeof buf, "%s", status == 0 ? s : mangled);
std::free(s);
return buf;
}
int main()
{
PlainChild pc;
PolyChild yc;
Plain& rp = pc;
Poly& ry = yc;
std::printf("typeid(rp).name() = %-12s -> %s\n", typeid(rp).name(), demangle(typeid(rp).name())); // 정적 타입
std::printf("typeid(ry).name() = %-12s -> %s\n", typeid(ry).name(), demangle(typeid(ry).name())); // 동적 타입
std::printf("typeid(ry) == typeid(PolyChild) : %d\n", typeid(ry) == typeid(PolyChild));
std::printf("typeid(const int&) == typeid(int): %d\n", typeid(const int&) == typeid(int));
}$ g++ -std=c++20 -Wall -o ti typeid.cpp
$ ./ti
typeid(rp).name() = 5Plain -> Plain
typeid(ry).name() = 9PolyChild -> PolyChild
typeid(ry) == typeid(PolyChild) : 1
typeid(const int&) == typeid(int): 1
다형 클래스가 아니면 typeid는 컴파일 시점의 정적 타입(Plain)을 돌려주고, 다형 클래스면 vptr을 따라가 동적 타입(PolyChild)을 돌려준다. name()은 구현 정의 문자열이라 g++에서는 맹글링된 이름이 나오므로 abi::__cxa_demangle로 풀어 읽는다.
type_index로 타입별 테이블 만들기
#include <cstdio>
#include <memory>
#include <string>
#include <typeindex>
#include <unordered_map>
#include <vector>
struct Event { virtual ~Event() = default; };
struct Click : Event { int x = 3, y = 4; };
struct Key : Event { char c = 'q'; };
int main()
{
// 타입별 핸들러 테이블: type_info는 복사할 수 없으므로 type_index로 감싼다
std::unordered_map<std::type_index, std::string> handlers{
{typeid(Click), "on_click"},
{typeid(Key), "on_key"},
};
std::vector<std::unique_ptr<Event>> q;
q.push_back(std::make_unique<Key>());
q.push_back(std::make_unique<Click>());
q.push_back(std::make_unique<Event>());
for (const auto& e : q) {
auto it = handlers.find(typeid(*e)); // 동적 타입으로 조회
std::printf("%s\n", it != handlers.end() ? it->second.c_str() : "(no handler)");
}
Event* none = nullptr;
try { (void)typeid(*none); }
catch (const std::bad_typeid& ex) { std::printf("bad_typeid: %s\n", ex.what()); }
}$ g++ -std=c++20 -Wall -o tx typeindex.cpp
$ ./tx
on_key
on_click
(no handler)
bad_typeid: std::bad_typeid
type_info는 복사할 수 없어서 컨테이너 키로 쓰려면 std::type_index로 감싼다. 널 포인터를 역참조한 typeid(*p)는 크래시가 아니라 std::bad_typeid 예외를 던진다.
dynamic_cast의 교차 캐스트
#include <cstdio>
struct Readable { virtual ~Readable() = default; };
struct Writable { virtual ~Writable() = default; };
struct File : Readable, Writable { };
struct Pipe : Readable { };
void probe(Readable* r)
{
Writable* w = dynamic_cast<Writable*>(r); // 형제 타입으로 교차 캐스트(cross-cast)
void* whole = dynamic_cast<void*>(r); // 가장 파생된 객체의 시작 주소
std::printf(" r=%p writable=%p whole=%p\n",
static_cast<void*>(r), static_cast<void*>(w), whole);
}
int main()
{
File f; Pipe p;
std::puts("File:"); probe(&f);
std::puts("Pipe:"); probe(&p);
}$ g++ -std=c++20 -Wall -o cc crosscast.cpp
$ ./cc
File:
r=0x7ffe84b49f70 writable=0x7ffe84b49f78 whole=0x7ffe84b49f70
Pipe:
r=0x7ffe84b49f68 writable=(nil) whole=0x7ffe84b49f68
Readable*에서 상속 관계가 없는 Writable*로 바꾸는 것은 static_cast로는 불가능하고, 실제 객체가 둘 다 상속할 때만 dynamic_cast가 성공한다. 주소가 8바이트 이동한 것은 두 번째 기반 클래스로의 보정이고, dynamic_cast<void*>는 가장 파생된 객체의 시작 주소를 준다.
RTTI 연산의 비용
#include <chrono>
#include <cstdio>
#include <memory>
#include <typeinfo>
#include <vector>
struct Node { virtual ~Node() = default; virtual bool is_leaf() const { return false; } };
struct Expr : Node { };
struct Binary : Expr { };
struct Add : Binary { };
struct Leaf : Expr { bool is_leaf() const override { return true; } };
struct Num : Leaf { }; // 4단계 깊이
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("%-28s %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>());
else v.push_back(std::make_unique<Add>());
}
bench("virtual is_leaf()", v, [](const Node* n) { return n->is_leaf(); });
bench("typeid(*n) == typeid(Num)", v, [](const Node* n) { return typeid(*n) == typeid(Num); });
bench("dynamic_cast<const Num*>", v, [](const Node* n) { return dynamic_cast<const Num*>(n) != nullptr; });
bench("dynamic_cast<const Leaf*>", v, [](const Node* n) { return dynamic_cast<const Leaf*>(n) != nullptr; });
}$ g++ -std=c++20 -Wall -O2 -o cs cost.cpp
$ ./cs
virtual is_leaf() 36.3 ms (hits=10000000)
typeid(*n) == typeid(Num) 30.2 ms (hits=10000000)
dynamic_cast<const Num*> 293.2 ms (hits=10000000)
dynamic_cast<const Leaf*> 424.3 ms (hits=10000000)
typeid 비교는 가상 함수 호출과 비슷하지만, dynamic_cast는 상속 그래프를 탐색해 약 10~14배 느렸다. 정확히 일치하는 말단 타입(Num)보다 중간 기반(Leaf)을 찾을 때 더 오래 걸린다.
| 방법 | 판정 기준 | 파생의 파생도 매칭 | 상대 비용 |
|---|---|---|---|
가상 함수 (is_leaf()) | 설계한 질문 | 재정의에 따름 | 낮음 |
typeid(*p) == typeid(T) | 정확히 같은 타입 | 아니오 | 낮음 |
dynamic_cast<T*> | T이거나 T의 파생 | 예 | 높음 (계층 탐색) |
-fno-rtti
#include <typeinfo>
struct Node { virtual ~Node() = default; };
struct Num : Node {};
bool is_num(const Node* n)
{
return dynamic_cast<const Num*>(n) != nullptr;
}
const char* name_of(const Node& n) { return typeid(n).name(); }$ g++ -std=c++20 -Wall -O2 -fno-rtti -c nortti.cpp 2>&1 | grep error
nortti.cpp:8:12: error: ‘dynamic_cast’ not permitted with ‘-fno-rtti’
nortti.cpp:10:52: error: cannot use ‘typeid’ with ‘-fno-rtti’
#include <cstdio>
#include <memory>
#include <vector>
struct A { virtual ~A() = default; virtual int f() const { return 1; } };
struct B : A { int f() const override { return 2; } };
struct C : A { int f() const override { return 3; } };
struct D : B { int f() const override { return 4; } };
int main()
{
std::vector<std::unique_ptr<A>> v;
v.push_back(std::make_unique<B>()); v.push_back(std::make_unique<C>()); v.push_back(std::make_unique<D>());
int s = 0;
for (auto& p : v) s += p->f();
std::printf("%d\n", s);
}$ g++ -std=c++20 -Wall -O2 -o with_rtti size.cpp
$ g++ -std=c++20 -Wall -O2 -fno-rtti -o no_rtti size.cpp
$ size with_rtti no_rtti
text data bss dec hex filename
4749 896 8 5653 1615 with_rtti
4270 808 8 5086 13de no_rtti
$ nm -C with_rtti | grep -cE 'typeinfo (for|name for) [ABCD]$'
8
$ nm -C no_rtti | grep -cE 'typeinfo (for|name for) [ABCD]$'
0
-fno-rtti는 클래스마다 생성되던 type_info 객체와 이름 문자열(여기서는 4개 클래스에 8개 심볼)을 없애 바이너리를 줄인다. LLVM, 일부 게임 엔진과 임베디드 프로젝트가 이 옵션을 쓰고, 대신 다음 편처럼 RTTI를 직접 구현한다.
주의사항
| 상황 | 문제 | 대응 |
|---|---|---|
비다형 클래스에 typeid | 동적 타입이 아니라 정적 타입이 나옴 | 가상 소멸자라도 하나 두거나 설계 재검토 |
type_info::name()을 저장·비교 | 컴파일러마다 형식이 다름 | 비교는 ==나 type_index로 |
타입마다 dynamic_cast를 연달아 시도 | 느리고 새 타입 추가 시 누락 | 가상 함수로 동작을 옮긴다 |
핫 루프의 dynamic_cast | 계층 탐색 비용 | 정확한 타입만 필요하면 typeid 비교, 또는 직접 구현한 RTTI |
-fno-rtti 라이브러리와 혼합 | typeid·dynamic_cast를 쓰는 코드, std::any::type()·std::function::target_type() 사용 불가 | 빌드 옵션을 프로젝트 전체에서 통일 |
| 공유 라이브러리 경계 | 심볼 가시성에 따라 같은 타입의 type_info가 둘로 나뉨 | 타입을 기본 가시성으로 내보낸다 |
마무리
typeid는 다형 클래스에서만 동적 타입을 돌려주며, 컨테이너 키로는type_index를 쓴다.dynamic_cast는 다운캐스트·교차 캐스트를 검사하지만 계층 탐색 때문에typeid비교보다 약 10~14배 느렸다.-fno-rtti는type_info를 없애 크기를 줄이는 대신 두 연산을 모두 쓸 수 없게 한다.