类与对象的高级特性
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==和<=>作为方法。无需手动实现其他比较运算符。