2.1.2、模板Grid类
在上一节中的GameBoard很不错但还不够。一个问题是不能用GameBoard来以值来保存元素;它保存的是指针。另一个更严重的问题是,与类型安全相关。在GameBoard的每一个网格中保存了一个unique_ptr<GamePiece>。即使是保存ChessPiece,当使用at()来请示一个特定的棋子时,会得到一个unique_ptr<GamePiece>。这就意味着不得不将查询的GamePiece向下转化为ChessPiece以便能够使用ChessPiece的特定功能。还有,无法阻止在GameBoard中混合各种不同的GamePiece继承来的对象。例如,假定不仅有ChessPiece,还有TicTacToePiece:
class TicTacToePiece : public GamePiece
{
public:
std::unique_ptr<GamePiece> clone() const override
{
// Call the copy constructor to copy this instance
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>();
这个大问题就是不管怎么样都要记住在特定位置保存的是什么,以便调用at()时执行正确的向下转化。
GameBoard的一个缺点是它不能用于保存原始数据类型,比如int或double,因为在网格中的数据类型必须继承自GamePiece。
如果写一个通用的Grid类,能够用于保存ChessPiece,SpreadsheetCell,int,double,等等,会是非常好的。在C++中,可以通过写一个类模板来轮到,它是类定义的一个蓝图。在类模板中,数据类型还不明确。客户可以在想使用时通过指定类型来实例化模板。这叫做泛型编程。泛型编程最大的好处是类型安全。在实例化类定义与成员函数中使用的类型是具体的类型,而不是上一节中使用多态解决方案的基类类型中的抽象类型。
我们开始看如何书写这样的一个Grid类模板定义。
2.1.2.1、Grid类模板定义
要理解类模板,研究一下其语法是很有帮助的。下面的例子展示了如何修改GameBoard类生成一个参数化的Grid类模板。代码后会对语法细节进行解释。注意名字由GameBoard修改为了Grid。Grid也应该能用于原始数据类型,如int与double。这就是为什么优选使用值语法而不是与用于GameBoard实现的多态指针语法相比的多态,来实现该解决方案。m_cells容器保存了真实的对象,而不是指针。与指针语法相比使用值语法的向下转化,使得不能有真正的空网格。也就是说,网格必须要有值。而指针语法,空网格保存了Nullptr。幸运的是,std::optional来救场了。它允许使用值语法,同时也有表示空网格的方法。
export
template <typename T>
class Grid
{
public:
explicit Grid(std::size_t width = DefaultWidth, std::size_t height = DefaultHeight);
virtual ~Grid() = default;
// Explicitly default a copy constructor and copy assignment operator.
Grid(const Grid& src) = default;
Grid& operator=(const Grid& rhs) = default;
// Explicitly default a move constructor and move assignment operator.
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>”部分叫做模板头。在C++中template与typename都是关键字。我们前面讨论过,模板”参数化”类型以与函数”参数化”值一样的方式工作。与在函数中代表调用者传递的参数一样使用参数名字,在模板中使用模板类型参数名字(比如T)来代表调用者传递的模板类型参数一样的类型。名字T没有什么特殊的--可以使用想用的任意名字。传统意义上,当单个类型使用时,叫做T,但是这只是历史上的命名规范,就像调用整数数组索引用i与j一样。模板标识符对整个语句有效,在这个例子中是类模板定义。
注意:由于历史的原因,可以使用关键字class而不是typename来指定模板类型参数。这样的话,许多书籍与程序中的使用语法就变成了这样:template <class T>。然而,在上下文中使用class关键字会令人迷惑,因为它隐含的意思是类型必须是一个类,实际上不是这样的。类型可以是一个类,一个结构,一个联合,一个原始数据类型,如int或double,等等。为了避免这种疑惑,我们使用typename。
在前面的GameBoard类中,m_cells数据成员为指针vector,要求特别的代码来拷贝--就需要拷贝构造函数与拷贝赋值操作符。在Grid类中,m_cells是一个可选值的vector,所以编译器生成的拷贝构造函数与赋值操作符就可以了。然而,一旦有了用户声明的析构函数,编译器隐式生成的拷贝构造函数或拷贝赋值操作符就会过时,所以Grid类模板显式地对它们进行了缺省。也显式地缺省了move构造函数与move赋值操作符。下面是显式缺省的拷贝赋值操作符:
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类或类型时,不得不使用template ID,也就是说,Grid<T>,它是特定类型,比如int,SpreadsheetCell或ChessPiece的Grid类模板的实例化。
因为m_cells不再保存指针,而变成了可选值,at()成员函数现在返回optional<T>而不是unique_ptr,也就是说optional可以有一个类型T的值,或者为空:
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;