第15.10章 类型擦除:不用继承的 shape
1. 问题:装着不同东西的容器
::fast_io::vector<rectangle> 装不下 circle。
模板在编译期解析,vector 的元素类型也在编译期固定,所以“任意形状的集合”
需要别的办法。
在 C++ 里恰好只有两个答案:
- 带
virtual的继承 (第18章):一个声明虚函数的基类shape,再加vector<::std::unique_ptr<shape>>。 它能用,但它强迫每个具体形状去继承 —— 作者必须知道你的基类, 也不能让它只是一个装着两个数字的struct。 - 类型擦除,也就是本页:一个具体的、非模板的类
shape,装下任何满足某个概念的类型,并通过一张函数指针表分派。 没有继承、没有基类、没有virtual关键字,rectangle与circle一行都不用改。
第二种就是 Adobe 那套值语义库以及 Sean Parent“继承是万恶之基”演讲的做法, 本页把它完整造出来。
2. shape 必须做到什么
| 要求 | 做法 |
|---|---|
装下 rectangle、circle、square |
一个指向具体类型堆对象的 void * |
对被装的对象调用 area()、name()、grow() |
一个函数指针 struct,每个操作一个指针 |
| 像值一样工作:拷贝、移动、析构 | 再加两个函数指针:clone 与 destroy |
| 在具体类型已知处也能用 | 一个概念 shape_like,让同一套算法两条路都走得通 |
设计就这么点。那张函数指针表就是手写的 vtable;与 virtual
的差别在于,具体类型的任何信息都没有进入类型系统 —— rectangle 根本不知道
shape 存在。
3. 具体类型与概念
这里没有任何特殊之处。三个 struct,每个都有同样三个
noexcept 成员,每个都写得好像 shape 不存在:
struct rectangle
{
::std::float64_t w{}, h{};
[[nodiscard]] ::std::float64_t area() const noexcept { return w * h; }
[[nodiscard]] ::std::string_view name() const noexcept { return "rectangle"; }
void grow(::std::float64_t d) noexcept { w += d; h += d; }
};
struct square
{
::std::float64_t side{};
[[nodiscard]] ::std::float64_t area() const noexcept { return side * side; }
[[nodiscard]] ::std::string_view name() const noexcept { return "square"; }
void grow(::std::float64_t d) noexcept { side += d; }
};
struct circle
{
::std::float64_t r{};
[[nodiscard]] ::std::float64_t area() const noexcept { return ::std::numbers::pi * r * r; }
[[nodiscard]] ::std::string_view name() const noexcept { return "circle"; }
void grow(::std::float64_t d) noexcept { r += d; }
};
概念写明了 shape 需要什么,连错误契约一起:三个操作都是
noexcept,所以擦除后的版本也可以 noexcept
(第15.3章):
template <typename T>
concept shape_like = requires(T const c, T t, ::std::float64_t d) {
{ c.area() } noexcept -> ::std::same_as<::std::float64_t>;
{ c.name() } noexcept -> ::std::convertible_to<::std::string_view>;
{ t.grow(d) } noexcept -> ::std::same_as<void>;
};
同一个概念让算法的编译期版本能与擦除版本并存,既不共享代码也没有虚调用:
template <shape_like S, ::std::size_t N>
[[nodiscard]] inline ::std::float64_t total_area(S const (&arr)[N]) noexcept
{
::std::float64_t sum{};
for (auto const &s : arr)
{
sum += s.area();
}
return sum;
}
4. 手写的 vtable
五个函数指针。前三个是操作,后两个是让 shape 成为值类型、而不是借用引用的东西:
class shape
{
struct vtable
{
::std::float64_t (*area)(void const *) noexcept;
::std::string_view (*name)(void const *) noexcept;
void (*grow)(void *, ::std::float64_t) noexcept;
void *(*clone)(void const *) throws;
void (*destroy)(void *) noexcept;
};
clone 是 throws,因为它要分配;另外四个是
noexcept,这是概念已经保证的。void * 形参是擦除的代价:
表里的函数不可能知道具体类型,所以它们要转回去;这个转换是安全的,因为表与指针总是被
一起设置。
每个具体类型生成一张表,靠一个 static constexpr 成员完成,它的初值是一组
无捕获 lambda 转成的函数指针(前面的 + 强制发生这次转换):
template <typename T>
static constexpr vtable table_for{
+[](void const *p) noexcept -> ::std::float64_t { return static_cast<T const *>(p)->area(); },
+[](void const *p) noexcept -> ::std::string_view { return static_cast<T const *>(p)->name(); },
+[](void *p, ::std::float64_t d) noexcept { static_cast<T *>(p)->grow(d); },
+[](void const *p) throws -> void * { return allocate_and_copy<T>(*static_cast<T const *>(p)); },
+[](void *p) noexcept
{
static_cast<T *>(p)->~T();
allocator::deallocate_aligned_n(p, alignof(T), sizeof(T));
}};
这是 shape 里唯一的模板,每个实例化只是五个一行的转发薄片 ——
真正干活的东西留在具体类型里,这就是把
第15.9章的去模板化规则用在了分派上。
5. 存储:每个形状一次分配
被擦除的对象住在堆上,用 fast_io 的分配器
(::fast_io::native_global_allocator)而不是 ::new 分配,
这样分配失败会变成行为明确的赫布异常,对齐也是显式的:
using allocator = ::fast_io::native_global_allocator;
template <typename T>
[[nodiscard]] static void *allocate_and_copy(T const &v) throws
{
auto const res{allocator::allocate_aligned_at_least(alignof(T), sizeof(T))};
void *const p{res.ptr};
::new (p) T(v);
return p;
}
allocate_aligned_at_least(alignof(T), sizeof(T)) 返回
{ptr, count},并且可能抛 ::std::error;
deallocate_aligned_n(p, align, n) 是配套的 noexcept 释放,
这就是析构薄片可以 noexcept 的原因。定位 new 在原始存储上构造,
没有别的分配发生。
每个形状一次堆分配是这套设计诚实的代价。替代方案 —— 一个足够大的 union —— 省掉分配, 但一旦某个形状比 union 大、或对齐是你没预料到的值,它就崩了。在性能剖析说别的之前, 选堆。
6. 再一次五法则
第14章说过:拥有资源的类必须回答五个问题。
shape 拥有一个堆对象,所以它面对同样五个,由 clone 与
destroy 干活:
| 成员 | 契约 | 实现 |
|---|---|---|
| 构造函数 | throws |
分配、构造、记下表 |
| 拷贝构造 | throws |
用 clone 复制被持有的对象 |
| 移动构造 | noexcept |
接管指针与表,把源置空 |
| 拷贝赋值 | throws |
拷贝并交换(第14.8章) |
| 移动赋值 | noexcept |
交换(第14.6章) |
| 析构函数 | noexcept |
destroy 被持有的对象 |
两个不变式撑起这个类,并且每个成员都保持它们:ptr_ 与 vt_
要么都为空、要么都有效;表与 ptr_ 后面的对象恰好匹配。
这就是 swap 只交换这两个成员的原因。
7. 完整程序
#include <fast_io.h>
#include <stdfloat>
#include <numbers>
#include <string_view>
#include <utility>
#include <concepts>
struct rectangle
{
::std::float64_t w{}, h{};
[[nodiscard]] ::std::float64_t area() const noexcept { return w * h; }
[[nodiscard]] ::std::string_view name() const noexcept { return "rectangle"; }
void grow(::std::float64_t d) noexcept { w += d; h += d; }
};
struct square
{
::std::float64_t side{};
[[nodiscard]] ::std::float64_t area() const noexcept { return side * side; }
[[nodiscard]] ::std::string_view name() const noexcept { return "square"; }
void grow(::std::float64_t d) noexcept { side += d; }
};
struct circle
{
::std::float64_t r{};
[[nodiscard]] ::std::float64_t area() const noexcept { return ::std::numbers::pi * r * r; }
[[nodiscard]] ::std::string_view name() const noexcept { return "circle"; }
void grow(::std::float64_t d) noexcept { r += d; }
};
template <typename T>
concept shape_like = requires(T const c, T t, ::std::float64_t d) {
{ c.area() } noexcept -> ::std::same_as<::std::float64_t>;
{ c.name() } noexcept -> ::std::convertible_to<::std::string_view>;
{ t.grow(d) } noexcept -> ::std::same_as<void>;
};
template <shape_like S, ::std::size_t N>
[[nodiscard]] inline ::std::float64_t total_area(S const (&arr)[N]) noexcept
{
::std::float64_t sum{};
for (auto const &s : arr)
{
sum += s.area();
}
return sum;
}
class shape
{
struct vtable
{
::std::float64_t (*area)(void const *) noexcept;
::std::string_view (*name)(void const *) noexcept;
void (*grow)(void *, ::std::float64_t) noexcept;
void *(*clone)(void const *) throws;
void (*destroy)(void *) noexcept;
};
using allocator = ::fast_io::native_global_allocator;
template <typename T>
[[nodiscard]] static void *allocate_and_copy(T const &v) throws
{
auto const res{allocator::allocate_aligned_at_least(alignof(T), sizeof(T))};
void *const p{res.ptr};
::new (p) T(v);
return p;
}
template <typename T>
static constexpr vtable table_for{
+[](void const *p) noexcept -> ::std::float64_t { return static_cast<T const *>(p)->area(); },
+[](void const *p) noexcept -> ::std::string_view { return static_cast<T const *>(p)->name(); },
+[](void *p, ::std::float64_t d) noexcept { static_cast<T *>(p)->grow(d); },
+[](void const *p) throws -> void * { return allocate_and_copy<T>(*static_cast<T const *>(p)); },
+[](void *p) noexcept
{
static_cast<T *>(p)->~T();
allocator::deallocate_aligned_n(p, alignof(T), sizeof(T));
}};
void *ptr_{};
vtable const *vt_{};
public:
template <shape_like T>
explicit shape(T v) throws : ptr_{allocate_and_copy<T>(v)}, vt_{&table_for<T>}
{
}
shape(shape const &other) throws
: ptr_{other.ptr_ == nullptr ? nullptr : other.vt_->clone(other.ptr_)}, vt_{other.vt_}
{
}
shape(shape &&other) noexcept : ptr_{other.ptr_}, vt_{other.vt_}
{
other.ptr_ = nullptr;
other.vt_ = nullptr;
}
shape &operator=(shape const &other) throws
{
shape tmp{other};
swap(tmp);
return *this;
}
shape &operator=(shape &&other) noexcept
{
swap(other);
return *this;
}
~shape() noexcept
{
if (ptr_ != nullptr)
{
vt_->destroy(ptr_);
}
}
void swap(shape &other) noexcept
{
::std::swap(ptr_, other.ptr_);
::std::swap(vt_, other.vt_);
}
[[nodiscard]] ::std::float64_t area() const noexcept { return vt_->area(ptr_); }
[[nodiscard]] ::std::string_view name() const noexcept { return vt_->name(ptr_); }
void grow(::std::float64_t d) noexcept { vt_->grow(ptr_, d); }
};
int main()
{
try
{
shape a{rectangle{3.0, 4.0}};
shape b{circle{3.0}};
shape e{square{2.0}};
::fast_io::io::println(a.name(), " ", a.area());
::fast_io::io::println(b.name(), " ", b.area());
::fast_io::io::println(e.name(), " ", e.area());
a.grow(1.0);
::fast_io::io::println(a.name(), " ", a.area());
shape c{a};
c.grow(1.0);
::fast_io::io::println("copy: ", a.area(), " ", c.area());
rectangle const rects[2]{rectangle{1.0, 2.0}, rectangle{3.0, 4.0}};
::fast_io::io::println("total_area(rects)=", total_area(rects));
shape d{::std::move(c)};
::fast_io::io::println("moved: ", d.name(), " ", d.area());
}
catch throws (::std::error e)
{
::fast_io::io::perrln(e);
}
}
输出:
rectangle 12
circle 28.274333882308138
square 4
rectangle 20
copy: 20 30
total_area(rects)=14
moved: rectangle 30
把这段运行记录当清单读:一个类里装三种具体类型;拷贝是独立的(20 与
30);移动保住了值;编译期算法仍然能用在普通
rectangle 数组上。
8. 擦除与继承:诚实的对比
带 virtual 的继承 |
类型擦除(本页) | |
|---|---|---|
| 具体类型必须知道基类 | 是 —— 它们继承自它 | 不 —— 任何满足概念的类型都行 |
| 增加一个操作 | 改基类,全部重编译 | 改表与类;具体类型不动 |
| 增加一个具体类型 | 必须写成派生类 | 在任何地方写都行:shape s{my_new_thing{}}; |
| 值语义 | 没有 —— 得靠指针、克隆、小心翼翼的归属管理 | 有 —— 可拷贝、可移动、可析构;能放进
::fast_io::vector |
| 侵入性 | 基类拥有接口 | 概念拥有接口;不改任何东西 |
| 每次调用代价 | 一次虚分派 | 一次经表的间接调用 |
| 与库兼容 | 第三方类型不写包装就没法适配 | 加一个表实例化即可适配 |
两种方案的代价相同:一次间接调用,而分支预测器通常处理得很好,因为一直用的是同一张表。 变的是“谁必须配合”。这就是为什么值语义类型擦除是值的默认选择 —— 形状、颜色、日志器、分配器 —— 而当接口真的是一套被他人扩展的层次结构时,继承才是对的工具。
9. 有时你根本不需要擦除
在写函数指针表之前,先确认类型真的必须在运行期决定。三个更便宜的答案,按优先级排列:
- 模板。一个调用点只处理一个具体类型时,模板更快(可内联、无分配)——
就是上面的
total_area。 - variant。编译期已知的封闭类型集合可以用
::std::variant加访问者;没有分配、没有表,而且编译器会检查穷尽性。 - 概念加边界处的一次类型切换。只在做决定的地方擦除一次, 它下面的部分全部保持模板。
在边界处擦除,不要在中间擦除。如果程序每一层都被类型擦除,它会付掉全部代价而拿不到任何好处 —— 第11.17章对 I/O 分层讲过同样的道理。