C++ Templates

PART 1 THE BASICS

WHY TEMPLATES?

C++ 要求我们使用指定的类型来声明变量、函数 和 大部分其他实体。然而,很多代码对于不同的类型看起来都是一样的。比如,对于不同的数据结构,如 int 数组或 string 字符串向量,只要包含的类型可以相互比较,快速排序算法的实现在结构上看起来是一样的。

如果使用的编程语言不支持 通用性(genericity)的 特殊语言功能(special language feature),将不得不面临如下选择,坏的选择(bad alternatives):

  • 你可以为不同的数据类型一遍又一遍地进行着相同的行为声明;
  • 你可以为常见的基本类型(common base type)编写一段代码,例如 objectvoid*
  • 你可以使用特殊的预处理器。

如果使用其他语言,可能以前就做过一些或者所有上方所述的内容了。然而,这些方法的每一种都有其缺点:

  • 如果你一遍又一遍地实现着这些行为,毫无疑问是叠矩重规(reinvent the wheel)。你犯了相同的错误,并且你试图寻找避免复杂但能更好的算法,因为它们会制造出更多的错误。
  • 如果你为常见基本类编写代码,你会失去类型检查的好处。另外,类可能要求继承于(be derived from)能够使代码更加难以维护的特殊基础类。
  • 如果你使用特殊的预处理器,代码会被一些没有 作用范围(scope)和类型且能够造成 奇怪语义错误(strange semantic errors)的 ”愚蠢的文本替换机制“ 替代掉。

模板是能够解决重复代码编写问题且不会造成上述这些短板的方案。模板是为一种或更多尚未声明类型的函数或者类。当使用模板时,可以隐式或显式地将类型当做参数传递过去。因为模板是语言的特点,可以拥有全部的类型检查和作用范围的支持(full support of type checking and scope)。

在C++标准库中,几乎所有的代码都是模板代码。该库提供排序算法以对 对象、指定类型的值、数据结构(也叫做 容器类(Container Classes)) 进行排序,以管理指定类型的元素 和 字符串(被参数化的字符串类型)等等。

然而,以上仅是模板的使用入门,模板还允许 参数化行为(parameterize behavior) 以 优化代码(optimize code) 和 参数化信息(parameterize infomation)。

FUNCTION TEMPLATES

函数模板(function templates),即 参数化的函数(functions that are parameterized),因此代表了 一个函数系列(a family of functions)。

函数模板为不同的数据类型提供了相同的功能行为调用,或换句话说,代表了一个函数系列。该函数系列看起来就像个普通函数,除了函数的某些元素未被确定,因为这些元素被参数化了。

TEMPLATES DELARATION AND DEFINITION

EXAMPLE

以下样例 声明 了一个函数系列,且下面参数的类型被空置(is left open),如参数 T

1
2
3
4
5
template<typename T>
T max(T a, T b){
// if b < a then yield a else yield b
return b < a ? a : b;
}

在上述样例中,模板参数必须使用 template <comma-separated-list-of-parameters> 的格式进行声明。

此处的关键字 typename 代表着类型参数,这是迄今为止C++程序中最常见的一种模板参数,当然其他参数也可以,后续介绍。此处的类型参数是 T ,可以使用任意标识符当做参数名称,但使用 T 就是惯例而已。类型参数代表着 任意类型(arbitrary type),当函数调用时,由调用的函数决定具体数据类型。开发者可以调用任何类型,只要其提供模板使用的操作。

在上述样例中,类型 T 必须支持 操作符(operator) < ,因为 ab 使用该操作符进行比较,也许在 max() 的定义中很难发现,但要说明的是,T 类型必须是可复制的才能够被返回(T must be copyable in order to be returned)。

由于历史遗留原因,仍可以使用关键词 class 来定义类型参数。typename 关键字是在 C++98 标准之后才出现的,在那之前,class 是引入类型参数的唯一方法,至今仍可以使用。因此,上述模板也可以用下面的代码平替(在语义上没有区别)。

1
2
3
4
temelate<class T>
T max(T a, T b){
return b<a?a:b;
}

但因为 class 也是 的关键字,可能导致二义性,最好在声明模板时使用关键字 typename 。需要注意的是,与类声明不同,当声明类型参数时,关键字 struct 不能用来代替 typename

在C++17之前,类型 T 也必须是可复制的,以确保能够传递参数。但C++17之后,可以传递临时数(temporary),即使没有一个备份或者一个构造器可用(even if neither a copy nor a move constructor is valid)。

如何编写多个函数模板,需要注意什么?

同一个文件内可以编写多个不同的模板函数,如下:

1
2
3
4
5
6
7
8
template<typename T>
T Max(T a, T b){
return b<a?a:b;
}

template<typename t2>
t2 foo(t2*){
}

