这段 Outline(大纲) 是一篇或一场讲座关于 Type Erasure(类型擦除) 的内容结构。以下是对各部分的理解:
std::string
, std::vector
, std::function
都是有值语义的。class Shape { virtual void draw() = 0; }; // 传统接口
std::variant
(联合类型)std::function
、std::any
、boost::any
、any_iterator
。new
分配(或类似)any_cast
)类型擦除是一种强大的技术,它允许我们在保留值语义的同时实现灵活的运行时多态,是现代C++中多态性设计的重要选择之一。
顺带一提,您在本次演示中看到的所有代码都可以成功编译,并且如预期那样工作。
这一部分讨论了 “值类型(Value Types)” 的重要性,以及它相较于 “引用类型(Reference Types)” 带来的优势,重点体现在两个方面:
foo_t* foo_factory();
foo_t* foo = foo_factory();
这段代码的问题是:
foo
?是你负责释放它,还是别人?foo_factory()
返回的是裸指针,没有清楚地表达所有权语义。std::shared_ptr<foo_t> foo_factory();
std::shared_ptr<foo_t> foo = foo_factory();
这更安全一些:
shared_ptr
明确表达了资源共享和引用计数的语义。foo_factory
内部持有全局状态,这仍然不理想。这是指代码的行为可以通过变量等式来简单理解、替换,类似数学中的代数推理。
foo_t* foo_factory();
void foo_user(foo_t* f);
void some_function() {
foo_t* foo = foo_factory();
foo_user(foo);
// 此时 foo 处于什么状态?是否被改变了?
// 你无法简单得出结论
}
这里的问题在于:
foo
是一个指针(引用类型),foo_user(foo)
是否修改了 foo
指向的数据,int foo_factory(); // 返回一个值
void foo_user(int f); // 不会修改传入值
void some_function() {
int foo = foo_factory();
foo_user(foo);
// foo 的值没有变化,推理更清晰
}
这时你可以安心地推断出:
foo
仍然等于调用 foo_factory()
时的结果,foo
。值类型 (Value) | 引用类型 (Reference) |
---|---|
语义清晰,谁拥有值一目了然 | 所有权不明确,易造成资源泄漏或重复释放 |
易于推理(如数学代数) | 难以推理,需关注其他代码对其影响 |
无副作用,状态独立 | 有副作用,可能被其他代码悄悄修改 |
结论:
在设计接口和数据结构时,如果没有特别理由,优先考虑使用 值类型 —— 它更安全、可维护性更强,并能简化你的逻辑推理和调试过程。
bool my_predicate(some_t obj);
some_t
类型的对象。some_t
部分。bool my_predicate(const base_t& obj);
base_t
派生类的引用。template <typename T>
bool my_predicate(T obj);
struct base { virtual ~base () {} };
struct derived : base { virtual int foo () { return 42; } };
void some_function () {
base * b_pointer = new derived;
requires_foo(static_cast<derived*>(b_pointer)); // 强制转换
}
真正的多态是 “你可以把一个类型 当作另一个类型 来使用”。
在这个例子中:
requires_foo()
需要 derived*
。base*
。derived*
。base
接口自然地调用 foo()
,你必须知道对象实际是 derived
,并强转。方法 | 类型 | 优点 | 缺点 |
---|---|---|---|
bool my_pred(some_t) |
非多态 | 简单、明确 | 只适用于一个类型 |
bool my_pred(const base_t&) |
运行时多态 | 灵活,可处理派生类对象 | 必须建立继承关系,复杂,耦合 |
template |
编译时多态 | 非常灵活,零开销(模板实例化时优化) | 模板错误难调试,代码膨胀,需元编程能力 |
这部分介绍了 “类型擦除” 之前,已经存在的两种主流 多态实现方案,即:
继承实现的运行时多态
struct Base {
virtual void foo() = 0;
virtual ~Base() {}
};
struct Derived : Base {
void foo() override { std::cout << "Derived foo\n"; }
};
void do_something(Base* obj) {
obj->foo(); // 运行时决定调用 Derived::foo()
}
模板实现的编译时多态
template<typename T>
void do_something(T obj) {
obj.foo(); // 编译时检查 T 是否有 foo()
}
方式 | 类型 | 优点 | 缺点 |
---|---|---|---|
Inheritance + Virtual | 运行时多态 | 接口明确,动态行为 | 必须继承,灵活性差,有运行时开销 |
Templates (Generic Functions) | 编译时多态 | 灵活泛型,无继承、零开销 | 编译错误难读,复杂时难以维护 |
因为这两种方式虽然强大,但在以下场景中都不够理想:
“Inheritance 给了我们多态,但也绑住了我们的手脚。”
你写了一个接口(抽象基类):
struct base {
virtual int foo () const = 0;
};
struct derived : base {
virtual int foo () const override { return 42; }
float bar () const { return 3.0f; }
};
void some_function() {
base* b_pointer = new derived;
uses_foo(b_pointer); // 成功:base 有 foo()
// uses_bar(b_pointer); // 失败:base 不知道 bar()
}
** 问题:**
base
类接口太窄,你不能通过基类指针访问 bar()
之类的扩展功能。
struct base { virtual ~base() {} };
struct has_foo : virtual base { virtual int foo() { return 42; } };
struct has_bar : virtual base { virtual float bar() { return 3.0f; } };
struct derived : has_foo, has_bar {};
void some_function() {
base* b_pointer = new derived;
uses_foo(dynamic_cast<has_foo*>(b_pointer)); // OK,但需要 RTTI
uses_bar(dynamic_cast<has_bar*>(b_pointer)); // 啊这……
}
** 问题:**
foo
和 bar
,需要 dynamic_cast
,而不是通过通用 base*
。base*
实际上还有 has_foo
和 has_bar
的接口。base
使用这些功能了。多继承经常引发以下问题:
base
/ \
has_foo has_bar
\ /
derived
base
,容易造成对象重复构造、二义性调用。virtual
继承。virtual
继承带来复杂的对象布局、构造顺序、额外的内存开销等问题。“This leads to the diamond of death,
which leads to virtual inheritance,
which leads to fear,
which leads to anger…”
—— 这是在模仿星战名言,也幽默地说明:越修补越糟糕!
当你不得不使用 dynamic_cast
来“提醒”代码这是 has_foo
或 has_bar
,
你其实已经不再是真正的多态,而是:
手动携带类型信息 + 手动类型转换。
这时,你不如使用类型擦除(type erasure)来封装这些“接口”,让调用者不再关心底层类型,只关心它能foo()
或bar()
。
struct int_foo {
virtual int foo () { return 42; }
virtual void log () const;
};
struct float_foo {
virtual float foo () { return 3.0f; }
virtual void log () const;
};
这两个类都实现了 log()
方法,但:
它们 没有共同的基类,所以你不能将它们通过一个统一的接口传递(如参数传递、集合管理等)。
void log_to_terminal(const int_foo& loggee);
void log_to_terminal(const float_foo& loggee);
只能为每种类型写一个独立的函数,没有代码复用,冗余严重、可维护性差。
struct log_base { virtual void log () const; };
struct int_foo : log_base {/*...*/};
struct float_foo : log_base {/*...*/};
void log_to_terminal (const log_base & loggee);
虽然这可以复用 log_to_terminal()
,但你必须强行将两个不相关的类继承自一个“假的”接口基类(log_base
)——这在逻辑上并不自然,是为了多态硬造的继承关系。
使用继承时,接口和实现高度耦合。如果要组合多个接口(例如 foo()
和 log()
),通常要借助 多重继承,这导致类层级结构变得复杂且脆弱。
override
和 final
能帮忙,但不能根本解决设计问题。log()
接口,只能重写继承结构,非常不灵活。使用继承和虚函数时,你必须:
void f(const base& b); // 通过引用传递
你不能使用值类型传递对象(如 base b
),从而失去了值语义带来的优势,如:
Widget
:按钮、文本框等 UI 元素。Layout
:用于管理、排列 Widget
的容器。Layout
也可以嵌套 Layout
(子布局)。Layout
既能包含 Widget
,又能包含 Layout
。struct UIElement { ... };
struct Widget : UIElement { ... };
struct Layout : UIElement { ... }; //
你就被迫让 Layout
和 Widget
拥有共同的父类(UIElement
),即使它们语义上完全不同。
继承(inheritance)并不是灵活、通用接口设计的最佳方式。
这些问题为我们指出方向:我们需要一种机制可以:
“Metaprogramming requires a large body of knowledge about a large number of obscure language rules…”
std::enable_if
、decltype
、模板偏特化、递归类型定义等)。“This is true for experts, but is moreso in a team of varying skill levels.”
“Metaprogramming might be simply impossible to use where you work…”
“Compile times and object code size can get away from you…”
“Does not play well with runtime variation.”
举例说明了这个问题:
template <typename T1, typename T2, bool Select>
typename std::conditional<Select, T1, T2>::type
factory_function () {
return typename std::conditional<Select, T1, T2>::type();
}
int an_int = factory_function<int, float, true>(); // 返回 int
float a_float = factory_function<int, float, false>(); // 返回 float
这里用的是编译时常量 true/false
,模板能正常工作。
template <typename T1, typename T2>
auto factory_function(bool selection) -> /* ??? */ {
return /* ??? */; // 无法推导返回值类型
}
在运行时你没法动态选出 T1 或 T2,因为模板需要在编译时就确定类型。所以:
如果你用了模板,你就被“编译时类型锁死”了。
这个问题直接引出了下一章内容 —— Type Erasure(类型擦除),它可以:
我们需要一个机制,既不要:
类型擦除是 C++ 中实现“值语义的运行时多态”的一种模式。
你可以把它理解为:
在运行时,把不同类型“包裹”在一个共同的接口中,就像
std::any
或std::function
一样。
struct foo { int value () const; };
struct bar { int value () const; };
int value_of (magic_type obj)
{ return obj.value(); }
void some_function () {
if (value_of(foo()) == value_of(bar())) {
// ...
}
}
注意:
foo
和 bar
不是从一个共同的 base class 派生。value_of()
接口却接受统一的 magic_type
。.value()
就行!anything
这是类型擦除的经典实现方式(类似 boost::any
或 std::any
):
struct anything {
...
struct handle_base {
virtual ~handle_base () {}
virtual handle_base * clone () const = 0;
};
template <typename T>
struct handle : handle_base {
handle (T value);
virtual handle_base * clone () const;
T value_;
};
std::unique_ptr<handle_base> handle_;
};
anything
的类型都用 handle
包装;handle_base
是一个统一接口,用于克隆(copy);std::function
、std::any
、std::unique_ptr
等现代 C++ 类中。int i = 1;
int* iptr = &i;
anything a;
a = i; // int
a = iptr; // int*
a = 2.0; // double
a = std::string("3"); // std::string
a = foo(); // 自定义类型 foo
这就是 动态类型行为,只要对象满足你预期的接口(比如 .value()
),你就可以传进去。
std::function
、std::any
、std::variant
的核心思想。“Consider dumping scripting languages for this. That’s not a joke.”
意思是:如果你用 C++ 做到这一步,已经可以模拟很多脚本语言的灵活特性,比如:
value()
、render()
、geometry()
),以 anything
为例:
struct anything {
struct handle_base {
virtual ~handle_base() {}
virtual int value() const = 0;
virtual handle_base* clone() const = 0;
};
template<typename T>
struct handle : handle_base {
T value_;
handle(const T& val) : value_(val) {}
int value() const override { return value_.value(); }
handle_base* clone() const override { return new handle(value_); }
};
std::unique_ptr<handle_base> handle_;
template<typename T>
anything(const T& val) : handle_(new handle<T>(val)) {}
anything(const anything& other) : handle_(other.handle_ ? other.handle_->clone() : nullptr) {}
int value() const { return handle_->value(); }
};
anything
就能存储任何有 .value()
函数的类型,并对外表现为统一接口。
你说的 widget
和 layoutable
,可以这么设计:
struct widget {
struct handle_base {
virtual ~handle_base() {}
virtual void render() const = 0;
virtual handle_base* clone() const = 0;
};
template<typename T>
struct handle : handle_base {
T value_;
handle(const T& val) : value_(val) {}
void render() const override { value_.render(); }
handle_base* clone() const override { return new handle(value_); }
};
std::unique_ptr<handle_base> handle_;
template<typename T>
widget(const T& val) : handle_(new handle<T>(val)) {}
widget(const widget& other) : handle_(other.handle_ ? other.handle_->clone() : nullptr) {}
void render() const { handle_->render(); }
};
struct layoutable {
struct handle_base {
virtual ~handle_base() {}
virtual layout_geometry geometry() const = 0;
virtual handle_base* clone() const = 0;
};
template<typename T>
struct handle : handle_base {
T value_;
handle(const T& val) : value_(val) {}
layout_geometry geometry() const override { return value_.geometry(); }
handle_base* clone() const override { return new handle(value_); }
};
std::unique_ptr<handle_base> handle_;
template<typename T>
layoutable(const T& val) : handle_(new handle<T>(val)) {}
layoutable(const layoutable& other) : handle_(other.handle_ ? other.handle_->clone() : nullptr) {}
layout_geometry geometry() const { return handle_->geometry(); }
};
这样,button
只要实现了 .render()
和 .geometry()
,就能被 widget
和 layoutable
接受:
struct button {
void render() const { /* ... */ }
layout_geometry geometry() const { /* ... */ }
// ...
};
void do_layout(layoutable l) {
auto geom = l.geometry();
// ...
}
void render_widget(widget w) {
w.render();
}
int main() {
button b;
do_layout(b);
render_widget(b);
}
这部分讲的是类型擦除的性能代价,以及和传统继承的对比:
操作 | 继承多态 | 简单类型擦除 |
---|---|---|
构造 | 是 | 是 |
复制 | 否 | 是 |
赋值 | 否 | 是 |
获取备用接口(多接口) | 否* | 是 |
dynamic_cast<>
,这既不免费,且表现不一致;类型擦除通常是直接调用包装的接口,更简单一致。std::reference_wrapper
来传递引用,避免深拷贝:
// 当 handle 持有的是引用类型时(T 是引用类型),
// 通过启用条件使得这个构造函数生效,
// 直接把引用 value_ 初始化为传入的 value。
// 这里不会做拷贝,保持引用语义。
template <typename U = T>
handle (T value,
typename std::enable_if<
std::is_reference<U>::value
>::type * = 0) :
value_ (value) // 直接赋值引用
{}
// 当 handle 持有的是非引用类型时(T 不是引用类型),
// 走这个构造函数,
// 使用 std::move 对传入的 value 进行移动构造,
// 减少不必要的复制开销。
template <typename U = T>
handle (T value,
typename std::enable_if<
!std::is_reference<U>::value,
int
>::type * = 0) noexcept :
value_ (std::move(value)) // 移动构造成员变量
{}
// 对 std::reference_wrapper 的特化,
// 继承 handle,即持有 T 类型的引用。
// 构造函数将 std::reference_wrapper 解包,
// 传递实际引用给基类 handle,
// 实现对引用的透明封装。
template <typename T>
struct handle<std::reference_wrapper<T>> :
handle<T &> // 继承持有引用的 handle
{
handle (std::reference_wrapper<T> ref) :
handle<T &> (ref.get()) // 解包获得实际引用
{}
};
std::reference_wrapper
)handle
直接持有类型 T
的值,构造时会复制或移动。enable_if
)区分 T
是否为引用类型,分别处理:
T
是引用,直接存储引用,不复制。T
不是引用,移动构造(保持高效)。std::reference_wrapper
handle>
做特化,继承自 handle
,直接存储对引用的引用。widget
传入 std::ref(b)
或 std::cref(b)
时,不会发生复制,而是存储对原对象的引用。操作 | 继承多态 | 简单类型擦除 | 类型擦除 + 引用优化 |
---|---|---|---|
构造 | 是 | 是 | 否 |
复制 | 否 | 是 | 否 |
赋值 | 否 | 是 | 否 |
获取备用接口(多接口) | 否* | 是 | 否 |
handle_base
还是通过指针管理),但避免了拷贝对象本身,特别适合大对象或非拷贝友好类型。总结一下重点:
copy_on_write<widget> w_1(widget{button()}); // 构造,持有一个widget对象,可能分配一次堆内存
copy_on_write<widget> w_2 = w_1; // 赋值,不做拷贝,两个对象共享同一底层资源
widget & mutable_w_2 = w_2.write(); // 当w_2调用写接口时,发现共享,需要复制底层资源
// 这时才发生深拷贝,保证修改不影响w_1
操作 | 继承 (Inheritance) | 简单类型擦除 (Simple TE) | 类型擦除 + COW (TE + COW) |
---|---|---|---|
构造 (Construct) | 1 | 1 | 2 |
复制 (Copy) | 0 | 1 | 0 |
赋值 (Assign) | 0 | 1 | 0 |
切换接口 (Alt Interface) | 0* | 1 | 2 |
* 这里的“切换接口”指的是获取替代接口的成本,继承中通常是动态转换。
handle_
成员变量(存储实际数据的指针)上。widget w_1 = button(); // 只进行了一次内存分配
widget w_2 = w_1; // 这里没有复制数据,只是共享指针和引用计数
widget& mutable_w_2 = w_2.write(); // 写操作,触发数据复制(copy-on-write)
操作 | 继承(Inheritance) | 简单类型擦除(Simple TE) | 类型擦除 + COW (TE w/COW) |
---|---|---|---|
构造 | 1 | 1 | 1 |
复制 | 0 | 1 | 0 |
赋值 | 0 | 1 | 0 |
替代接口 | 0* | 1 | 1 |
*注:动态转换
dynamic_cast
不算自由开销。
总结:
集成 Copy-On-Write 技术后,类型擦除的复制和赋值操作更加高效,避免了不必要的深拷贝,尤其适合不可变对象的共享场景,同时保持了写时复制的语义。
std::array<int, 1024> big_array;
anything small = 1; // 存储int,SBO生效,不分配堆内存
anything ref = std::ref(big_array); // std::ref本身很小,SBO生效,不分配堆内存
anything large = big_array; // big_array对象大,无法放入缓冲区,需要堆分配
操作 | 继承(Inheritance) | 简单类型擦除(Simple TE) | 类型擦除+SBO (TE w/SBO) |
---|---|---|---|
构造(小/大) | 1 / 1 | 1 / 1 | 0 / 1 |
复制(小/大) | 0 / 0 | 1 / 1 | 0 / 1 |
赋值(小/大) | 0 / 0 | 1 / 1 | 0 / 1 |
替代接口(小/大) | 0 / 0* | 1 / 1 | 0 / 1 |
int
或 std::reference_wrapper
),直接内置在类型擦除对象内部的缓冲区里存储,避免了堆分配。std::array
)才会触发堆分配。std::array<int, 1024> big_array;
anything small = 1; // 存储int,无堆分配
anything ref = std::ref(big_array); // 存储reference_wrapper,无堆分配
anything large = big_array; // 大对象,堆分配发生
anything copied = large; // 复制 large,无堆分配,使用共享
操作 | 继承(Inheritance) | 简单类型擦除(Simple TE) | 类型擦除 w/SBO + COW |
---|---|---|---|
构造 | 1 / 1 | 1 / 1 | 0 / 1 |
复制 | 0 / 0 | 1 / 1 | 0 / 0 |
赋值 | 0 / 0 | 1 / 1 | 0 / 0 |
替代接口 | 0* / 0* | 1 / 1 | 0 / 1 |
*注:std::reference_wrapper 总是“小”对象。 |
整体来说,类型擦除用现代C++技巧弥补了继承机制的缺陷,带来更灵活、安全和高效的设计,但需要更复杂的实现,适合对性能和灵活性有较高要求的场景。
operator++
、operator<<
等可以被要求实现。any
,要求支持拷贝构造、类型标识、递增操作和输出流操作。BOOST_TYPE_ERASURE_MEMBER
定义了一个成员函数概念 push_back
。push_back
。操作 | 继承 (Inheritance) | 优化手写类型擦除 (Optimized TE) | Boost 类型擦除 (Boost TE) |
---|---|---|---|
构造 | 1 / 1 / - | 0 / 1 / 0 | 1 / 1 / 0 |
复制 | 0 / 0 / - | 0 / 0 / 0 | 1 / 1 / 0 |
赋值 | 0 / 0 / - | 0 / 0 / 0 | 1 / 1 / 0 |
替代接口访问 | 0 / 0 / -* | 0 / 1 / 0 | 1 / 1 / 0 |
-* 表示继承方式使用 dynamic_cast<> ,但其性能和一致性不算理想。 |
技术 | 持有对象大小 | 总空间需求 |
---|---|---|
继承 (Inheritance) | 所有对象 | P + O |
手写类型擦除 (TE) 小对象 | 小(小于缓冲区大小) | P + B |
手写类型擦除 (TE) 大对象 | 大(超出缓冲区大小) | P + B + O |
Boost 类型擦除 (Boost TE) | 所有对象 | F * M + O |
**emtypen**
,它的目标是自动化“手写类型擦除”过程中大量冗余样板代码的生成。下面是核心要点的提炼和说明:emtypen
是一个基于 libclang
的工具,它能够将你写的“概念接口”(即 struct
或 class
)转换为完整的 类型擦除类,就像你在这个系列中逐步优化出的那些 widget
、layoutable
等类一样。
传统方式(手写) | emtypen 方式 |
---|---|
手动写接口的类型擦除实现(大量重复代码) | 自动生成 |
容易出错,修改冗余代码困难 | 一改接口源头,重新生成即可 |
每个接口都要从头写虚表、COW、SBO 等 | 支持一键生成所有优化(可配置) |
接口文件,合法 C++ 代码,例如:
#ifndef LOGGABLE_INTERFACE_INCLUDED__
#define LOGGABLE_INTERFACE_INCLUDED__
#include
struct loggable {
std::ostream& log(std::ostream& os) const;
};
#endif
emtypen
会生成一个包含完整类型擦除逻辑的类(例如带有虚函数表、类型擦除处理、引用支持、小对象优化等):
struct loggable {
public:
std::ostream& log(std::ostream& os) const {
assert(handle_);
return handle_->log(os);
}
private:
// 类型擦除的细节:handle_ 指向多态基类
};
form
文件和 header 区块生成逻辑。