C++ 고급 문법 (24) — RTTI(Run-Time Type Information)

기반 클래스 포인터만 들고 있는데 실제 객체가 어떤 타입인지 알아야 할 때가 있다. 이벤트 큐에서 꺼낸 객체를 타입별 핸들러로 보내거나, 특정 파생 타입에만 있는 기능을 쓰는 경우다. C++은 실행 시점 타입 정보(RTTI, Run-Time Type Information)로 이를 지원한다. 연산자는 typeid와 dynamic_cast 두 가지이고, 둘 다 다형 클래스(가상 함수가 하나 이상 있는 클래스)의 vtable에 붙은 type_info를 이용한다. 이 글에서는 g++ 13.3으로 typeid와 type_index, 교차 캐스트, RTTI 연산의 실제 비용, -fno-rtti의 효과를 확인한다. 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를 없애 크기를 줄이는 대신 두 연산을 모두 쓸 수 없게 한다.

참고

답글 남기기