C++ 不仅为面向对象编程提供了语言支持,还支持泛型编程。正如第6章《设计可重用性》中讨论的,泛型编程的目标是编写可重用的代码。C++ 中支持泛型编程的基本工具是模板。虽然模板不严格是面向对象的特性,但它们可以与面向对象编程结合产生强大的效果。
在过程化编程范式中,主要编程单元是过程或函数。函数之所以有用,主要是因为它们允许你编写与特定值无关的算法,因此可以用于许多不同的值。例如,C++ 中的 sqrt() 函数计算调用者提供的值的平方根。只计算一个数字(如四)的平方根的函数不会特别有用!sqrt() 函数是针对一个参数编写的,该参数是调用者传递的任何值的代表。
对象导向编程范式增加了对象的概念,对象将相关数据和行为组合在一起,但它并没有改变函数和方法参数化值的方式。
模板将参数化概念进一步扩展,允许你对类型以及值进行参数化。C++ 中的类型包括 int、double 等基本类型,以及 SpreadsheetCell、CherryTree 等用户定义的类。有了模板,你可以编写不仅与它将要给定的值无关,而且与这些值的类型无关的代码。例如,你可以编写一个堆栈类定义,而不是编写用于存储 int、Cars 和 SpreadsheetCells 的单独堆栈类,这个堆栈类定义可以用于任何这些类型。
尽管模板是一项惊人的语言特性,但 C++ 中的模板在语法上可能令人困惑,许多程序员避免自己编写模板。然而,每个程序员至少需要知道如何使用模板,因为它们被广泛用于库,例如 C++ 标准库。本章教你如何在 C++ 中支持模板,重点是在标准库中出现的方面。在此过程中,你将了解一些除了使用标准库之外,你可以在程序中运用的巧妙特性。
类模板定义了一个类,其中一些变量的类型、方法的返回类型和/或方法的参数被指定为模板参数。类模板主要用于容器,即存储对象的数据结构。这一节通过运行示例 Grid 容器来说明。为了保持示例的合理长度并足够简单以阐明特定要点,本章的不同部分将为 Grid 容器添加不在后续部分使用的功能。
假设你想要一个通用的游戏棋盘类,可以用作国际象棋棋盘、跳棋棋盘、井字棋棋盘或任何其他二维游戏棋盘。为了使其具有通用性,你应该能够存储国际象棋棋子、跳棋棋子、井字棋棋子或任何类型的游戏棋子。
在没有模板的情况下,构建通用游戏棋盘的最佳方法是使用多态性来存储通用的 GamePiece 对象。然后,你可以让每个游戏的棋子从 GamePiece 类继承。例如,在国际象棋游戏中,ChessPiece 将是 GamePiece 的派生类。通过多态性,编写为存储 GamePiece 的 GameBoard 也可以存储 ChessPiece。因为可能需要复制 GameBoard,所以 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 的实现使用向量的向量和 unique_ptr 来存储 GamePieces:
GameBoard::GameBoard(size_t width, size_t height) : m_width { width }, m_height { height } {
m_cells.resize(m_width);
for (auto& column : m_cells) {
column.resize(m_height);
}
}
GameBoard::GameBoard(const GameBoard& src) : GameBoard { src.m_width, src.m_height } {
// The ctor-initializer of this constructor delegates first to the
// non-copy constructor to allocate the proper amount of memory.
// The next step is to copy the data.
for (size_t i { 0 }; i < m_width; i++) {
for (size_t j { 0 }; j < m_height; j++) {
if (src.m_cells[i][j]) {
m_cells[i][j] = src.m_cells[i][j]->clone();
}
}
}
}
void GameBoard::verifyCoordinate(size_t x, size_t y) const {
if (x >= m_width) {
throw out_of_range { format("{} must be less than {}.", x, m_width) };
}
if (y >= m_height) {
throw out_of_range { format("{} must be less than {}.", 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 idiom
GameBoard temp { rhs }; // Do all the work in a temporary instance.
swap(temp); // Commit the work with only non-throwing operations.
return *this;
}
const unique_ptr<GamePiece>& GameBoard::at(size_t x, size_t y) const {
verifyCoordinate(x, y);
return m_cells[x][y];
}
unique_ptr<GamePiece>& GameBoard::at(size_t x, size_t y) {
return const_cast<unique_ptr<GamePiece>&>(as_const(*this).at(x, y));
}
在这个实现中,at() 返回指定位置的棋子的引用,而不是棋子的副本。GameBoard 作为二维数组的抽象,应该通过给出索引处的实际对象而不是对象的副本来提供数组访问语义。
这个实现为 at() 提供了两个版本,一个返回非常量引用,另一个返回常量引用。
使用复制和交换习语(copy-and-swap idiom)用于赋值运算符,以及 Scott Meyer 的 const_cast() 模式来避免代码重复。
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;
这个 GameBoard 类运行得相当好,它可以用于国际象棋棋盘的创建和棋子的放置。
在上一节中的 GameBoard 类虽然实用,但有其局限性。首先,你无法使用 GameBoard 来按值存储元素;它总是存储指针。更严重的问题与类型安全有关。GameBoard 中的每个单元格都存储一个 unique_ptr<GamePiece>。即使你存储的是 ChessPieces,当你使用 at() 请求特定单元格时,你将得到一个 unique_ptr<GamePiece>。这意味着你必须将检索到的 GamePiece 向下转型为 ChessPiece 才能使用 ChessPiece 的特定功能。GameBoard 的另一个缺点是它不能用于存储原始类型,如 int 或 double,因为单元格中存储的类型必须派生自 GamePiece。
因此,如果你能编写一个通用的 Grid 类来存储 ChessPieces、SpreadsheetCells、ints、doubles 等就很好了。在 C++ 中,你可以通过编写类模板来实现这一点,这允许你编写一个不指定一个或多个类型的类。然后客户端通过指定他们想要使用的类型来实例化模板。这就是所谓的泛型编程。
泛型编程的最大优势是类型安全。类及其方法中使用的类型是具体类型,而不是像上一节中多态解决方案那样的抽象基类类型。例如,假设不仅有 ChessPiece,还有 TicTacToePiece:
class TicTacToePiece : public GamePiece {
public:
std::unique_ptr<GamePiece> clone() const override {
// 调用复制构造函数来复制这个实例
return std::make_unique<TicTacToePiece>(*this);
}
};
使用上一节中的多态解决方案,没有什么能阻止你在同一个棋盘上存储井字棋棋子和国际象棋棋子:
GameBoard chessBoard { 8, 8 };
chessBoard.at(0, 0) = std::make_unique<ChessPiece>();
chessBoard.at(0, 1) = std::make_unique<TicTacToePiece>();
这样做的一个大问题是,你需要记住一个单元格存储了什么,以便在调用 at() 时执行正确的向下转型。
为了理解类模板,检查其语法非常有帮助。以下示例展示了如何将 GameBoard 类修改为模板化的 Grid 类。代码后面会详细解释语法。请注意,类名已从 GameBoard 改为 Grid。
与 GameBoard 实现中使用的多态指针语义相比,我选择使用值语义而不是多态来实现这个解决方案。m_cells 容器存储实际对象,而不是指针。与指针语义相比,使用值语义的一个缺点是不能有真正的空单元格;也就是说,单元格必须始终包含某个值。使用指针语义时,空单元格存储 nullptr。 std::optional 在这里提供了帮助。它允许你在仍然有表示空单元格的方法的同时使用值语义。
template <typename T>
class Grid {
public:
explicit Grid(size_t width = DefaultWidth, size_t height = DefaultHeight);
virtual ~Grid() = default;
// Explicitly default a copy constructor and assignment operator.
Grid(const Grid& src) = default;
Grid& operator=(const Grid& rhs) = default;
// Explicitly default a move constructor and assignment operator.
Grid(Grid&& src) = default;
Grid& operator=(Grid&& rhs) = default;
std::optional<T>& at(size_t x, size_t y);
const std::optional<T>& at(size_t x, size_t y) const;
size_t getHeight() const { return m_height; }
size_t getWidth() const { return m_width; }
static const size_t DefaultWidth { 10 };
static const size_t DefaultHeight { 10 };
private:
void verifyCoordinate(size_t x, size_t y) const;
std::vector<std::vector<std::optional<T>>> m_cells;
size_t m_width { 0 }, m_height { 0 };
};
export template <typename T>:这一行表示接下来的类定义是一个关于类型 T 的模板,并且它正在从模块中导出。template 和 typename 是 C++ 中的关键字。如前所述,模板在类型上“参数化”,就像函数在值上“参数化”一样。
使用模板类型参数名(如 T)来表示调用者将作为模板类型参数传递的类型。T 的名称没有特殊含义——你可以使用任何你想要的名称。
出于历史原因,你可以使用关键字 class 而不是 typename 来指定模板类型参数。因此,许多书籍和现有程序使用类似 template <class T> 的语法。然而,在这种情况下使用 class 这个词是令人困惑的,因为它暗示类型必须是一个类,这实际上并不正确。类型可以是类、结构体、联合、语言的原始类型,如 int 或 double 等。
在之前的 GameBoard 类中,m_cells 数据成员是指针的向量的向量,这需要特殊的复制代码,因此需要拷贝构造函数和拷贝赋值操作符。在 Grid 类中,m_cells 是可选值的向量的向量,所以编译器生成的拷贝构造函数和赋值操作符是可以的。
一旦你有了用户声明的析构函数,就不推荐编译器隐式生成拷贝构造函数或拷贝赋值操作符,因此 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)实际上是模板名。当你想谈论实际的 Grid 类或类型时,你必须使用模板 ID,即 Grid<T>,这些是针对特定类型(如 int、SpreadsheetCell 或 ChessPiece)的 Grid 类模板的实例化。
由于 m_cells 不再存储指针,而是存储可选值,at() 方法现在返回 std::optional<T> 而不是 unique_ptrs,即可以有类型 T 的值,也可以为空的 optionals:
std::optional<T>& at(size_t x, size_t y);
const std::optional<T>& at(size_t x, size_t y) const;
每个 Grid 模板的方法定义都必须以 template <typename T> 说明符开头。构造函数如下所示:
template <typename T>
Grid<T>::Grid(size_t width, size_t height) : m_width { width }, m_height { height } {
m_cells.resize(m_width);
for (auto& column : m_cells) {
column.resize(m_height);
}
}
注意:类模板的方法定义需要对使用该类模板的任何客户端代码可见。这对方法定义的位置施加了一些限制。通常,它们直接放在类模板定义本身的同一文件中。本章后面讨论了绕过这一限制的一些方法。
请注意,:: 前的类名是 Grid<T>,而不是 Grid。在所有方法和静态数据成员定义中,你必须指定 Grid<T> 作为类名。构造函数的主体与 GameBoard 构造函数相同。其他方法定义也类似于 GameBoard 类中的对应方法,但有适当的模板和 Grid<T> 语法变化:
template <typename T>
void Grid<T>::verifyCoordinate(size_t x, size_t y) const {
if (x >= m_width) {
throw std::out_of_range { std::format("{} must be less than {}.", x, m_width) };
}
if (y >= m_height) {
throw std::out_of_range { std::format("{} must be less than {}.", y, m_height) };
}
}
template <typename T>
const std::optional<T>& Grid<T>::at(size_t x, size_t y) const {
verifyCoordinate(x, y);
return m_cells[x][y];
}
template <typename T>
std::optional<T>& Grid<T>::at(size_t x, size_t y) {
return const_cast<std::optional<T>&>(std::as_const(*this).at(x, y));
}
注意:如果类模板方法的实现需要某个模板类型参数的默认值(例如 T),则可以使用 T{} 语法。T{} 调用对象的默认构造函数(如果 T 是类类型),或生成零(如果 T 是基本类型)。这种语法称为零初始化语法。它是为尚不知道类型的变量提供合理默认值的好方法。
当你想要创建 Grid 对象时,不能单独使用 Grid 作为类型;你必须指定将存储在该 Grid 中的类型。为特定类型创建类模板对象称为实例化模板。以下是一个示例:
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>。你不能在这些网格中存储 SpreadsheetCells 或 ChessPieces;如果尝试这样做,编译器将生成错误。
还要注意 value_or() 的使用。at() 方法返回一个可选引用,可能包含值也可能不包含。value_or() 方法在可选项中有值时返回该值;否则,它返回给 value_or() 的参数。
模板类型的指定非常重要;以下两行都无法编译:
Grid test; // 无法编译
Grid<> test; // 无法编译
如果你想声明一个接受 Grid 对象的函数或方法,你必须在 Grid 类型中指定存储在网格中的类型:
void processIntGrid(Grid<int>& grid) { /* 省略正文以简洁 */ }
或者,你可以使用本章后面讨论的函数模板,编写一个模板化的函数,该函数根据网格中元素的类型进行模板化。
注意:你可以使用类型别名来简化完整的 Grid 类型的重复书写,例如 Grid<int>:
using IntGrid = Grid<int>;void processIntGrid(IntGrid& grid) { /* 正文 */ }
Grid 类模板可以存储的不仅仅是 int。例如,你可以实例化一个存储 SpreadsheetCells 的 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) };