类型擦除这玩意吧,在 C++ 中只干一件事:
我们把一个具体类型塞进去,它偷偷藏好,然后对外装傻:"俺不知道啥类型,俺只知道你能 draw()、能 clone()、能 operator()"。
编译器本来就是个事儿逼,啥类型都得对得严丝合缝。
类型擦除就跟它说:"哥,别查了,都是自己人"。
想实现它?只需这几招:
- 用一个非模板基类声明我们要的操作(比如clone()、call())。
- 用一个模板派生类包装具体类型,把这些操作实现掉。
- 外面只持有基类指针。
这就是 std::function、std::any 的底层骨架。
类型擦除的基本思想
我们先了解一下它的基本思想,再谈别的。
1. 问题场景:当我们想要一个能装各种东西的容器
假设我们在写一个图形编辑器。
我们有 Circle、Rectangle、Triangle......它们都有 draw() 方法。我们想这样用:
c++
std::vector<???> shapes;
shapes.push_back(Circle{5});
shapes.push_back(Rectangle{3,4});
for (auto& s : shapes) s.draw();
问题来了:vector 里的元素类型必须相同。
我们不能直接写 vector<Shape> 然后把派生类放进去,会导致对象切片,只保留 Shape 基类部分,多态没了。
我们当然可以用 vector<Shape*> 自己管理生命周期,但那是手动档,容易漏 delete。
更现代的做法是 vector<unique_ptr<Shape>>,但这就要求 Shape 是一个公共基类,所有图形都得继承它。
如果 Circle 来自第三方库、是个 int 或者 std::function<void()> 呢?我们没法改它的定义。
类型擦除 要解决的就是:在不修改已有类型的前提下,让一个容器(或函数参数)能够统一地处理一组行为相同但类型无关的对象。
2. 类型擦除的核心
核心三要素:
- 概念 :我们要保留什么操作?例如"可以 draw"、"可以拷贝"、"可以析构"。这些构成一个隐式的接口。
- 模型:对每个具体类型(比如 Circle),写一个小的包装类(通常叫 Model<T>),它知道如何执行 T 的 draw(),并且以虚函数的形式暴露出去。
- 擦除:对外只暴露一个非模板的、固定类型的外观类(比如 Drawable)。这个类内部持有一个指向 Model 基类的指针,通过虚函数分发调用。
一个极简的手工版本:
c++
// 1. 抽象接口
struct DrawConcept
{
virtual void draw() const = 0;
virtual ~DrawConcept() = default;
};
// 2. 模型模板:适配任意 T
template<typename T>
struct DrawModel : DrawConcept
{
T obj;
DrawModel(T o) : obj(std::move(o)) {}
void draw() const override { obj.draw(); }
};
// 3. 擦除后的外观类
class Drawable
{
std::unique_ptr<DrawConcept> pimpl;
public:
template<typename T>
Drawable(T t) : pimpl(std::make_unique<DrawModel<T>>(std::move(t))) {}
void draw() const { pimpl->draw(); }
};
我们来试试调用它:
c++
struct Circle
{
void draw() const { std::cout << "画一个圆" << std::endl; }
};
struct Square
{
void draw() const { std::cout << "画一个正方形" << std::endl; }
};
struct Triangle
{
void draw() const { std::cout << "画一个三角形" << std::endl; }
};
int main()
{
// 用 Drawable 擦除不同类型,存入同一个 vector
std::vector<Drawable> shapes;
shapes.emplace_back(Circle{});
shapes.emplace_back(Square{});
shapes.emplace_back(Triangle{});
// 统一调用 draw()
for (const auto& shape : shapes) shape.draw();
return 0;
}
最后输出:
画一个圆
画一个正方形
画一个三角形
我们可以往里面扔任何东西,甚至一个普通的 int(只要为 int 定义了 draw() 或者单独适配)。
因为这些东西的类型信息被擦除成了 DrawConcept 的那一组虚函数表,这就是名字的由来。
3. 类型擦除 vs 模板
这时候爱提问的小明就要问了:模板不也能接受任意类型吗?
它们之间还是有区别的:
| 维度 | 模板 | 类型擦除 |
|---|---|---|
| 绑定时间 | 编译期,每个实例化类型产生一份独立代码 | 运行期,通过虚函数表动态分发 |
| 容器内类型 | 不同模板实参会生成不同类型,不能放同一个 vector | 擦除后变成同一类型(如 Drawable),可以混放 |
| 代码膨胀 | 可能较大(每个类型一份逻辑) | 较小(只有模型模板生成少量代码,外观类非模板) |
| 性能 | 零开销,可内联 | 虚函数调用开销(一般可接受) |
| 典型用途 | 算法、容器(std::vector<T>) | 运行时多态容器、std::function、std::any |
一句话总结:
- 模板:"你告诉我类型,我编译时给你造个专属工具。"
- 类型擦除:"你拿来什么类型,我用同一套工具箱帮你包一层,运行时我再偷偷调那个类型的正确方法。"
4. 记忆小剧场:不知道当时怎么想的,可跳过
(当时觉得挺有趣的,现在觉得挺尬的,果然人不能共情之前的自己,哪怕一天都不行)
厨房里,一台智能喂食器立在墙角。猫、狗、仓鼠排排坐,等着干饭。
阿黄 ( ◜◡‾):"铁疙瘩,给我来份大份狗粮!多加肉!"
花花 (´~`):"你喊辣么大声干什么嘛?它根本不认识你。"
团子 (✪ω✪):"对对对,它只知道一件事,站上去的家伙都能'吃'。"
阿黄 (◔౪◔):"啥?连我是谁都分不清?那我冒充团子,岂不是能爽吃双份?"
花花 (´-ω-`):"你站上去试试?"
阿黄 (。◕∀◕。):"......那我贴个'猫'的标签总行吧?"
团子 (๑╹◡╹๑):"我试过!我贴了'狗'的标签站上去,结果掉出来的还是五谷丸子。"
花花 (╬゚д゚):"就算你们贴个'我是如来佛祖'也没用。它早把'类型'擦掉了,只留下'能吃饭'这个行为。"
阿黄 (・∀・):"那明天我减肥成功,是不是就能冒充仓鼠了?"
花花 (・`ω´・):"你就算瘦成个杆子,也是电线杆。就你那百来斤往称上一压,它就知道这玩意绝对不是仓鼠。"
团子 (◞‸◟):"类型擦除了,但体重没有。这大概就是生活的残酷吧。"
阿黄(⌓‿⌓):"喂食器大人,别看我外表是只狗,但我内心其实是一只仓鼠,请给我五谷丸子。"
喂食器 ( •́ _ •̀)?:"请勿放置煤气罐!"
花花 (๑¯∀¯๑):"哈哈哈哈!"
阿黄 ( ˘・з・):"得,我还是老老实实当我的煤气罐吧......那个,猫粮能不能匀我一口?"
(类型擦除:只关心 void eat(Portion),不关心我们是 class Dog 还是 class Cat。)
现代 C++ 实现技术
没啥好说的,直接进入正题。
1. std::function
std::function 能装任何可以 () 调用的东西:函数指针、lambda、有 operator() 的类对象,甚至成员函数。
它是怎么擦除类型?
本质上就是我们之前写的 Drawable 的翻版,只不过把 draw() 换成了 operator()。
- 概念:可复制构造 + 可析构 + 可调用(给定参数类型 Args... 返回 R)。
- 模型:模板类 <typename T> 内部存一个 T,实现 operator() 转发给 T::operator()。
- 擦除:外观类 function<R(Args...)> 持有一个指向抽象基类 callable_base 的指针,基类有虚函数 invoke 和 clone(用于拷贝)。
关键优化:小对象优化(SBO)
很多实现不会一上来就 new。
如果可调用对象大小不超过一个指针(比如 16 或 32 字节),就直接存在 function 对象内部的缓冲区里,避免堆分配。
这就是为什么我们塞一个 lambda []{}(通常 1 字节)进去不会触发 new。
核心代码骨架(简化,去掉了 SBO):
c++
template<typename T>
class function;
template<typename Ret, typename... Args>
class function<Ret(Args...)>
{
struct callable_base
{
virtual Ret invoke(Args... args) = 0;
virtual callable_base* clone() const = 0;
virtual ~callable_base() = default;
};
template<typename F>
struct callable_model : callable_base
{
F f;
callable_model(F&& f_) : f(std::forward<F>(f_)) {}
Ret invoke(Args... args) override { return f(args...); }
callable_base* clone() const override { return new callable_model(f); }
};
callable_base* ptr;
public:
template<typename F>
function(F f) : ptr(new callable_model<F>(std::move(f))) {}
// 拷贝、移动、析构...
Ret operator()(Args... args) { return ptr->invoke(args...); }
};
ps:这段代码只是为了阐述类型擦除的核心思想,故省略些细节。
2. std::any
std::any 比 function 更极端:它只保留拷贝/移动/析构的能力,不保留任何业务操作。
我们只能问它"你里面有东西吗?"、"能换成别的类型吗?"、"能取出原来的类型吗?"。
原理:更简单的类型擦除,连 invoke 虚函数都没有,只有 clone 和析构。
c++
class any
{
struct holder_base
{
virtual holder_base* clone() const = 0;
virtual ~holder_base() = default;
};
template<typename T>
struct holder : holder_base
{
T value;
holder(T&& v) : value(std::move(v)) {}
holder_base* clone() const override { return new holder(value); }
};
holder_base* content;
public:
template<typename T>
any(T&& v) : content(new holder<std::decay_t<T>>(std::forward<T>(v))) {}
// 拷贝、移动、析构...
template<typename T>
T& any_cast()
{
if (auto* h = dynamic_cast<holder<T>*>(content); h) return h->value;
throw bad_any_cast();
}
};
因为 any_cast<T> 依赖 RTTI 即运行时类型识别(使用 dynamic_cast 或手写 typeid 比较)。
所以 any 会擦除类型,但保留了一个 type_index 用于运行时安全检查。
这是它和 std::variant 的重要区别:variant 的类型集编译期固定,不需要 RTTI。
3. std::variant vs 类型擦除
先来介绍一下 std::variant 的基础概念:它提供一种类型安全的方式来存储和访问多种不同类型的值。
一个 std::variant 对象在任何时刻只能包含其定义的类型之一的值,或者在出错的情况下不包含任何值。
这么一看 variant 是不是和 any 差不多呢?
其实它们是互补的:
| 特性 | std::variant<A,B,C> | 类型擦除(any / function) |
|---|---|---|
| 类型集合 | 编译期固定,显式列出 | 运行期无限,只要满足概念 |
| 存储方式 | 栈上 union(无堆分配) | 通常堆分配 + 可能的小对象优化 |
| 访问方式 | std::visit 或 get<...> | any_cast / operator() |
| 错误处理 | 编译期检查 | 运行时抛异常或返回空 |
| 性能 | 极快(无虚函数,无堆) | 有虚函数或函数指针开销 |
| 适用场景 | 我们知道所有可能类型的有限集合 | 我们不知道所有类型,但知道它们能做的操作 |
variant 没有进行类型擦除,它保留了完整的类型信息(只是用 union 安全地存其中之一)。
类型擦除的核心是丢掉具体类型名,只保留操作表。
variant 恰恰相反:我们必须知道具体类型才能 get 或 visit。
理解了它们的取舍,我们就懂了 C++ 的一些设计哲学:用编译期的复杂换运行期的灵活,或者反过来。
性能优化:小对象优化(SBO)
这是类型擦除里特别好的一个优化,毕竟大部分时候我们的对象很小,干嘛非要上堆呢?
1. 问题:每次擦除都进行堆分配,影响性能
我们之前写的 Drawable 是这样的:
c++
template<typename T>
Drawable(T t) : pimpl(std::make_unique<DrawModel<T>>(std::move(t))) {}
每次构造 Drawable,都会 new 一个 DrawModel<T> 对象。
这有几个问题:
- 堆分配开销:new/delete 涉及系统调用或内存池操作,比栈操作慢几十到几百倍。
- 缓存不友好:每个 Drawable 对象里只有一个指针,真正的数据散落在堆上。遍历 vector<Drawable> 时,每次访问都要间接寻址,缓存 miss 率高。
- 内存碎片:频繁分配小对象(比如一个 int 的模型对象也就一两个指针大小)会导致堆碎片。
但是,很多场景下擦除的对象其实很小:
- std::function 里装一个无捕获的 lambda(通常 1 字节)
- 我们的 Drawable 装一个 Circle。
- std::any 装一个 int 或指针
如果能让这些小对象直接存储在 Drawable 对象内部,不经过堆,性能就会大幅提升,这就需要 SBO 登场。
2. 原理:就地存储,超过容量再上堆
SBO 的核心思想很简单:在 Drawable 对象内部预留一块原始字节缓冲区(比如 32 字节),外加一个对齐保证。
- 如果 T 的大小 ≤ 缓冲区大小,且 T 满足某些条件,就把 T 直接 placement new 到缓冲区里。
- 否则,还是走堆分配。
关键点:Drawable 必须能够区分当前是"小对象存本地"还是"大对象上堆",并且对于拷贝/移动/析构要分别处理两种情况。
通常用一个控制块指针来标记,或者把指针和缓冲区放在一个 union(联合体)里。
3. 实现要点
如果我们给之前的 Drawable 加 SBO,那么就需要改这几个地方:
3.1 定义缓冲区大小和对齐
c++
static constexpr size_t buffer_size = 32; // 预留 32 字节栈内存,存放小型对象
static constexpr size_t buffer_align = alignof(std::max_align_t); // 获取平台最大基础对齐,作为缓冲区对齐值
alignas(buffer_align) std::byte buffer[buffer_size]; // 声明一块对齐良好的原始内存缓冲区
一些说明:
- alignof(type-id):C++11 引入的运算符,返回由 type-id 指示的对齐要求(以字节为单位)。
- std::max_align_t:其对齐要求至少与所有标量类型一样严格(通常等于平台最大基础对齐,例如 8 或 16 字节)。
- std::byte:C++17 引入的字节类型,专门用于表示原始内存,比 char 语义更清晰。
3.2 修改概念基类
原来的 DrawConcept 只有 draw() 和虚析构。
为了支持本地存储,基类提供一个 clone() 方法,用于将自身拷贝到给定的目标缓冲区,否则返回一个新的堆对象指针。
不过我们也可以让模型类自己决定是放在堆上还是栈上。
外观类通过一个标志位(比如 bool is_small)来区分。
简化版:我们在外观类中直接通过类型判断是否用小对象。
模板模型类 DrawModel<T> 本身可以存储 T 对象,它的大小就是 sizeof(T)。
我们只需要决定 DrawModel<T> 对象放在哪,缓冲区还是堆。
一个小细节:DrawModel<T> 是一个完整的对象,包含虚表指针和 T。
所以判断大小应该是 sizeof(DrawModel<T>) 而不是 sizeof(T)。
因为虚表指针的存在使得小对象优化阈值要减去 sizeof(void*)。
不过我们只是简单实现,所以直接判断 sizeof(DrawModel<T>) <= buffer_size。
3.3 修改外观类,存储方式改为 union
c++
class Drawable
{
union Storage
{
alignas(buffer_align) std::byte local[buffer_size];
Storage() {}
~Storage() {}
} storage;
bool is_small; // true: 使用 local 缓冲区; false: 使用 heap
};
为了简化演示,union 就设计的简单些。
3.4 构造时的分支逻辑
c++
template<typename T>
Drawable(T&& t)
{
using Model = DrawModel<std::decay_t<T>>;
constexpr bool use_small = (sizeof(Model) <= buffer_size) &&
(alignof(Model) <= buffer_align) &&
std::is_nothrow_move_constructible_v<Model>; // 判断 Model 类型在进行移动构造时能否不抛出异常。
if constexpr (use_small)
{
// 小对象:placement new 到 local 缓冲区
new (&storage.local) Model(std::forward<T>(t));
ptr = reinterpret_cast<DrawConcept*>(&storage.local);
is_small = true;
} else
{
// 大对象:堆分配
ptr = new Model(std::forward<T>(t));
is_small = false;
}
}
ptr 是 Drawable 的一个 DrawConcept* 成员变量。
这样统一访问:ptr->draw()。
3.5 析构、拷贝、移动的分支
- 析构:如果 !is_small,delete ptr;否则需要显式调用析构函数(ptr->~DrawConcept()),因为 placement new 的对象不会自动析构。
- 拷贝构造:需要根据源对象的 is_small 决定是拷贝整个缓冲区还是 new 一个新对象。
- 移动构造:类似。
这些细节实现起来比较啰嗦,但核心就是分两条路处理。
4. 示例:为前面的 Drawable 添加 SBO
啰嗦了这么久,思路理的应该差不多了,现在我们就动手实现一个完整精简版示例:
概念基类
c++
struct DrawConcept
{
virtual void draw() const = 0;
virtual ~DrawConcept() = default;
virtual DrawConcept* clone() const = 0; // 堆上克隆(用于大对象)
virtual void copy_to(void* dest) const = 0; // 就地拷贝到 dest(用于小对象)
virtual void move_to(void* dest) = 0; // 移动同理
};
模型模板
c++
template<typename T>
struct DrawModel final : DrawConcept
{
T obj;
explicit DrawModel(T&& o) : obj(std::move(o)) {}
explicit DrawModel(const T& o) : obj(o) {}
void draw() const override { obj.draw(); }
DrawConcept* clone() const override { return new DrawModel(obj); }
void copy_to(void* dest) const override { new (dest) DrawModel(obj); }
void move_to(void* dest) override { new (dest) DrawModel(std::move(obj)); }
};
带 SBO 的 Drawable
c++
class Drawable
{
static constexpr size_t buffer_size = 32;
static constexpr size_t buffer_align = alignof(std::max_align_t);
// 存储
union Storage
{
alignas(buffer_align) std::byte local[buffer_size];
Storage() {}
~Storage() {}
} storage;
DrawConcept* ptr; // 统一接口指针
bool is_small; // true: 使用 local 缓冲区; false: 使用 heap
// 销毁当前对象
void destroy() noexcept
{
if (ptr) {
if (!is_small)
{
delete ptr; // 堆对象
}
else
{
ptr->~DrawConcept(); // 本地对象,显式调析构
}
ptr = nullptr;
}
}
// 深拷贝
void copy_from(const Drawable& other)
{
if (other.ptr == nullptr)
{
ptr = nullptr;
is_small = false;
return;
}
if (!other.is_small)
{
// 大对象:直接 clone 得到堆上对象
ptr = other.ptr->clone();
is_small = false;
}
else
{
// 小对象:placement new 到本地缓冲区
other.ptr->copy_to(&storage.local); // 拷贝构造
ptr = reinterpret_cast<DrawConcept*>(&storage.local);
is_small = true;
}
}
// 从另一个 Drawable 移动
void move_from(Drawable&& other) noexcept
{
if (other.is_small)
{
other.ptr->move_to(&storage.local);
ptr = reinterpret_cast<DrawConcept*>(&storage.local);
is_small = true;
other.ptr->~DrawConcept(); // 销毁源对象
other.ptr = nullptr;
}
else
{
// 大对象
ptr = other.ptr;
is_small = false;
other.ptr = nullptr;
}
}
public:
Drawable() : ptr(nullptr), is_small(false) {}
// 模板构造:决定用小对象还是堆
template<typename T, typename = std::enable_if_t<!std::is_same_v<std::decay_t<T>, Drawable>>>
Drawable(T&& t)
{
using Model = DrawModel<std::decay_t<T>>;
constexpr bool use_small = (sizeof(Model) <= buffer_size) &&
(alignof(Model) <= buffer_align) &&
std::is_nothrow_move_constructible_v<Model>;
if constexpr (use_small)
{
// 小对象:placement new 到 local 缓冲区
new (&storage.local) Model(std::forward<T>(t));
ptr = reinterpret_cast<DrawConcept*>(&storage.local);
is_small = true;
}
else
{
// 大对象:堆分配
ptr = new Model(std::forward<T>(t));
is_small = false;
}
}
// 拷贝构造
Drawable(const Drawable& other) { copy_from(other); }
// 移动构造
Drawable(Drawable&& other) noexcept { move_from(std::move(other)); }
// 拷贝赋值
Drawable& operator=(const Drawable& other)
{
if (this != &other)
{
destroy();
copy_from(other);
}
return *this;
}
// 移动赋值
Drawable& operator=(Drawable&& other) noexcept
{
if (this != &other)
{
destroy();
move_from(std::move(other));
}
return *this;
}
~Drawable() { destroy(); }
void draw() const { if (ptr) ptr->draw(); }
};
ps:上面代码为了展示 SBO 的结构,在移动和拷贝上做了简化。
所以对于 std::function 来说,大多数 lambda 都很小,有了 SBO,它们几乎永远不会触发堆分配,性能接近裸函数指针。
这就是为什么现代 C++ 里用 std::function 传 lambda 往往比我们想的要快。
补充说明
前面我们搭好了 Drawable 的基本骨架和 SBO,现在再进行些补充。
1. 多操作接口
实际场景中,被擦除的类型往往需要多个操作。
比如图形对象除了 draw(),可能还要 rotate()、scale()、serialize()。
类型擦除的威力在于:我们可以定义一组操作(一个概念),然后让模型模板实现它们。
在 Concept 基类里加更多纯虚函数
c++
struct DrawConcept
{
virtual ~DrawConcept() = default;
virtual void draw() const = 0;
virtual void rotate(double angle) = 0;
virtual void scale(double factor) = 0;
virtual std::vector<char> serialize() const = 0;
};
模型模板为每个操作转发:
c++
template<typename T>
struct DrawModel : DrawConcept
{
T obj;
// ... 构造等
void draw() const override { obj.draw(); }
void rotate(double angle) override { obj.rotate(angle); }
void scale(double factor) override { obj.scale(factor); }
std::vector<char> serialize() const override { return obj.serialize(); }
// ... clone/copy_to/move_to
};
如果我们有很多操作,每个 DrawModel 都要写一堆转发,很烦。
可以用一个辅助模板 ModelBase<T> 来自动生成转发,偷个懒就不写了。
2. 拷贝与移动语义
类型擦除的对象通常是值语义的,因此我们希望 Drawable 用起来像 int 一样:拷贝独立、移动高效。
那么需要哪些能力?
- 拷贝构造:深拷贝内部对象。
- 拷贝赋值:同上。
- 移动构造:转移所有权,源对象变为空。
- 移动赋值:同上。
前面我们写的 clone() 和 copy_to/move_to:
- 小对象的拷贝必须是非堆的
我们已经用 copy_to 解决了这个问题,在目标缓冲区 placement new 一个新对象。
- 移动后源对象的状态
对于小对象,move_to 会窃取资源,但源对象仍然存在于它的缓冲区中。
所以我们需要在移动后销毁源对象(调用析构)并标记源对象为空。
这里我们调用 other.destroy(),让 other 变为空 Drawable。
- 赋值操作符要考虑自赋值
虽然很少出现自赋值,但保险起见我们还是检查了一下。
详情请看拷贝赋值那部分代码。
- 移动赋值时,被赋值对象要先释放自己的资源
和拷贝赋值一样,但移动时可以直接窃取。
如果 this 已经持有资源,必须先释放,否则容易内存泄漏。
正确的顺序:destroy() → 然后从 other 移动资源 → 将 other 置空。
我们不能简单地交换指针,因为小对象涉及缓冲区。
详情请看移动赋值那部分代码。
3. 类型查询与安全向下转换
有时我们需要知道擦除后的对象原始类型,例如"如果它是 Circle,就调用 set_radius()"。
这是 std::any 和 std::variant 提供的功能。
我们可以先在 DrawConcept 中加入虚函数 const std::type_info& type() const。
然后模型模板实现它:
c++
const std::type_info& type() const override { return typeid(T); }
在外观类中提供 type() 和 is<T>():
c++
const std::type_info& type() const { return ptr->type(); }
template<typename T>
bool is() const
{
return type() == typeid(T);
}
对于 as() 的实现,我们也在 DrawConcept 中添加虚函数 void* get_target(const std::type_info&)。
在模型模板中比较类型并返回 &obj 或 nullptr。
c++
void* get_target(const std::type_info& ti) override
{
if (ti == typeid(T)) return &obj;
return nullptr;
}
然后是在外观类中:
c++
template<typename T>
T* as()
{
if (ptr && ptr->get_target(typeid(T)))
return static_cast<T*>(ptr->get_target(typeid(T)));
return nullptr;
}
使用示例:
c++
Drawable d = Circle{};
if (d.is<Circle>())
{
d.as<Circle>()->draw();
}
结尾
回顾一下吧:
我们从最初的手写 Drawable,到 std::function 和 std::any 的剖析,再到小对象优化和多操作接口。
我们会发现:
-
类型擦除的本质不是丢掉类型,而是有选择地遗忘。我们只忘记具体类型名,却牢牢记住了一组操作。
-
三种经典实现:
- std::function 擦除到可调用,是回调的救世主。
- std::any 擦除到可存可取,极大的提高了灵活性。
- std::variant 拒绝擦除,用编译期 union 换取极致性能。
-
小对象优化(SBO):只需加上 32 字节的栈缓冲区,就能让小对象避开堆分配。