深入理解C++20:类与对象的高级特性及运算符重载

开发 前端
就像基本的算术运算符一样,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==和<=>作为方法。无需手动实现其他比较运算符。

责任编辑:赵宁宁 来源: coding日记
相关推荐

2023-11-22 13:40:17

C++函数

2009-08-12 10:47:03

C#运算符重载

2009-08-12 12:46:11

C#运算符重载

2009-08-12 10:27:12

C#运算符重载运算符重载实例

2009-09-04 13:18:10

C#允许运算符重载

2009-08-12 10:56:47

C#运算符重载C#运算符重载实例

2009-08-14 10:16:57

C#运算符重载

2023-11-20 22:19:10

C++static

2020-11-26 14:05:39

C ++运算符数据

2021-12-15 10:25:57

C++运算符重载

2011-07-15 01:34:36

C++重载运算符

2024-01-26 16:37:47

C++运算符开发

2009-08-12 10:37:13

C#运算符重载

2021-12-16 10:40:11

C++运算符重载

2024-04-10 12:14:36

C++指针算术运算

2009-08-12 11:20:51

C#运算符重载

2020-09-30 14:04:25

C++运算符重载

2020-08-10 10:20:15

流插入运算符语言

2011-07-15 10:08:11

C++运算符重载

2009-08-11 15:51:08

C#运算符算术运算符
点赞
收藏

51CTO技术栈公众号