在你的类中,可以声明 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() 需要
m_height { min(height, MaxHeight) }
{
// 省略简略性
}
注意,你也可以选择在宽度或高度超过最大值时抛出异常,而不是自动将宽度和高度限制在其最大值内。但是,当你从构造函数中抛出异常时,析构函数将不会被调用,所以你需要小心处理这一点。这在第14章详细讨论了错误处理。
此类常量也可以用作参数的默认值。记住,你只能为从最右边参数开始的一连串参数提供默认值。这里有一个例子:
export class Spreadsheet {
public:
Spreadsheet(size_t width = MaxWidth, size_t height = MaxHeight);
// 省略简略性
};
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;
};
类定义不仅可以包含成员函数和数据成员,还可以编写嵌套类和结构体,声明类型别名或创建枚举类型。在类内部声明的任何内容都在该类的作用域内。如果它是公开的,你可以通过使用类名加上作用域解析运算符(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);
// 省略简略性
};
普通的访问控制适用于嵌套类定义。如果你声明了一个私有或受保护的嵌套类,你只能从外部类内部使用它。嵌套类可以访问外部类的所有受保护和私有成员。而外部类只能访问嵌套类的公共成员。
枚举类型也可以是类的数据成员。例如,你可以添加对 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
只有在你可以对其执行算术操作时才有用,比如求一整行单元格的和。
在你的类中定义比较运算符,如>
、<
、<=
、>=
、==
和!=
,是非常有用的。C++20标准为这些运算符带来了很多变化,并增加了三元比较运算符,即太空船运算符<=>
,在第1章中有介绍。为了更好地理解C++20所提供的内容,让我们先来看看在C++20之前你需要做些什么,以及在你的编译器还不支持三元比较运算符时你仍需要做些什么。
就像基本的算术运算符一样,C++20之前的六个比较运算符应该是全局函数,这样你可以在运算符的左右两边的参数上使用隐式转换。比较运算符都返回一个布尔值。当然,你可以更改返回类型,但这并不推荐。这里是声明,你需要用==
、<
、>
、!=
、<=
和>=
替换
,从而产生六个函数:
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+
一样,你可以通过实现显式函数来避免与双精度的比较。对于每个运算符
,你将需要以下三个重载:
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);
如果你想支持所有比较运算符,那么需要编写很多重复的代码!
现在让我们转换一下思路,看看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的东西,其中
is_lt()是一个命名比较函数;请参见第1章。所以,通过只实现
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
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;
}
在查看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==
和<=>
作为方法。无需手动实现其他比较运算符。