6.2. 何为类型擦除?

一般来说,类型擦除是一种编程技术,通过该技术将显式的类型信息从程序中移除。它是一种抽象形式,确保程序不显式地依赖于某些数据类型。

这个定义虽然完全正确,但也为类型擦除蒙上了一层神秘的面纱。它采用了某种循环论证的方式 —— 在你面前吊着一个看似不可能实现的希望:在一个强类型语言中编写一个不使用实际类型的程序。这怎么可能呢?当然是通过将类型抽象掉!于是,这种希望和神秘感便一直延续下去。

很难想象一个程序在使用类型的同时却不显式地提及(至少对于 C++ 程序是如此;当然也存在某些语言,其所有类型直到运行时才最终确定)。

因此,首先通过一个示例来演示类型擦除的真正含义。这将帮助我们直观地理解类型擦除的概念,并在本章后续部分逐步深化和严谨化这一理解。此处的目标是提高抽象层次 —— 与其为不同类型的对象编写特定的代码(可能需要编写多个版本),不如只编写一个更抽象的通用版本,以表达其核心概念。例如,不再编写一个接口为“对整数数组进行排序”的函数,而是希望编写一个更抽象的函数:“对任意数组进行排序”。

6.2.1 通过示例理解类型擦除

我们将详细解释什么是类型擦除以及如何在 C++ 中实现它。首先,来看看一个移除了显式类型信息的程序是什么样子。

从一个使用 std::unique_ptr 的非常简单的例子开始:

std::unique_ptr<int> p(new int(0));

这是一个拥有型指针(参见第3章) —— 包含此指针的实体(例如一个对象或一个函数作用域)也控制着整数的生命周期,并负责其删除。删除操作在代码中并不显式可见,会在 p 指针删除时发生(例如,当离开作用域时)。删除的具体实现方式在代码中也不显式可见 —— 默认情况下,std::unique_ptr 使用 operator delete 来删除其拥有的对象,或者更准确地说,是通过调用 std::default_delete,而后者又会调用 operator delete。

如果不想使用标准的 delete 操作怎么办?例如,可能有在自定义堆上分配的对象:

class MyHeap {
public:
  ...
  void* allocate(size_t size);
  void deallocate(void* p);
  ...
};

void* operator new(size_t size, MyHeap* heap) {
  return heap->allocate(size);
}

分配不是问题,借助重载的 operator new 即可实现:

MyHeap heap;
std::unique_ptr<int> p(new(&heap) int(0));

这种语法调用了双参数的 operator new 函数;第一个参数始终是大小,由编译器添加,第二个参数是堆指针。由于声明了这样的重载,将调用并返回从堆中分配的内存。

但尚未对对象的删除方式进行更改。将调用常规的 operator delete 函数,并试图将一些并非从全局堆分配的内存返还给全局堆。结果很可能是内存损坏,甚至导致程序崩溃。

我们可以定义一个带有相同参数的 operator delete 函数,但在这里对我们没有帮助 —— 与 operator new 不同,delete 操作没有地方可以传递参数(会看到这样定义的 operator delete 函数,应该具有这样的行为,但它与程序中你看到的 delete 操作都无关;仅在构造函数抛出异常时用于栈展开)。

我们需要以某种方式告诉这个独占指针,这个特定的对象需要以不同的方式删除。

事实证明,std::unique_ptr 有一个第二个模板参数。通常看不到它,其默认为 std::default_delete,但可以更改,并定义一个自定义的 deleter 对象来匹配分配机制。deleter 的接口非常简单 —— 需要是可调用的:

template <typename T> struct MyDeleter {
  void operator()(T* p);
};

std::default_delete 策略的实现大致如此,只是简单地在指针 p 上调用 delete。自定义删除器需要一个非简单的构造函数来存储指向堆的指针。虽然删除器通常需要能够删除可分配类型的对象,但其本身不必是模板类。只要类的数据成员不依赖于被删除的类型,一个带有模板成员函数的非模板类也同样适用。我们的例子中,数据成员仅依赖于堆的类型,而不依赖于删除的对象类型:

class MyDeleter {
  MyHeap* heap_;
public:
  MyDeleter(MyHeap* heap) : heap_(heap) {}
  template <typename T> void operator()(T* p) {
    p->~T();
    heap_->deallocate(p);
  }
};

删除器必须执行标准 operator delete 函数的两个功能 —— 必须调用删除对象的析构函数,然后必须释放为该对象分配的内存。

现在有了合适的删除器,终于可以在自己的堆上使用 std::unique_ptr 了:

// Example 01
MyHeap heap;
MyDeleter deleter(&heap);
std::unique_ptr<int, MyDeleter> p(
  new(&heap) int(0), deleter);

删除器对象通常在分配时按需创建:

MyHeap heap;
std::unique_ptr<int, MyDeleter> p(
  new(&heap) int(0), MyDeleter(&heap));

无论哪种方式,删除器都必须为不抛异常且可复制或不抛异常且可移动,必须具有复制构造函数或移动构造函数,并且该构造函数必须声明为 noexcept。内置类型(例如原始指针)当然是可复制的,且编译器生成的默认构造函数不会抛出异常。这些类型中的一个或多个作为数据成员组合而成的聚合类型(例如删除器),其默认构造函数也不会抛出异常(当然,除非重新定义过)。

