C++ 模板(二)Type Erasure: 解决 C++ 模板 Code Bloat 问题

The Fundamental Software Engineering Rule: Any software engineering problems can be solved by adding another layer.

这篇文章是我学习 C++ type erasure 的笔记,其中主要权衡了「代码生成」和「类型擦除」这两种泛型方式的优劣,但没有具体给出怎样在 C++ 中实现。这是因为实现方法已经在 C++ Templates 一书中详细地给出了。

两种泛型策略:「代码生成」和「类型擦除」

C++ 中常用的泛型(模板),其基本思想是在模板中把某些类型当作一个待填充的参数 [1],而在模板实例化的时候,把相应类型填充到模板类型参数被使用的位置上,从而生成一个独特的具体的类型。换句话说,一个 C++ 模板并不是一个具体的类型,而是用来生成类型的「蓝图」。使用时,把模板参数填充进去,编译器就按照这个蓝图为我们生成相应类型(注意每个函数和类都是独立的类型)。从这一点上看,std::vector<int>std::vector<std::string> 是两个不同的类型。即使它们之间共用一些逻辑,C++ 也会分别生成两套不相关的代码。在这篇文章中,我把这种策略简称为「代码生成」。

另一种实现泛型的方式是「类型擦除」(Type Erasure)。Java 语言的泛型使用的便是这种方法。它的基本思想是使用一个「通用」的基本类型实现通用逻辑,而遇到类型相关的操作 [2] 时再把这个「通用类型」转换为具体的类型。Java 语言中几乎所有对象都继承自 Object,所以天然地方便使用「类型擦除」来实现泛型。

如果你使用过 C 语言,或许会对 void* 有印象。为了在 C 语言中实现泛型,我们在通用的逻辑里使用 void* 指针指向任意的类型,然后在针对具体类型使用的时候再强制转换。这已经有了「类型擦除」的雏形。一个例子是 <stdlib.h> 里的 qsort。它接受 void* 类型的参数等实现通用逻辑,而用户需要提供一个 cmp 函数,用来进行具体的「类型相关」的比较。

#include <string.h>
#include <stdlib.h>

// An example of comparing two C-style strings.
int cmp(void const* lhs_, void const* rhs_) {
  char const* const* lhs = (char const* const*)lhs_;
  char const* const* rhs = (char const* const*)rhs_;
  return strcmp(*lhs, *rhs);
}

当然 C 中这种泛型方式很粗糙,也很易错(你能说清 qsort 二维数组的原理吗?)。而在如 Java 这样的语言中,编译器可以自动实现泛型类型转换相关的逻辑。比如,对 ArrayList<String> 中的某个 String 调用 append,第一步是 ArrayList 中的泛型代码(通用代码)把 Object 的引用转成 String 的引用(这一步由 Java 编译器为我们代劳了,不需要像 C 那样手动);第二步是调用 String 的成员函数。可以用下面的示例来理解:

ArrayList primitiveArr = getStringArrayList();
ArrayList<String> autoConvertedArr = getStringArrayList();

// get the 2nd element in the ArrayList (It is de facto a reference to an Object, not String)
// and convert it to String.
String str = (String)primitiveArr.get(1);

// And then append a character
str.append('a');

// The above two steps can be written in this:
autoConvertedArr.get(1).append('a');

我不打算具体讲 Java 中泛型特性如何实现和使用,只是为了说明类型擦除的大概原理。

[1]:C++ 模板参数也可以不是类型,这里是为了说明方便。

[2]:类型相关的操作,指需要些类型满足的特性。比如,看下面这个泛型函数:

template <typename T>
T add(T a, T b) {
  puts("Calling .add(T rhs) in generic function");   // (1)
  return a.add(b);                                   // (2)
}

此处类型相关的操作就是 (2) 处的 .add(T rhs)。类型 T 必须有一个名为 add 的成员函数,且接受一个 T 类型的参数。在类型擦除的泛型策略中,此处就必须要把 ab 转换为具体类型 T,然后调用 .add(T rhs)。而 (1) 就是共用的类型无关的代码。在 C++ 中,这条语句在每个 add<T> 中都会被生成一份,而在类型擦除的泛型策略中不会有这种 overhead。

我在上面的例子中忽略了左右引用、const volatile 等细节。

两种策略的比较

如果语言没有泛型支持,那么我们可能需要为雷同的逻辑实现两份代码:

void swap_int(int* __restrict__ lhs, int* __restrict__ rhs) {
  if (lhs == rhs) { return; }
  auto temp = *lhs;
  *lhs = *rhs;
  *rhs = temp;
}

