深入理解C++20:類與對象的高級特性及運算符重載
類與對象的高級特性
1.常量靜態數據成員
在你的類中,可以聲明 const 數據成員,這意味著它們在創建和初始化后不能被改變。當常量僅適用于類時,應該使用 static const(或 const static)數據成員來代替全局常量,這也稱為類常量。整型和枚舉類型的 static const 數據成員即使不將它們作為內聯變量,也可以在類定義內部定義和初始化。例如,你可能想要為Spreadsheet指定一個最大高度和寬度。如果用戶嘗試構造一個高度或寬度超過最大值的Spreadsheet,將使用最大值代替。你可以將最大高度和寬度作為 Spreadsheet 類的 static const 成員:
export class Spreadsheet {
public:
// 省略簡略性
static const size_t MaxHeight { 100 };
static const size_t MaxWidth { 100 };
};
你可以在構造函數中使用這些新常量,如下所示:
Spreadsheet::Spreadsheet(size_t width, size_t height)
: m_id { ms_counter++ },
m_width { min(width, MaxWidth) } // std::min() 需要 <algorithm>
m_height { min(height, MaxHeight) }
{
// 省略簡略性
}
注意,你也可以選擇在寬度或高度超過最大值時拋出異常,而不是自動將寬度和高度限制在其最大值內。但是,當你從構造函數中拋出異常時,析構函數將不會被調用,所以你需要小心處理這一點。這在第14章詳細討論了錯誤處理。
2.數據成員的不同種類
此類常量也可以用作參數的默認值。記住,你只能為從最右邊參數開始的一連串參數提供默認值。這里有一個例子:
export class Spreadsheet {
public:
Spreadsheet(size_t width = MaxWidth, size_t height = MaxHeight);
// 省略簡略性
};
3.引用數據成員
Spreadsheet和 SpreadsheetCells 很棒,但它們本身并不構成一個有用的應用程序。你需要代碼來控制整個Spreadsheet程序,你可以將其打包到一個名為 SpreadsheetApplication 的類中。假設我們希望每個 Spreadsheet 都存儲對應用程序對象的引用。SpreadsheetApplication 類的確切定義此刻并不重要,因此下面的代碼簡單地將其定義為一個空類。Spreadsheet 類被修改為包含一個新的引用數據成員,稱為 m_theApp:
export class SpreadsheetApplication {
};
export class Spreadsheet {
public:
Spreadsheet(size_t width, size_t height, SpreadsheetApplication& theApp);
// 省略簡略性
private:
// 省略簡略性
SpreadsheetApplication& m_theApp;
};
這個定義為數據成員添加了一個 SpreadsheetApplication 引用。建議在這種情況下使用引用而不是指針,因為 Spreadsheet 應該總是引用一個 SpreadsheetApplication,而指針則不能保證這一點。請注意,將應用程序的引用存儲起來僅是為了演示引用作為數據成員的用法。不建議以這種方式將 Spreadsheet 和 SpreadsheetApplication 類耦合在一起,而是使用模型-視圖-控制器(MVC)范例。
在其構造函數中,應用程序引用被賦給每個 Spreadsheet。引用不能存在而不指向某些東西,因此 m_theApp 必須在構造函數的 ctor-initializer 中被賦值:
Spreadsheet::Spreadsheet(size_t width, size_t height, SpreadsheetApplication& theApp)
: m_id { ms_counter++ },
m_width { std::min(width, MaxWidth) },
m_height { std::min(height, MaxHeight) },
m_theApp { theApp }
{
// 省略簡略性
}
你還必須在拷貝構造函數中初始化引用成員。這是自動處理的,因為 Spreadsheet 拷貝構造函數委托給非拷貝構造函數,后者初始化了引用數據成員。記住,一旦你初始化了一個引用,你就不能改變它所引用的對象。在賦值操作符中不可能對引用進行賦值。根據你的用例,這可能意味著你的類不能為含有引用數據成員的類提供賦值操作符。如果是這種情況,賦值操作符通常被標記為刪除。
最后,引用數據成員也可以標記為 const。例如,你可能決定 Spreadsheets 只應該對應用程序對象有一個常量引用。你可以簡單地更改類定義,將 m_theApp 聲明為對常量的引用:
export class Spreadsheet {
public:
Spreadsheet(size_t width, size_t height, const SpreadsheetApplication& theApp);
// 省略簡略性
private:
// 省略簡略性
const SpreadsheetApplication& m_theApp;
};
3.嵌套類
類定義不僅可以包含成員函數和數據成員,還可以編寫嵌套類和結構體,聲明類型別名或創建枚舉類型。在類內部聲明的任何內容都在該類的作用域內。如果它是公開的,你可以通過使用類名加上作用域解析運算符(ClassName::)來在類外部訪問它。
例如,你可能會決定 SpreadsheetCell 類實際上是 Spreadsheet 類的一部分。由于它成為 Spreadsheet 類的一部分,你可能會將其重命名為 Cell。你可以像這樣定義它們:
export class Spreadsheet {
public:
class Cell {
public:
Cell() = default;
Cell(double initialValue);
// 省略簡略性
};
Spreadsheet(size_t width, size_t height, const SpreadsheetApplication& theApp);
// 省略其他 Spreadsheet 聲明
};
現在,Cell 類在 Spreadsheet 類內部定義,所以在 Spreadsheet 類外部引用 Cell 時,你必須使用 Spreadsheet:: 作用域來限定名稱。這甚至適用于方法定義。例如,Cell 的雙精度構造函數現在看起來像這樣:
Spreadsheet::Cell::Cell(double initialValue)
: m_value { initialValue } {
}
即使是在 Spreadsheet 類本身的方法的返回類型(但不是參數)中,也必須使用此語法:
Spreadsheet::Cell& Spreadsheet::getCellAt(size_t x, size_t y) {
verifyCoordinate(x, y);
return m_cells[x][y];
}
直接在 Spreadsheet 類內部完全定義嵌套的 Cell 類會使 Spreadsheet 類的定義變得臃腫。你可以通過僅在 Spreadsheet 類中包含 Cell 的前向聲明,然后分別定義 Cell 類來緩解這種情況:
export class Spreadsheet {
public:
class Cell;
Spreadsheet(size_t width, size_t height, const SpreadsheetApplication& theApp);
// 省略其他 Spreadsheet 聲明
};
class Spreadsheet::Cell {
public:
Cell() = default;
Cell(double initialValue);
// 省略簡略性
};
普通的訪問控制適用于嵌套類定義。如果你聲明了一個私有或受保護的嵌套類,你只能從外部類內部使用它。嵌套類可以訪問外部類的所有受保護和私有成員。而外部類只能訪問嵌套類的公共成員。
4.類內部的枚舉類型
枚舉類型也可以是類的數據成員。例如,你可以添加對 SpreadsheetCell 類的單元格著色支持,如下所示:
export class SpreadsheetCell {
public:
// 省略簡略性
enum class Color {
Red = 1, Green, Blue, Yellow
};
void setColor(Color color);
Color getColor() const;
private:
// 省略簡略性
Color m_color { Color::Red };
};
setColor() 和 getColor() 方法的實現很直接:
void SpreadsheetCell::setColor(Color color) {
m_color = color;
}
SpreadsheetCell::Color SpreadsheetCell::getColor() const {
return m_color;
}
新方法的使用方式如下:
SpreadsheetCell myCell { 5 };
myCell.setColor(SpreadsheetCell::Color::Blue);
auto color { myCell.getColor() };
運算符重載
你經常需要對對象執行操作,例如添加它們、比較它們,或將它們流入流出文件。例如,Spreadsheet只有在你可以對其執行算術操作時才有用,比如求一整行單元格的和。
1.重載比較運算符
在你的類中定義比較運算符,如>、<、<=、>=、==和!=,是非常有用的。C++20標準為這些運算符帶來了很多變化,并增加了三元比較運算符,即太空船運算符<=>,在第1章中有介紹。為了更好地理解C++20所提供的內容,讓我們先來看看在C++20之前你需要做些什么,以及在你的編譯器還不支持三元比較運算符時你仍需要做些什么。
就像基本的算術運算符一樣,C++20之前的六個比較運算符應該是全局函數,這樣你可以在運算符的左右兩邊的參數上使用隱式轉換。比較運算符都返回一個布爾值。當然,你可以更改返回類型,但這并不推薦。這里是聲明,你需要用==、<、>、!=、<=和>=替換<op>,從而產生六個函數:
export class SpreadsheetCell { /* 省略以便簡潔 */ };
export bool operator<op>(const SpreadsheetCell& lhs, const SpreadsheetCell& rhs);
以下是operator==的定義。其他的定義類似。
bool operator==(const SpreadsheetCell& lhs, const SpreadsheetCell& rhs) {
return (lhs.getValue() == rhs.getValue());
}
注意:前述重載的比較運算符正在比較雙精度值。大多數時候,對浮點值進行等于或不等于測試并不是一個好主意。你應該使用所謂的epsilon測試,但這超出了本書的范圍。在具有更多數據成員的類中,比較每個數據成員可能很痛苦。然而,一旦你實現了==和<,你就可以用這兩個運算符來寫其它的比較運算符。例如,這里是一個使用operator<的operator>=定義:
bool operator>=(const SpreadsheetCell& lhs, const SpreadsheetCell& rhs) {
return !(lhs < rhs);
}
你可以使用這些運算符來比較SpreadsheetCells與其他SpreadsheetCells,也可以與雙精度和整型比較:
if (myCell > aThirdCell || myCell < 10) {
cout << myCell.getValue() << endl;
}
正如你所見,你需要編寫六個不同的函數來支持六個比較運算符,這只是為了比較兩個SpreadsheetCells。隨著當前六個實現的比較函數,可以將SpreadsheetCell與一個雙精度值進行比較,因為雙精度參數被隱式轉換為SpreadsheetCell。如前所述,這種隱式轉換可能效率低下,因為需要創建臨時對象。就像之前的operator+一樣,你可以通過實現顯式函數來避免與雙精度的比較。對于每個運算符<op>,你將需要以下三個重載:
bool operator<op>(const SpreadsheetCell& lhs, const SpreadsheetCell& rhs);
bool operator<op>(double lhs, const SpreadsheetCell& rhs);
bool operator<op>(const SpreadsheetCell& lhs, double rhs);
如果你想支持所有比較運算符,那么需要編寫很多重復的代碼!
2.C++20
現在讓我們轉換一下思路,看看C++20帶來了什么。C++20極大地簡化了為你的類添加比較運算符的支持。首先,使用C++20,實際上建議將operator==實現為類的成員函數,而不是全局函數。還要注意,添加[[nodiscard]]屬性是個好主意,這樣運算符的結果就不能被忽略了。這里是一個例子:
[[nodiscard]] bool operator==(const SpreadsheetCell& rhs) const;
使用C++20,這一個operator==重載就可以使以下比較工作:
if (myCell == 10) {
cout << "myCell == 10\n";
}
if (10 == myCell) {
cout << "10 == myCell\n";
}
例如10==myCell這樣的表達式會被C++20編譯器重寫為myCell==10,可以調用operator==成員函數。此外,通過實現operator==,C++20會自動增加對!=的支持。
接下來,為了實現對完整套比較運算符的支持,在C++20中你只需要實現一個額外的重載運算符,即operator<=>。一旦你的類有了operator==和<=>的重載,C++20會自動為所有六個比較運算符提供支持!對于SpreadsheetCell類,operator<=>如下所示:
[[nodiscard]] std::partial_ordering operator<=>(const SpreadsheetCell& rhs) const;
注意:C++20編譯器不會用<=>重寫==或!=比較,這是為了避免性能問題,因為顯式實現operator==通常比使用<=>更高效。
SpreadsheetCell中存儲的值是一個雙精度值。請記住,從第1章開始,浮點類型只有部分排序,這就是為什么重載返回std::partial_ordering。實現很簡單:
std::partial_ordering SpreadsheetCell::operator<=>(const SpreadsheetCell& rhs) const {
return getValue() <=> rhs.getValue();
}
通過實現operator<=>,C++20會自動為>、`<、<=和>=提供支持,通過將使用這些運算符的表達式重寫為使用<=>的表達式。例如,類似于myCell<aThirdCell的表達式會自動重寫為類似于std::is_ lt(myCell<=>aThirdCell)的東西,其中is_lt()是一個命名比較函數;所以,通過只實現operator==和operator<=>`,SpreadsheetCell類支持完整的比較運算符集:
if (myCell < aThirdCell) {
// ...
}
if (aThirdCell < myCell) {
// ...
}
if (myCell <= aThirdCell) {
// ...
}
if (aThirdCell <= myCell) {
// ...
}
if (myCell > aThirdCell) {
// ...
}
if (aThirdCell > myCell) {
// ...
}
if (myCell >= aThirdCell) {
// ...
}
if (aThirdCell >= myCell) {
// ...
}
if (myCell == aThirdCell) {
// ...
}
if (aThirdCell == myCell) {
// ...
}
if (myCell != aThirdCell) {
// ...
}
if (aThirdCell != myCell) {
// ...
}
由于SpreadsheetCell類支持從雙精度到SpreadsheetCell的隱式轉換,因此也支持以下比較:
if (myCell < 10) {
}
if (10 < myCell) {
}
if (10 != myCell) {
}
就像比較兩個SpreadsheetCell對象一樣,編譯器會將這些表達式重寫為使用operator==和<=>的形式,并根據需要交換參數的順序。例如,10<myCell首先被重寫為類似于is_lt(10<=>myCell)的東西,這不會起作用,因為我們只有<=>作為成員的重載,這意味著左側參數必須是SpreadsheetCell。注意到這一點后,編譯器再嘗試將表達式重寫為類似于is_gt(myCell<=>10)的東西,這就可以工作了。與以前一樣,如果你想避免隱式轉換的輕微性能影響,你可以為雙精度提供特定的重載。而這現在,多虧了C++20,甚至不是很多工作。你只需要提供以下兩個額外的重載運算符作為方法:
[[nodiscard]] bool operator==(double rhs) const;
[[nodiscard]] std::partial_ordering operator<=>(double rhs) const;
這些實現如下:
bool SpreadsheetCell::operator==(double rhs) const {
return getValue() == rhs;
}
std::partial_ordering SpreadsheetCell::operator<=>(double rhs) const {
return getValue() <=> rhs;
}
2.編譯器生成的比較運算符
在查看SpreadsheetCell的operator和<=>的實現時,可以看到它們只是簡單地比較所有數據成員。在這種情況下,我們可以進一步減少編寫代碼的行數,因為C++20可以為我們完成這些工作。就像可以顯式默認化拷貝構造函數一樣,operator和<=>也可以被默認化,這種情況下編譯器將為你編寫它們,并通過依次比較每個數據成員來實現它們。此外,如果你只顯式默認化operator<=>,編譯器還會自動包含一個默認的operator。因此,對于沒有顯式operator和<=>用于雙精度的SpreadsheetCell版本,我們可以簡單地編寫以下單行代碼,為比較兩個SpreadsheetCell添加對所有六個比較運算符的完全支持:
[[nodiscard]] std::partial_ordering operator<=>(const SpreadsheetCell&) const = default;
此外,你可以將operator<=>的返回類型使用auto,這種情況下編譯器會基于數據成員的<=>運算符的返回類型來推斷返回類型。如果你的類有不支持operator<=>的數據成員,那么返回類型推斷將不起作用,你需要顯式指定返回類型為strong_ordering、partial_ordering或weak_ordering。為了讓編譯器能夠編寫默認的<=>運算符,類的所有數據成員都需要支持operator<=>,這種情況下返回類型可以是auto,或者是operator<和==,這種情況下返回類型不能是auto。由于SpreadsheetCell有一個雙精度數據成員,編譯器推斷返回類型為partial_ordering。
[[nodiscard]] auto operator<=>(const SpreadsheetCell&) const = default;
單獨的顯式默認化的operator<=>適用于沒有顯式operator==和<=>用于雙精度的SpreadsheetCell版本。如果你添加了這些顯式的雙精度版本,你就添加了一個用戶聲明的operator==(double)。因為這個原因,編譯器將不再自動生成operator==(const SpreadsheetCell&),所以你必須自己顯式默認化一個,如下所示:
export class SpreadsheetCell {
public:
// Omitted for brevity
[[nodiscard]] auto operator<=>(const SpreadsheetCell&) const = default;
[[nodiscard]] bool operator==(const SpreadsheetCell&) const = default;
[[nodiscard]] bool operator==(double rhs) const;
[[nodiscard]] std::partial_ordering operator<=>(double rhs) const;
// Omitted for brevity
};
如果你的類可以顯式默認化operator<=>,我建議這樣做,而不是自己實現它。通過讓編譯器為你編寫,它將隨著新添加或修改的數據成員保持最新狀態。如果你自己實現了運算符,那么每當你添加數據成員或更改現有數據成員時,你都需要記得更新你的operator<=>實現。如果operator==沒有被編譯器自動生成,同樣的規則也適用于它。只有當它們作為參數有對類類型的引用時,才能顯式默認化operator==和<=>。例如,以下不起作用:
[[nodiscard]] auto operator<=>(double) const = default; // 不起作用!
注意:要在C++20中向類添加對所有六個比較運算符的支持: ? 如果默認化的operator<=>適用于你的類,那么只需要一行代碼顯式默認化operator<=>作為方法即可。在某些情況下,你可能需要顯式默認化operator==。 ? 否則,只需重載并實現operator==和<=>作為方法。無需手動實現其他比較運算符。