使用模板编写泛型代码
C++ 不仅为面向对象编程提供语言支持,也为泛型编程(generic programming)提供支持。正如 第 6 章“面向复用的设计”中所讨论的那样,泛型编程的目标是编写可复用的代码。C++ 中用于泛型编程的核心工具就是模板(template)。尽管模板本身并不严格属于面向对象特性,但它们可以与面向对象编程结合,产生非常强大的效果。使用现有模板——例如标准库提供的 std::vector、unique_ptr 等——通常是比较直接的。然而,许多程序员认为编写自己的模板是 C++ 中最困难的部分,也正因为如此,往往会回避编写模板。不过,作为一名专业的 C++ 程序员,你必须知道如何编写类模板和函数模板。
本章将给出实现 第 6 章 所讨论的一般性设计原则所需的编码细节,而 第 26 章“高级模板”则会深入介绍一些更高级的模板特性。
在过程式编程范式中,最主要的编程单元是过程(procedure)或函数(function)。函数之所以有用,主要是因为它们允许你编写独立于具体值的算法,从而可以针对许多不同的值重复使用。例如,C++ 中的 sqrt() 函数用于计算调用方提供的某个值的平方根。如果一个平方根函数只能计算某一个数字——例如数字 4——的平方根,那它几乎没什么用!sqrt() 函数是围绕一个参数(parameter)来编写的,这个参数充当调用方传入值的占位符。计算机科学家会说,函数是在对值进行参数化(parameterize)。
面向对象编程范式加入了对象(object)的概念,用于把相关的数据和行为组织在一起,但它并没有改变函数和成员函数对值进行参数化的方式。
模板则把参数化的概念更推进了一步,让你不仅可以对值进行参数化,还可以对类型进行参数化。C++ 中的类型既包括像 int 和 double 这样的基本类型,也包括像 SpreadsheetCell 和 CherryTree 这样的用户自定义类。有了模板,你就可以编写一种代码,它不仅独立于将来会接收什么值,也独立于这些值的类型。例如,你不再需要分别编写存储 int、Car 和 SpreadsheetCell 的栈类,而是可以写一个栈类模板定义,适用于这些任意类型。
尽管模板是非常惊人的语言特性,但 C++ 中模板的语法确实容易让人困惑,因此很多程序员会避免亲自编写模板。不过,每一位专业的 C++ 程序员都需要知道如何编写模板,而且每位程序员至少也需要知道如何使用模板,因为包括 C++ 标准库在内的各种库都在广泛使用它们。
本章将教你 C++ 中的模板支持,重点放在那些在标准库中经常出现的方面。在这个过程中,你还会学到一些很巧妙的特性,除了使用标准库之外,它们也可以直接应用到你自己的程序中。
类模板(class template)定义了一份蓝图(blueprint, 也就是模板),用来生成一族类定义;在这些类定义中,某些变量的类型、成员函数的返回类型以及/或成员函数参数的类型,是通过模板类型参数来指定的。类模板很像建筑蓝图。它们允许编译器通过用具体类型替换模板类型参数,来构造(也称为实例化(instantiate))真正的具体类定义。
类模板主要用于容器(container)或其他存储对象的数据结构。你在本书前面已经频繁使用过类模板,例如 std::vector、unique_ptr、string 等。本节会通过一个持续演进的 Grid 容器示例,来讨论如何编写你自己的类模板。为了让示例保持适中的长度,并且足够简单以说明特定要点,本章不同小节会陆续给 Grid 容器添加一些功能,而这些功能未必会在后续小节继续使用。
假设你想要一个通用的棋盘类,它既能作为国际象棋棋盘、跳棋棋盘、井字棋棋盘,也能作为任何其他二维游戏棋盘。为了使其具有通用性,你应该能够在其中存储国际象棋棋子、跳棋棋子、井字棋棋子,或者任何其他类型的游戏棋子。
不使用模板的写法
Section titled “不使用模板的写法”如果不用模板,构建一个通用棋盘的最佳方法,是使用多态来存储通用的 GamePiece 对象。然后,你可以让每种具体游戏中的棋子都继承自 GamePiece 类。例如,在国际象棋游戏中,ChessPiece 就可以作为 GamePiece 的派生类。借助多态,用于存储 GamePiece 的 GameBoard,也就能够存储 ChessPiece。由于 GameBoard 应当是可复制的,它就必须能够复制 GamePiece。这个实现采用了多态,因此一种可行的办法,是在 GamePiece 基类中添加一个纯虚的 clone() 成员函数,要求派生类实现它,以返回某个具体 GamePiece 的副本。下面是 GamePiece 的基本接口:
export class GamePiece{ public: virtual ~GamePiece() = default; virtual std::unique_ptr<GamePiece> clone() const = 0;};GamePiece 是一个抽象基类。像 ChessPiece 这样的具体类会从它派生,并实现 clone() 成员函数:
class ChessPiece : public GamePiece{ public: std::unique_ptr<GamePiece> clone() const override { // 调用拷贝构造函数来拷贝此实例 return std::make_unique<ChessPiece>(*this); }};GameBoard 表示一个二维网格,因此,用于在 GameBoard 中存储 GamePiece 的一种做法,可以是由 vector 组成的 vector,其中每个元素再是一个 unique_ptr。不过,这种数据表示方式并不理想,因为数据在内存中会是碎片化的。更好的做法,是把 GamePiece 的线性化表示存储为一个单独的 unique_ptr vector。将二维坐标(例如 (x,y))转换为线性表示中的一维位置,可以很容易地使用公式 x+y*width 完成。
export class GameBoard{ public: explicit GameBoard(std::size_t width = DefaultWidth, std::size_t height = DefaultHeight); GameBoard(const GameBoard& src); // 拷贝构造函数 virtual ~GameBoard() = default; // 虚默认析构函数 GameBoard& operator=(const GameBoard& rhs); // 赋值运算符
// 显式默认化移动构造函数和移动赋值运算符。 GameBoard(GameBoard&& src) = default; GameBoard& operator=(GameBoard&& src) = default;
std::unique_ptr<GamePiece>& at(std::size_t x, std::size_t y); const std::unique_ptr<GamePiece>& at(std::size_t x, std::size_t y) const;
std::size_t getHeight() const { return m_height; } std::size_t getWidth() const { return m_width; }
static constexpr std::size_t DefaultWidth { 10 }; static constexpr std::size_t DefaultHeight { 10 };
void swap(GameBoard& other) noexcept; private: void verifyCoordinate(std::size_t x, std::size_t y) const;
std::vector<std::unique_ptr<GamePiece>> m_cells; std::size_t m_width { 0 }, m_height { 0 };};export void swap(GameBoard& first, GameBoard& second) noexcept;在这个实现中,at() 返回的是给定位置上的游戏棋子的引用,而不是棋子的副本。GameBoard 是二维数组的一种抽象,因此它应该提供类似数组访问的语义——返回该位置上实际对象的引用,而不是对象的副本。客户端代码不应为了将来使用而长期保存这个引用,因为该引用可能会失效,例如当 m_cells vector 需要重新调整大小时。相反,客户端代码应当在真正使用该引用之前立即调用 at()。这遵循了标准库 vector 类的设计理念。
下面是成员函数定义。请注意,这个实现对赋值运算符使用了 copy-and-swap 惯用法,并使用了 Scott Meyers 的 const_cast() 模式来避免重复代码,这两者都在 第 9 章“精通类和对象”中讨论过。
GameBoard::GameBoard(size_t width, size_t height) : m_width { width }, m_height { height }{ m_cells.resize(m_width * m_height);}
GameBoard::GameBoard(const GameBoard& src) : GameBoard { src.m_width, src.m_height }{ // 此构造函数的构造函数初始化列表首先委派给 // 非拷贝构造函数以分配适当数量的内存。
// 下一步是拷贝数据。 for (size_t i { 0 }; i < m_cells.size(); ++i) { if (src.m_cells[i]) { m_cells[i] = src.m_cells[i]->clone(); } }}
void GameBoard::verifyCoordinate(size_t x, size_t y) const{ if (x >= m_width) { throw out_of_range { format("x ({}) must be less than width ({}).", x, m_width) }; } if (y >= m_height) { throw out_of_range { format("y ({}) must be less than height ({}).", y, m_height) }; }}
void GameBoard::swap(GameBoard& other) noexcept{ std::swap(m_width, other.m_width); std::swap(m_height, other.m_height); std::swap(m_cells, other.m_cells);}
void swap(GameBoard& first, GameBoard& second) noexcept{ first.swap(second);}
GameBoard& GameBoard::operator=(const GameBoard& rhs){ // Copy-and-swap 惯用法 GameBoard temp { rhs }; // 在临时实例中完成所有工作。 swap(temp); // 仅通过非抛出操作提交工作。 return *this;}
const unique_ptr<GamePiece>& GameBoard::at(size_t x, size_t y) const{ verifyCoordinate(x, y); return m_cells[x + y * m_width];}
unique_ptr<GamePiece>& GameBoard::at(size_t x, size_t y){ return const_cast<unique_ptr<GamePiece>&>(as_const(*this).at(x, y));}这个 GameBoard 类工作得相当不错:
GameBoard chessBoard { 8, 8 };auto pawn { std::make_unique<ChessPiece>() };chessBoard.at(0, 0) = std::move(pawn);chessBoard.at(0, 1) = std::make_unique<ChessPiece>();chessBoard.at(0, 1) = nullptr;模板化的 Grid 类
Section titled “模板化的 Grid 类”前一节中的 GameBoard 类虽然不错,但仍然不够理想。一个问题是,你不能让 GameBoard 按值存储元素; 它始终存储的是指针。另一个更严重的问题与类型安全有关。GameBoard 中的每个单元格都存储一个 unique_ptr<GamePiece>。即使你实际存储的是 ChessPiece,当你使用 at() 请求某个棋子时,得到的仍然是 unique_ptr<GamePiece>。这意味着,为了使用 ChessPiece 特有的功能,你必须把取回来的 GamePiece 向下转型为 ChessPiece。此外,也没有任何机制阻止你把各种不同的 GamePiece 派生对象混放在同一个 GameBoard 中。例如,假设现在除了 ChessPiece 之外,还有一个 TicTacToePiece:
class TicTacToePiece : public GamePiece{ public: std::unique_ptr<GamePiece> clone() const override { // 调用拷贝构造函数来拷贝此实例 return std::make_unique<TicTacToePiece>(*this); }};采用前一节的多态方案时,没有任何东西会阻止你把井字棋棋子和国际象棋棋子放在同一个棋盘上:
GameBoard gameBoard { 8, 8 };gameBoard.at(0, 0) = std::make_unique<ChessPiece>();gameBoard.at(0, 1) = std::make_unique<TicTacToePiece>();这里最大的麻烦在于,你必须 somehow 记住某个位置到底存储了什么,这样在调用 at() 时才能执行正确的向下转型。
GameBoard 的另一个不足之处是,它不能用来存储像 int 或 double 这样的基本类型,因为单元格中存储的类型必须派生自 GamePiece。
如果你能够编写一个通用的 Grid 类,用来存储 ChessPiece、SpreadsheetCell、int、double 等各种类型,那就好了。在 C++ 中,你可以通过编写类模板(class template)来做到这一点,类模板本质上是类定义的蓝图。在类模板中,并不是所有类型都已知。随后,客户端通过指定自己想要使用的类型来实例化(instantiate)模板。这就是所谓的泛型编程(generic programming)。泛型编程最大的优点是类型安全。实例化后的类定义及其成员函数所使用的都是具体类型,而不是前一节多态方案里那种抽象基类类型。
下面我们先来看看,这样一个 Grid 类模板定义是如何编写出来的。
Grid 类模板定义
Section titled “Grid 类模板定义”为了理解类模板,仔细观察语法会很有帮助。下面的例子展示了如何修改 GameBoard 类,将其变成参数化的 Grid 类模板。代码后面会详细解释语法。请注意,名字已经从 GameBoard 改成了 Grid。Grid 还应当能够与 int、double 这样的基本类型一起使用。这就是为什么,与 GameBoard 实现里使用多态指针语义相比, I 选择在这个方案中使用按值语义而不使用多态。m_cells 容器存储的是实际对象,而不是指针。与指针语义相比,按值语义的一个缺点是你无法真正拥有一个空单元格;也就是说,每个单元格都必须总是包含某个值。而使用指针语义时,空单元格可以存储 nullptr。幸运的是,在 第 1 章“C++ 与标准库速成”中介绍过的 std::optional 在这里派上了用场。它允许你在继续使用按值语义的同时,仍然能够表示空单元格。
export template <typename T>class Grid{ public: explicit Grid(std::size_t width = DefaultWidth, std::size_t height = DefaultHeight); virtual ~Grid() = default;
// 显式默认化拷贝构造函数和拷贝赋值运算符。 Grid(const Grid& src) = default; Grid& operator=(const Grid& rhs) = default;
// 显式默认化移动构造函数和移动赋值运算符。 Grid(Grid&& src) = default; Grid& operator=(Grid&& rhs) = default;
std::optional<T>& at(std::size_t x, std::size_t y); const std::optional<T>& at(std::size_t x, std::size_t y) const;
std::size_t getHeight() const { return m_height; } std::size_t getWidth() const { return m_width; }
static constexpr std::size_t DefaultWidth { 10 }; static constexpr std::size_t DefaultHeight { 10 };
private: void verifyCoordinate(std::size_t x, std::size_t y) const;
std::vector<std::optional<T>> m_cells; std::size_t m_width { 0 }, m_height { 0 };};现在你已经看到了完整的类模板定义,不妨从第一行开始再仔细看一遍。
export template <typename T>这第一行表示,后面的类定义是一个基于单一类型 T 的模板,并且它会从模块中导出。template <typename T> 这一部分称为模板头(template header)。template 和 typename 都是 C++ 关键字。正如前面所说,模板对类型进行“参数化”,就像函数对值进行“参数化”一样。正如你在函数中使用参数名来表示调用方将会传入的实参,你在模板中使用模板类型参数(template type parameter)名(例如 T)来表示调用方将会传入的模板类型实参(template type argument)。T 这个名字本身并没有任何特殊之处——你完全可以使用任何你喜欢的名字。按照传统,当只涉及单一类型时,通常把它命名为 T,但这不过是一种历史惯例,就像把数组索引整数命名为 i 或 j 一样。这个模板说明符作用于整个语句,在这里,也就是整个类模板定义。
在前面的 GameBoard 类中,m_cells 数据成员是一个由指针组成的 vector,这就需要专门的复制代码——因此才需要拷贝构造函数和拷贝赋值运算符。而在 Grid 类中,m_cells 是由 optional 值组成的 vector,所以编译器生成的拷贝构造函数和赋值运算符就足够了。不过,正如 第 8 章“精通类和对象”中解释的那样,一旦你声明了用户自定义析构函数,编译器再隐式生成拷贝构造函数或拷贝赋值运算符就会被弃用,因此 Grid 类模板显式将它们默认化。它还显式默认化了移动构造函数和移动赋值运算符。下面是显式默认化的拷贝赋值运算符:
Grid& operator=(const Grid& rhs) = default;如你所见,rhs 参数的类型不再是 const GameBoard&,而是 const Grid&。在类定义内部,编译器会在需要时把 Grid 解释为 Grid<T>;不过如果你愿意,也可以显式写成 Grid<T>:
Grid<T>& operator=(const Grid<T>& rhs) = default;不过,在类定义外部你就必须使用 Grid<T>。当你编写一个类模板时,原本你以为的“类名”(这里是 Grid)实际上只是模板名(template name)。当你要讨论真正的 Grid 类或类型时,就必须使用模板 ID(template ID),也就是 Grid<T>。它们是 Grid 类模板针对某个具体类型(例如 int、SpreadsheetCell 或 ChessPiece)的实例化结果。
由于 m_cells 不再存储指针,而是存储 optional 值,at() 成员函数现在返回的是 optional<T>,而不再是 unique_ptr;也就是说,返回的是一个可能包含 T 类型值、也可能为空的 optional:
std::optional<T>& at(std::size_t x, std::size_t y);const std::optional<T>& at(std::size_t x, std::size_t y) const;Grid 类模板成员函数定义
Section titled “Grid 类模板成员函数定义”对于 Grid 类模板,每个成员函数定义前面都必须带上 template <typename T> 模板头。构造函数如下:
template <typename T>Grid<T>::Grid(std::size_t width, std::size_t height) : m_width { width }, m_height { height }{ m_cells.resize(m_width * m_height);}请注意,:: 前面的名字是 Grid<T>,而不是 Grid。构造函数的函数体与 GameBoard 构造函数完全相同。其余成员函数定义也与 GameBoard 类中对应的定义类似,区别只是多了合适的模板头,以及需要使用 Grid<T> 语法:
template <typename T>void Grid<T>::verifyCoordinate(std::size_t x, std::size_t y) const{ if (x >= m_width) { throw std::out_of_range { std::format("x ({}) must be less than width ({}).", x, m_width) }; } if (y >= m_height) { throw std::out_of_range { std::format("y ({}) must be less than height ({}).", y, m_height) }; }}
template <typename T>const std::optional<T>& Grid<T>::at(std::size_t x, std::size_t y) const{ verifyCoordinate(x, y); return m_cells[x + y * m_width];}
template <typename T>std::optional<T>& Grid<T>::at(std::size_t x, std::size_t y){ return const_cast<std::optional<T>&>(std::as_const(*this).at(x, y));}使用 Grid 模板
Section titled “使用 Grid 模板”当你想创建 Grid 对象时,不能单独把 Grid 当作类型使用;你必须指明该 Grid 中要存储的类型。为特定类型创建类模板的具体实例,称为模板实例化(template instantiation)。例如:
Grid<int> myIntGrid; // 声明一个存储 int 的网格, // 使用构造函数的默认参数。Grid<double> myDoubleGrid { 11, 11 }; // 声明一个 11x11 的 double 网格。
myIntGrid.at(0, 0) = 10;int x { myIntGrid.at(0, 0).value_or(0) };
Grid<int> grid2 { myIntGrid }; // 拷贝构造函数Grid<int> anotherIntGrid;anotherIntGrid = grid2; // 赋值运算符请注意,myIntGrid、grid2 和 anotherIntGrid 的类型都是 Grid<int>。你不能在这些网格中存储 SpreadsheetCell 或 ChessPiece; 如果你尝试这样做,编译器就会报错。
还要注意 value_or() 的使用。at() 成员函数返回的是一个 optional 引用,它可能包含值,也可能不包含。若 optional 中有值,value_or() 会返回其中的值;否则,它会返回传给 value_or() 的参数。
类型说明非常重要; 下面这两行都无法编译:
Grid test; // 无法编译Grid<> test; // 无法编译第一行会导致编译器报出类似“使用类模板时必须提供模板实参列表”这样的错误。第二行则会导致类似“模板实参数量过少”的错误。
如果你想声明一个接收 Grid 对象的函数,那么你必须把该网格中存储的类型也写进 Grid 类型中:
void processIntGrid(Grid<int>& grid) { /* 为简洁起见省略主体 */ }或者,你也可以使用本章稍后会讨论的函数模板,编写一个按网格元素类型参数化的函数。
Grid 类模板不仅能存储 int。例如,你可以实例化一个存储 SpreadsheetCell 的 Grid:
Grid<SpreadsheetCell> mySpreadsheet;SpreadsheetCell myCell { 1.234 };mySpreadsheet.at(3, 4) = myCell;你也可以存储指针类型:
Grid<const char*> myStringGrid;myStringGrid.at(2, 2) = "hello";指定的类型甚至还可以是另一个模板类型:
Grid<vector<int>> gridOfVectors;vector<int> myVector { 1, 2, 3, 4 };gridOfVectors.at(5, 6) = myVector;你还可以在自由存储区上动态分配 Grid 对象:
auto myGridOnFreeStore { make_unique<Grid<int>>(2, 2) }; // 自由存储区上的 2x2 网格。myGridOnFreeStore->at(0, 0) = 10;int x { myGridOnFreeStore->at(0, 0).value_or(0) };编译器如何处理模板
Section titled “编译器如何处理模板”要理解模板的细微之处,你需要先了解编译器是如何处理模板代码的。当编译器遇到类模板成员函数定义时,它会执行语法检查,但不会真正去编译模板。它之所以无法直接编译模板定义,是因为它还不知道这些模板将会用于哪些类型。像 x = y 这样的语句,如果不知道 x 和 y 的类型,编译器根本无法为其生成代码。这种语法检查步骤,是两阶段名称查找(two-phase name lookup)过程中的第一步。
两阶段名称查找过程的第二步,发生在编译器遇到模板实例化时,例如 Grid<int>。在那一刻,编译器会通过将类模板定义中的每个 T 替换为 int,为 Grid 模板生成一个 int 版本的代码。当编译器遇到另一个不同的模板实例化,比如 Grid<SpreadsheetCell> 时,它又会为 SpreadsheetCell 生成另一个 Grid 类版本。编译器所做的,其实就是你在没有语言级模板支持时本来不得不手工去写的那种代码——为每种元素类型分别写一个类。这里没有什么神秘魔法; 模板只是把这个恼人的过程自动化了。如果你在程序中没有针对任何类型去实例化某个类模板,那么这个类模板的成员函数定义就永远不会被编译。
这种实例化过程也解释了为什么你必须在定义中的很多地方使用 Grid<T> 语法。当编译器针对某个特定类型(例如 int)来实例化模板时,它会把 T 替换为 int,于是 Grid<int> 才真正成为一个具体类型。
选择性/隐式实例化
Section titled “选择性/隐式实例化”对于像下面这样的隐式类模板实例化(implicit class template instantiation):
Grid<int> myIntGrid;编译器总是会为类模板的所有 virtual 成员函数生成代码。不过,对于非 virtual 成员函数,编译器只会为那些实际被调用的非 virtual 成员函数生成代码。例如,沿用前面的 Grid 类模板,假设你在 main() 中只写了下面这段代码:
Grid<int> myIntGrid;myIntGrid.at(0, 0) = 10;那么,编译器只会为 Grid 的 int 版本生成零参数构造函数、析构函数以及非常量版 at() 成员函数。它不会生成拷贝构造函数、赋值运算符或 getHeight() 等其他成员函数。这就叫做选择性实例化(selective instantiation)。
隐式实例化存在一个风险: 某些类模板成员函数里可能有编译错误,但你却完全注意不到。由于未使用的类模板成员函数不会被编译,它们里面即便存在语法错误也不会暴露出来。这使得对所有代码进行语法错误测试变得很困难。你可以通过使用显式模板实例化(explicit template instantiation),强制编译器为所有成员函数(不论 virtual 还是非 virtual)生成代码。例子如下:
template class Grid<string>;使用显式模板实例化时,不要只尝试用 int 这样的基本类型来实例化类模板,还应当尝试用 string 这样更复杂的类型——当然前提是类模板本身接受这种类型。
模板对类型的要求
Section titled “模板对类型的要求”当你编写独立于具体类型的代码时,你其实是在对这些类型做某些假设。例如,在 Grid 类模板中,你假设元素类型(也就是 T)是可析构的、可拷贝/移动构造的,并且可拷贝/移动赋值的。
当编译器尝试用某些类型去实例化模板,而这些类型又不支持类模板成员函数中实际使用到的全部操作时,代码就无法编译,而且错误信息通常会相当晦涩。不过,即使你想使用的类型并不支持该类模板所有成员函数所要求的操作,你依然可以利用选择性实例化,只使用某些成员函数而不使用另一些。
你可以使用概念(concept)来为模板参数编写要求,而编译器能够理解并验证这些要求。如果传入用于实例化模板的模板实参不满足这些要求,编译器就能生成更易读的错误信息。本章后面会讨论概念。
在多个文件之间分发模板代码
Section titled “在多个文件之间分发模板代码”对于类模板,类模板定义和成员函数定义都必须对所有使用该类模板的源文件中的编译器可见。实现这一点有多种机制。
把成员函数定义放在类模板定义所在的同一文件中
Section titled “把成员函数定义放在类模板定义所在的同一文件中”你可以把成员函数定义直接写在定义类模板本身的模块接口文件中。当你在另一个使用该模板的源文件中导入这个模块时,编译器就能访问它所需的全部代码。前面的 Grid 实现就采用了这种机制。
把成员函数定义放在单独文件中
Section titled “把成员函数定义放在单独文件中”另一种做法是,把类模板成员函数定义放在单独的模块接口分区文件中。这样一来,你也需要把类模板定义本身放在它自己的模块接口分区里。例如,Grid 类模板的主模块接口文件可以写成这样:
export module grid;
export import :definition;export import :implementation;这里导入并导出了两个模块接口分区:definition 和 implementation。类模板定义位于 definition 分区中:
export module grid:definition;
import std;
export template <typename T> class Grid { /* … */ };成员函数实现位于 implementation 分区中,而它也必须导入 definition 分区,因为它需要 Grid 类模板定义:
export module grid:implementation;
import :definition;import std;
export template <typename T>Grid<T>::Grid(std::size_t width, std::size_t height) : m_width { width }, m_height { height }{ /* … */ }// 为简洁起见省略其余部分。在 Grid 示例中,Grid 类模板只有一个模板参数(template parameter): 网格中存储的元素类型。当你编写类模板时,需要在尖括号中给出参数列表,例如:
template <typename T>这个参数列表与函数的参数列表类似。和函数一样,你可以让一个类模板拥有任意多个模板参数。此外,这些参数不一定非得是类型,它们也可以带有默认值。
非类型模板参数
Section titled “非类型模板参数”非类型模板参数(non-type template parameter)就是像 int、指针这类“普通”参数——也就是你在函数中早已熟悉的参数类型。不过,非类型模板参数只能是整数类型(char、int、long 等)、枚举、指针、引用、std::nullptr_t、auto、auto&、auto*、浮点类型以及类类型。不过最后这一类又带有很多限制,本书不再展开讨论。请记住,模板是在编译期实例化的,因此非类型模板参数的实参也会在编译期求值。这意味着,这类实参必须是字面量或编译期常量。
在 Grid 类模板中,你完全可以把网格的高度和宽度设计成非类型模板参数,而不是在构造函数中指定。相较于构造函数参数,使用非类型模板参数的主要优势在于,这些值在代码编译前就已经已知。别忘了,编译器是通过先替换模板参数再进行编译的方式来为模板实例化生成代码的。因此,在下面这个实现中,你就可以直接使用普通的二维数组,而不必再使用 vector 动态调整大小的线性化表示。下面是修改后的新类模板定义,相关变化已突出展示:
export template <typename T, std::size_t WIDTH, std::size_t HEIGHT>class Grid{ public: Grid() = default; virtual ~Grid() = default;
// 显式默认化拷贝构造函数和赋值运算符。 Grid(const Grid& src) = default; Grid& operator=(const Grid& rhs) = default;
// 显式默认化移动构造函数和移动赋值运算符。 Grid(Grid&& src) = default; Grid& operator=(Grid&& rhs) = default;
std::optional<T>& at(std::size_t x, std::size_t y); const std::optional<T>& at(std::size_t x, std::size_t y) const;
std::size_t getHeight() const { return HEIGHT; } std::size_t getWidth() const { return WIDTH; }
private: void verifyCoordinate(std::size_t x, std::size_t y) const;
std::optional<T> m_cells[WIDTH][HEIGHT];};现在模板参数列表中有三个参数: 网格中存储对象的类型,以及网格的宽度和高度。宽度和高度被用来创建二维数组,以存储这些对象。下面是类模板成员函数定义:
template <typename T, std::size_t WIDTH, std::size_t HEIGHT>void Grid<T, WIDTH, HEIGHT>::verifyCoordinate(std::size_t x, std::size_t y) const{ if (x >= WIDTH) { throw std::out_of_range { std::format("x ({}) must be less than width ({}).", x, WIDTH) }; } if (y >= HEIGHT) { throw std::out_of_range { std::format("y ({}) must be less than height ({}).", y, HEIGHT) }; }}
template <typename T, std::size_t WIDTH, std::size_t HEIGHT>const std::optional<T>& Grid<T, WIDTH, HEIGHT>::at( std::size_t x, std::size_t y) const{ verifyCoordinate(x, y); return m_cells[x][y];}
template <typename T, std::size_t WIDTH, std::size_t HEIGHT>std::optional<T>& Grid<T, WIDTH, HEIGHT>::at(std::size_t x, std::size_t y){ return const_cast<std::optional<T>&>(std::as_const(*this).at(x, y));}请注意,凡是你之前写 Grid<T> 的地方,现在都必须写成 Grid<T, WIDTH, HEIGHT>,以指明这三个模板参数。
你可以像下面这样实例化并使用这个模板:
Grid<int, 10, 10> myGrid;Grid<int, 10, 10> anotherGrid;myGrid.at(2, 3) = 42;anotherGrid = myGrid;println("{}", anotherGrid.at(2, 3).value_or(0));这段代码看起来很不错,但遗憾的是,限制比你一开始预想的要多。首先,你不能使用非常量整数来指定高度或宽度。下面的代码无法编译:
size_t height { 10 };Grid<int, 10, height> testGrid; // 无法编译如果你把 height 定义为常量,它就可以编译:
const size_t height { 10 };Grid<int, 10, height> testGrid; // 编译并运行返回类型正确的 constexpr 函数也同样可行。例如,如果你有一个返回 size_t 的 constexpr 函数,就可以用它来初始化高度模板参数:
constexpr size_t getHeight() { return 10; }…Grid<double, 2, getHeight()> myDoubleGrid;第二个限制可能更重要。既然宽度和高度现在成了模板参数,它们也就成了每个网格类型的一部分。这意味着 Grid<int,10,10> 和 Grid<int,10,11> 是两种不同类型。你不能把其中一种类型的对象赋值给另一种类型的对象,也不能把一种类型的变量传给期望另一种类型变量的函数。
模板参数的默认值
Section titled “模板参数的默认值”如果你继续采用把高度和宽度做成模板参数的方式,那你可能也想像之前在 Grid<T> 类模板构造函数中那样,为高度和宽度这两个非类型模板参数提供默认值。C++ 允许你用类似的语法为模板参数提供默认值。既然如此,你甚至还可以顺手为 T 类型参数也提供默认值。类定义如下:
export template <typename T = int, std::size_t WIDTH = 10, std::size_t HEIGHT = 10>class Grid{ // 其余部分与之前的版本相同};在成员函数定义的模板头中,你不需要再次写出 T、WIDTH 和 HEIGHT 的默认值。例如,下面是 at() 的实现:
template <typename T, std::size_t WIDTH, std::size_t HEIGHT>const std::optional<T>& Grid<T, WIDTH, HEIGHT>::at( std::size_t x, std::size_t y) const{ verifyCoordinate(x, y); return m_cells[x][y];}有了这些修改之后,你就可以实例化一个完全不写模板参数的 Grid,也可以只指定元素类型,或者指定元素类型和宽度,或者指定元素类型、宽度和高度:
Grid<> myIntGrid;Grid<int> myGrid;Grid<int, 5> anotherGrid;Grid<int, 5, 5> aFourthGrid;请注意,如果你不显式指定任何类模板参数,仍然必须写上一对空尖括号。例如,下面这种写法仍然无法编译!
Grid myIntGrid;类模板参数列表中的默认实参规则与函数默认参数规则相同; 也就是说,只能从右往左依次提供默认值。
类模板实参推导
Section titled “类模板实参推导”借助类模板实参推导(class template argument deduction, CTAD),编译器可以根据传给类模板构造函数的参数,自动推导出模板类型参数。
例如,标准库中有一个名为 std::pair 的类模板,它定义在 <utility> 中,并在 第 1 章 介绍过。pair 恰好存储两个值,而且这两个值的类型可能不同,通常你需要把它们作为模板类型参数显式写出来。示例如下:
pair<int, double> pair1 { 1, 2.3 };为了避免显式写出模板类型参数,标准库提供了一个辅助函数模板 std::make_pair()。如何编写你自己的函数模板会在本章稍后讨论。函数模板一直都支持根据传给函数模板的参数自动推导模板类型参数。因此,make_pair() 能够根据你传给它的值自动推导模板类型参数。例如,对于下面这次调用,编译器会推导出 pair<int, double>:
auto pair2 { make_pair(1, 2.3) };有了类模板实参推导(CTAD)之后,这类辅助函数模板就不再是必须的了。编译器现在可以根据传给构造函数的参数自动推导模板类型参数。对于 pair 类模板,你现在可以直接写:
pair pair3 { 1, 2.3 }; // pair3 的类型是 pair<int, double>当然,只有当类模板的所有模板类型参数要么有默认值,要么能从构造函数参数中推导出来时,这种写法才可行。
请注意,CTAD 必须有初始化器才能生效。下面这种写法是非法的:
pair pair4;标准库中很多类都支持 CTAD,例如 vector、array 等等。
用户自定义推导指引
Section titled “用户自定义推导指引”你也可以编写自己的用户自定义推导指引(deduction guides)来帮助编译器。它们允许你写出模板类型参数应当如何被推导的规则。下面就是一个演示其用法的例子。
假设你有这样一个 SpreadsheetCell 类模板:
template <typename T>class SpreadsheetCell{ public: explicit SpreadsheetCell(T t) : m_content { move(t) } { } const T& getContent() const { return m_content; } private: T m_content;};由于有 CTAD,你可以使用 std::string 类型来创建 SpreadsheetCell。被推导出来的类型会是 SpreadsheetCell<string>:
string myString { "Hello World!" };SpreadsheetCell cell { myString };然而,如果你向 SpreadsheetCell 构造函数传入的是 const char*,那么 T 就会被推导为 const char*,而这往往不是你想要的! 你可以创建下面这样的用户自定义推导指引,确保当构造函数参数是 const char* 时,T 被推导为 std::string:
SpreadsheetCell(const char*) -> SpreadsheetCell<std::string>;这个推导指引必须定义在类定义之外,但要放在与 SpreadsheetCell 类相同的命名空间里。
它的一般语法如下。explicit 关键字是可选的,其行为与构造函数上的 explicit 完全一致。此类推导指引通常也常常本身就是模板。
template <…>explicit TemplateName(Parameters) -> DeducedTemplate<…>;成员函数模板
Section titled “成员函数模板”C++ 允许你对类中的单个成员函数进行参数化。这类成员函数称为成员函数模板(member function template),它们既可以出现在普通类中,也可以出现在类模板中。当你编写一个成员函数模板时,你实际上是在为许多不同类型编写该成员函数的许多不同版本。成员函数模板在类模板中的赋值运算符和拷贝构造函数场景下尤其有用。
虚成员函数和析构函数不能是成员函数模板。
考虑最初那个只有一个模板参数——元素类型——的 Grid 模板。你可以实例化出很多不同类型的网格,例如 int 网格和 double 网格:
Grid<int> myIntGrid;Grid<double> myDoubleGrid;不过,Grid<int> 和 Grid<double> 是两种不同的类型。如果你写了一个接收 Grid<double> 对象的函数,那你就不能传入 Grid<int>。尽管你明知道 int 网格中的元素是可以复制到 double 网格中的,因为 int 可以转换成 double,但你仍然不能把 Grid<int> 类型对象赋值给 Grid<double> 类型对象,也不能用 Grid<int> 来构造 Grid<double>。下面这两行都无法编译:
myDoubleGrid = myIntGrid; // 无法编译Grid<double> newDoubleGrid { myIntGrid }; // 无法编译问题在于,Grid 模板的拷贝构造函数和赋值运算符如下:
Grid(const Grid& src);Grid& operator=(const Grid& rhs);它们等价于:
Grid(const Grid<T>& src);Grid<T>& operator=(const Grid<T>& rhs);Grid 的拷贝构造函数和 operator= 都接受一个 const Grid<T> 的引用。当你实例化出 Grid<double>,并尝试调用其拷贝构造函数和 operator= 时,编译器生成的成员函数原型会是:
Grid(const Grid<double>& src);Grid<double>& operator=(const Grid<double>& rhs);而在生成出来的 Grid<double> 类中,并不存在接受 Grid<int> 的构造函数或 operator=。
幸运的是,你可以通过给 Grid 类模板增加参数化版本的拷贝构造函数和赋值运算符来修正这个缺陷,从而生成能够在不同网格类型之间进行转换的成员函数。下面是新的 Grid 类模板定义:
export template <typename T>class Grid{ public: template <typename E> Grid(const Grid<E>& src);
template <typename E> Grid& operator=(const Grid<E>& rhs);
void swap(Grid& other) noexcept;
// 为简洁起见省略};原来的拷贝构造函数和拷贝赋值运算符并不能删除。如果 E 与 T 相同,编译器不会调用这些新的参数化拷贝构造函数和参数化赋值运算符。
先来看新的参数化拷贝构造函数:
template <typename E>Grid(const Grid<E>& src);你可以看到,这里又出现了一个新的模板头,使用了不同的类型名 E(可理解为“element”的缩写)。这个类本身是基于类型 T 参数化的,而这个新的拷贝构造函数又额外基于另一个不同的类型 E 参数化。这种双重参数化允许你把一种类型的网格复制到另一种类型的网格中。下面是新的拷贝构造函数定义:
template <typename T>template <typename E>Grid<T>::Grid(const Grid<E>& src) : Grid { src.getWidth(), src.getHeight() }{ // 此构造函数的构造函数初始化列表首先委派给 // 非拷贝构造函数以分配适当数量的内存。
// 下一步是拷贝数据。 for (std::size_t i { 0 }; i < m_width; ++i) { for (std::size_t j { 0 }; j < m_height; ++j) { at(i, j) = src.at(i, j); } }}如你所见,你必须先声明类模板头(包含 T 参数),然后再写成员模板头(包含 E 参数)。你不能像下面这样把它们合并起来:
template <typename T, typename E> // 嵌套模板构造函数的写法错误!Grid<T>::Grid(const Grid<E>& src)除了在构造函数定义前多出来的模板头之外,还要注意一点: 你必须使用 getWidth()、getHeight() 和 at() 这些公共访问成员函数,来访问 src 中的元素。原因在于,你复制到的对象类型是 Grid<T>,而你复制自的对象类型是 Grid<E>。它们不是同一种类型,因此你必须通过公共成员函数访问。
swap() 成员函数非常直接:
template <typename T>void Grid<T>::swap(Grid& other) noexcept{ std::swap(m_width, other.m_width); std::swap(m_height, other.m_height); std::swap(m_cells, other.m_cells);}这个参数化赋值运算符接受 const Grid<E>&,但返回的是 Grid<T>&:
template <typename T>template <typename E>Grid<T>& Grid<T>::operator=(const Grid<E>& rhs){ // Copy-and-swap 惯用法 Grid<T> temp { rhs }; // 在临时实例中完成所有工作。 swap(temp); // 仅通过非抛出操作提交工作。 return *this;}这个赋值运算符的实现使用了 第 9 章 中介绍的 copy-and-swap 惯用法。swap() 成员函数只能交换相同类型的 Grid,但这里完全没问题,因为这个参数化赋值运算符会先利用参数化拷贝构造函数,把给定的 Grid<E> 转换为一个名为 temp 的 Grid<T>。之后,它再使用 swap() 把这个临时的 Grid<T> 与同样也是 Grid<T> 类型的 this 交换。
带非类型模板参数的成员函数模板
Section titled “带非类型模板参数的成员函数模板”前面那个使用整数模板参数 HEIGHT 和 WIDTH 的 Grid 类模板,有一个很大的问题: 高度和宽度会成为类型的一部分。这一限制会让你无法把一个尺寸的网格赋值给另一个不同尺寸的网格。不过,在某些场景中,把一个尺寸的网格复制或赋值给另一个不同尺寸的网格其实是很有用的。与其让目标对象成为源对象的完美拷贝,不如只复制那些在目标数组中放得下的源元素,而当源数组在任一维度上更小时,目标数组剩余位置则用默认值填充。利用成员函数模板为赋值运算符和拷贝构造函数做参数化,你就可以做到这一点,从而允许不同尺寸网格之间的复制和赋值。类定义如下:
export template <typename T, std::size_t WIDTH = 10, std::size_t HEIGHT = 10>class Grid{ public: Grid() = default; virtual ~Grid() = default;
// 显式默认化拷贝构造函数和赋值运算符。 Grid(const Grid& src) = default; Grid& operator=(const Grid& rhs) = default;
// 显式默认化移动构造函数和移动赋值运算符。 Grid(Grid&& src) = default; Grid& operator=(Grid&& rhs) = default;
template <typename E, std::size_t WIDTH2, std::size_t HEIGHT2> Grid(const Grid<E, WIDTH2, HEIGHT2>& src);
template <typename E, std::size_t WIDTH2, std::size_t HEIGHT2> Grid& operator=(const Grid<E, WIDTH2, HEIGHT2>& rhs);
void swap(Grid& other) noexcept;
std::optional<T>& at(std::size_t x, std::size_t y); const std::optional<T>& at(std::size_t x, std::size_t y) const;
std::size_t getHeight() const { return HEIGHT; } std::size_t getWidth() const { return WIDTH; }
private: void verifyCoordinate(std::size_t x, std::size_t y) const;
std::optional<T> m_cells[WIDTH][HEIGHT];};这个新定义包含了拷贝构造函数和赋值运算符的成员函数模板,以及一个辅助成员函数 swap()。请注意,无参版本的拷贝构造函数和赋值运算符都被显式默认化了(因为类中声明了析构函数)。它们只是把源对象中的 m_cells 复制或赋值给目标对象,而这正是两个相同尺寸网格之间你所期望的语义。
下面是参数化拷贝构造函数:
template <typename T, std::size_t WIDTH, std::size_t HEIGHT>template <typename E, std::size_t WIDTH2, std::size_t HEIGHT2>Grid<T, WIDTH, HEIGHT>::Grid(const Grid<E, WIDTH2, HEIGHT2>& src){ for (std::size_t i { 0 }; i < WIDTH; ++i) { for (std::size_t j { 0 }; j < HEIGHT; ++j) { if (i < WIDTH2 && j < HEIGHT2) { m_cells[i][j] = src.at(i, j); } else { m_cells[i][j].reset(); } } }}请注意,这个拷贝构造函数只会从 src 中复制 x 和 y 两个维度上分别不超过 WIDTH 和 HEIGHT 的元素,即使 src 比这更大也是如此。如果 src 在某个维度上更小,那么多出来位置上的 std::optional 对象会通过 reset() 成员函数被清空。
下面是 swap() 和 operator= 的实现:
template <typename T, std::size_t WIDTH, std::size_t HEIGHT>void Grid<T, WIDTH, HEIGHT>::swap(Grid& other) noexcept{ std::swap(m_cells, other.m_cells);}
template <typename T, std::size_t WIDTH, std::size_t HEIGHT>template <typename E, std::size_t WIDTH2, std::size_t HEIGHT2>Grid<T, WIDTH, HEIGHT>& Grid<T, WIDTH, HEIGHT>::operator=( const Grid<E, WIDTH2, HEIGHT2>& rhs){ // Copy-and-swap 惯用法 Grid<T, WIDTH, HEIGHT> temp { rhs }; // 在临时实例中完成所有工作。 swap(temp); // 仅通过非抛出操作提交工作。 return *this;} 使用带显式对象参数的成员函数模板来避免重复代码
Section titled “ 使用带显式对象参数的成员函数模板来避免重复代码”我们一直使用的、只有单个模板类型参数 T 的 Grid 类模板示例中,包含了 at() 成员函数的两个重载版本: const 版 and 非常量版。回顾一下:
export template <typename T>class Grid{ public: std::optional<T>& at(std::size_t x, std::size_t y); const std::optional<T>& at(std::size_t x, std::size_t y) const; // 为简洁起见省略其余部分};它们的实现使用了 Scott Meyers 的 const_cast() 模式来避免代码重复:
template <typename T>const std::optional<T>& Grid<T>::at(std::size_t x, std::size_t y) const{ verifyCoordinate(x, y); return m_cells[x + y * m_width];}
template <typename T>std::optional<T>& Grid<T>::at(std::size_t x, std::size_t y){ return const_cast<std::optional<T>&>(std::as_const(*this).at(x, y));}尽管这里没有真正重复实现逻辑,你仍然得显式地定义 const 和非常量两个重载版本。从 C++23 开始,你可以通过使用显式对象参数(explicit object parameter, 见 第 8 章)来避免手写这两个重载。技巧在于,把 at() 成员函数改写为一个成员函数模板,其中显式对象参数 self 的类型本身也是模板类型参数 Self,因此可以自动推导。这个特性被称为deducing this。声明可以写成这样:
export template <typename T>class Grid{ public: template <typename Self> auto&& at(this Self&& self, std::size_t x, std::size_t y); // 为简洁起见省略其余部分};它的实现使用了一个转发引用(forwarding reference) Self&&; 详见下面的注释。这样的转发引用可以绑定到 Grid<T>&、const Grid<T>& 以及 Grid<T>&&。
下面是实现。请记得 第 8 章 中讲过,在使用显式对象参数的成员函数体内部,你需要通过显式对象参数本身——这里是 self——来访问对象; 此时并不存在 this 指针。
template <typename T>template <typename Self>auto&& Grid<T>::at(this Self&& self, std::size_t x, std::size_t y){ self.verifyCoordinate(x, y); return std::forward_like<Self>(self.m_cells[x + y * self.m_width]);}这里用到了 C++23 引入的 std::forward_like<Self>(x)。它会返回一个对 x 的引用,并让这个引用拥有与 Self&& 相似的性质。因此,由于 m_cells 元素类型是 optional<T>,就有如下结论:
- 如果
Self&&绑定到Grid<T>&,返回类型将是optional<T>&。 - 如果
Self&&绑定到const Grid<T>&,返回类型将是const optional<T>&。 - 如果
Self&&绑定到Grid<T>&&,返回类型将是optional<T>&&。
总结一下,结合成员函数模板,显式对象参数,转发引用以及 forward_like(),你就可以只声明并定义一个成员函数模板,同时提供 const 和非常量两种实例化版本。
你可以为特定类型提供类模板的替代实现。例如,你可能会认为 Grid 对 const char*(C 风格字符串)的默认行为并不合理。Grid<const char*> 会把元素存储在一个 vector<optional<const char*>> 中。其拷贝构造函数和赋值运算符会对这种 const char* 指针类型执行浅拷贝。而对于 const char*,做字符串的深拷贝往往更有意义。解决这个问题最简单的方法,就是专门为 const char* 编写一个替代实现: 它会把这些值转换成 C++ string,并将其存储在 vector<optional<string>> 中。
模板的替代实现称为模板特化(template specialization)。一开始你可能会觉得语法有点怪。当你编写类模板特化时,必须同时表明两件事: 这依然是基于模板的,并且你正在为某个特定类型编写该模板的一个版本。下面就是 Grid 针对 const char* 的特化语法。对于这个实现,原始 Grid 类模板被放到了一个名为 main 的模块接口分区中,而这个特化则放在名为 string 的模块接口分区中。
export module grid:string;import std;// 当使用模板特化时,原始模板也必须可见。import :main;
export template <>class Grid<const char*>{ public: explicit Grid(std::size_t width = DefaultWidth, std::size_t height = DefaultHeight); virtual ~Grid() = default;
// 显式默认化拷贝构造函数和赋值运算符。 Grid(const Grid& src) = default; Grid& operator=(const Grid& rhs) = default;
// 显式默认化移动构造函数和移动赋值运算符。 Grid(Grid&& src) = default; Grid& operator=(Grid&& rhs) = default;
std::optional<std::string>& at(std::size_t x, std::size_t y); const std::optional<std::string>& at(std::size_t x, std::size_t y) const;
std::size_t getHeight() const { return m_height; } std::size_t getWidth() const { return m_width; }
static constexpr std::size_t DefaultWidth { 10 }; static constexpr std::size_t DefaultHeight { 10 };
private: void verifyCoordinate(std::size_t x, std::size_t y) const;
std::vector<std::optional<std::string>> m_cells; std::size_t m_width { 0 }, m_height { 0 };};请注意,在这个特化中,你不再引用任何类型变量(如 T); 你直接处理 const char* 和 string。到这里有一个很自然的问题: 为什么这个类依然有模板头? 也就是说,下面这样的语法究竟有什么意义?
template <>class Grid<const char*>这段语法是在告诉编译器: 这个类是 Grid 类模板针对 const char* 的一个特化。假设你不使用这种语法,而只是试图写成下面这样:
class Grid编译器不会允许你这么做,因为已经存在一个名为 Grid 的类模板(也就是原始类模板)。只有通过特化,你才能复用这个名字。特化的主要好处在于,它们对用户来说可以是完全“幕后”的。当用户创建 Grid<int> 或 Grid<SpreadsheetCell> 时,编译器会根据原始 Grid 模板生成代码。而当用户创建 Grid<const char*> 时,编译器则会使用 const char* 特化版本。这一切都可以在后台自动完成。
主模块接口文件只需要简单地导入并导出这两个模块接口分区:
export module grid;
export import :main;export import :string;这个特化可以这样测试:
Grid<int> myIntGrid; // 使用原始 Grid 模板。Grid<const char*> stringGrid1 { 2, 2 }; // 使用 const char* 特化。
const char* dummy { "dummy" };stringGrid1.at(0, 0) = "hello";stringGrid1.at(0, 1) = dummy;stringGrid1.at(1, 0) = dummy;stringGrid1.at(1, 1) = "there";
Grid<const char*> stringGrid2 { stringGrid1 };当你特化一个类模板时,并不会“继承”任何代码; 特化并不等同于派生。你必须重写整个类的实现。也没有任何要求说你必须提供与原始模板完全相同名字或行为的成员函数。举个例子,Grid 针对 const char* 的特化,其 at() 成员函数返回的是 optional<string>,而不是 optional<const char*>。事实上,你甚至可以写出一个与原始版本毫无关系的完全不同的类。当然,那样做就是在滥用模板特化能力了,除非你有充分理由,否则不应如此。下面是 const char* 特化版本成员函数的实现。与类模板定义不同的是,在每个成员函数定义前,你不需要重复写 template<> 模板头。
Grid<const char*>::Grid(std::size_t width, std::size_t height) : m_width { width }, m_height { height }{ m_cells.resize(m_width * m_height);}
void Grid<const char*>::verifyCoordinate(std::size_t x, std::size_t y) const{ if (x >= m_width) { throw std::out_of_range { std::format("x ({}) must be less than width ({}).", x, m_width) }; } if (y >= m_height) { throw std::out_of_range { std::format("y ({}) must be less than height ({}).", y, m_height) }; }}
const std::optional<std::string>& Grid<const char*>::at( std::size_t x, std::size_t y) const{ verifyCoordinate(x, y); return m_cells[x + y * m_width];}
std::optional<std::string>& Grid<const char*>::at(std::size_t x, std::size_t y){ return const_cast<std::optional<std::string>&>( std::as_const(*this).at(x, y));}本节介绍了如何使用类模板特化,为类模板编写一个特殊实现,并将所有模板类型参数都替换为具体类型。这称为完全模板特化(full template specialization)。这种完整的类模板特化本身已经不再是类模板,而是一个普通的类定义。第 26 章“高级模板”会继续讨论类模板特化,并介绍一个更高级的特性: 偏特化(partial specialization)。
从类模板派生
Section titled “从类模板派生”你可以从类模板继承。如果派生类继承的是模板本身,那么派生类也必须是模板。另一种做法是,从某个特定的类模板实例化结果派生,此时派生类本身就不需要是模板。以前一种情况为例,假设你觉得通用的 Grid 类还不足以直接拿来当游戏棋盘使用。具体来说,你想给游戏棋盘添加一个 move() 成员函数,用来把棋子从棋盘上的一个位置移动到另一个位置。下面是 GameBoard 模板的类定义:
import grid;
export template <typename T>class GameBoard : public Grid<T>{ public: // 从 Grid<T> 继承构造函数。 using Grid<T>::Grid;
void move(std::size_t xSrc, std::size_t ySrc, std::size_t xDest, std::size_t yDest);};这个 GameBoard 模板派生自 Grid 模板,从而继承了它的全部功能。你不需要重写 at()、getHeight() 或其他任何成员函数。你也不需要添加拷贝构造函数、operator= 或析构函数,因为 GameBoard 中没有任何动态分配的内存。此外,GameBoard 还显式继承了来自基类 Grid<T> 的构造函数。如何从基类继承构造函数,会在 第 10 章“理解继承技术”中解释。
这里的继承语法看起来很正常,唯一的区别是基类写成了 Grid<T>,而不是 Grid。之所以要这样写,是因为 GameBoard 模板并不是真正继承自“通用的” Grid 模板。更准确地说,GameBoard 模板针对某个特定类型的每一个实例化版本,都继承自 Grid 针对该同一类型的实例化版本。比如,如果你用 ChessPiece 类型来实例化 GameBoard,那么编译器也会相应生成 Grid<ChessPiece> 的代码。: public Grid<T> 这种语法的意思就是: 这个类继承自对于 T 这个类型参数而言最合适的那个 Grid 实例化版本。
下面是 move() 成员函数的实现:
template <typename T>void GameBoard<T>::move(std::size_t xSrc, std::size_t ySrc, std::size_t xDest, std::size_t yDest){ Grid<T>::at(xDest, yDest) = std::move(Grid<T>::at(xSrc, ySrc)); Grid<T>::at(xSrc, ySrc).reset(); // 重置源单元格 // 或者: // this->at(xDest, yDest) = std::move(this->at(xSrc, ySrc)); // this->at(xSrc, ySrc).reset();}你可以像下面这样使用 GameBoard 模板:
GameBoard<ChessPiece> chessboard { 8, 8 };ChessPiece pawn;chessBoard.at(0, 0) = pawn;chessBoard.move(0, 0, 0, 1);有些程序员会把模板继承(template inheritance)和模板特化(template specialization)混为一谈。下表总结了它们之间的区别:
| 继承 | 特化 | |
|---|---|---|
| 复用代码? | 是: 派生类会包含基类的全部数据成员和成员函数。 | 否: 你必须在特化版本中重写所有需要的代码。 |
| 复用名字? | 否: 派生类的名字必须不同于基类名字。 | 是: 特化版本必须与原始模板拥有相同名字。 |
| 支持多态? | 是: 派生类对象可以替代基类对象使用。 | 否: 模板针对每种类型的实例化结果都是不同类型。 |
第 1 章 介绍了类型别名(type alias)和 typedef 的概念。它们允许你为某个特定类型起另一个名字。简单回顾一下,例如,你可以写出下面这样的类型别名,为 int 类型取一个第二名字:
using MyInt = int;同样地,你也可以使用类型别名为某个类模板的实例取另一个名字。假设你有如下类模板:
template <typename T1, typename T2>class MyClassTemplate { /* … */ };你可以定义下面这样的类型别名,并在其中同时指定这个类模板的两个类型参数:
using OtherName = MyClassTemplate<int, double>;当然,你也可以使用 typedef 来代替这种类型别名。
另外,你还可以只指定一部分类型,而把其余类型保留为模板类型参数。这就叫做别名模板(alias template)。例如:
template <typename T1>using OtherName = MyClassTemplate<T1, double>;这一点是 typedef 做不到的。
你同样可以为独立函数编写模板。其语法与类模板非常相似。例如,你可以编写下面这个通用函数,用于在数组中查找某个值并返回它的索引:
template <typename T>optional<size_t> Find(const T& value, const T* arr, size_t size){ for (size_t i { 0 }; i < size; ++i) { if (arr[i] == value) { return i; // 找到了,返回索引。 } } return {}; // 未找到,返回空的 optional。}Find() 函数模板可以作用于任何类型的数组。例如,你可以用它在 int 数组中查找某个 int,或者在 SpreadsheetCell 数组中查找某个 SpreadsheetCell。
调用这个函数有两种方式: 要么显式使用尖括号给出模板类型参数,要么省略类型,让编译器根据实参推导(deduce)类型参数。示例如下:
int myInt { 3 }, intArray[] {1, 2, 3, 4};const size_t sizeIntArray { size(intArray) };
optional<size_t> res;res = Find(myInt, intArray, sizeIntArray); // 通过推导调用 Find<int>。res = Find<int>(myInt, intArray, sizeIntArray); // 显式调用 Find<int>。if (res) { println("{}", *res); }else { println("Not found"); }
double myDouble { 5.6 }, doubleArray[] {1.2, 3.4, 5.7, 7.5};const size_t sizeDoubleArray { size(doubleArray) };
// 通过推导调用 Find<double>。res = Find(myDouble, doubleArray, sizeDoubleArray);// 显式调用 Find<double>。res = Find<double>(myDouble, doubleArray, sizeDoubleArray);if (res) { println("{}", *res); }else { println("Not found"); }
//res = Find(myInt, doubleArray, sizeDoubleArray); // 无法编译! // 参数类型不同。// 即使使用 myInt,也显式调用 Find<double>。res = Find<double>(myInt, doubleArray, sizeDoubleArray);
SpreadsheetCell cell1 { 10 }SpreadsheetCell cellArray[] { SpreadsheetCell { 4 }, SpreadsheetCell { 10 } };const size_t sizeCellArray { size(cellArray) };
res = Find(cell1, cellArray, sizeCellArray);res = Find<SpreadsheetCell>(cell1, cellArray, sizeCellArray);前面这个 Find() 函数模板的实现要求把数组大小作为一个参数传入。有时候,编译器本来就知道数组的确切大小,例如对于栈上数组。如果能够在调用 Find() 时不必再显式传数组大小,那会更方便。可以通过添加下面这个函数模板来实现这一点。它的实现只是把调用转发给前面的 Find() 函数模板。这也同时展示了: 函数模板和类模板一样,也可以接收非类型参数。
template <typename T, size_t N>optional<size_t> Find(const T& value, const T(&arr)[N]){ return Find(value, arr, N);}这个 Find() 重载的语法看起来有点奇怪,但它的用法很直接,例如:
int myInt { 3 }, intArray[] {1, 2, 3, 4};optional<size_t> res { Find(myInt, intArray) };与类模板成员函数定义一样,函数模板定义(不仅仅是原型)也必须对所有使用该类模板的源文件可见。因此,如果有多个源文件会用到它们,你就应该把它们的定义放在模块接口文件中,并导出它们。
最后,函数模板的模板参数同样也可以带默认值,就像类模板一样。
函数重载 vs. 函数模板
Section titled “函数重载 vs. 函数模板”当你想提供一个能够处理不同数据类型的函数时,有两种选择: 提供一组函数重载,或者提供一个函数模板。那该如何在二者之间做选择呢?
如果你要编写的函数需要处理不同数据类型,而且对所有数据类型来说函数体都相同,那么请使用函数模板。如果对于每一种数据类型,函数体都不同,那就应该提供函数重载。
函数模板重载
Section titled “函数模板重载”从理论上讲,C++ 语言允许你像编写类模板特化那样,去编写函数模板特化。不过,这种做法通常并不值得,因为这类函数模板特化并不会参与重载决议,因此行为可能会出乎意料。
更好的方式是,让函数模板与非模板函数,或者与其他函数模板形成重载。例如,你可能希望为 const char* C 风格字符串编写一个 Find() 重载版本,并让它用 strcmp()(见 第 2 章“处理字符串和字符串视图”)来比较,而不是用 operator==,因为后者只会比较指针,不会比较真正的字符串内容。下面就是这样的一个重载:
optional<size_t> Find(const char* value, const char** arr, size_t size){ for (size_t i { 0 }; i < size; ++i) { if (strcmp(arr[i], value) == 0) { return i; // 找到了,返回索引。 } } return {}; // 未找到,返回空的 optional。}这个函数重载可以像下面这样使用:
// 对 word 使用数组以确保不发生字面量池化,见第 2 章。const char word[] { "two" };const char* words[] { "one", "two", "three", "four" };const size_t sizeWords { size(words) };optional<size_t> res { Find(word, words, sizeWords) }; // 调用非模板 Find。if (res) { println("{}", *res); }else { println("Not found"); }这次 Find() 调用会正确地在索引 1 处找到字符串 “two”。
但如果你像下面这样显式指定模板类型参数,那么被调用的就会是函数模板,其中 T=const char*,而不是那个为 const char* 编写的重载版本:
res = Find<const char*>(word, words, sizeWords);这次 Find() 调用将找不到任何匹配项,因为它比较的不是实际字符串内容,而只是指针。
当编译器的重载决议过程得到两个候选——一个是函数模板,另一个是非模板函数——时,编译器总是优先选择非模板函数。
函数模板作为类模板的友元
Section titled “函数模板作为类模板的友元”当你想在类模板中重载某些运算符时,函数模板就很有用了。例如,你可能想为 Grid 类模板重载加法运算符(operator+),从而让两个网格可以相加。相加的结果会是一个大小等于两个操作数中较小 Grid 的新 Grid。只有当对应单元格都包含实际值时,这些对应单元格才会被相加。假设你希望把 operator+ 写成一个独立的函数模板。其定义应当放在 Grid.cppm 模块接口文件中,如下所示。这个实现使用了定义在 <algorithm> 中的 std::min(),用于返回两个给定参数中的较小值:
export template <typename T>Grid<T> operator+(const Grid<T>& lhs, const Grid<T>& rhs){ std::size_t minWidth { std::min(lhs.m_width, rhs.m_width) }; std::size_t minHeight { std::min(lhs.m_height, rhs.m_height) };
Grid<T> result { minWidth, minHeight }; for (std::size_t y { 0 }; y < minHeight; ++y) { for (std::size_t x { 0 }; x < minWidth; ++x) { const auto& leftElement { lhs.at(x, y) }; const auto& rightElement { rhs.at(x, y) }; if (leftElement.has_value() && rightElement.has_value()) { result.at(x, y) = leftElement.value() + rightElement.value(); } } } return result;}要判断一个 optional 是否包含实际值,可以使用 has_value() 成员函数,而要取出该值,则使用 value()。
这个函数模板适用于任何 Grid,只要网格中存储元素的类型支持加法运算符即可。这个实现唯一的问题在于,它访问了 Grid 类中的 private 成员 m_width 和 m_height。显而易见的解决方案,是改用 public 的 getWidth() 和 getHeight() 成员函数。不过,这里我们先看看,怎样让一个函数模板成为类模板的友元。对于这个例子来说,你可以让这个运算符成为 Grid 类模板的 friend。不过,Grid 和 operator+ 都是模板。你真正想表达的是: operator+ 针对某个特定类型 T 的每一个实例化版本,都应当成为 Grid 模板针对同一类型 T 的实例化版本的友元。其语法如下:
export template <typename T>class Grid{ public: friend Grid operator+<T>(const Grid& lhs, const Grid& rhs); // 为简洁起见省略};这个 friend 声明有点绕: 你实际上是在说,对于类型为 T 的这个类模板实例,operator+ 的 T 实例化版本是它的友元。换句话说,类实例化版本和函数实例化版本之间存在一一对应的友元关系。尤其要注意 operator+ 后面显式写出的模板说明 <T>。这个语法是在告诉编译器: operator+ 本身也是一个模板。
下面可以这样测试这个友元 operator+。先定义两个辅助函数模板:fillGrid() 用于把递增数字填入任意 Grid,而 printGrid() 用于把任意 Grid 打印到控制台。
template <typename T> void fillGrid(Grid<T>& grid){ T index { 0 }; for (size_t y { 0 }; y < grid.getHeight(); ++y) { for (size_t x { 0 }; x < grid.getWidth(); ++x) { grid.at(x, y) = ++index; } }}
template <typename T> void printGrid(const Grid<T>& grid){ for (size_t y { 0 }; y < grid.getHeight(); ++y) { for (size_t x { 0 }; x < grid.getWidth(); ++x) { const auto& element { grid.at(x, y) }; if (element.has_value()) { print("{}\t", element.value()); } else { print("n/a\t"); } } println(""); }}
int main(){ Grid<int> grid1 { 2, 2 }; Grid<int> grid2 { 3, 3 }; fillGrid(grid1); println("grid1:"); printGrid(grid1); fillGrid(grid2); println("\ngrid2:"); printGrid(grid2); auto result { grid1 + grid2 }; println("\ngrid1 + grid2:"); printGrid(result);}关于模板类型参数推导的更多内容
Section titled “关于模板类型参数推导的更多内容”编译器会根据传给函数模板的实参来推导函数模板参数的类型。那些无法被推导出来的模板参数,则必须显式给出。
例如,下面这个 add() 函数模板需要三个模板参数: 返回值类型,以及两个操作数的类型:
template <typename RetType, typename T1, typename T2>RetType add(const T1& t1, const T2& t2) { return t1 + t2; }你可以像下面这样,在调用该函数模板时把这三个参数全部显式指定出来:
auto result { add<long long, int, int>(1, 2) };不过,由于模板参数 T1 和 T2 同时也是函数参数类型的一部分,编译器可以推导出它们,因此你可以只显式指定返回值类型来调用 add():
auto result { add<long long>(1, 2) };这只有在需要推导的参数位于参数列表末尾时才有效。假设函数模板定义成下面这样:
template <typename T1, typename RetType, typename T2>RetType add(const T1& t1, const T2& t2) { return t1 + t2; }你必须显式指定 RetType,因为编译器无法推导出该类型。不过,由于 RetType 是第二个参数,你也就不得不同时显式给出 T1:
auto result { add<int, long long>(1, 2) };你还可以为返回值类型模板参数提供默认值,从而让你在调用 add() 时完全不必显式指定任何类型:
template <typename RetType = long long, typename T1, typename T2>RetType add(const T1& t1, const T2& t2) { return t1 + t2; }…auto result { add(1, 2) };函数模板的返回类型
Section titled “函数模板的返回类型”继续以 add() 函数模板为例,如果让编译器来推导返回值类型,岂不是更方便? 确实如此; 不过返回值类型依赖于模板类型参数,那该怎么做呢? 例如,考虑下面这个函数模板:
template <typename T1, typename T2>RetType add(const T1& t1, const T2& t2) { return t1 + t2; }在这个例子里,RetType 应当是表达式 t1+t2 的类型,可你现在并不知道这一点,因为你并不知道 T1 和 T2 究竟是什么。
正如 第 1 章 所说,从 C++14 开始,你可以要求编译器自动推导函数的返回类型。因此,你完全可以把 add() 写成下面这样:
template <typename T1, typename T2>auto add(const T1& t1, const T2& t2) { return t1 + t2; }不过,使用 auto 来推导表达式类型时,会去掉引用和 const 限定符,而 decltype 不会去掉这些限定符。对于 add() 这个函数模板来说,这种去除通常没问题,因为 operator+ 往往本来就会返回一个新对象; 但对某些其他函数模板而言,这种去除可能并不是你想要的,因此接下来我们会看看,该如何避免这一点。
不过在继续函数模板示例之前,先通过一个非模板示例来看看 auto 与 decltype 的区别。假设你有下面这个函数:
const std::string message { "Test" };
const std::string& getString() { return message; }你可以调用 getString() 并将结果存入一个以 auto 指定类型的变量中,写法如下:
auto s1 { getString() };由于 auto 会去掉引用和 const 限定符,因此 s1 的类型是 string,所以这里会发生一次拷贝。如果你想得到一个常量引用,可以像下面这样显式把它声明为引用并加上 const:
const auto& s2 { getString() };另一种解决方案是使用 decltype,它不会去掉任何限定:
decltype(getString()) s3 { getString() };在这种情况下,s3 的类型是 const string&; 不过这里存在代码重复,因为你需要把 getString() 写两次。如果 getString() 是一个更复杂的表达式,这种写法就会显得很累赘。这个问题可以用 decltype(auto) 来解决:
decltype(auto) s4 { getString() };s4 的类型同样是 const string&。
因此,基于这些知识,我们就可以用 decltype(auto) 来编写 add() 函数模板,从而避免去掉任何 const 和引用限定符:
template <typename T1, typename T2>decltype(auto) add(const T1& t1, const T2& t2) { return t1 + t2; }在 C++14 之前——也就是还没有函数返回类型推导和 decltype(auto) 的时候——这个问题通常通过 C++11 引入的 decltype(expression) 来解决。例如,你或许会以为自己可以这样写:
template <typename T1, typename T2>decltype(t1+t2) add(const T1& t1, const T2& t2) { return t1 + t2; }然而,这是错误的。因为你在原型行开头使用了 t1 和 t2,但此时它们还尚未可见。只有当语义分析器处理完整个参数列表之后,t1 和 t2 才成为已知名字。
这个问题以前通常通过替代函数语法(alternative function syntax)来解决。请注意,在这种语法中,原型行开头使用的是 auto,而真正的返回类型则写在参数列表之后(尾置返回类型, trailing return type); 这样一来,参数名(以及它们的类型,进而表达式 t1+t2 的类型)就都已经可见了:
template <typename T1, typename T2>auto add(const T1& t1, const T2& t2) -> decltype(t1+t2){ return t1 + t2;}另一种选择是使用 std::declval<>(),它会返回你所请求类型的一个右值引用。这并不是一个真正构造完成的对象,因为根本不会调用任何构造函数! 你不能在运行期真正使用这个对象。它只应该用于诸如与 decltype() 结合使用的场景中。它在泛型代码中尤其有用,因为有时候你需要“构造”一个某种未知类型的对象,而此时你根本不知道这个未知类型支持哪些构造函数,因此也就无从调用一个“合理”的构造函数。来看个例子。前面那个在原型行开头显式写 decltype(t1+t2) 返回类型的 add() 代码无法编译,因为那时 t1 和 t2 这两个名字还不可见。为了修正这一点,你可以像下面这样使用 declval<>():
template <typename T1, typename T2>decltype(std::declval<T1>() + std::declval<T2>()) add(const T1& t1, const T2& t2){ return t1 + t2;}缩写函数模板语法
Section titled “缩写函数模板语法”缩写函数模板语法(abbreviated function template syntax)让函数模板的编写变得更容易。再来看看上一节中的 add() 函数模板:
template <typename T1, typename T2>decltype(auto) add(const T1& t1, const T2& t2) { return t1 + t2; }从这个角度看,要表达一个简单的函数模板,这个语法还是相当冗长的。使用缩写函数模板语法后,它可以更优雅地写成下面这样:
decltype(auto) add(const auto& t1, const auto& t2) { return t1 + t2; }在这种语法下,不再需要 template<> 这样的模板头来显式声明模板参数。取而代之的是,在原本实现中作为函数参数类型的 T1 和 T2,现在都直接写成 auto。这种缩写语法只是语法糖; 编译器会自动把这种缩写实现转换回原本较长的那种代码。基本上,每一个被写成 auto 的函数参数,都会变成一个模板类型参数。
这里有两个你必须牢记的注意点。第一,每个被写成 auto 的参数,都会变成一个不同的模板类型参数。假设你有这样一个函数模板:
template <typename T>decltype(auto) add(const T& t1, const T& t2) { return t1 + t2; }这个版本只有一个模板类型参数,而函数的两个参数 t1 和 t2 的类型都是 const T&。对于这样的函数模板,你不能使用缩写语法,因为那会被翻译成具有两个不同模板类型参数的函数模板。
第二个问题是,你无法在函数模板实现中显式使用这些被自动推导出来的类型,因为这些自动推导类型并没有名字。如果你确实需要用到它们,那就要要么继续使用较长的函数模板语法,要么使用 decltype() 来推断它们的类型。
除了类模板、类成员函数模板和函数模板之外,C++ 还支持变量模板(variable template)。其语法如下:
template <typename T>constexpr T pi { T { 3.141592653589793238462643383279502884 } };这是一个表示 π 值的变量模板。若要以某种具体类型获取 pi 的值,可以使用如下语法:
float piFloat { pi<float> };auto piLongDouble { pi<long double> };你总是会得到在请求类型中可表示的最接近 pi 的值。和其他类型的模板一样,变量模板也可以进行特化。
概念(concept)是一些带名字的要求,用于约束类模板和函数模板的模板实参。它们以谓词(predicate)的形式编写,并在编译期求值,用于验证传给模板的模板实参。概念最核心的目标,是让与模板相关的编译器错误变得更易读。每个人都遇到过这样一种情况: 当你给类模板或函数模板传了错误的参数时,编译器会倾泻出成百上千行错误信息。而要从这些错误里真正挖出根本原因,往往并不容易。
编译器之所以会生成如此多的错误,是因为它只是盲目地用你提供的模板实参去实例化模板。模板一旦被实例化,接着就会被编译,而只有到了那时,编译器才能发现你提供的模板类型实参并不支持模板实现深处所要求的某些操作。这个出错点通常会离你实例化模板的位置非常远,因此才会引发大量错误。而有了概念之后,编译器在真正开始实例化模板之前,就可以先验证所提供的模板实参是否满足特定约束。
概念允许编译器在某些类型约束未满足时,输出更易读的错误信息。因此,为了得到更有意义的语义错误,推荐你编写能够刻画语义要求的概念。应当避免编写那些只验证语法层面、却没有任何语义含义的概念,例如仅仅检查某个类型是否支持 operator+ 的概念。这样的概念检查的只是语法,而非语义。std::string 支持 operator+,但显然它与整数上的 operator+ 语义完全不同。相反,像 sortable、swappable 这样的概念就是很好的例子,因为它们刻画了某种明确的语义含义。
下面先来看看编写概念时的语法。
概念定义(concept definition)——也就是一组具名约束(constraints)的模板——语法如下:
template <parameter-list>concept concept-name = constraints-expression;它以一个熟悉的模板头 template<> 开始,但与类模板和函数模板不同,概念本身从不会 be 实例化。接着会出现一个新的关键字 concept,然后是概念的名字。你可以使用任何你喜欢的名字。constraints-expression 可以是任意常量表达式,也就是任何能够在编译期求值的表达式。这个约束表达式(constraints expression)必须得到一个布尔值(而且必须是精确的 bool,因为编译器不会插入类型转换)。它也可以是若干常量表达式的合取(&&)或析取(||)。这些约束永远不会在运行期求值。下一节会更详细地讨论约束表达式。
概念表达式(concept expression)的语法如下:
concept-name<argument-list>概念表达式的求值结果要么是 true,要么是 false。如果结果为 true,就说给定的模板实参满足该概念(model the concept)。下一节会给出例子。
那些求值结果为布尔值的常量表达式,可以直接用作概念定义的约束。它必须在不做任何类型转换的前提下,精确求值为布尔值。例子如下:
template <typename T>concept Big = sizeof(T) > 4;基于这个概念,像 Big<char> 和 Big<short> 这样的概念表达式通常会求值为 false,而像 Big<long double> 这样的概念表达式通常会求值为 true。概念表达式会在编译期求值为一个布尔值,并可通过静态断言(static assertion)进行验证。静态断言使用 static_assert(),允许你在编译期断言某个条件必须成立。所谓断言,就是某件事必须为 true。如果断言为 false,编译器就会报错。第 26 章 会更详细讨论静态断言,但它与概念表达式结合使用时的写法非常直接。下面的代码断言 Big<char> 和 Big<short> 的求值结果确实为 false,而 Big<long double> 的求值结果为 true:
static_assert(!Big<char>);static_assert(!Big<short>);static_assert(Big<long double>);编译这段代码时不应出现任何错误。不过,如果你去掉第一行中的感叹号,那么编译器就会报出类似下面这样的错误:
error C2607: static assertion failed01_Big.cpp(4,15): message : the concept 'Big<char>' evaluated to false01_Big.cpp(2,25): message : the constraint was not satisfied随着概念的引入,C++ 还新增了一种常量表达式,称为requires 表达式(requires expression),它用于定义概念的语法要求,下一小节就会解释。
requires 表达式
Section titled “requires 表达式”requires 表达式的语法如下:
requires (parameter-list) { requirements; }其中的 (parameter-list) 是可选的,其语法与函数参数列表类似,但不允许默认参数值。requires 表达式的参数列表用于引入一些有名字的变量,这些变量的作用域仅限于 requires 表达式体内部。requires 表达式的主体中不能出现普通的变量声明。
requirements 是一系列要求的序列。每一项要求都必须以分号结尾。
要求一共有四种: 简单要求(simple requirement)、类型要求(type requirement)、复合要求(compound requirement)和嵌套要求(nested requirement)。接下来的几节会逐一讨论。
简单要求(simple requirement)就是一条任意的表达式语句,但不能以 requires 开头。变量声明、循环、条件语句等都不允许出现。这条表达式语句永远不会真正被求值; 编译器只是验证它是否能够编译通过。
例如,下面这个概念定义规定,类型 T 必须是可递增的; 也就是说,类型 T 必须同时支持前置和后置 ++ 运算符。请记住,你不能在 requires 表达式体内定义局部变量; 因此要像这里一样,把它们定义为参数,例如本例中的 x。
template <typename T>concept Incrementable = requires(T x) { x++; ++x; };类型要求(type requirement)用于验证某个类型是否合法。它以关键字 typename 开头,后面跟上要检查的类型。例如,下面这个概念要求某个类型 T 拥有名为 value_type 的成员:
template <typename T>concept C = requires { typename T::value_type; };类型要求还可以用于验证某个模板能否用给定类型完成实例化。例如:
template <typename T>concept C = requires { typename SomeTemplate<T>; };复合要求(compound requirement)可用于验证某个操作不会抛出异常,以及/或者验证某个函数返回特定类型。其语法如下:
{ expression } noexcept -> type-constraint;其中 noexcept 和 ->type-constraint 都是可选的。花括号内部的 expression 后面没有分号,但整个复合要求末尾需要一个分号。
来看一个例子。下面这个概念要求某个给定类型拥有不会抛出异常的析构函数,以及不会抛出异常的 swap() 成员函数:
template <typename T>concept C = requires (T x, T y) { { x.~T()} noexcept; { x.swap(y) } noexcept;};这里的 type-constraint 可以是任意类型约束(type constraint)。所谓类型约束,其实就是某个概念的名字,后面跟零个或多个模板类型实参。箭头左边表达式的类型,会被自动作为第一个模板类型实参传给该类型约束。因此,一个类型约束所需显式写出的参数数量,总是比对应概念定义的模板类型参数数量少一个。例如,如果某个概念定义只有一个模板类型参数,那么相应的类型约束就不需要任何模板实参; 你既可以显式写空尖括号 <>,也可以直接省略不写。这个听起来可能有点绕,不过看个例子就会清楚了。下面这个概念会验证: 某个给定类型是否拥有名为 size() 的成员函数,并且该函数的返回类型能够转换为 size_t。它还会验证 size() 是否被标记为 const,因为这里的参数 x 类型是 const T。
template <typename T>concept C = requires (const T x) { { x.size() } -> convertible_to<size_t>;};std::convertible_to<From, To> 是标准库在 <concepts> 中预定义的一个概念,它有两个模板类型参数。箭头左边表达式的类型会被自动作为第一个模板类型实参传给 convertible_to 这个类型约束。因此,这里你只需要显式指定 To 这个模板类型实参,也就是本例中的 size_t。
再看另一个例子。下面这个概念要求类型 T 的实例是可比较的:
template <typename T>concept Comparable = requires(const T a, const T b) { { a == b } -> convertible_to<bool>; { a < b } -> convertible_to<bool>; // … 其余比较运算符类似 …};请记住,复合要求中的 type-constraint 必须是一个类型约束,绝不能是一个普通类型。例如,下面这样的写法就无法编译:
requires 表达式还可以包含嵌套要求(nested requirement)。例如,下面这个概念要求某个类型支持前置和后置的自增、自减操作。除此之外,该 requires 表达式还包含一个嵌套要求,用于验证该类型的大小是 4 个字节。
template <typename T>concept C = requires (T t) { ++t; --t; t++; t--; requires sizeof(t) == 4;};组合概念表达式
Section titled “组合概念表达式”现有的概念表达式可以使用合取(&&)和析取(||)来组合。例如,假设你已经有了一个类似 Incrementable 的 Decrementable 概念; 下面这个例子展示了一个要求某个类型既可递增又可递减的概念:
template <typename T>concept IncrementableAndDecrementable = Incrementable<T> && Decrementable<T>;标准库预定义概念
Section titled “标准库预定义概念”标准库定义了整整一大批预定义概念——超过 100 个——并把它们分成若干类别。下面这个列表只是列出了每个类别中的少量示例概念; 它们都定义在 <concepts> 中,位于 std 命名空间下:
- 核心语言概念:
same_as、derived_from、convertible_to、integral、floating_point、copy_constructible等 - 比较概念:
equality_comparable、totally_ordered等 - 对象概念:
movable、copyable等 - 可调用概念:
invocable、predicate等
另外,<iterator> 中还定义了与迭代器相关的概念,例如 random_access_iterator、forward_iterator、incrementable、indirectly_copyable、indirectly_swappable 等。像 indirectly_copyable 这样的概念,并不是用来验证某个给定迭代器本身是否可复制,而是用来验证该迭代器所指向的元素是否可复制,这也正是名字里 “indirectly” 的含义。最后,<iterator> 还定义了与算法要求相关的概念,例如 mergeable、sortable、permutable 等。
C++ ranges 库也提供了若干标准概念。第 17 章“理解迭代器与 ranges 库”会详细讨论迭代器与 ranges,而 第 20 章 则会更深入地介绍标准库提供的算法。有关所有可用标准概念的完整列表,请查阅你喜欢的标准库参考资料。
如果这些标准概念中恰好有你需要的,那你就可以直接使用它们,无需自己再去实现。例如,下面这个概念要求类型 T 派生自类 Foo:
template <typename T>concept IsDerivedFromFoo = derived_from<T, Foo>;下面这个概念要求类型 T 可转换为 bool:
template <typename T>concept IsConvertibleToBool = convertible_to<T, bool>;接下来的几节还会给出更具体的例子。
当然,这些标准概念也可以继续组合成更具体的概念。例如,下面这个概念要求类型 T 同时是默认可构造和可拷贝构造的:
template <typename T>concept DefaultAndCopyConstructible = default_initializable<T> && copy_constructible<T>;受类型约束的 auto
Section titled “受类型约束的 auto”类型约束(type constraint)可用于约束那些通过 auto 类型推导定义的变量,也可以用于在使用函数返回类型推导时约束返回类型,还可以用于约束缩写函数模板和泛型 lambda 表达式中的参数,等等。把类型约束与 auto 类型推导结合使用,会让代码的自说明性更强。而且一旦某个约束在将来被违反,编译器也能给出更好的错误信息,因为此时错误会直接指向变量定义本身,而不是稍后代码中某个不被支持的操作。
例如,下面这段代码可以无错误编译,因为推导出来的类型是 int,而它满足 Incrementable 概念:
Incrementable auto value1 { 1 };不过,下面这段代码会导致编译错误,指出约束并未满足。由于这里使用了标准字符串字面量 s,推导出来的类型是 std::string,而 string 并不满足 Incrementable:
Incrementable auto value { "abc"s };类型约束与函数模板
Section titled “类型约束与函数模板”在函数模板中使用类型约束,存在几种语法上不同的方式。第一种语法是使用 requires 子句(requires clause),如下所示:
template <typename T> requires constraints-expressionvoid process(const T& t);这里的 constraints-expression 可以是任意常量表达式,也可以是常量表达式的合取与析取,其结果类型必须是布尔值,这与概念定义里的 constraints-expression 完全一致。例如,约束表达式可以是概念表达式:
template <typename T> requires Incrementable<T>void process(const T& t);也可以是一个标准库预定义概念:
template <typename T> requires convertible_to<T, bool>void process(const T& t);也可以是一个 requires 表达式(请注意这里出现了两个 requires 关键字):
template <typename T> requires requires(T x) { x++; ++x; }void process(const T& t);还可以是任意结果为布尔值的常量表达式:
template <typename T> requires (sizeof(T) == 4)void process(const T& t);或者是合取与析取的组合:
template <typename T> requires Incrementable<T> && Decrementable<T>void process(const T& t);还可以使用类型特征(type trait)(见 第 26 章):
template <typename T> requires is_arithmetic_v<T>void process(const T& t);requires 子句还可以写在函数头之后,这称为尾置 requires 子句(trailing requires clause):
template <typename T>void process(const T& t) requires Incrementable<T>;另一种语法仍然使用熟悉的 template<> 样式,不过此时你不是使用 typename(或 class),而是直接使用类型约束。下面是两个例子:
template <convertible_to<bool> T>void process(const T& t);
template <Incrementable T>void process(const T& t);这些都是前面在复合要求一节中讨论过的类型约束,因此它们所需显式写出的模板类型参数数量,会比平常少一个。具体来说:
template <convertible_to<bool> T>void process(const T& t);与下面这段代码完全等价:
template <typename T> requires convertible_to<T, bool>void process(const T& t);还有另一种更优雅的语法,它把本章前面介绍过的缩写函数模板语法与类型约束结合在一起,从而形成下面这种简洁而漂亮的写法。请注意,即便这里没有 template<> 模板头,也不要被迷惑: process() 依然是一个函数模板。
void process(const Incrementable auto& t);当要求未满足时,编译错误通常已经很可读了。使用整数参数调用 process() 会按预期正常工作。而若用 std::string 调用,则会得到一个说明约束不满足的错误。下面给出一个例子。Clang 编译器会生成如下错误。第一眼看上去也许还是略显冗长,但实际上已经相当容易读懂了。
<source>:17:2: error: no matching function for call to 'process' process(str); ^~~~~~~<source>:9:6: note: candidate template ignored: constraints not satisfied [with T = std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char>>]void process(const T& t) ^<source>:8:11: note: because 'std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char>>' does not satisfy 'Incrementable'template <Incrementable T> ^<source>:6:42: note: because 'x++' would be invalid: cannot increment value of type 'std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char>>'concept Incrementable = requires(T x) { x++; ++x; };你完全可以使用自己最顺手的语法。不过在某些情况下,你别无选择,只能使用尾置 requires 子句语法:
- 当约束表达式用到了函数的参数名时,必须使用尾置 requires 子句语法; 否则,函数模板参数名此时还不在作用域中。
- 当要约束某个类模板中直接定义在类体里的成员函数时,也必须使用尾置 requires 子句语法,因为这种成员函数本身并没有模板头。
约束包含关系
Section titled “约束包含关系”你可以为同一个函数模板编写带有不同类型约束的重载。编译器总是会选择约束更具体的那个模板; 更具体的约束会包含/蕴含(subsume/imply) 较弱的约束。示例如下:
template <typename T> requires integral<T>void process(const T& t) { println("integral<T>"); }
template <typename T> requires (integral<T> && sizeof(T) == 4)void process(const T& t) { println("integral<T> && sizeof(T) == 4"); }假设你有如下对 process() 的调用:
process(int { 1 });process(short { 2 });那么,在一个典型系统(其中 int 为 32 位,short 为 16 位)上的输出将如下所示:
integral<T> && sizeof(T) == 4integral<T>编译器在解析这种包含关系时,会先对约束表达式做规范化(normalization)。在规范化过程中,所有概念表达式都会递归展开为它们的定义,直到最终结果变成一个由常量布尔表达式的合取和析取组成的单一常量表达式。如果编译器能够证明一个规范化后的约束表达式蕴含另一个,那么前者就包含后者。请注意,编译器在证明包含关系时,只会考虑合取与析取,不会考虑否定。
这种包含关系推理只在语法层面进行,而不是语义层面。例如,sizeof(T)>4 在语义上比 sizeof(T)>=4 更具体,但在语法层面上,前者并不会包含后者。
不过还有一个注意点: 像前面用过的 std::is_arithmetic 这样的类型特征(type trait),在规范化过程中不会被展开。因此,如果标准库同时提供了某个概念和对应的类型特征,你应当使用概念而不是类型特征。比如,请优先使用 std::integral 概念,而不是 std::is_integral 类型特征。
类型约束与类模板
Section titled “类型约束与类模板”到目前为止,所有类型约束示例都围绕函数模板展开。类型约束同样可以用于类模板,语法也很类似。举个例子,让我们回到本章前面介绍过的 GameBoard 类模板。下面是它的一个新定义,要求其模板类型参数必须派生自 GamePiece:
template <std::derived_from<GamePiece> T>class GameBoard : public Grid<T>{ public: // 从 Grid<T> 继承构造函数。 using Grid<T>::Grid;
void move(std::size_t xSrc, std::size_t ySrc, std::size_t xDest, std::size_t yDest);};成员函数实现同样也需要相应更新。例如:
template <std::derived_from<GamePiece> T>void GameBoard<T>::move(std::size_t xSrc, std::size_t ySrc, std::size_t xDest, std::size_t yDest) { /*…*/ }当然,你也可以像下面这样使用 requires 子句:
template <typename T> requires std::derived_from<T, GamePiece>class GameBoard : public Grid<T> { /*…*/ };类型约束与类成员函数
Section titled “类型约束与类成员函数”你还可以对类模板中的某些特定成员函数施加额外约束。例如,GameBoard 类模板的 move() 成员函数还可以进一步约束为: 要求类型 T 是可移动的:
template <std::derived_from<GamePiece> T>class GameBoard : public Grid<T>{ public: // 从 Grid<T> 继承构造函数。 using Grid<T>::Grid;
void move(std::size_t xSrc, std::size_t ySrc, std::size_t xDest, std::size_t yDest) requires std::movable<T>;};这样的 requires 子句同样也必须重复写在成员函数定义上:
template <std::derived_from<GamePiece> T>void GameBoard<T>::move(std::size_t xSrc, std::size_t ySrc, std::size_t xDest, std::size_t yDest) requires std::movable<T>{ /*…*/ }请记住,得益于本章前面讨论过的选择性实例化,即使某种类型不可移动,你仍然可以使用这个 GameBoard 类模板——前提是你从来不去调用它的 move()。
基于约束的类模板特化与函数模板重载
Section titled “基于约束的类模板特化与函数模板重载”如本章前面所述,你可以为类模板编写特化,也可以为函数模板编写重载,从而为某些特定类型提供不同实现。同样地,你也可以为一组满足某些约束的类型编写特化或重载。
让我们再看一眼本章前面那个 Find() 函数模板。回忆一下:
template <typename T>optional<size_t> Find(const T& value, const T* arr, size_t size){ for (size_t i { 0 }; i < size; ++i) { if (arr[i] == value) { return i; // 找到了,返回索引。 } } return {}; // 未找到,返回空的 optional。}这个实现使用 == 运算符来比较值。通常并不建议用 == 来比较浮点类型是否相等,更好的做法是使用epsilon 测试。下面这个为浮点类型编写的 Find() 重载,就使用了一个 AreEqual() 辅助函数中实现的 epsilon 测试,而不是直接用 operator==:
template <std::floating_point T>optional<size_t> Find(const T& value, const T* arr, size_t size){ for (size_t i { 0 }; i < size; ++i) { if (AreEqual(arr[i], value)) { return i; // 找到了,返回索引。 } } return {}; // 未找到,返回空的 optional。}AreEqual() 的定义如下,它同样使用了类型约束。关于 epsilon 测试背后的数学原理,已经超出了本书范围,而且对本节讨论也并不重要。
template <std::floating_point T>bool AreEqual(T x, T y, int precision = 2){ // 将机器 epsilon 缩放到给定值的量级, // 并乘以所需的精度。 return fabs(x - y) <= numeric_limits<T>::epsilon() * fabs(x + y) * precision || fabs(x - y) < numeric_limits<T>::min(); // 结果是次正规数。}正如本节所展示的那样,概念是一种非常强大的类型约束机制。它们提供了极高的灵活性。请始终牢记以下几点:
- 尽量优先使用标准库预定义概念或它们的组合,而不是自己编写概念,因为自己写出完整且正确的概念既困难又耗时。
- 如果你确实要自己编写概念,请确保它们建模的是语义要求,而不仅仅是语法要求。例如,如果你的代码在技术层面只需要
operator==和<,也不要只写一个要求这两个运算符存在的概念,因为那只是语法约束。你真正应当表达的是“这个类型可排序”——那才是语义约束。 - 提前使用恰当的语义类型要求,你将来就更不容易不得不再添加额外约束。举例来说,如果你的类模板只用一个要求
operator==和<的概念进行约束,那么未来某一天你可能还要再补上operator>的要求。这样一来,你就会破坏已有代码。而如果一开始你就用了一个真正表达可排序性的恰当概念,则不会破坏现有代码。 - 如果 requires 表达式中的某个参数本来就不应被修改,请把它标记为
const,以准确表达这一要求。 - 编写新的类模板或函数模板时,尽量为所有模板类型参数都添加恰当的类型约束。无约束的模板类型参数应当成为过去式。
- 记住,你还可以把类型约束与
auto类型推导结合使用。
本章开启了如何使用模板进行泛型编程的讨论。你看到了编写模板的语法,以及模板真正有用的那些场景。它解释了如何编写类模板、类成员函数模板,以及如何使用模板参数。本章还进一步讨论了如何使用类模板特化,来为模板参数替换成特定实参的情形编写专门实现。
你还学习了变量模板、函数模板,以及优雅的缩写函数模板语法。本章最后讨论了概念,它允许你对模板参数施加约束。
第 26 章“高级模板”会继续讨论模板,介绍一些更高级的特性,例如类模板偏特化、可变参数模板以及元编程。
通过完成下面这些练习,你可以练习本章讨论过的内容。所有练习的解答都包含在本书网站 www.wiley.com/go/proc++6e 上的代码下载包中。不过,如果你在某道练习上卡住了,请先回头重读本章相关内容,尽量自己找到答案,然后再查看网站上的解答。
-
练习 12-1: 编写一个
KeyValuePair类模板,它有两个模板类型参数:Key和Value。该类应当包含两个私有数据成员,分别存储键和值。提供一个接受键和值的构造函数,并添加合适的 getter 和 setter。通过在main()中创建几个实例来测试你的类,并尝试使用类模板实参推导。 -
练习 12-2: 练习 12-1 中的
KeyValuePair类模板允许它的键和值模板类型参数使用任意数据类型。例如,下面这段代码会以std::string作为键和值的类型来实例化该类模板:KeyValuePair<std::string, std::string> kv { "John Doe", "New York" };不过,如果把
const char*用作模板类型实参,最终得到的数据成员类型就会是const char*,而这并不是我们想要的。请为
const char*键和值编写一个类模板特化,把给定字符串转换为std::string。 -
练习 12-3: 以你在练习 12-1 中的解答为基础,做出适当修改,使得键的类型只允许整数类型,而值的类型只允许浮点类型。
-
练习 12-4: 编写一个名为
concat()的函数模板,它有两个模板类型参数以及两个函数参数t1和t2。该函数会先把t1和t2转换成字符串,然后返回这两个字符串拼接后的结果。对于这个练习,你只需关注那些std::to_string()支持的类型。请创建并使用一个合适的概念,确保用户不会拿不受支持的类型来调用该函数模板。尽量尝试在不使用template关键字的前提下编写这个函数模板。 -
练习 12-5: 练习 12-4 中的
concat()函数模板仅适用于那些std::to_string()支持的类型。在这个练习中,请修改你的解答,让它也能处理字符串,以及字符串与其他类型的各种组合。 -
练习 12-6: 以前面本章中的原始
Find()函数模板为基础,为类型T添加一个合适的约束。