第1章: 模板简介
作为一名C++开发者,你至少应该对模板元编程有所了解,如果不是精通的话。模板元编程是一种编程技术,它使用模板作为蓝图,让编译器生成代码,帮助开发者避免编写重复的代码。尽管通用库大量使用模板,但C++语言中模板的语法和内部工作机制可能会令人望而却步。即使是由C++语言的创造者比亚尼·斯特劳斯特卢普和C++标准化委员会主席赫布·萨特编辑的C++核心准则,也称模板为“相当可怕”。
本书旨在阐明C++语言中这一领域,并帮助你在模板元编程方面变得熟练。
在本章中,我们将讨论以下主题:
- 理解模板的需求
- 编写你的第一个模板
- 理解模板术语
- 模板的简史
- 模板的优缺点
学习如何使用模板的第一步是理解它们实际解决的问题。让我们从这里开始。
理解模板的需求
每个语言特性都旨在帮助解决开发者在使用该语言时遇到的问题或任务。模板的目的是帮助我们避免编写只有细微差别的重复代码。
为了举例说明,让我们来看一个max
函数的经典例子。这样的函数接受两个数值参数,并返回两者中的较大值。我们可以轻松实现如下:
int max(int const a, int const b)
{
return a > b ? a : b;
}
这个实现效果很好,但正如你所见,它只适用于int
类型的值(或那些可以转换为int
的值)。如果我们需要相同的函数,但参数类型为double
呢?然后,我们可以为double
类型重载这个函数(创建一个名称相同但参数数量或类型不同的函数):
double max(double const a, double const b)
{
return a > b ? a : b;
}
然而,int
和double
并不是唯一的数值类型。还有char
、short
、long
、long
及其无符号对应类型unsigned char
、unsigned short
、unsigned long
和unsigned long
。还有float
和long double
类型。以及其他类型,如int8_t
、int16_t
、int32_t
和int64_t
。而且可能还有其他可以进行比较的类型,如bigint
、Matrix
、point2d
,以及任何重载了operator>
的用户定义类型。通用库如何为所有这些类型提供像max
这样的通用函数?它可以为所有内置类型和可能的其他库类型重载该函数,但不能对任何用户定义类型这样做。
与其使用不同参数重载函数,不如使用void*
传递不同类型的参数。请记住,这是一种糟糕的做法,以下示例仅作为一个没有模板的世界中的可能替代方案展示。然而,为了讨论,我们可以设计一个排序函数,它将对任何可能类型的元素数组运行快速排序算法。快速排序算法的细节可以在网上查找,比如在维基百科上 https://en.wikipedia.org/wiki/Quicksort。
快速排序算法需要比较和交换任意两个元素。但是,由于我们不知道它们的类型,所以实现无法直接做到这一点。解决方案是依赖回调函数,这是作为参数传递的函数,将在必要时调用。可能的实现如下:
using swap_fn = void(*)(void*, int const, int const);
using compare_fn = bool(*)(void*, int const, int const);
int partition(void* arr, int const low, int const high,
compare_fn fcomp, swap_fn fswap)
{
int i = low - 1;
for (int j = low; j <= high - 1; j++)
{
if (fcomp(arr, j, high))
{
i++;
fswap(arr, i, j);
}
}
fswap(arr, i + 1, high);
return i + 1;
}
void quicksort(void* arr, int const low, int const high,
compare_fn fcomp, swap_fn fswap)
{
if (low < high)
{
int const pi = partition(arr, low, high, fcomp,
fswap);
quicksort(arr, low, pi - 1, fcomp, fswap);
quicksort(arr, pi + 1, high, fcomp, fswap);
}
}
为了调用quicksort
函数,我们需要为传递给该函数的每种类型的数组提供比较和交换函数的实现。以下是int
类型的实现:
void swap_int(void* arr, int const i, int const j)
{
int* iarr = (int*)arr;
int t = iarr[i];
iarr[i] = iarr[j];
iarr[j] = t;
}
bool less_int(void* arr, int const i, int const j)
{
int* iarr = (int*)arr;
return iarr[i] <= iarr[j];
}
有了这些定义,我们可以编写如下代码,对整数数组进行排序:
int main()
{
int arr[] = { 13, 1, 8, 3, 5, 2, 1 };
int n = sizeof(arr) / sizeof(arr[0]);
quicksort(arr, 0, n - 1, less_int, swap_int);
}
这些示例侧重于函数,但同样的问题也适用于类。考虑到你想编写一个类,用来模拟具有可变大小的数值集合,并将元素连续地存储在内存中。你可以提供以下实现(这里只草拟了声明),用于存储整数:
struct int_vector
{
int_vector();
size_t size() const;
size_t capacity() const;
bool empty() const;
void clear();
void resize(size_t const size);
void push_back(int value);
void pop_back();
int at(size_t const index) const;
int operator[](size_t const index) const;
private:
int* data_;
size_t size_;
size_t capacity_;
};
这一切看起来都很好,但当你需要存储double
类型、std::string
类型或任何用户定义类型的值时,你将不得不编写相同的代码,每次只更改元素的类型。这是没人想做的事情,因为这是重复的工作,而且当需要更改某些内容(例如添加新功能或修复错误)时,你需要在多个地方应用相同的更改。
最后,尽管不那么常见,但在需要定义变量时也会遇到类似的问题。让我们考虑一个保存换行字符的变量的情况。你可以如下声明它:
constexpr char NewLine = '\n';
如果你需要相同的常量,但用于不同的编码,比如宽字符串字面量、UTF-8等呢?你可以有多个变量,名称不同,例如以下示例:
constexpr wchar_t NewLineW = L'\n';
constexpr char8_t NewLineU8 = u8'\n';
constexpr char16_t NewLineU16 = u'\n';
constexpr char32_t NewLineU32 = U'\n';
模板是一种技术,它允许开发者编写蓝图,使编译器为我们生成所有这些重复的代码。在接下来的章节中,我们将看到如何将前面的代码片段转换为C++模板。
编写你的第一个模板
现在是时候看看如何在C++语言中编写模板了。在本节中,我们将从三个简单的示例开始,每个示例对应之前介绍的代码片段。
先前讨论的max
函数的模板版本如下所示:
template <typename T>
T max(T const a, T const b)
{
return a > b ? a : b;
}
你会注意到这里的类型名称(如int
或double
)已被T
替换(代表类型)。T
称为类型模板参数,通过语法template<typename T>
或template<class T>
引入。请记住,T
是一个参数,因此它可以有任何名称。我们将在下一章更多地了解模板参数。
此时,你在源代码中放置的模板只是一个蓝图。编译器将根据其使用情况从中生成代码。更确切地说,它会为模板使用的每种类型实例化一个函数重载。这里有一个示例:
struct foo{};
int main()
{
foo f1, f2;
max(1, 2); // OK, 比较 ints
max(1.0, 2.0); // OK, 比较 doubles
max(f1, f2); // 错误, foo 类型没有重载 operator>
}
在这个片段中,我们首先用两个整数调用max
,这是可以的,因为operator>
适用于int
类型。这将生成一个重载int max(int const a, int const b)
。其次,我们用两个双精度浮点数调用max
,这同样是可以的,因为operator>
适用于双精度浮点数。因此,编译器将生成另一个重载double max(double const a, double const b)
。然而,第三次调用max
将产生编译器错误,因为foo
类型没有重载operator>
。
在此阶段不深入细节,但应该提到调用max
函数的完整语法如下:
max<int>(1, 2);
max<double>(1.0, 2.0);
max<foo>(f1, f2);
编译器能够推断出模板参数的类型,使得写出它变得多余。然而,在某些情况下,这是不可能的;在这些情况下,你需要使用这种语法明确指定类型。
前一节理解模板的需求中涉及函数的第二个示例是处理void*
参数的quicksort()
实现。这个实现可以很容易地转换成一个模板版本,只需很少的改动。以下是代码片段:
template <typename T>
void swap(T* a, T* b)
{
T t = *a;
*a = *b;
*b = t;
}
template <typename T>
int partition(T arr[], int const low, int const high)
{
T pivot = arr[high];
int i = (low - 1);
for (int j = low; j <= high - 1; j++)
{
if (arr[j] < pivot)
{
i++;
swap(&arr[i], &arr[j]);
}
}
swap(&arr[i + 1], &arr[high]);
return i + 1;
}
template <typename T>
void quicksort(T arr[], int const low, int const high)
{
if (low < high)
{
int const pi = partition(arr, low, high);
quicksort(arr, low, pi - 1);
quicksort(arr, pi + 1, high);
}
}
使用quicksort
函数模板的方法与我们之前看到的非常相似,只是不需要传递回调函数的指针:
int main()
{
int arr[] = { 13, 1, 8, 3, 5, 2, 1 };
int n = sizeof(arr) / sizeof(arr[0]);
quicksort(arr, 0, n - 1);
}
我们在前一节中看到的第三个示例是vector
类。其模板版本如下所示:
template <typename T>
struct vector
{
vector();
size_t size() const;
size_t capacity() const;
bool empty() const;
void clear();
void resize(size_t const size);
void push_back(T value);
void pop_back();
T at(size_t const index) const;
T operator[](size_t const index) const;
private:
T* data_;
size_t size_;
size_t capacity_;
};
与max
函数的情况一样,更改很小。在类上方有模板声明,元素的类型int
已被类型模板参数T
替换。这个实现可以如下使用:
int main()
{
vector<int> v;
v.push_back(1);
v.push_back(2);
}
这里需要注意的一点是,我们在声明变量v
时必须指定元素的类型,这在我们的片段中是int
,因为否则编译器将无法推断出它们的类型。在C++17中有可能做到这一点,这个主题,称为类模板参数推断,将在第4章,高级模板概念中讨论。
第四个也是最后一个示例涉及到的是仅类型不同的几个变量的声明。我们可以用一个模板替换所有这些变量,如下所示:
template<typename T>
constexpr T NewLine = T('\n');
此模板的使用方式如下:
int main()
{
std::wstring test = L"demo";
test += NewLine<wchar_t>;
std::wcout << test;
}
本节中的示例表明,无论模板代表的是函数、类还是变量,声明和使用模板的语法都是相同的。这引导我们进入下一节,我们将讨论模板的类型和模板术语。
理解模板术语
在本章到目前为止,我们使用了通用术语模板。然而,我们编写的模板有四个不同的术语描述:
- 函数模板是用于模板化函数的术语。例如之前看到的
max
模板。 - 类模板是用于模板化类的术语(可以用
class
、struct
或union
关键字定义)。例如我们在上一节中编写的vector
类。 - 变量模板是用于模板化变量的术语,如上一节中的
NewLine
模板。 - 别名模板是用于模板化类型别名的术语。我们将在下一章中看到别名模板的例子。
模板是用一个或多个参数参数化的(在我们到目前为止看到的例子中,有一个参数)。这些被称为模板参数,可以分为三类:
- 类型模板参数,如
template<typename T>
中的参数,当使用模板时,参数代表一个指定的类型。 - 非类型模板参数,如
template<size_t N>
或template<auto n>
,其中每个参数必须有一个结构类型,包括整数类型、浮点类型(如C++20)、指针类型、枚举类型、左值引用类型等。 - 模板模板参数,如
template<typename K, typename V, template<typename> typename C>
,其中参数的类型是另一个模板。
模板可以通过提供替代实现来专门化。这些实现可以取决于模板参数的特征。专门化的目的是启用优化或减少代码膨胀。有两种形式的专门化:
- 部分专门化:这是为一些模板参数提供的替代实现。
- (显式)完全专门化:当提供了所有模板参数时,模板的一种专门化。
编译器从模板生成代码的过程称为模板实例化。这通过用模板参数替代模板定义中使用的模板参数来实现。例如,在我们使用vector<int>
的例子中,编译器在每个出现T
的地方替换了int
类型。
模板实例化可以有两种形式:
- 隐式实例化:当编译器由于代码中的使用而实例化一个模板时发生。这只发生在使用的参数组合上。例如,如果编译器遇到了
vector<int>
和vector<double>
的使用,它将为int
和double
类型实例化vector
类模板。 - 显式实例化:这是明确告诉编译器创建哪些模板实例的一种方式,即使这些实例在你的代码中没有明确使用。这在创建库文件时很有用,因为未实例化的模板不会放入目标文件。它们还有助于减少编译时间和对象大小,我们稍后会看到这一点。
本节提到的所有术语和话题将在本书的其他章节中详细说明。本节旨在作为模板术语的简短参考指南。不过请记住,还有许多其他与模板相关的术语将在适当的时候引入。
模板简史
模板元编程是泛型编程在C++中的实现。这个范式最早在20世纪70年代被探索,第一个主要支持它的语言是1980年代上半期的Ada和Eiffel。David Musser和Alexander Stepanov在1989年的一篇名为泛型编程的论文中这样定义泛型编程:
泛型编程围绕着从具体的、高效的算法中抽象出来,以获得可以与不同的数据表示结合的泛型算法,产生广泛有用的软件的思想。
这定义了一种编程范式,算法是根据稍后指定并根据其使用情况实例化的类型定义的。
模板不是Bjarne Stroustrup开发的最初的C with Classes语言的一部分。Stroustrup关于C++中模板的第一篇论文出现在1986年,也就是他的书The C++ Programming Language, First Edition发表的一年后。模板在1990年成为C++语言的一部分,在ANSI和ISO C++标准化委员会成立之前。
在1990年代初,Alexander Stepanov、David Musser和Meng Lee开始尝试在C++中实现各种泛型概念。这导致了标准模板库(STL)的第一个实现。当ANSI/ISO委员会在1994年意识到这个库时,它迅速将其添加到起草的规范中。STL与C++语言一起在1998年标准化,被称为C++98。
C++标准的新版本,统称为现代C++,引入了对模板元编程的各种改进。以下表格简要列出了它们:
这些特性以及模板元编程的其他方面,将是本书的唯一主题,并将在以下章节中详细介绍。现在,让我们看看使用模板的优点和缺点是什么。
C++模板的优缺点
在开始使用模板之前,理解使用它们的好处以及可能带来的缺点是非常重要的。
我们首先来看看优点:
- 模板帮助我们避免编写重复的代码。
- 模板促进了通用库的创建,这些库提供算法和类型,例如标准C++库(有时错误地称为STL),这些库可以在多种类型的应用程序中使用。
- 使用模板可以减少并改善代码。例如,使用标准库中的算法可以帮助写出更少、更易于理解和维护的代码,并且由于在这些算法的开发和测试中投入的努力,代码可能更加健壮。
至于缺点,以下几点值得一提:
- 语法被认为复杂和笨重,尽管有一点练习后这不应该真正成为开发和使用模板的障碍。
- 与模板代码相关的编译器错误通常很长且难以理解,使得很难识别错误原因。C++编译器的新版本在简化这些错误方面取得了进展,尽管它们通常仍然是一个重要问题。C++20标准中引入的概念被视为试图提供更好的编译错误诊断的尝试之一。
- 由于模板完全在头文件中实现,它们增加了编译时间。每当对模板进行更改时,包含该头文件的所有翻译单元都必须重新编译。
- 模板库作为一个或多个必须与使用它们的代码一起编译的头文件集合提供。
- 模板在头文件中实现的另一个缺点是没有信息隐藏。整个模板代码在头文件中可供任何人阅读。库开发人员通常会使用诸如
detail
或details
之类的命名空间来包含本应作为库内部的代码,并且不应由库的使用者直接调用。 - 由于编译器不会实例化未使用的代码,因此验证模板可能更加困难。因此,在编写单元测试时,确保良好的代码覆盖率非常重要。对于库来说尤其如此。
尽管缺点列表可能看起来更长,但使用模板并不是坏事或应该避免的事情。相反,模板是C++语言的一项强大功能。模板并不总是被正确理解,有时会被误用或过度使用。然而,恰当地使用模板具有无可置疑的优势。本书将试图提供对模板及其使用的更好理解。
概述
本章介绍了C++编程语言中模板的概念。
我们首先了解了使用模板解决的问题。然后,我们通过函数模板、类模板和变量模板的简单示例看到了模板的样子。我们介绍了模板的基本术语,这将在后续章节中进一步讨论。在本章末尾,我们简要回顾了C++编程语言中模板的历史。我们以讨论使用模板的优点和缺点结束了本章。所有这些主题将帮助我们更好地理解接下来的章节。
在下一章中,我们将探索C++中模板的基础知识。
问题
- 为什么我们需要模板?它们提供了哪些优势?
- 如何调用模板的函数?模板的类呢?
- 存在多少种模板参数,它们是什么?
- 什么是部分专门化?完全专门化又是什么?
- 使用模板的主要缺点是什么?
延伸阅读
- Generic Programming, David Musser, Alexander Stepanov, http://stepanovpapers.com/genprog.pdf
- A History of C++: 1979−1991, Bjarne Stroustrup, https://www.stroustrup.com/hopl2.pdf
- History of C++, https://en.cppreference.com/w/cpp/language/history
- Templates in C++ - Pros and Cons, Sergey Chepurin, https://www.codeproject.com/Articles/275063/Templates-in-Cplusplus-Pros-and-Cons