std::function<void>에 람다를 저장하면, 컴파일러는 어떻게 전혀 다른 타입들을 하나의 변수에 담는 것인가? 함수 포인터도 아니고, 가상 함수도 아닌 방법으로?
답은 Type Erasure에 있다. C++ 템플릿 메타프로그래밍의 정수이자, std::function, std::any, std::shared_ptr의 커스텀 deleter 등이 사용하는 핵심 기법이다. Adobe의 Sean Parent는 GoingNative 2013의 "Inheritance Is The Base Class of Evil"이라는 도발적인 제목의 강연에서, C++의 다형성은 상속이 아니라 값 시맨틱을 갖춘 regular type 위에 세워야 한다고 주장했다.
상속 없이 다형성을 구현하는 방법. 컴파일 타임 타입을 런타임에 지우고, 필요할 때 복원하는 기술. 이 글에서는 Type Erasure의 본질을 정리하고, std::function/std::any/std::variant의 내부 구조와 커스텀 구현, 그리고 가상 함수와의 성능 비교를 다룬다.
가상 함수의 동적 디스패치는 객체 내부의 vptr이 타입별 vtable을 가리키고, 호출 시 함수 포인터를 간접 참조하는 구조다. 이 글에서는 Type Erasure가 그 메커니즘을 명시적인 함수 포인터 테이블 형태로 어떻게 재구성하는지에 초점을 맞춘다.
void free_func() { std::cout << "function\n"; }
struct Functor {
void operator()() { std::cout << "functor\n"; }
};
auto lambda = []() { std::cout << "lambda\n"; };
// std::function은 모두 저장할 수 있다
std::function<void()> f1 = free_func;
std::function<void()> f2 = Functor{};
std::function<void()> f3 = lambda;
세 callable의 타입은 다음과 같다.
free_func의 타입: void(*).Functor{}의 타입: Functor.lambda의 타입: __lambda_1_impl(컴파일러 생성).세 타입은 호환되지 않는다. 그런데 하나의 std::function<void>에 모두 저장된다.
// 시도 1: 가상 함수로 해결
class CallableBase {
public:
virtual void operator()() = 0;
virtual ~CallableBase() = default;
};
template<typename F>
class CallableImpl : public CallableBase {
F func_;
public:
CallableImpl(F f) : func_(std::move(f)) {}
void operator()() override { func_(); }
};
class function {
std::unique_ptr<CallableBase> impl_;
public:
template<typename F>
function(F f) : impl_(std::make_unique<CallableImpl<F>>(std::move(f))) {}
void operator()() { (*impl_)(); }
};
이 방식은 작동한다. 실제로 많은 Type Erasure 구현이 이 패턴을 사용한다. 하지만 두 가지 문제가 있다.
std::function의 실제 구현은 더 영리하다.
template<typename Signature>
class function;
template<typename R, typename... Args>
class function<R(Args...)> {
static constexpr size_t BufferSize = 16;
alignas(std::max_align_t) char buffer_[BufferSize];
void* heap_ptr_ = nullptr;
// vtable 역할: 함수 포인터 테이블
struct VTable {
R (*invoke)(void*, Args...);
void (*destroy)(void*);
void (*copy)(void* dst, const void* src);
};
const VTable* vtable_ = nullptr;
public:
template<typename F>
function(F f) {
using Functor = std::decay_t<F>;
if constexpr (sizeof(Functor) <= BufferSize &&
std::is_nothrow_move_constructible_v<Functor>) {
new(buffer_) Functor(std::move(f));
vtable_ = &get_vtable_for<Functor, true>();
} else {
heap_ptr_ = new Functor(std::move(f));
vtable_ = &get_vtable_for<Functor, false>();
}
}
R operator()(Args... args) {
void* obj_ptr = heap_ptr_ ? heap_ptr_ : buffer_;
return vtable_->invoke(obj_ptr, std::forward<Args>(args)...);
}
~function() {
if (vtable_) {
void* obj_ptr = heap_ptr_ ? heap_ptr_ : buffer_;
vtable_->destroy(obj_ptr);
}
}
private:
template<typename Functor, bool InBuffer>
static const VTable& get_vtable_for() {
static const VTable vtable = {
[](void* obj, Args... args) -> R {
return (*static_cast<Functor*>(obj))(std::forward<Args>(args)...);
},
[](void* obj) {
auto* functor = static_cast<Functor*>(obj);
if constexpr (InBuffer) {
// 버퍼에 placement new로 만든 객체는 소멸자만 호출한다.
functor->~Functor();
} else {
// 힙 객체는 delete가 소멸자 호출 + 메모리 해제를 같이 처리한다.
delete functor;
}
},
[](void* dst, const void* src) {
new(dst) Functor(*static_cast<const Functor*>(src));
}
};
return vtable;
}
};
1) 타입 지우기
template<typename F>
function(F f) {
// F의 타입 정보는 생성자에서만 알려짐
new(buffer_) F(std::move(f));
// vtable에는 F를 위한 특수화된 함수 포인터 저장
vtable_ = &get_vtable_for<F>();
}
// 이후 operator()에서는 F가 무엇인지 모름
// void* 포인터와 vtable을 통해 간접 호출
2) SBO로 힙 할당 회피
auto small = [x = 0]() { return x; }; // sizeof(small) == 4 → buffer_에 저장
auto big = [data = std::array<int, 100>()]() { }; // sizeof(big) == 400 → 힙 할당
3) 명시적 vtable
가상 함수의 vtable이 컴파일러에 의해 자동 생성되는 데 비해, Type Erasure는 함수 포인터 테이블을 프로그래머가 명시적으로 정의한다. 이로 인해 가상 함수보다 더 자유로운 의미론(임의 타입 저장, 값 시맨틱, 커스텀 SBO 크기 등)을 가질 수 있다. 호출 비용 자체는 두 방식 모두 한 번의 간접 호출이라는 점에서 비슷하다.
| 항목 | 가상 함수 | Type Erasure |
|---|---|---|
| vtable 위치 | 컴파일러가 자동 생성 | 프로그래머가 명시적 정의 |
| vptr 저장 | 객체 시작 부분 | 별도 멤버 변수 |
| 타입 안전성 | 컴파일 타임 검사 | 템플릿으로 보장 |
| 오버헤드 | vptr 8 bytes | vtable 포인터 8 bytes |
| 유연성 | 상속 계층 고정 | 임의 타입 저장 가능 |
std::function은 시그니처가 고정되어 있다. std::any는 아무 타입이나 저장한다.
std::any a = 42; // int
a = std::string("hello"); // string
a = std::vector<double>{1.0, 2.0}; // vector<double>
int* pi = std::any_cast<int>(&a); // nullptr (현재 string)
std::string* ps = std::any_cast<std::string>(&a); // 성공
class any {
static constexpr size_t BufferSize = 32;
alignas(std::max_align_t) char buffer_[BufferSize];
void* heap_ptr_ = nullptr;
const std::type_info* type_ = nullptr;
struct VTable {
void (*destroy)(void*);
void (*copy)(void* dst, const void* src);
void (*move)(void* dst, void* src);
};
const VTable* vtable_ = nullptr;
public:
template<typename T>
any(T value) {
using ValueType = std::decay_t<T>;
if constexpr (sizeof(ValueType) <= BufferSize &&
std::is_nothrow_move_constructible_v<ValueType>) {
new(buffer_) ValueType(std::move(value));
} else {
heap_ptr_ = new ValueType(std::move(value));
}
type_ = &typeid(ValueType);
vtable_ = &get_vtable<ValueType>();
}
bool has_value() const noexcept { return type_ != nullptr; }
const std::type_info& type() const noexcept {
return type_ ? *type_ : typeid(void);
}
template<typename T>
T* cast() {
if (type_ == &typeid(T)) {
return static_cast<T*>(heap_ptr_ ? heap_ptr_ : buffer_);
}
return nullptr;
}
private:
template<typename T>
static const VTable& get_vtable() {
static const VTable vt = {
[](void* obj) { static_cast<T*>(obj)->~T(); },
[](void* dst, const void* src) { new(dst) T(*static_cast<const T*>(src)); },
[](void* dst, void* src) { new(dst) T(std::move(*static_cast<T*>(src))); }
};
return vt;
}
};
template<typename T>
T* any_cast(any* operand) {
if (operand && operand->type() == typeid(T)) {
return static_cast<T*>(operand->heap_ptr_ ? operand->heap_ptr_ : operand->buffer_);
}
return nullptr;
}
template<typename T>
T any_cast(const any& operand) {
using U = std::remove_cvref_t<T>;
auto* p = any_cast<U>(&operand);
if (!p) {
throw std::bad_any_cast();
}
return static_cast<T>(*p);
}
sizeof(std::any), SBO 버퍼 크기와 SBO 허용 조건은 표준이 정하지 않는다. 어떤 구현은 nothrow-move 가능한 작은 타입만 내부에 저장하고 나머지는 힙을 쓴다. any_cast 비용도 타입 메타데이터 구현, 인라이닝, 성공·실패 경로에 따라 달라진다.
| 경로 | 확인할 비용 |
|---|---|
| 작은 객체 생성·이동 | SBO 적용 여부와 manager 함수 호출 |
| 큰 객체 생성·복사 | 할당 횟수, 복사 생성자, 예외 경로 |
| 성공 cast | 타입 검사와 값 복사/참조 반환 |
| 실패 cast | 포인터 overload의 null과 값 overload의 예외 비용 |
std::any는 편리하지만, 타입을 미리 알 수 있다면 std::variant가 더 효율적이다.
std::any는 RTTI에 의존하고, 캐스팅 실패 시 예외를 던진다. 게임에서는 둘 다 부담이다. std::variant는 타입 집합을 컴파일 타임에 고정하는 대신, RTTI 없이 작동한다.
std::variant<int, float, std::string> v;
v = 42;
v = 3.14f;
v = "hello";
std::visit([](auto&& arg) {
std::cout << arg << "\n";
}, v);
if (auto* p = std::get_if<int>(&v)) {
std::cout << *p;
}
template<typename... Types>
class variant {
static constexpr size_t BufferSize = std::max({sizeof(Types)...});
static constexpr size_t Alignment = std::max({alignof(Types)...});
alignas(Alignment) char buffer_[BufferSize];
size_t index_ = 0;
using DestroyFunc = void(*)(void*);
static constexpr DestroyFunc destroy_table_[] = {
[](void* obj) { static_cast<Types*>(obj)->~Types(); }...
};
public:
template<typename T>
variant(T&& value) {
using U = std::decay_t<T>;
constexpr size_t index = index_of<U, Types...>();
new(buffer_) U(std::forward<T>(value));
index_ = index;
}
~variant() {
destroy_table_[index_](buffer_);
}
size_t index() const noexcept { return index_; }
template<typename T>
T* get_if() {
constexpr size_t index = index_of<T, Types...>();
if (index_ == index) {
return static_cast<T*>(static_cast<void*>(buffer_));
}
return nullptr;
}
private:
template<typename T, typename First, typename... Rest>
static constexpr size_t index_of() {
if constexpr (std::is_same_v<T, First>) {
return 0;
} else {
return 1 + index_of<T, Rest...>();
}
}
};
std::visit은 컴파일 타임에 모든 타입 조합에 대한 점프 테이블을 생성한다.
template<typename Visitor, typename... Types>
auto visit(Visitor&& vis, variant<Types...>& v) {
using ReturnType = /* 모든 호출의 공통 반환 타입 */;
static constexpr auto jump_table = make_jump_table<Visitor, Types...>();
return jump_table[v.index()](std::forward<Visitor>(vis), &v.buffer_);
}
template<typename Visitor, typename... Types>
constexpr auto make_jump_table() {
return std::array{
[](Visitor&& vis, void* buf) {
return std::forward<Visitor>(vis)(*static_cast<Types*>(buf));
}...
};
}
struct Data { virtual ~Data() = default; };
struct IntData : Data { int value; };
struct FloatData : Data { float value; };
struct StringData : Data { std::string value; };
// 1) 가상 함수
std::vector<std::unique_ptr<Data>> vec_virtual;
// 2) std::any
std::vector<std::any> vec_any;
// 3) std::variant
using DataVariant = std::variant<IntData, FloatData, StringData>;
std::vector<DataVariant> vec_variant;
이 네 표현은 저장 레이아웃까지 같게 만들지 않으면 호출 방식만 비교할 수 없다. unique_ptr<Data>는 pointee를 흩어 놓고, any는 구현과 타입 크기에 따라 SBO/heap이 갈리며, variant는 가장 큰 대안의 크기와 정렬을 모든 원소가 부담한다.
variant의 가능한 이점은 다음과 같다.
vector<variant<...>> 자체는 연속이지만, 가장 큰 대안 때문에 stride가 커질 수 있다.비교 시에는 같은 의미의 payload, 같은 타입 분포와 방문 순서, 같은 할당 전략을 사용하고 총 bytes/element, branch miss, cache miss, p95 시간을 함께 기록한다. 수동 tagged union도 수명·예외 안전성을 정확히 구현해야 하며 자동으로 더 빠르다고 가정할 수 없다.
std::function은 범용적이지만, 게임에서는 제약이 있다.
게임 엔진은 종종 자체 Type Erasure를 구현한다.
template<typename Signature>
class unique_function;
template<typename R, typename... Args>
class unique_function<R(Args...)> {
static constexpr size_t BufferSize = 32;
alignas(std::max_align_t) char buffer_[BufferSize];
void* heap_ptr_ = nullptr;
struct VTable {
R (*invoke)(void*, Args...);
void (*destroy)(void*);
void (*move)(void* dst, void* src);
};
const VTable* vtable_ = nullptr;
public:
unique_function() = default;
template<typename F>
unique_function(F&& f) {
using Functor = std::decay_t<F>;
if constexpr (sizeof(Functor) <= BufferSize) {
new(buffer_) Functor(std::forward<F>(f));
} else {
heap_ptr_ = new Functor(std::forward<F>(f));
}
vtable_ = &get_vtable<Functor>();
}
unique_function(const unique_function&) = delete;
unique_function& operator=(const unique_function&) = delete;
unique_function(unique_function&& other) noexcept {
if (other.vtable_) {
vtable_ = other.vtable_;
if (other.heap_ptr_) {
// 힙 객체는 포인터만 훔치면 된다 (객체 이동 불필요)
heap_ptr_ = other.heap_ptr_;
other.heap_ptr_ = nullptr;
} else {
// 버퍼 객체는 이동 생성으로 옮긴다
vtable_->move(buffer_, other.buffer_);
}
other.vtable_ = nullptr;
}
}
R operator()(Args... args) {
void* obj_ptr = heap_ptr_ ? heap_ptr_ : buffer_;
return vtable_->invoke(obj_ptr, std::forward<Args>(args)...);
}
~unique_function() {
if (vtable_) {
void* obj_ptr = heap_ptr_ ? heap_ptr_ : buffer_;
vtable_->destroy(obj_ptr);
}
}
private:
template<typename Functor>
static const VTable& get_vtable() {
constexpr bool in_buffer = sizeof(Functor) <= BufferSize;
static const VTable vt = {
[](void* obj, Args... args) -> R {
return (*static_cast<Functor*>(obj))(std::forward<Args>(args)...);
},
[](void* obj) {
auto* f = static_cast<Functor*>(obj);
if constexpr (in_buffer) {
f->~Functor(); // 버퍼 객체: 소멸자만
} else {
delete f; // 힙 객체: delete가 소멸자까지 처리
}
},
[](void* dst, void* src) {
// 버퍼 객체 전용 (힙 객체는 포인터를 훔치므로 호출되지 않음)
new(dst) Functor(std::move(*static_cast<Functor*>(src)));
}
};
return vt;
}
};
사용 예시:
// std::function으로는 불가능
auto ptr = std::make_unique<int>(42);
unique_function<int()> f = [p = std::move(ptr)]() { return *p; };
unique_function<int()> f2 = std::move(f); // OK
int result = f2(); // 42
template<typename Signature>
class delegate;
template<typename R, typename... Args>
class delegate<R(Args...)> {
void* object_ = nullptr;
R (*stub_)(void*, Args...) = nullptr;
public:
delegate() = default;
// 자유 함수
template<R(*Func)(Args...)>
static delegate from_function() {
delegate d;
d.stub_ = [](void*, Args... args) -> R {
return Func(std::forward<Args>(args)...);
};
return d;
}
// 멤버 함수
template<typename C, R(C::*Method)(Args...)>
static delegate from_method(C* obj) {
delegate d;
d.object_ = obj;
d.stub_ = [](void* obj, Args... args) -> R {
return (static_cast<C*>(obj)->*Method)(std::forward<Args>(args)...);
};
return d;
}
// 람다 (저장소 필요)
// 주의: static 저장소는 F 타입당 하나뿐이다. 같은 타입의 람다로
// 두 번째 delegate를 만들어도 첫 번째 객체가 유지된다. 데모용 코드이며,
// 실전에서는 별도 버퍼나 unique_function을 사용해야 한다.
template<typename F>
delegate(F&& f) requires (!std::is_same_v<std::decay_t<F>, delegate>) {
static F stored = std::forward<F>(f);
object_ = &stored;
stub_ = [](void* obj, Args... args) -> R {
return (*static_cast<F*>(obj))(std::forward<Args>(args)...);
};
}
R operator()(Args... args) const {
return stub_(object_, std::forward<Args>(args)...);
}
explicit operator bool() const { return stub_ != nullptr; }
};
사용 예시:
struct Player {
void take_damage(int amount) { health -= amount; }
int health = 100;
};
void log_damage(int amount) {
std::cout << "Damage: " << amount << "\n";
}
Player player;
delegate<void(int)> on_damage;
on_damage = delegate<void(int)>::from_function<&log_damage>();
on_damage(25); // "Damage: 25"
on_damage = delegate<void(int)>::from_method<Player, &Player::take_damage>(&player);
on_damage(25); // player.health = 75
크기 비교:
| 타입 | 크기 | 힙 할당 |
|---|---|---|
std::function<void(int)> | 32 bytes | 때로 |
delegate<void(int)> | 16 bytes | 없음 |
| 함수 포인터 | 8 bytes | 없음 |
전통적인 OOP:
class Drawable {
public:
virtual void draw() const = 0;
virtual ~Drawable() = default;
};
class Circle : public Drawable {
public:
void draw() const override { /* ... */ }
};
class Rectangle : public Drawable {
public:
void draw() const override { /* ... */ }
};
// 새 타입 추가 시 Drawable 상속 필수
// 기존 타입을 Drawable로 만들 수 없음 (예: 서드파티 라이브러리)
Sean Parent의 아이디어:
// 어떤 타입이든 draw() 메서드만 있으면 저장 가능
class object {
struct concept_t {
virtual ~concept_t() = default;
virtual void draw() const = 0;
virtual concept_t* clone() const = 0;
};
template<typename T>
struct model_t : concept_t {
T data_;
model_t(T x) : data_(std::move(x)) {}
void draw() const override {
draw_impl(data_); // ADL로 찾음
}
concept_t* clone() const override {
return new model_t(*this);
}
};
std::unique_ptr<concept_t> self_;
public:
template<typename T>
object(T x) : self_(std::make_unique<model_t<T>>(std::move(x))) {}
object(const object& x) : self_(x.self_->clone()) {}
object(object&&) noexcept = default;
object& operator=(const object& x) {
object tmp(x);
*this = std::move(tmp);
return *this;
}
void draw() const {
self_->draw();
}
};
// 자유 함수로 draw 구현
struct Circle { /* ... */ };
void draw_impl(const Circle& c) { /* ... */ }
struct Rectangle { /* ... */ };
void draw_impl(const Rectangle& r) { /* ... */ }
// 사용
std::vector<object> shapes;
shapes.emplace_back(Circle{});
shapes.emplace_back(Rectangle{});
for (const auto& shape : shapes) {
shape.draw();
}
class document {
struct concept_t {
virtual ~concept_t() = default;
virtual void draw() const = 0;
virtual void serialize(std::ostream&) const = 0;
virtual concept_t* clone() const = 0;
};
template<typename T>
struct model_t : concept_t {
T data_;
model_t(T x) : data_(std::move(x)) {}
void draw() const override { draw_impl(data_); }
void serialize(std::ostream& os) const override { serialize_impl(data_, os); }
concept_t* clone() const override { return new model_t(*this); }
};
std::unique_ptr<concept_t> self_;
public:
template<typename T>
document(T x) : self_(std::make_unique<model_t<T>>(std::move(x))) {}
document(const document& x) : self_(x.self_->clone()) {}
document(document&&) noexcept = default;
document& operator=(document x) { self_ = std::move(x.self_); return *this; }
void draw() const { self_->draw(); }
void serialize(std::ostream& os) const { self_->serialize(os); }
};
struct MyShape {
int x, y, radius;
};
void draw_impl(const MyShape& s) {
std::cout << "Circle at (" << s.x << ", " << s.y << ")\n";
}
void serialize_impl(const MyShape& s, std::ostream& os) {
os << s.x << " " << s.y << " " << s.radius;
}
std::vector<document> docs;
docs.emplace_back(MyShape{10, 20, 5});
docs[0].draw();
// 1) 가상 함수
struct ShapeBase {
virtual double area() const = 0;
virtual ~ShapeBase() = default;
};
struct Circle : ShapeBase {
double radius;
double area() const override { return 3.14159 * radius * radius; }
};
struct Rectangle : ShapeBase {
double width, height;
double area() const override { return width * height; }
};
std::vector<std::unique_ptr<ShapeBase>> shapes_virtual;
// 2) Type Erasure (std::function 스타일)
class shape_erased {
std::unique_ptr<void, void(*)(void*)> data_;
double (*area_func_)(const void*);
public:
template<typename T>
shape_erased(T shape)
: data_(new T(std::move(shape)), [](void* p) { delete static_cast<T*>(p); })
, area_func_([](const void* p) { return static_cast<const T*>(p)->area(); })
{}
double area() const { return area_func_(data_.get()); }
};
std::vector<shape_erased> shapes_erased;
// 3) std::variant
using Shape = std::variant<Circle, Rectangle>;
std::vector<Shape> shapes_variant;
// 4) Tagged Union (수동)
enum class ShapeType { Circle, Rectangle };
struct shape_tagged {
ShapeType type;
union {
Circle circle;
Rectangle rect;
};
double area() const {
switch (type) {
case ShapeType::Circle: return circle.area();
case ShapeType::Rectangle: return rect.area();
}
return 0;
}
};
std::vector<shape_tagged> shapes_tagged;
| 방식 | 시간 (ms) | 메모리 (MB) | L1 캐시 미스 (%) | 비고 |
|---|---|---|---|---|
| 가상 함수 | 125 | 32 | 15% | vptr + 힙 할당 |
| Type Erasure | 118 | 32 | 14% | 명시적 vtable |
| std::variant | 45 | 24 | 3% | 인라인 가능 |
| Tagged Union | 38 | 24 | 2% | 최적화 용이 |
| 직접 배열 | 12 | 16 | 0.5% | 타입별 분리 |
가상 함수와 Type Erasure는 본질적으로 같은 동적 디스패치 메커니즘을 다른 형태로 구현한 것이므로 성능이 거의 같다. 차이를 만드는 것은 데이터 배치다.
variant의 우월성은 다음에서 온다.
std::visit 내부를 인라인 가능.Tagged Union의 극한 최적화:
직접 배열의 압도적 성능:
std::vector<Circle> circles;
std::vector<Rectangle> rectangles;
for (auto& c : circles) total += c.area();
for (auto& r : rectangles) total += r.area();
가능하다면 이것이 최선이다. 데이터 지향 설계의 핵심이다.
// UObject: 가상 함수 사용
class UObject {
public:
virtual void Tick(float DeltaTime) {}
virtual void BeginPlay() {}
};
// FTimerDelegate: Type Erasure
struct FTimerDelegate {
TFunction<void()> Callback;
};
// FProperty: Type Erasure로 임의 타입 저장
class FProperty {
void* ValuePtr;
const FPropertyVTable* VTable;
};
선택 기준:
// 컴포넌트는 타입별 배열 (DoD)
struct TransformComponent { /* ... */ };
struct RigidbodyComponent { /* ... */ };
// 시스템은 Type Erasure로 저장
class ISystem {
virtual void Update(float dt) = 0;
};
std::vector<std::unique_ptr<ISystem>> systems;
// 하지만 실제 데이터는 타입별 분리
std::vector<TransformComponent> transforms;
std::vector<RigidbodyComponent> rigidbodies;
enum entity_type {
ET_PLAYER,
ET_MONSTER,
ET_ITEM
};
struct entity {
entity_type type;
union {
player_state_t player;
monster_state_t monster;
item_state_t item;
};
void (*think)(entity* self, float dt);
};
이 방식의 이유:
| 상황 | 권장 방식 | 이유 |
|---|---|---|
| 플러그인 시스템, 스크립팅 | Type Erasure | 타입 독립성 |
| 이벤트, 콜백 | std::function 또는 커스텀 | 유연성 |
| 엔티티 시스템 (대량) | DoD (타입별 배열) | 성능 |
| 고정된 타입 집합 | std::variant | 타입 안전성 + 성능 |
| UI 위젯 | 가상 함수 | 개발 편의성 |
| 네트워크 패킷 | Tagged Union | 직렬화 용이 |
template<typename Signature, size_t BufferSize = 32, size_t Alignment = alignof(std::max_align_t)>
class optimized_function;
template<typename R, typename... Args, size_t BufferSize, size_t Alignment>
class optimized_function<R(Args...), BufferSize, Alignment> {
alignas(Alignment) char buffer_[BufferSize];
void* heap_ptr_ = nullptr;
// VTable...
public:
template<typename F>
optimized_function(F&& f) {
using Functor = std::decay_t<F>;
constexpr bool fits = sizeof(Functor) <= BufferSize &&
alignof(Functor) <= Alignment &&
std::is_nothrow_move_constructible_v<Functor>;
if constexpr (fits) {
void* aligned_ptr = buffer_;
std::align(alignof(Functor), sizeof(Functor), aligned_ptr, BufferSize);
new(aligned_ptr) Functor(std::forward<F>(f));
} else {
heap_ptr_ = aligned_alloc(alignof(Functor), sizeof(Functor));
new(heap_ptr_) Functor(std::forward<F>(f));
}
}
};
template<typename T, size_t BufferSize = 32>
class sbo_shared {
struct control_block {
std::atomic<int> refcount{1};
alignas(T) char buffer[sizeof(T)];
};
control_block* control_ = nullptr;
static constexpr bool use_sbo = sizeof(control_block) <= BufferSize;
alignas(control_block) char inline_storage_[BufferSize];
public:
template<typename... Args>
sbo_shared(Args&&... args) {
if constexpr (use_sbo) {
control_ = new(inline_storage_) control_block();
} else {
control_ = new control_block();
}
new(control_->buffer) T(std::forward<Args>(args)...);
}
sbo_shared(const sbo_shared& other) : control_(other.control_) {
if (control_) {
control_->refcount.fetch_add(1, std::memory_order_relaxed);
}
}
~sbo_shared() {
if (control_ && control_->refcount.fetch_sub(1, std::memory_order_acq_rel) == 1) {
get()->~T();
if constexpr (!use_sbo) {
delete control_;
}
}
}
T* get() const { return std::launder(reinterpret_cast<T*>(control_->buffer)); }
};
이 코드에는 본질적인 한계가 있다. inline 저장(usesbo) 시 복사본들이 원본 객체의 `inline_storage를 가리키므로, 원본이 사본보다 먼저 소멸하면 댕글링이 된다. **공유 소유권과 SBO는 양립할 수 없으며**, 이것이 std::shared_ptr`에 SBO가 없는 이유다. 위 패턴은 원본의 수명이 모든 사본보다 길다고 보장되는 제한된 상황에서만 성립한다.
template<typename Signature>
class cache_aligned_function {
static constexpr size_t CacheLineSize = 64; // x86-64
alignas(CacheLineSize) char buffer_[CacheLineSize];
alignas(CacheLineSize) const void* vtable_;
};
trait Drawable {
fn draw(&self);
}
struct Circle { radius: f64 }
impl Drawable for Circle {
fn draw(&self) { println!("Circle"); }
}
// Type erasure (dyn Trait)
let shapes: Vec<Box<dyn Drawable>> = vec![
Box::new(Circle { radius: 5.0 }),
];
for shape in shapes {
shape.draw(); // 동적 디스패치
}
template<typename T>
concept Drawable = requires(T t) {
{ t.draw() } -> std::same_as<void>;
};
template<Drawable T>
void process(T& obj) {
obj.draw(); // 정적 디스패치 (템플릿)
}
// 동적 디스패치가 필요하면 Type Erasure
class erased_drawable {
std::unique_ptr<void, void(*)(void*)> obj_;
void (*draw_)(void*);
public:
template<Drawable T>
erased_drawable(T obj)
: obj_(new T(std::move(obj)), [](void* p) { delete static_cast<T*>(p); })
, draw_([](void* p) { static_cast<T*>(p)->draw(); })
{}
void draw() { draw_(obj_.get()); }
};
| 항목 | Rust dyn Trait | C++ Type Erasure |
|---|---|---|
| 문법 | 언어 내장 | 수동 구현 |
| 안전성 | 컴파일 타임 보장 | 프로그래머 책임 |
| 성능 | Fat pointer (16 bytes) | 구현에 따라 다름 |
| 저장 방식 | &dyn Trait는 borrow라 별도 heap 불필요, Box<dyn Trait>는 heap 소유 | 구현에 따라 inline/SBO 또는 heap |
Type Erasure는 C++ 템플릿의 정수다. 컴파일 타임 타입 정보를 런타임 다형성으로 변환하는 기법이다.
핵심 원리:
언제 사용할까:
std::function: 콜백, 델리게이트.std::any: 임의 타입 저장(RTTI 필요).std::variant: 고정된 타입 집합(RTTI 불필요).성능 특성:
std::variant가 타입이 고정되어 있을 때 가장 빠름.Sean Parent의 표현을 빌리자면, 좋은 C++ 코드는 복잡한 상속 계층이 아니라 일반 타입과 알고리즘에 관한 것이다. Type Erasure는 상속 없이 다형성을 달성하는 강력한 도구다. 다만 만능은 아니다. 성능이 중요하면 std::variant나 타입별 배열을, 유연성이 중요하면 Type Erasure를, 개발 편의성이 중요하면 가상 함수를 고른다. 트레이드오프를 이해하고 상황에 맞게 선택하는 것이 핵심이다.