No-RTTI C++ 설계 — dynamic_cast 제거와 정적 타입 분기
#한 줄 요약
“RTTI 없이도 type-safe한 다형성이 가능합니다.” enum tag,
std::variant, CRTP가 세 가지 대안입니다.
#어떤 문제를 푸는가
C++의 RTTI(Run-Time Type Information)는 세 가지 기능을 제공합니다.
typeid(obj)로 type 정보를 얻습니다.dynamic_cast<T*>(p)로 안전한 down-cast를 수행합니다.- 예외 처리에서 type-based catch를 가능하게 합니다.
비용은 다음과 같습니다.
- type info 테이블이 클래스당 수십~수백 byte를 차지합니다.
- vtable에 type info 포인터가 추가됩니다.
- 총량이 수 KB에 이를 수 있습니다.
임베디드는 -fno-rtti로 빌드합니다. 그렇다면 type 정보가 필요한 상황은 어떻게 처리할까요?
class Shape { /* virtual */ };class Circle : public Shape {};
Shape* s = create();auto* c = dynamic_cast<Circle*>(s); // RTTI 필요if (c) c->circle_method();이 글은 RTTI 없는 세 가지 대안을 다룹니다.
#대안 1 — Enum Tag
가장 단순한 방식으로, type을 enum으로 표현합니다.
enum class ShapeType { Circle, Square, Triangle };
class Shape {public: Shape(ShapeType t) : type_(t) {} ShapeType type() const { return type_; } virtual ~Shape() = default;
private: ShapeType type_;};
class Circle : public Shape {public: Circle() : Shape(ShapeType::Circle) {} void roll() { /* */ }};
// 사용Shape* s = create();if (s->type() == ShapeType::Circle) { static_cast<Circle*>(s)->roll();}type_ enum이 type을 식별하는 역할을 합니다. static_cast는 안전 검증을 해 주지 않으므로 enum 체크와 짝지어 씁니다.
비용은 클래스당 enum size(4 byte) 정도로, RTTI보다 작습니다.
#헬퍼 매크로
template<typename Derived, typename Base>Derived* checked_cast(Base* base) { if (base && base->type() == TypeOf<Derived>::value) { return static_cast<Derived*>(base); } return nullptr;}
template<typename T> struct TypeOf;template<> struct TypeOf<Circle> { static constexpr ShapeType value = ShapeType::Circle; };template<> struct TypeOf<Square> { static constexpr ShapeType value = ShapeType::Square; };
auto* c = checked_cast<Circle>(s);dynamic_cast를 흉내 낼 수 있지만, 모든 type을 enum과 TypeOf 매핑으로 등록해야 합니다.
#대안 2 — std::variant (C++17)
type-safe tagged union입니다. closed type set에 자연스럽게 들어맞습니다.
#include <variant>
class Circle {public: void roll() { /* */ } float area() const { return 3.14f * r * r; }private: float r = 1.0f;};
class Square {public: float area() const { return s * s; }private: float s = 1.0f;};
class Triangle {public: float area() const { return 0.5f * b * h; }private: float b = 1.0f, h = 1.0f;};
using Shape = std::variant<Circle, Square, Triangle>;
Shape s = Circle{};
// 1. holds_alternativeif (std::holds_alternative<Circle>(s)) { std::get<Circle>(s).roll();}
// 2. std::visit — 모든 가능 타입에 적용float a = std::visit([](auto&& shape) { return shape.area();}, s);
// 3. if constexpr 분기std::visit([](auto&& shape) { using T = std::decay_t<decltype(shape)>; if constexpr (std::is_same_v<T, Circle>) { shape.roll(); } else if constexpr (std::is_same_v<T, Square>) { // square 전용 }}, s);std::variant의 내부 size는 max(sizeof of all types) + index이며 heap을 쓰지 않습니다.
#가상 함수의 완전 대체
// 전통 — virtualclass IShape {public: virtual ~IShape() = default; virtual float area() const = 0;};
// variant 대체 — vtable 없음, RTTI 없음using Shape = std::variant<Circle, Square, Triangle>;
float compute_area(const Shape& s) { return std::visit([](auto&& sh) { return sh.area(); }, s);}컴파일 타임에 모든 type을 알고 있어야 하며, 런타임에 type을 추가할 수는 없습니다.
장점은 다음과 같습니다.
- vtable과 type info가 모두 0입니다.
- visitor가 인라인되어 간접 호출이 0입니다.
- value semantics를 가집니다.
단점도 있습니다.
- type set이 closed라서 런타임 확장이 불가합니다.
- 모든 type의 메모리를 차지하므로(가장 큰 type이 sizeof를 결정합니다) 큰 type 하나가 전체를 부풉니다.
자세한 비교는 Part 4-06: State Machine에서 다룹니다.
#대안 3 — CRTP (Static Polymorphism)
Part 2-08에서 다룬 패턴으로, 컴파일 타임에 type을 결정합니다.
template<typename Derived>class ShapeBase {public: float area() const { return static_cast<const Derived*>(this)->area_impl(); }};
class Circle : public ShapeBase<Circle> {public: float area_impl() const { return 3.14f * r * r; }private: float r;};
Circle c;float a = c.area(); // compile-time dispatchRTTI와 vtable이 모두 0입니다. 다만 runtime polymorphism은 사용할 수 없습니다.
#자체 type-id 시스템
도메인 특화된 type id가 필요할 때 직접 정의합니다.
template<typename T>struct TypeId { static const void* value() { static const int dummy = 0; return &dummy; }};
// 각 type마다 다른 주소 — 유니크 IDclass Base {public: virtual const void* type_id() const = 0; virtual ~Base() = default;};
class Derived : public Base {public: const void* type_id() const override { return TypeId<Derived>::value(); }};
// 사용Base* b = get();if (b->type_id() == TypeId<Derived>::value()) { auto* d = static_cast<Derived*>(b);}전역 변수의 주소가 unique type id 역할을 합니다. RTTI 없이도 strict type 비교가 가능합니다.
typeid의 대체 구현이며, Boost.TypeIndex 같은 일부 라이브러리도 같은 아이디어를 사용합니다.
#dynamic_cast 대체
// dynamic_cast (RTTI 필요)auto* d = dynamic_cast<Derived*>(base);
// 대체 1 — enum tag + static_castif (base->type() == NodeType::Derived) { auto* d = static_cast<Derived*>(base);}
// 대체 2 — type_idif (base->type_id() == TypeId<Derived>::value()) { auto* d = static_cast<Derived*>(base);}
// 대체 3 — visitor (std::variant)std::visit([](auto&& obj) { using T = std::decay_t<decltype(obj)>; if constexpr (std::is_same_v<T, Derived>) { // Derived 전용 }}, variant_obj);각 패턴이 조금씩 다른 트레이드오프를 가집니다. type set이 닫혀 있고 value semantics를 원한다면 variant가 가장 깔끔합니다.
#std::any — 사용 가능?
std::any는 type-erased container이며 내부적으로 typeid를 사용합니다.
#include <any>
std::any a = 42;auto* p = std::any_cast<int>(&a); // RTTI 필요-fno-rtti에서는 컴파일 에러가 발생하므로 임베디드에서는 std::any를 사용할 수 없습니다.
대안은 closed type set의 std::variant입니다.
#임베디드 — Event 시스템
// 전통 — virtualclass IEvent {public: virtual ~IEvent() = default; virtual void dispatch() = 0;};
class ClickEvent : public IEvent { void dispatch() override { /* */ } };class KeyEvent : public IEvent { void dispatch() override { /* */ } };
// variant 기반struct ClickEvent { int x, y; };struct KeyEvent { int keycode; };struct TimerEvent { uint32_t ms; };
using Event = std::variant<ClickEvent, KeyEvent, TimerEvent>;
void dispatch(const Event& e) { std::visit([](auto&& ev) { using T = std::decay_t<decltype(ev)>; if constexpr (std::is_same_v<T, ClickEvent>) { handle_click(ev); } else if constexpr (std::is_same_v<T, KeyEvent>) { handle_key(ev); } // ... }, e);}vtable과 RTTI가 모두 0이며 코드 크기를 수 KB 절약할 수 있습니다.
#Exception 처리도 무관
-fno-exceptions와 -fno-rtti를 함께 끄는 것이 일반적인 임베디드 표준입니다.
CXXFLAGS += -fno-exceptions -fno-rtti#자주 보는 함정과 안티패턴
#1. dynamic_cast 호출 후 nullptr 안 체크
RTTI가 없으면 dynamic_cast 자체가 컴파일 에러가 됩니다. 코드 변환이 필요합니다.
#2. typeid 사용
typeid 호출도 컴파일 에러로 떨어집니다. 직접 type_id 시스템이나 enum을 씁니다.
#3. enum tag와 type 일관성 깨짐
class Circle : public Shape {public: Circle() : Shape(ShapeType::Square) {} // 잘못 — 컴파일 에러 없음};static_assert나 factory function으로 일관성을 보장합니다.
#4. variant에 큰 type
using Event = std::variant<SmallEvent, HugeEvent>;// sizeof(Event) = sizeof(HugeEvent) + 인덱스큰 type을 분리하거나 pointer를 사용합니다.
#5. std::function의 RTTI 의존
std::function은 내부 type erasure에 typeid를 사용합니다. RTTI를 끄면 일부 기능이 제한되므로 함수 포인터나 etl::delegate로 대체합니다.
#6. exception 일부만 끔
예외와 RTTI는 세트로 끕니다. 한 모듈만 RTTI를 켜 두면 링크 충돌이 발생할 수 있습니다.
#측정 — RTTI 끄기 효과
같은 코드를 RTTI on/off로 비교합니다(STM32F4, 단순한 상속 프로젝트).
-frtti -fexceptions: .text: 52 KB type info tables: 6 KB total: 58 KB
-fno-rtti -fno-exceptions: .text: 38 KB type info tables: 0 total: 38 KB
차이: 20 KB (35% 감소)큰 프로젝트일수록 차이가 더 벌어집니다. RTTI 끄기는 임베디드의 기본 설정입니다.
#정리
-fno-rtti로 type info table을 제거하면 수 KB를 절약할 수 있습니다.- 대안은 세 가지입니다 — enum tag,
std::variant(closed set), CRTP(compile-time). dynamic_cast는 type_id 시스템이나 visitor로 대체합니다.std::any는 사용할 수 없고std::function도 부분 제한이 있습니다.- RTTI와 예외는 세트로 켜고 끄는 것이 보통입니다.
#관련 항목
- Part 1-02: 컴파일러 플래그 —
-fno-rtti - Part 2-08: Static Polymorphism — CRTP
- Part 4-06: State Machine — std::variant 활용
- GoF 23: Visitor — std::visit
#다음 글
Part 3-09: 스마트 포인터 선택 — unique_ptr, shared_ptr, raw pointer 사이에서 임베디드의 기본 선택을 정리합니다.
Embedded C++ for Real Systems · 27 of 41
- 1Embedded C++ for Real Systems — 임베디드 모던 C++ 시리즈 소개
- 2임베디드 C++ vs C — 런타임·코드 크기·ABI 관점 비교
- 3임베디드 C++ 컴파일러 플래그 분석 — -fno-rtti·-fno-exceptions·-Os
- 4임베디드 C++ 런타임 요구사항 — libstdc++·newlib·crt0 분석
- 5C++ 코드 크기 분석 — 가상 함수·템플릿·예외 비용 추적
- 6C++ ABI 호환성 — Itanium ABI·name mangling·vtable 레이아웃
- 7C++ 스타트업 코드 분석 — .init_array·전역 생성자 호출 순서
- 8임베디드 C++ 링커 스크립트 — vtable·정적 객체 배치 추적
- 9임베디드 C++ 표준 선택 가이드 — C++11/14/17/20/23 트레이드오프
- 10임베디드 RAII 기초 — 리소스 안전성과 결정적 소멸 보장
- 11임베디드 RAII 실전 패턴 — Lock·Pin·DMA·Power 관리
- 12constexpr 기초와 임베디드 적용 — 컴파일 타임 계산 활용
- 13constexpr 고급 활용 — 룩업 테이블·CRC·해시 컴파일 타임 생성
- 14consteval과 constinit 분석 — C++20 컴파일 타임 강제 메커니즘
- 15임베디드 Templates 기초 — 타입 안전과 코드 재사용 분석
- 16Template 비용 분석 — 코드 폭증·인스턴스화·디버그 정보 측정
- 17CRTP 패턴 분석 — vtable 없는 정적 다형성
- 18Type Traits 임베디드 활용 — SFINAE·is_pod·컴파일 타임 검사
- 19C++20 Concepts 활용 — 템플릿 제약과 가독성 개선
- 20동적 할당 없는 임베디드 C++ — placement new·정적 객체·풀
- 21Custom Allocator 기초 — std::allocator 인터페이스 분석
- 22Pool Allocator 구현 — Fixed-Size Block과 O(1) 보장
- 23std::pmr 임베디드 활용 — Polymorphic Memory Resource 분석
- 24No-Exception C++ 설계 — 코드 크기·결정성 트레이드오프
- 25임베디드 에러 처리 패턴 — Result·errno·optional 비교
- 26std::expected 분석 — C++23 결과 타입과 에러 전파
- 27No-RTTI C++ 설계 — dynamic_cast 제거와 정적 타입 분기
- 28임베디드 스마트 포인터 선택 — unique·shared·custom 비교
- 29임베디드 C++ 소유권 모델 — single·shared·borrow 패턴
- 30Intrusive Containers 분석 — 동적 할당 없는 컨테이너 설계
- 31ETL 라이브러리 분석 — Embedded Template Library의 STL 대체
- 32임베디드 Lock-free 기초 — atomic·memory ordering·CAS
- 33Lock-free Container 구현 — SPSC Queue·Ring Buffer
- 34Type-safe Flags 패턴 — Enum Class·Strong Typedef·Tag
- 35임베디드 State Machine 패턴 — Variant·Visitor·Table-driven 비교
- 36Compile-time FSM 구현 — 템플릿으로 상태 전이 검증
- 37Singleton 대안 패턴 — Service Locator·Static Init·Phantom
- 38MMIO Register 추상화 — 타입 안전한 비트 필드 접근
- 39GPIO 추상화 패턴 — Template·Concept으로 보드 독립성
- 40Peripheral 추상화 — UART·SPI·I2C 공통 인터페이스 설계
- 41임베디드 HAL 설계 패턴 — Static·Dynamic·Hybrid 비교
관련 글
임베디드 State Machine 패턴 — Variant·Visitor·Table-driven 비교
타입 안전한 상태 머신 — enum + switch부터 std::variant, etl::fsm까지.
CRTP 패턴 분석 — vtable 없는 정적 다형성
Curiously Recurring Template Pattern — virtual 함수 없이 컴파일 타임 다형성. vtable 0, 간접 호출 0.
임베디드 HAL 설계 패턴 — Static·Dynamic·Hybrid 비교
범용 HAL 구조 — 벤더 종속성 격리, 다중 보드/MCU 지원, 시리즈 마무리.
이 글을 참조하는 글 (5)
- Compile-time FSM 구현 — 템플릿으로 상태 전이 검증— Embedded C++ for Real Systems
- 임베디드 State Machine 패턴 — Variant·Visitor·Table-driven 비교— Embedded C++ for Real Systems
- std::expected 분석 — C++23 결과 타입과 에러 전파— Embedded C++ for Real Systems
- Type Traits 임베디드 활용 — SFINAE·is_pod·컴파일 타임 검사— Embedded C++ for Real Systems
- 임베디드 C++ 컴파일러 플래그 분석 — -fno-rtti·-fno-exceptions·-Os— Embedded C++ for Real Systems