// Let's ignore the member function swap of std::string
void swap_string(std::string* __restrict__ lhs, std::string* __restrict__ rhs) {
  if (lhs == rhs) { return; }
  auto temp = std::move(*lhs);
  *lhs = std::move(*rhs);
  *rhs = std::move(temp);
}

This is tedious。使用 C++ 模板可以只写一份代码:

// Note that this is rather coarse
template <typename T>
void swap_string(T* __restrict__ lhs, T* __restrict__ rhs) {
  if (lhs == rhs) { return; }
  auto temp = std::move(*lhs);
  *lhs = std::move(*rhs);
  *rhs = std::move(temp);
}

为了方便写模板代码,C++ 中对 int 等栈上原始类型也可以取右值引用等,虽然这没什么特殊效果,但免去了写特例的麻烦

C++ 编译器会为 intstd::string 分别生成对应的代码,最终得到的可执行文件里的代码与第一个块中手写的差别不会很大。

像这样的「代码生成」的好处有:

  • 最终生成的代码里不会产生从参数 T 到具体类型的转换——可以避免这个过程中出现的异常等。当然,对于内置类型,我们没有这种担忧。
  • 类型信息得到保留,在继承等方面有一定优势(可以保留子类向基类转换的特性等;如果擦除了类型,一个 Array<Derived> 无法得知其中元素是否能转为 Base)。但这也带来一些问题,我将在下面进行讨论。
  • 方便类型重载。如果我们希望为 swap<int>swap<std::string> 提供不同的实现,只需要对模板特化(详情可以查询相关资料)即可。

代码生成的最明显的坏处就是「代码膨胀」(Code Bloat) 了。即使是相同的逻辑,C++ 也会为其生成独立的代码。对于 swap 这样的小函数,问题不大(事实上基本没有 overhead,因为此函数内很大一部分代码都是类型相关的)。但如果是较大的函数,且类型无关的代码较多,那么将生成巨量的无用代码(overhead),便程序体积增大,甚至影响缓存性能。

其它问题包括编译速度问题和源代码可见性的问题。如果使用 C++ 模板编写库,那么库的使用者必须能够看到模板源代码;因为模板只是一份蓝图,需要把使用处的具体类型(如 std::string)填充进模板去编译,所以模板代码不能封装。C++ 尝试过一些如 export 的机制,但最终不了了之。

而类型擦除只会为每一个泛型类型生成一套代码,而不是生成多份。这使得编译过程变得简单,也使得模板提前编译成为可能。libc 库中的 qsort 等就是一个例子(它们可能是以已编译的形式提供,头文件里只有函数声明)。它的灵魂在于:通用的逻辑中使用通用的类型,而涉及到类型相关的逻辑时又使用具体类型。

这种特性还使得模板可以应用在虚函数上。众所周知,虚函数的调用是在「运行时」决定的。在编译时,我们不能知道到底调用了哪个虚函数。然而,C++ 模板又必须在编译时生成,所以 C++ 模板不能应用在虚函数上(不然就会在运行时调用不存在的函数)。使用类型擦除实现泛型的 Java 则没有这个问题。

但是,类型擦除在效率上不如代码生成。这不仅仅是因为类型转换(事实上这个开销可能较小),更是因为我们无法在这种泛型代码中直接使用栈变量:

template <typename T>
void func(T a) {
  T temp; // How much space does this occupy?
}

假设 C++ 泛型使用类型擦除的方法实现,那么生成的唯一的通用代码中,temp 应该占多大空间?更具体的,temp 应该如何构造?如何析构?这将成为不可能完成的任务。换句话说,类型擦除的泛型几乎必然使用间接访问方法和堆内存(指针、引用等)。所以,这种策略更适合用在经典的如 Java 那样的 OOP 体系上。

Java 的函数参数只能是引用,于是根本不存在这个问题了

在 C++ 中,我们可以通过一些手段(加中间层),从而以类型擦除的方法实现泛型。这部分详见 C++ Templates 相关章节。

使用类型擦除的场景一般如下:

  • 代码膨胀问题很严重。我们不希望重量级的代码被反复生成,这时可以使用类型擦除。举个例子,std::function 就是使用类型擦除的方法实现的。这是因为函数可能有很多种,甚至是 lambda 和可调用对象(这种对象可能很重型)。为这些每种类型都生成一份代码的代价不可忽略。
  • 支持动态语言特性。比如 std::any 等。
posted @ 2020-08-19 18:33  seideun  阅读(503)  评论(0)    收藏  举报