C++模板基礎及代碼實戰
一、C++ 模板概覽
1.泛型編程的支持
C++ 不僅為面向對象編程提供了語言支持,還支持泛型編程。正如第6章《設計可重用性》中討論的,泛型編程的目標是編寫可重用的代碼。C++ 中支持泛型編程的基本工具是模板。雖然模板不嚴格是面向對象的特性,但它們可以與面向對象編程結合產生強大的效果。
2.模板的核心
在過程化編程范式中,主要編程單元是過程或函數。函數之所以有用,主要是因為它們允許你編寫與特定值無關的算法,因此可以用于許多不同的值。例如,C++ 中的 sqrt() 函數計算調用者提供的值的平方根。只計算一個數字(如四)的平方根的函數不會特別有用!sqrt() 函數是針對一個參數編寫的,該參數是調用者傳遞的任何值的代表。
對象導向編程范式增加了對象的概念,對象將相關數據和行為組合在一起,但它并沒有改變函數和方法參數化值的方式。
3.模板的進階參數化
模板將參數化概念進一步擴展,允許你對類型以及值進行參數化。C++ 中的類型包括 int、double 等基本類型,以及 SpreadsheetCell、CherryTree 等用戶定義的類。有了模板,你可以編寫不僅與它將要給定的值無關,而且與這些值的類型無關的代碼。例如,你可以編寫一個堆棧類定義,而不是編寫用于存儲 int、Cars 和 SpreadsheetCells 的單獨堆棧類,這個堆棧類定義可以用于任何這些類型。
4.模板的使用和重要性
盡管模板是一項驚人的語言特性,但 C++ 中的模板在語法上可能令人困惑,許多程序員避免自己編寫模板。然而,每個程序員至少需要知道如何使用模板,因為它們被廣泛用于庫,例如 C++ 標準庫。本章教你如何在 C++ 中支持模板,重點是在標準庫中出現的方面。在此過程中,你將了解一些除了使用標準庫之外,你可以在程序中運用的巧妙特性。
二、類模板
1.類模板的定義和應用
類模板定義了一個類,其中一些變量的類型、方法的返回類型和/或方法的參數被指定為模板參數。類模板主要用于容器,即存儲對象的數據結構。這一節通過運行示例 Grid 容器來說明。為了保持示例的合理長度并足夠簡單以闡明特定要點,本章的不同部分將為 Grid 容器添加不在后續部分使用的功能。
2.編寫類模板
假設你想要一個通用的游戲棋盤類,可以用作國際象棋棋盤、跳棋棋盤、井字棋棋盤或任何其他二維游戲棋盤。為了使其具有通用性,你應該能夠存儲國際象棋棋子、跳棋棋子、井字棋棋子或任何類型的游戲棋子。
三、不使用模板編寫代碼
1.通過多態性建立通用游戲棋盤
在沒有模板的情況下,構建通用游戲棋盤的最佳方法是使用多態性來存儲通用的 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);
}
};
2.GameBoard 的實現
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 作為二維數組的抽象,應該通過給出索引處的實際對象而不是對象的副本來提供數組訪問語義。
3.注意事項
這個實現為 at() 提供了兩個版本,一個返回非常量引用,另一個返回常量引用。
使用復制和交換習語(copy-and-swap idiom)用于賦值運算符,以及 Scott Meyer 的 const_cast() 模式來避免代碼重復。
4.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;
這個 GameBoard 類運行得相當好,它可以用于國際象棋棋盤的創建和棋子的放置。
四、類模板實現的 Grid 類
1.GameBoard 的局限性
在上一節中的 GameBoard 類雖然實用,但有其局限性。首先,你無法使用 GameBoard 來按值存儲元素;它總是存儲指針。更嚴重的問題與類型安全有關。GameBoard 中的每個單元格都存儲一個 unique_ptr<GamePiece>。即使你存儲的是 ChessPieces,當你使用 at() 請求特定單元格時,你將得到一個 unique_ptr<GamePiece>。這意味著你必須將檢索到的 GamePiece 向下轉型為 ChessPiece 才能使用 ChessPiece 的特定功能。GameBoard 的另一個缺點是它不能用于存儲原始類型,如 int 或 double,因為單元格中存儲的類型必須派生自 GamePiece。
2.實現通用 Grid 類
因此,如果你能編寫一個通用的 Grid 類來存儲 ChessPieces、SpreadsheetCells、ints、doubles 等就很好了。在 C++ 中,你可以通過編寫類模板來實現這一點,這允許你編寫一個不指定一個或多個類型的類。然后客戶端通過指定他們想要使用的類型來實例化模板。這就是所謂的泛型編程。
3.泛型編程的優勢
泛型編程的最大優勢是類型安全。類及其方法中使用的類型是具體類型,而不是像上一節中多態解決方案那樣的抽象基類類型。例如,假設不僅有 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() 時執行正確的向下轉型。
五、Grid 類模板的定義
1.類模板的語法
為了理解類模板,檢查其語法非常有幫助。以下示例展示了如何將 GameBoard 類修改為模板化的 Grid 類。代碼后面會詳細解釋語法。請注意,類名已從 GameBoard 改為 Grid。
2.使用值語義實現 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 };
};
3.類模板的詳細解讀
export template <typename T>:這一行表示接下來的類定義是一個關于類型 T 的模板,并且它正在從模塊中導出。template 和 typename 是 C++ 中的關鍵字。如前所述,模板在類型上“參數化”,就像函數在值上“參數化”一樣。
使用模板類型參數名(如 T)來表示調用者將作為模板類型參數傳遞的類型。T 的名稱沒有特殊含義——你可以使用任何你想要的名稱。
4.關于模板類型參數的注意事項
出于歷史原因,你可以使用關鍵字 class 而不是 typename 來指定模板類型參數。因此,許多書籍和現有程序使用類似 template <class T> 的語法。然而,在這種情況下使用 class 這個詞是令人困惑的,因為它暗示類型必須是一個類,這實際上并不正確。類型可以是類、結構體、聯合、語言的原始類型,如 int 或 double 等。
六、Grid 類模板與 GameBoard 類的對比
1.數據成員的變化
在之前的 GameBoard 類中,m_cells 數據成員是指針的向量的向量,這需要特殊的復制代碼,因此需要拷貝構造函數和拷貝賦值操作符。在 Grid 類中,m_cells 是可選值的向量的向量,所以編譯器生成的拷貝構造函數和賦值操作符是可以的。
2.顯式默認構造函數和操作符
一旦你有了用戶聲明的析構函數,就不推薦編譯器隱式生成拷貝構造函數或拷貝賦值操作符,因此 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 類模板的實例化。
3.at() 方法的更新
由于 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 類模板的方法定義
1.模板方法定義格式
每個 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);
}
}
注意:類模板的方法定義需要對使用該類模板的任何客戶端代碼可見。這對方法定義的位置施加了一些限制。通常,它們直接放在類模板定義本身的同一文件中。本章后面討論了繞過這一限制的一些方法。
2.類名和方法定義
請注意,:: 前的類名是 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));
}
3.類模板方法的默認值
注意:如果類模板方法的實現需要某個模板類型參數的默認值(例如 T),則可以使用 T{} 語法。T{} 調用對象的默認構造函數(如果 T 是類類型),或生成零(如果 T 是基本類型)。這種語法稱為零初始化語法。它是為尚不知道類型的變量提供合理默認值的好方法。
八、使用 Grid 類模板
1.模板實例化
當你想要創建 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;如果嘗試這樣做,編譯器將生成錯誤。
2.使用 value_or()
還要注意 value_or() 的使用。at() 方法返回一個可選引用,可能包含值也可能不包含。value_or() 方法在可選項中有值時返回該值;否則,它返回給 value_or() 的參數。
3.模板類型的重要性
模板類型的指定非常重要;以下兩行都無法編譯:
Grid test; // 無法編譯
Grid<> test; // 無法編譯
如果你想聲明一個接受 Grid 對象的函數或方法,你必須在 Grid 類型中指定存儲在網格中的類型:
void processIntGrid(Grid<int>& grid) { /* 省略正文以簡潔 */ }
或者,你可以使用本章后面討論的函數模板,編寫一個模板化的函數,該函數根據網格中元素的類型進行模板化。
注意:你可以使用類型別名來簡化完整的 Grid 類型的重復書寫,例如 Grid<int>:
using IntGrid = Grid<int>;void processIntGrid(IntGrid& grid) { /* 正文 */ }
4.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) };