← 문제로

20. RTTI: dynamic_cast / typeid 의 내부 동작

난이도 상
내 리뷰 · C++
해설 · C++

해설 — RTTI: dynamic_cast / typeid 의 내부 동작

난이도: 상

요약

다형적 타입은 vtable 을 통해 실행 시점의 실제 타입 정보(type_info) 에 접근한다. typeid(*e) 는 이 정보를 읽어 런타임 타입을 비교하고, dynamic_cast 는 상속 그래프를 따라 목표 서브오브젝트를 찾아 포인터를 보정하며, 실패 시 포인터 형은 nullptr, 참조 형은 std::bad_cast 예외로 갈린다. 예상 출력:

1: 1        // typeid(*e) == typeid(Boss)      → 실제 타입은 Boss
2: 0        // typeid(*e) == typeid(Monster)   → 정확 일치만 참(파생≠기저)
3: 1        // dynamic_cast<Monster*>(e)        → Boss is-a Monster, 성공
4: 0        // dynamic_cast<Character*>(e)      → Boss 는 Character 아님, nullptr
5: 1        // dynamic_cast<void*>(e)           → 최파생 객체(Boss) 주소
6: bad_cast // dynamic_cast<Character&>(*e)     → 참조 실패는 예외
7: 1        // dynamic_cast<Collidable*>(r)     → 형제 베이스 cross-cast 성공
8: 1        // typeid(*npp) == typeid(NonPoly)  → 비다형은 정적 타입으로 결정

핵심 개념

  • type_info: 각 타입마다 하나 존재하는 RTTI 서술자. typeid 는 이것의 참조를 준다. == 비교, name(), before(), hash_code() 제공. 다형적 타입의 type_info 포인터는 vtable 안(보통 슬롯 −1, 구현 정의) 에 저장된다.
  • 다형 vs 비다형: 가상 함수가 하나라도 있으면 다형적 → 런타임 타입 조회 가능. 없으면 typeid/dynamic_cast정적 타입으로 컴파일타임에 결정(런타임 조회 없음).
  • dynamic_cast: 다운캐스트/사이드캐스트를 런타임에 안전하게 수행. 실제 객체의 상속 구조를 조회해 목표 타입 서브오브젝트가 있으면 그 위치로 포인터를 보정, 없으면 실패.

단계별 내부 동작

  1. typeid(*e) (라인 1,2): e 는 다형적이므로 *e 의 vtable 을 찾아 그 안의 type_info* 를 읽는다. 그 type_infotypeid(Boss)/typeid(Monster)type_info 를 비교. Boss 객체이므로 Boss 와만 일치(정확 타입 일치). "Monster 이기도 하다" 는 참이 아님 — typeid 는 is-a 가 아니라 정확 동일성을 본다.
  2. 포인터 dynamic_cast (라인 3,4): 런타임이 *e 의 실제 타입(Boss)에서 목표(Monster/Character)로 가는 경로를 상속 그래프에서 찾는다. Boss→Monster→Entity 경로에 Monster 가 있으니 성공(포인터를 Monster 서브오브젝트로 보정, 단일 상속이라 여기선 오프셋 0). Character 는 경로에 없으니 nullptr.
  3. dynamic_cast<void*> (라인 5): 특수 규칙 — 다형적 포인터를 void* 로 dynamic_cast 하면 최파생(most-derived) 객체의 시작 주소를 준다. 다중 상속에서 기저 포인터가 객체 중간을 가리켜도 이걸로 실제 객체 머리를 복원할 수 있다.
  4. 참조 dynamic_cast 실패 (라인 6): 포인터는 실패를 nullptr 로 표현할 수 있지만 참조에는 "널 참조" 가 없다. 그래서 참조 캐스트 실패는 std::bad_cast 예외로 알린다.
  5. cross-cast (라인 7): GameObject : Renderable, Collidable. Renderable* 에서 형제 베이스 Collidable* 로의 변환은 static_cast 로는 불가능(상속 관계가 아님)하지만, dynamic_cast 는 실제 객체(GameObject)를 조회해 Collidable 서브오브젝트를 찾아 포인터를 그 오프셋으로 보정해 성공. 두 서브오브젝트 주소는 서로 다르다.
  6. 비다형 typeid (라인 8): NonPoly 는 가상 함수가 없어 typeid(*npp)*npp 를 실제로 역참조하지 않고 정적 타입 NonPoly 로 컴파일타임 결정. (그래서 npp 가 널이어도 UB 가 아니라는 점이 다형 타입과 다르다.)

흔한 오해·함정

  • "typeid 는 is-a 를 검사한다" → 아니다. 정확 타입 일치만 참(라인 2). is-a 판정은 dynamic_cast 로.
  • 비다형 타입에서 런타임 타입을 기대 → 가상 함수가 없으면 typeid/dynamic_cast 는 정적 타입. void* 나 슬라이스된 값에서 원 타입 복원 불가.
  • 참조 실패를 널 체크로 잡으려 함 → 참조 dynamic_cast 실패는 예외다. try/catch(std::bad_cast) 필요.
  • 성능 오해: dynamic_cast 는 상속 그래프 탐색이라 비용이 있다(특히 깊은/다중 상속). 핫 패스에서 남발하면 느리다 → 가상 함수 디스패치나 태그/방문자(Visitor)로 대체 고려.
  • 비교는 type_info::operator==: name() 문자열 비교는 이식성·성능 모두 나쁨. == 또는 hash_code()/type_index 사용.

면접 포인트

  1. typeid(정확 타입 동일성) vs dynamic_cast(is-a + 포인터 보정)의 차이, 그리고 다형/비다형에서 각각이 컴파일타임/런타임 중 언제 결정되는지.
  2. 포인터 실패(nullptr) vs 참조 실패(std::bad_cast)가 갈리는 이유, dynamic_cast<void*> 로 최파생 주소를 얻는 용도(다중 상속에서 객체 신원 복원).
  3. 게임 서버에서 엔티티 다운캐스트 남용의 비용 — dynamic_cast 핫패스 회피, 가상 디스패치/컴포넌트(ECS)/Visitor 로 타입 분기 설계.