删除器是 unique 指针类型的一部分。两个拥有相同类型对象但具有不同删除器的 unique 指针,属于不同的类型:

// Example 02
MyHeap heap;
std::unique_ptr<int, MyDeleter> p(
  new(&heap) int(0), MyDeleter(&heap));
std::unique_ptr<int> q(new int(0));
p = std::move(q); // 错误:p和q的类型不同

同样地,unique 指针必须使用正确类型的删除器进行构造:

std::unique_ptr<int> p(new(&heap) int(0),
  MyDeleter(&heap)); // 无法通过编译

顺便提一下,在尝试使用不同类型 unique 指针时,可能会注意到前面代码中的两个指针 p 和 q,虽然不能相互赋值,但却是可以比较的:p == q 能够编译通过。这是因为比较运算符实际上是一个模板 —— 可以接受两个不同类型的 unique 指针,并比较原始指针(如果底层指针类型也不同,编译错误信息可能根本不会提到 unique 指针,而是在没有类型转换的情况下,比较指向不同类型的指针)。

现在用共享指针 std::shared_ptr 来做同样的例子。首先,将共享指针指向一个使用常规 operator new 函数构造的对象:

std::unique_ptr<int> p(new int(0));
std::shared_ptr<int> q(new int(0));

作为对比,我们也保留了 unique 指针的声明。这两个智能指针的声明和构造方式完全相同。现在,在下面的代码块中,将共享指针指向一个在我们自定义堆上分配的对象:

MyHeap heap;
std::unique_ptr<int, MyDeleter> p(
  new(&heap) int(0), MyDeleter(&heap));
std::shared_ptr<int> q(
  new(&heap) int(0), MyDeleter(&heap));

即使使用了自定义删除器创建的共享指针,其类型也与使用默认删除器的共享指针完全相同!所有指向 int 的共享指针都具有相同的类型 std::shared_ptr<int> —— 该模板没有另一个模板参数。

删除器是在构造函数中指定的,但只在析构时使用,必须在智能指针对象内部将其存储起来,直到需要时使用。如果丢失了构造时传入的对象,就无法再恢复它。std::shared_ptr 和 std::unique_ptr 都必须在指针对象内部存储类型的删除器对象。但只有 std::unique_ptr 类将其删除器信息包含在类型中,而 std::shared_ptr 类对于所有删除器类型都是相同的。回到本节开头,使用 std::shared_ptr<int> 的程序没有删除器类型的显式信息。

这个类型信息已经从程序中擦除了,这就是类型擦除后程序的样子。

// Example 03
void some_function(std::shared_ptr<int>); // 无删除器
MyHeap heap;
{
  std::shared_ptr<int> p( // 类型中没有删除器
    new(&heap) int(0),
    MyDeleter(&heap)); // 删除器仅在构造函数中
  std::shared_ptr<int> q(p); // 地方都没有删除器类型
  some_function(p); // 使用 p,没有删除器
} // 删除发生时,调用MyDeleter

我们花了如此多的时间来剖析 std::shared_ptr,因为它提供了一个非常简单的类型擦除示例,特别是因为它可以与 std::unique_ptr 形成鲜明对比 —— 后者需要解决完全相同的问题,但却选择了相反的方法。然而,这个简单的例子并没有突出选择类型擦除所带来的设计影响,也没有说明这种模式解决了哪些设计问题。为此,我们应该看看 C++ 中最典型的类型擦除对象:std::function。

6.2.2 从示例到一般化

在 C++ 中,std::function 是一个通用的多态函数包装器,或者说是一个通用的可调用对象。它用于存储可调用的实体,例如函数、Lambda 表达式、函数对象(带有 operator() 的对象)或成员函数指针。这些不同可调用实体的唯一要求是它们必须具有相同的调用签名,即接受相同的参数并返回相同类型的值。签名在声明特定的 std::function 对象时指定:

std::function<int(long, double)> f;

我们刚刚声明了一个可调用对象,可以用两个参数(long 和 double,或者更准确地说,可转换为 long 和 double 的两个参数)来调用,并返回一个可以转换为 int 的结果。它对参数做什么,结果是什么?这由分配给 f 的具体可调用实体决定:

// Example 04
std::function<size_t(const std::string&)> f;
size_t f1(const std::string& s) { return s.capacity(); }

f = f1;
std::cout << f("abcde"); // 15

char c = 'b';

f = [=](const std::string& s) { return s.find(c); };
std::cout << f("abcde"); // 1

f = &std::string::size;
std::cout << f("abcde"); // 5

在这个例子中,首先将一个非成员函数 f1 赋值给 f;现在调用 f(s) 会返回字符串 s 的容量,这正是 f1 的功能。接着,将 f 更改为包含一个 Lambda 表达式;现在调用 f(s) 会执行该表达式。这两个函数唯一的共同点是它们的接口:它们接受相同的参数并具有相同的返回类型。最后,将一个成员函数指针赋值给 f;虽然函数 std::string::size() 不接受参数,但所有成员函数都有一个隐式的第一个参数,即对象本身的引用,因此它符合接口要求。

现在,可以看到类型擦除更普遍的形式:它是一种对多种不同实现的抽象,这些实现都提供了相同的行为。让我们思考一下它开启了哪些设计能力。