CppCon 2014 学习:Pragmatic Type Erasure
这段 Outline(大纲) 是一篇或一场讲座关于 Type Erasure(类型擦除) 的内容结构。以下是对各部分的理解:
Outline 总览
1. The Importance of Values(值的重要性)
- 核心观点:在现代C++编程中,值类型(value types)越来越重要,特别是在泛型编程中。
- 与传统的面向对象中以指针或引用操纵对象不同,值语义更清晰、更易于管理生命周期、并发更安全。
- 例子:
std::string
,std::vector
,std::function
都是有值语义的。
2. Why Polymorphism?(为什么需要多态?)
- 目的:允许我们用统一的接口处理不同类型的对象。
- 在运行时通过虚函数(或接口类)实现行为的多态是面向对象的核心。
- 问题:传统多态需要显式继承、定义虚函数、使用指针,并带来额外开销与复杂性。
- 示例问题:
class Shape { virtual void draw() = 0; }; // 传统接口
3. Those Other Solutions(其他解决方案)
- 这里探讨的是与多态相关的其他方法,比如:
- 模板(compile-time 多态)
std::variant
(联合类型)- 类型擦除(运行时多态的一种现代替代方案)
- 每种方式各有优缺点。模板灵活但可能导致代码膨胀;继承耦合度高;variant 不适合大规模扩展类型。
4. Type Erasure(类型擦除)
- 核心内容:使用统一的接口隐藏具体类型的实现,消除对模板参数或虚基类的依赖。
- 示例:
std::function
、std::any
、boost::any
、any_iterator
。 - 原理:
- 借助接口 + 模板包装,将具体类型“抹去”(erase),转为通过接口指针间接调用。
- 好处:
- 实现运行时多态
- 避免显式继承
- 使用简单,接口整洁
- 代价:
- 需要
new
分配(或类似) - 运行时类型转换(如
any_cast
) - 可维护性要谨慎控制
- 需要
总结一句话:
类型擦除是一种强大的技术,它允许我们在保留值语义的同时实现灵活的运行时多态,是现代C++中多态性设计的重要选择之一。
顺带一提,您在本次演示中看到的所有代码都可以成功编译,并且如预期那样工作。
这一部分讨论了 “值类型(Value Types)” 的重要性,以及它相较于 “引用类型(Reference Types)” 带来的优势,重点体现在两个方面:
1. 清晰的所有权 / 生命周期语义(Clear ownership/lifetime semantics)
示例比较:
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
明确表达了资源共享和引用计数的语义。- 但是否更好,还要看你是否依赖全局状态(global state)。如果
foo_factory
内部持有全局状态,这仍然不理想。
2. 等式推理(Equational Reasoning)
这是指代码的行为可以通过变量等式来简单理解、替换,类似数学中的代数推理。
问题代码示例:
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) |
---|---|
语义清晰,谁拥有值一目了然 | 所有权不明确,易造成资源泄漏或重复释放 |
易于推理(如数学代数) | 难以推理,需关注其他代码对其影响 |
无副作用,状态独立 | 有副作用,可能被其他代码悄悄修改 |
结论:
在设计接口和数据结构时,如果没有特别理由,优先考虑使用 值类型 —— 它更安全、可维护性更强,并能简化你的逻辑推理和调试过程。
“为什么需要多态(Polymorphism)” 的探讨,它深入分析了不同方式的代码复用手段,并指出了它们各自的利与弊。
目的:代码复用(Code Reuse)
问题 1:函数作者期望你如何使用这个函数?
bool my_predicate(some_t obj);
回答:非多态(Non-polymorphic)
- 你必须传入一个
some_t
类型的对象。 - 如果你传的是子类对象,它会 切片(slicing):即只保留
some_t
部分。 - 复用性差:只能处理一个具体类型。
问题 2:这个版本如何?
bool my_predicate(const base_t& obj);
回答:运行时多态(Runtime polymorphism)
- 你可以传入任意
base_t
派生类的引用。 - 函数通过虚函数机制,在运行时分发行为。
- 更可复用,但:
- 必须维护继承体系。
- 有虚函数表开销。
- 引入了紧耦合和继承污染。
问题 3:再看看这个?
template <typename T>
bool my_predicate(T obj);
回答:编译时多态(Compile-time polymorphism)
- 接收任意可以用于实例化模板的类型。
- 非常灵活 —— 比如泛型算法。
- 但要求调用方遵守某种约定(有“duck typing”的味道),
- 会导致模板膨胀,难调试,需具备一定的元编程知识。
这是多态吗?
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<typename T> bool my_pred(T) | 编译时多态 | 非常灵活,零开销(模板实例化时优化) | 模板错误难调试,代码膨胀,需元编程能力 |
结论
- 多态(尤其是编译时多态)提供了强大的代码复用能力。
- 但必须注意选择合适的多态手段:滥用继承或模板都可能导致难维护的代码。
- 最佳实践通常结合使用:用值语义 + 编译时多态 + 类型擦除来封装实现,提供清晰的接口。
这部分介绍了 “类型擦除” 之前,已经存在的两种主流 多态实现方案,即:
Part 3: Those Other Solutions(其他解决方案)
主要内容:对比两种经典多态方案
① Inheritance-Based Runtime Polymorphism
继承实现的运行时多态
特点:
- 使用**虚函数(virtual function)**机制。
- 父类定义接口,子类实现具体行为。
- 动态绑定:运行时决定调用哪个函数。
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()
}
优点:
- 经典、成熟的 OOP 技术。
- 易于理解,接口清晰。
缺点:
- 继承耦合重:类必须统一继承一个基类。
- 只能在一个“类族”内扩展,不灵活。
- 引入虚表开销(vtable)。
- 无法跨多个无关类型使用同一接口。
② Template-Based Compile-time Polymorphism
模板实现的编译时多态
特点:
- 使用 C++ 模板机制。
- 不要求类之间有继承关系,只要“语义兼容”。
- 静态绑定:编译时生成具体代码。
template<typename T>
void do_something(T obj) {obj.foo(); // 编译时检查 T 是否有 foo()
}
优点:
- 不需要继承关系。
- 编译器生成特化代码,性能极高。
- 更泛型,更易复用。
缺点:
- 报错难懂、调试困难。
- 编译时间长,模板膨胀(code bloat)。
- 过度使用会变成复杂的“模板地狱”。
总结对比:
方式 | 类型 | 优点 | 缺点 |
---|---|---|---|
Inheritance + Virtual | 运行时多态 | 接口明确,动态行为 | 必须继承,灵活性差,有运行时开销 |
Templates (Generic Functions) | 编译时多态 | 灵活泛型,无继承、零开销 | 编译错误难读,复杂时难以维护 |
为什么还需要 Type Erasure?
因为这两种方式虽然强大,但在以下场景中都不够理想:
- 你想写一个容器,能存任何“具有某一接口”的对象,而不想模板爆炸或继承污染。
- 你希望将多种实现通过统一接口封装成一个类型(但这些实现间无继承关系)。
- 你想构建插件式、运行时可配置的系统,支持异构数据和行为。
这时候,类型擦除(Type Erasure) 就成为更合适的方案 —— 它结合了运行时多态的通用性和模板的灵活性,提供统一接口的同时隐藏了具体类型。
这部分讲的是 继承作为运行时多态机制时的问题,深入剖析了其局限性,幽默而深刻。
(Problems with) Inheritance as Runtime Polymorphism
“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,但需要 RTTIuses_bar(dynamic_cast<has_bar*>(b_pointer)); // 啊这……
}
** 问题:**
- 要访问
foo
和bar
,需要dynamic_cast
,而不是通过通用base*
。 - 你需要“偷偷知道”这个
base*
实际上还有has_foo
和has_bar
的接口。 - 这破坏了真正的多态性:你不能只通过
base
使用这些功能了。
“Diamond of Death”(菱形继承问题)
多继承经常引发以下问题:
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);
只能为每种类型写一个独立的函数,没有代码复用,冗余严重、可维护性差。
解决方式二:使用“丑陋”的基类 Hack
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()
),通常要借助 多重继承,这导致类层级结构变得复杂且脆弱。
继承的复杂性会迅速上升
- 虚函数在大型层次结构中容易出错。
- C++11 的
override
和final
能帮忙,但不能根本解决设计问题。 - 常见困扰:子类实现的虚函数到底该不该调用基类的实现?
这些都让代码难以维护、难以预测行为。
限制多态能力的根本原因
- 你只能给类型加一个或少数几个基类,所以能“获得”的接口是受限的。
- 如果你后来想让这个类型也有
log()
接口,只能重写继承结构,非常不灵活。 - 更糟的是,你不能让“无关”的类型(例如 int_foo 和 float_foo)共享接口并互换使用。
值语义无法与运行时多态共存
使用继承和虚函数时,你必须:
void f(const base& b); // 通过引用传递
这意味着:
你不能使用值类型传递对象(如 base b
),从而失去了值语义带来的优势,如:
- 清晰的生命周期管理
- 无副作用的代码推理(equational reasoning)
案例分析:Widgets 和 Layouts
Widget
:按钮、文本框等 UI 元素。Layout
:用于管理、排列Widget
的容器。
问题来了:
Layout
也可以嵌套Layout
(子布局)。- 所以
Layout
既能包含Widget
,又能包含Layout
。
如果你用继承实现这层关系:
struct UIElement { ... };
struct Widget : UIElement { ... };
struct Layout : UIElement { ... }; //
你就被迫让 Layout
和 Widget
拥有共同的父类(UIElement
),即使它们语义上完全不同。
核心结论
继承(inheritance)并不是灵活、通用接口设计的最佳方式。
- 它限制了接口的分发(只有父类才定义接口)。
- 不能让无关类型共享相同的接口。
- 导致耦合、脆弱性、难以维护的层级结构。
- 还破坏了值语义。
更优解决方案(铺垫 Type Erasure)
这些问题为我们指出方向:我们需要一种机制可以:
- 支持共享接口
- 不要求类型有共同父类
- 支持值语义(可以按值传递)
- 保持灵活、低耦合
这正是 Type Erasure(类型擦除) 登场的理由!接下来很可能会讲如何用类型擦除来解决上面的问题。需要继续讲解吗?
模板(Templates)作为编译时多态(Compile-time Polymorphism) 所面临的问题。下面是详细的中文理解和解析:
模板的经典问题
1. 模板元编程(TMP)门槛高
“Metaprogramming requires a large body of knowledge about a large number of obscure language rules…”
- 使用模板实现复杂逻辑(如类型选择、递归、SFINAE)需要理解很多晦涩的语言细节。
- 大多数 C++ 程序员都不熟悉 TMP 特技(如
std::enable_if
、decltype
、模板偏特化、递归类型定义等)。 - 即使是高手,也容易写出难以维护的“模板黑魔法”。
2. 团队协作难维护
“This is true for experts, but is moreso in a team of varying skill levels.”
- 如果团队成员技术水平不一致,模板代码极易引起困惑。
- 很多时候出错信息(compiler errors)极其难读,比如模板嵌套20层以上导致的错误堆栈。
3. 可能在工作环境中根本无法使用
“Metaprogramming might be simply impossible to use where you work…”
- 一些企业/项目强制要求 C++98、没有 C++11/14/17 支持,无法使用现代 TMP 工具。
- 有些公司代码规范限制使用 TMP,以保证可读性和可调试性。
4. 编译时间和目标文件大小失控
“Compile times and object code size can get away from you…”
- 模板实例化每次都会生成新代码,如果使用不慎,最终编译出的二进制体积巨大。
- 每次修改一个模板都会导致大面积重编译,开发效率下降。
5. 不适用于运行时变化
“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,因为模板需要在编译时就确定类型。所以:
如果你用了模板,你就被“编译时类型锁死”了。
模板使用的陷阱总结
- 一旦选择使用 TMP(模板元编程),你很可能就必须“一路走到底”。
- TMP 设计难以与传统的运行时机制(如 virtual)集成使用。
- 编译期灵活,运行时死板。
提示下一步解决方向:Type Erasure
这个问题直接引出了下一章内容 —— Type Erasure(类型擦除),它可以:
- 在运行时选择不同类型的行为
- 保持值语义(可按值传递)
- 提供统一接口,不要求共享父类
- 同时解决模板和继承的局限
你这部分讲的是 Part 4:Type Erasure(类型擦除),是对模板和继承的一种灵活替代方案。
总结与中文理解
问题复述:有没有更好的方法?
我们需要一个机制,既不要:
- 耦合代码(如继承必须共享一个 base class);
- 模板泛滥(如模板函数编译膨胀、难以组合);
- 牺牲值语义(virtual + references 不支持 copy 等);
还要能: - 实现运行时多态;
- 具有接口统一性;
- 像 Python 那样 “看起来像鸭子就是鸭子”(duck typing);
于是引出了 —— Type Erasure(类型擦除)。
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
。- 传入对象具有 duck typing 特性:只要有
.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<T>
包装; handle_base
是一个统一接口,用于克隆(copy);- 所有存入的对象都有值语义(通过 clone() 实现);
- 构造时自动擦除类型并封装(用模板);
- 类似的结构也存在于
std::function
、std::any
、std::unique_ptr
等现代 C++ 类中。
使用场景(dynamic typing in 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()
),你就可以传进去。
为什么这很强?
- 和 Python 类似的 duck typing;
- 保留值语义(复制、移动都可以);
- 可以把完全无继承关系的对象放在一起用;
- 可以用于构造“插件式”架构;
- 是
std::function
、std::any
、std::variant
的核心思想。
最后一句强调
“Consider dumping scripting languages for this. That’s not a joke.”
意思是:如果你用 C++ 做到这一步,已经可以模拟很多脚本语言的灵活特性,比如:
- 动态类型
- duck typing
- 接口解耦
- 值语义
类型擦除的典型用法:
- 把不同类型统一封装成“魔法类型”,这个类型定义了需要的接口(比如
value()
、render()
、geometry()
), - 内部通过基类指针调用虚接口,外部使用时表现为统一接口,无需继承,也无需模板,
- 实现了对无关类型的运行时多态,同时仍保持了值语义。
核心思想(你说的“Easy — just forward the calls”)
以 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<T> 的特化,
// 继承 handle<T&>,即持有 T 类型的引用。
// 构造函数将 std::reference_wrapper<T> 解包,
// 传递实际引用给基类 handle<T&>,
// 实现对引用的透明封装。
template <typename T>
struct handle<std::reference_wrapper<T>> :handle<T &> // 继承持有引用的 handle
{handle (std::reference_wrapper<T> ref) :handle<T &> (ref.get()) // 解包获得实际引用{}
};
优化步骤概述
1. 接受引用类型(std::reference_wrapper
)
- 原先
handle
直接持有类型T
的值,构造时会复制或移动。 - 改成利用 SFINAE(
enable_if
)区分T
是否为引用类型,分别处理:- 如果
T
是引用,直接存储引用,不复制。 - 如果
T
不是引用,移动构造(保持高效)。
- 如果
2. 特化处理 std::reference_wrapper<T>
- 对
handle<std::reference_wrapper<T>>
做特化,继承自handle<T&>
,直接存储对引用的引用。 - 这样在给
widget
传入std::ref(b)
或std::cref(b)
时,不会发生复制,而是存储对原对象的引用。
结果对比表
操作 | 继承多态 | 简单类型擦除 | 类型擦除 + 引用优化 |
---|---|---|---|
构造 | 是 | 是 | 否 |
复制 | 否 | 是 | 否 |
赋值 | 否 | 是 | 否 |
获取备用接口(多接口) | 否* | 是 | 否 |
- 引用优化后,构造、复制、赋值操作不再触发底层对象的拷贝,大幅降低了性能开销。
额外说明
- 虽然堆分配次数没变(因为
handle_base
还是通过指针管理),但避免了拷贝对象本身,特别适合大对象或非拷贝友好类型。 - 这样就结合了值语义的灵活性和引用的效率。
理解了!这是在讲如何通过**写时复制(Copy-On-Write, COW)**优化类型擦除(Type Erasure)对象的性能。
总结一下重点:
Step 2: 使用写时复制包装器 (Copy-On-Write Wrapper)
- 目标:
避免每次赋值或复制都做实际对象的深拷贝,只在真正修改(写操作)对象时才复制,达到性能优化。 - 示例:
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
- 优点:
- 避免了不必要的拷贝和内存分配,提高效率
- 线程安全性增强,因为读操作是共享,写操作时才复制,互不干扰
指针继承 vs 简单类型擦除 vs 类型擦除 + COW 资源开销对比:
操作 | 继承 (Inheritance) | 简单类型擦除 (Simple TE) | 类型擦除 + COW (TE + COW) |
---|---|---|---|
构造 (Construct) | 1 | 1 | 2 |
复制 (Copy) | 0 | 1 | 0 |
赋值 (Assign) | 0 | 1 | 0 |
切换接口 (Alt Interface) | 0* | 1 | 2 |
* 这里的“切换接口”指的是获取替代接口的成本,继承中通常是动态转换。
理解:
- 继承方案构造时分配,复制赋值不拷贝(通常是指指针拷贝)
- 简单类型擦除每次复制都会拷贝底层对象,开销较大
- 类型擦除 + COW构造时稍多分配,复制赋值不复制对象,只复制指针计数,只有写操作才真正拷贝底层对象
理解了!这是关于将 Copy-On-Write (COW) 技术集成到类型擦除(Erased Types)里的优化说明。
Step 3: 在类型擦除中集成 Copy-On-Write (COW)
- 背景:
之前简单类型擦除(Simple TE)在构造时可能会有多次分配,复制操作也会导致对象数据被复制。 - 优化思路:
将 copy-on-write 直接应用到handle_
成员变量(存储实际数据的指针)上。 - 效果:
- 构造时只发生一次分配(不管是新对象还是复制对象都只分配一次)。
- 复制(复制构造函数调用)时不会复制底层数据,复制的只是指针和引用计数。
- 只有当调用非const成员函数修改对象时,才会触发真正的复制(深拷贝)。
使用示例:
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 技术后,类型擦除的复制和赋值操作更加高效,避免了不必要的深拷贝,尤其适合不可变对象的共享场景,同时保持了写时复制的语义。
理解了!这是在讲Small Buffer Optimization (SBO) 对类型擦除的优化。
Step 4: 应用小缓冲区优化 (Small Buffer Optimization, SBO)
- 目的:
减少小对象的堆分配次数,通过在类型擦除对象内部预留一个固定大小的缓冲区,用来直接存储小型对象。 - 示例说明:
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 |
- † std::reference_wrapper 总是小对象,SBO总生效。
总结: - SBO 技术让类型擦除的性能大幅提升
- 只对“小对象”分配堆外存储,减少内存分配和释放的开销
- 大对象依然使用堆内存,保持灵活性
理解了!这是关于将 Small Buffer Optimization (SBO) 和 Copy-On-Write (COW) 技术同时应用于类型擦除(Erased Types)的最终优化方案。
Step 5: 结合 SBO 和 集成 COW
关键点:
- SBO(Small Buffer Optimization)
- 对于小对象(比如
int
或std::reference_wrapper
),直接内置在类型擦除对象内部的缓冲区里存储,避免了堆分配。 - 只有较大对象(比如
std::array<int,1024>
)才会触发堆分配。
- 对于小对象(比如
- COW(Copy-On-Write)
- 复制时不复制数据,只复制指针和引用计数。
- 只有调用修改操作(非const成员函数)时才触发真正的复制。
运行效果示例:
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 总是“小”对象。 |
总结:
- 对小对象,实现了无堆分配,减少内存碎片与分配开销。
- 对大对象,只有首次存储时发生堆分配,复制操作均为轻量指针拷贝。
- 调用写操作时才真正进行复制,节约资源。
- 保持了类型擦除的灵活性,支持多种接口调用。
Gains(优点)
- 值语义(Value semantics)
类型擦除的对象像普通值一样使用,语义更明确。 - 不用显式 new/delete
内部自动管理内存,无需程序员手动管理堆内存。 - 动态绑定任意接口
能让完全无关的类型绑定到某个接口上,甚至支持多个接口。 - 线程安全
通过复制时写(Copy-On-Write)机制,天然支持线程安全。 - 小对象与引用无堆分配
结合 Small Buffer Optimization,避免了小对象和引用的堆分配开销。
Losses(缺点)
- 实现复杂度提升
类型擦除结合 COW 和 SBO 逻辑,代码比传统继承复杂。 - 线程安全付出代价
线程安全带来了额外的原子操作开销,且在写操作时会发生额外的复制。
整体来说,类型擦除用现代C++技巧弥补了继承机制的缺陷,带来更灵活、安全和高效的设计,但需要更复杂的实现,适合对性能和灵活性有较高要求的场景。
这段讲的是 Boost.TypeErasure 这款成熟的库型类型擦除方案,主要特点和缺点总结如下:
Boost.TypeErasure 优点
- 基于元编程的显式虚表构建
灵活控制虚函数接口,不依赖传统继承。 - 支持直接向底层类型的转换(类似 Boost.Any)
- 支持自由函数(free-function)要求
接口中不仅是成员函数,还能用自由函数定义契约。 - 支持操作符(operator)要求
例如operator++
、operator<<
等可以被要求实现。 - 支持关联类型(associated types)要求
允许接口中包含类型成员的约束。 - 支持概念映射(concept maps)
允许在类型和接口之间定义映射关系。
代码示例亮点
- 创建了一个任意类型
any
,要求支持拷贝构造、类型标识、递增操作和输出流操作。 - 通过
BOOST_TYPE_ERASURE_MEMBER
定义了一个成员函数概念push_back
。 - 使用这个概念定义的 erased type 可以调用
push_back
。
缺点
- 每个对象维护独立虚表,可能造成对象较大。
- 大量的元编程导致编译时间变长。
- 错误信息晦涩难懂,调试困难。
- 不支持小缓冲区优化(SBO)。
- const 成员函数的要求写起来不够直观。
总的来说,Boost.TypeErasure 功能强大,适合需要高度灵活且接口复杂的应用,但开发体验(编译时间和错误排查)上存在挑战。
总结了三种多态方案——继承(Inheritance)、手写优化的类型擦除(Optimized Type Erasure,简称TE)和 Boost.TypeErasure(Boost TE)——在不同操作和空间上的开销对比:
1. 堆分配次数对比(针对小对象 / 大对象 / 引用持有)
操作 | 继承 (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<> ,但其性能和一致性不算理想。 |
2. 空间占用需求(含持有对象存储)
技术 | 持有对象大小 | 总空间需求 |
---|---|---|
继承 (Inheritance) | 所有对象 | P + O |
手写类型擦除 (TE) 小对象 | 小(小于缓冲区大小) | P + B |
手写类型擦除 (TE) 大对象 | 大(超出缓冲区大小) | P + B + O |
Boost 类型擦除 (Boost TE) | 所有对象 | F * M + O |
- P:指针大小(指向堆分配对象的指针)
- F:函数指针大小
- O:存储的对象大小
- M:被擦除类型的接口函数个数(虚表大小)
- B:小缓冲区大小
总结
- 继承方案:简单直接,但只能有单个基础接口,空间使用仅指针和对象大小,堆分配固定。
- 手写类型擦除(经过优化)对小对象能避免堆分配,空间利用灵活且支持多接口,但代码实现复杂。
- Boost.TypeErasure灵活支持复杂接口(函数多,模板多),但每个接口成员都带来额外函数指针开销,体积较大,且堆分配不可避免。
一个非常实用的工具:**emtypen**
,它的目标是自动化“手写类型擦除”过程中大量冗余样板代码的生成。下面是核心要点的提炼和说明:
emtypen 的作用
emtypen
是一个基于 libclang
的工具,它能够将你写的“概念接口”(即 struct
或 class
)转换为完整的 类型擦除类,就像你在这个系列中逐步优化出的那些 widget
、layoutable
等类一样。
为什么使用 emtypen?
传统方式(手写) | emtypen 方式 |
---|---|
手动写接口的类型擦除实现(大量重复代码) | 自动生成 |
容易出错,修改冗余代码困难 | 一改接口源头,重新生成即可 |
每个接口都要从头写虚表、COW、SBO 等 | 支持一键生成所有优化(可配置) |
输入(archetype)
接口文件,合法 C++ 代码,例如:
#ifndef LOGGABLE_INTERFACE_INCLUDED__
#define LOGGABLE_INTERFACE_INCLUDED__
#include <iostream>
struct loggable {std::ostream& log(std::ostream& os) const;
};
#endif
输出(erased 类型)
emtypen
会生成一个包含完整类型擦除逻辑的类(例如带有虚函数表、类型擦除处理、引用支持、小对象优化等):
struct loggable {
public:std::ostream& log(std::ostream& os) const {assert(handle_);return handle_->log(os);}
private:// 类型擦除的细节:handle_ 指向多态基类
};
form 文件机制
- form 文件 是代码生成的“模板”,控制如何生成类型擦除逻辑。
- 你可以使用已有的 form(官方提供了支持 COW、SBO、引用封装等优化的版本)。
- 也可以自定义自己的 form 文件,适配你的风格或添加额外逻辑。
- 命令行可指定
form
文件和 header 区块生成逻辑。