上面两个模板函数就在同一个文件下,typename 关键字后面的 T 或者 t2 需要具有唯一性。

USING TEMPLATES

需要注意的是,在使用该函数模板时,要在该函数前加上双冒号 :: ,以确保函数模板能够在全局命名空间中被查找到。如果出现下方的 call to ’sth.’ is ambiguous 错误, 即是说需要调用的函数模糊不清,因为标准库中也有一个 std::max(),编译器查找不到该函数具体是在哪个函数文件中。

image-20211005171952999

当然,以上错误也可以通过写不同的函数名称来避免。

模板并不是被编译成一个可以处理所有数据类型的实体,而是被编译成了所有不同数据类型的实体。 即,int max()short max()string max() 等等。单实体多适应 的模板虽然好像可行,但是实际上并不存在。所有的语言规则都遵循 ” 不同的模板参数生成不同的实体“ 的原则。

上面这种用具体类型取代模板参数的过程被称为 实体化(instantiation)。需要注意的是,仅仅是对函数模板的使用就可以触发该实体化过程,因此开发者就没有必要要求实体化过程单独进行(request the instantiation separately)。

另外,只要产生的代码是有效的,void 型也是可用的模板参数,例如:

1
2
3
4
template<typename T>
T foo(T*){

}

1
2
void* vp = nullptr;  // 引出 void foo(void*)
void(vp);

TWO-PHASE TRANSLATION

两段式编译,即Two-phase translation。

如果试图为一个不支持所有操作的模板进行实例化,将会导致编译时错误。

1
2
3
std::complex<float> c1, c2;  //doesn't provide operator <

::max(c1, c2); //ERROR at compile time

因此,模板在被编译时会经过如下两个阶段:

  1. 在定义且没有实例化时,忽略模板参数来检查自身代码的正确性:
    • 标点符号错误被发现,例如缺少分号 ;
    • 使用不依赖已知模板参数的未知命名(类型名,函数名等);
    • 不依赖于已检查模板参数的 静态断言(static assertions)
  2. 在实例化时,模板代码会被再次检查以确保可用,特别是依赖于模板参数的都会被二次检查(double-checked),例如:
1
2
3
4
5
6
7
template<typename T>
void foo(T t){
undeclared();
undeclared(t);
static_assert(sizeof(int>10, "int too small");
static_assert(sizeof(T>10, "T too small");
}

注意到某些编译器在第一阶段没有进行全面检查(don’t perform the full checks),所以直到在最后一阶段的模板代码实例化之前都没办法看到问题。

COMPILE AND LINK

在实际处理模板时,两段式编译会导致很多重要的问题:当函数模板被用于触发其实例化时,编译器(在某些点)需要查看模板定义。当一个函数的声明足以编译它时,就打破了普通函数通常的编译和连接的区别。

TEMPLATE ARGUMENT DEDUCTION

模板实参推断,即 Template argument deduction。

当给函数模板传递实参时,模板参数由我们传递过去的实参决定。如果传递的是两个 int 型实参给 参数类型 T ,则C++编译器就能推断出此时的 T 一定是 int

然而 T 可能只是该参数类型的一部分。例如,声明 max() 可以使用 常参(constant references)。

1
2
3
4
template<typename T>
T max(T const& a, T const& b){
return b<a?a:b;
}

如上方代码所示,传递 整型 int 参数,T 又会被推断为 int ,因为函数参数和 int const& 匹配。

类型推导过程中的类型转换

注意,自动类型转换被限制在类型推导期间:

  • 引用传递:当通过引用声明来调用参数(declaring call parameters by reference)时,即使是 微不足道的转换(trivial conersion) 也不适用于类型推导。用同一个模板参数 T 声明的两个参数类型必须完全匹配。
  • 按值传递:当按数值声明调用参数时,只支持 decay 的琐碎转换。带有const或volatile的限定被忽略,引用转换为被引用的类型,而原始数组或函数转换为相应的指针类型。对于用同一模板参数T声明的两个参数,decayed 的类型必须匹配。

错误提示:对int类型的非恒定值引用不能与int类型的临时值绑定

image-20211007035818560

前方已经说了,类型的自动转换相关注意事项,同一模板参数的两个参数类型必须完全匹配。如果在同一个 .cpp 文件中书写下面下面代码,则会出现下方报错提示 “推导类型冲突(deduced conflicting types for parameter ’T’)”。

1
2
Max(4, 7.2);
Max("Hello",s);

错误提示如下:

image-20211007040416472

但是如果非要使用不一样的数据类型的两个参数来套用同一模板,以下有三种解决方法:

  1. .cpp 文件中进行参数传递时,使用参数类型强制转换。如 Max(static_cast<double>(4), 7.2);

TERMINOLOGY

  1. 按值传递 passing by value
  2. 引用传递 passing by reference

REFERENCE

  1. <C++ Templates> David Vandecoorde