Lesson29 C++ 对象模型

29.1 this 指针

创建类对象时,只有非静态成员变量才占用该对象的空间。静态成员变量和所有成员函数均共享一个实例,不会重复占用空间。

对于空对象,C++ 编译器会分配 1 个字节的空间,这是为了区分类对象的内存位置。换句话说,每个类对象都拥有一个独一无二的地址

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Person {
public:
Person() {
mA = 0;
}
int mA; // 占用对象空间
static int mB; // 共享一份实例
void func() {
cout << "mA:" << this->mA << endl;
}
// 共享一份实例
static void s_func() {
cout << "mB:" << this->mB << endl;
}
//共享一份实例
};

正如上文所言,所有类对象的成员函数仅有一份,那么当我们调用成员函数时,C++ 又是怎么知道是哪个类对象调用的呢?比如下面的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <iostream>

class Simple
{
private:
int m_id{};

public:
Simple(int id)
: m_id{ id }
{
}

int getID() const { return m_id; }
void setID(int id) { m_id = id; }

void print() const { std::cout << m_id; }
};

int main()
{
Simple a{1};
Simple b{2};
a.setID(3);
b.setID(4);

a.print();
b.print();

return 0;
}

当程序调用 23 行的 setID 函数时,m_id 到底是 a 的,还是 b 的?函数只接受到参数 id,至于运算结果属于谁…… 不造啊,定义里面没写啊!

因此 C++ 使用了 this 指针来解决这个问题。

每一个非静态成员函数都拥有 this 指针。它是指向当前成员函数所属的类对象的地址的 const 指针。大多数时候,我们会省略 this,因为编译器会帮我们补充这个指针,这时还写就有点多余了。

下面介绍编译器对 this 指针的处理思路(以上面的例子为例):

  1. 为了让函数明确是哪个对象在调用它,编译器会重写函数调用,比如 a.setID(3)(可能)会被重写为 Simple::setID(&a, 3)。此时类对象的地址会作为参数一并传入。
  2. 为了使用类对象的地址,编译器还会重写函数的一部分定义。最终的函数定义应类似于:static void setID(Simple* const this, int id) { this->m_id = id; }

这里 this 属于函数参数,不是成员变量。它不会占用类对象空间。

为什么 this 是指针而不是引用

The answer is simple: when this was added to C++, references didn’t exist yet. If this were added to the C++ language today, it would undoubtedly be a reference instead of a pointer. In other more modern C+±like languages, such as Java and C#, this is implemented as a reference.

——15.1 — The hidden “this” pointer and member function chaining

很简单,当 C++ 引入 this 时,“引用” 这个概念都不存在。


前面我们说过,编译器会为我们自动添加 this,所以大部分情况下,我们不需要操心 this 的事情。但如果遇到以下几种情况,显式 this 指针还是很有用的:

  1. 成员函数的参数和成员变量重名

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    class Something
    {
    private:
    int data{};

    public:
    void setData(int data)
    {
    this->data = data; // this->data is the member, data is the local parameter
    }
    };

    成员函数 setData 中的参数 data 会遮蔽类 Something 中的数据成员 data。如果希望在函数中使用这个数据成员,那么就需要用 this 指针。

  2. 成员函数返回类对象本身

    这样做的目的是使成员函数 “链式” 调用,即在单个表达式中对同一对象调用多个成员函数。这被称为函数链式调用 (function chaining)

    Note

    考虑这个输出语句:

    1
    std::cout << "Hello, " << userName;

    编译器将理解为:

    1
    (std::cout << "Hello, ") << userName;

    首先,std::cout << "Hello, "Hello, 打印到控制台,然后返回 std::cout(注:运算符 << 返回传入的输出流对象)。这个返回值又和 userName 结合,得到 std::cout << userName,然后将 userName 打印到控制台。

    这就是链式调用,在一个表达式内完成一连串的动作。

    在成员函数中,我们使用返回 *this 的方法实现链式调用:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    class Calc
    {
    private:
    int m_value{};

    public:
    Calc& add(int value) { m_value += value; return *this; }
    Calc& sub(int value) { m_value -= value; return *this; }
    Calc& mult(int value) { m_value *= value; return *this; }

    int getValue() const { return m_value; }
    };

    在连续使用 addsubmult 三个函数时:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    #include <iostream>

    int main()
    {
    Calc calc{};
    calc.add(5).sub(3).mult(4); // method chaining

    std::cout << calc.getValue() << '\n';

    return 0;
    }

    首先调用 calc.add(5) ,将 5 添加到 m_value 。然后 add() 返回对 *this 的引用, *this 是对隐式对象 calc 的引用,因此 calc 将是后续评估中使用的对象。接下来 calc.sub(3) 进行评估,从 m_value 中减去 3 ,再次返回 calc 。最后, calc.mult(4)m_value 乘以 4 ,返回 calc ,该值未被进一步使用,因此被忽略。

    由于每个函数在执行时都会修改 calc ,因此 calcm_value 现在包含的值是 (((0 + 5) - 3) * 4),即 8

  3. 将类对象重置为初始状态

    如果类有一个默认构造函数,那么将类重置为默认状态的最佳方法是创建一个 reset() 成员函数,让该函数创建一个新的对象(使用默认构造函数),然后将该新对象分配给当前的隐式对象,如下所示:

    1
    2
    3
    4
    void reset()
    {
    *this = {}; // value initialize a new object and overwrite the implicit object
    }

29.2 空指针访问成员函数

在 C++ 中,空指针也可以访问成员函数:

1
2
3
4
5
6
7
8
9
10
11
class Person {
public:
void showClassName() {
std::cout << "This is Person class" << std::endl;
}
}

int main(void) {
Person* p = NULL;
p -> showClassName();
}
1
This is Person class

但需要注意成员函数里是否使用了 this 指针:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Person {
private:
int m_age;
public:
void getAge() {
std::cout << m_age << std::endl;
}
}

int main(void) {
Person* p = NULL;
p -> getAge();
}

此时调试报错:

1
0x00007FF635A9103E 处引发的异常: 0xC0000005: 读取位置 0x0000000000000000 时发生访问冲突。

因为编译器理解为:

1
2
3
void getAge() {
std::cout << this -> m_age << std::endl;
}

此时 this 是一个空指针,没有对应的可读的类对象。

如果使用指针调用成员函数,需要考虑到空指针下代码的健壮性。最简单的解决方法就是加一个判断,遇到空指针时让函数避免使用 this 指针:

1
2
3
4
5
6
void getAge() {
if (this == NULL) {
return;
}
std::cout << m_age << std::endl;
}

29.3 const 修饰类对象

在 C 语言中,我们可以通过 const 关键字设置基本数据类型对象的常量,常量必须在创建时被初始化。

现在我们也可以使用 const 修饰类对象,后者称为常对象。常对象同样在创建时被初始化。一旦初始化了常对象,任何修改对象成员变量的尝试都是不允许的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
struct Date
{
int year {};
int month {};
int day {};

void incrementDay()
{
++day;
}
};

int main()
{
const Date today { 2020, 10, 14 }; // const

today.day += 1; // compile error: can't modify member of const object
today.incrementDay(); // compile error: can't call member function that modifies member of const object

return 0;
}

常对象不能调用非 const 修饰的成员函数,因为后者仍有可能破坏常对象的 const 属性。所以我们需要为成员函数也加上 const 属性,使之成为常函数。常函数保证不会修改对象或调用任何非 const 修饰的成员函数,尝试调用的话编译器会抛出编译错误:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <iostream>

struct Date
{
int year {};
int month {};
int day {};

void incrementDay() const // made const
{
++day; // compile error: const function can't modify member
}

void print() const // now a const member function
{
std::cout << year << '/' << month << '/' << day;
}
};

int main()
{
const Date today { 2020, 10, 14 }; // const

today.incrementDay();
today.print(); // ok: const object can call const member function

return 0;
}
Warning

构造函数不能被设置为常函数,因为它必定修改成员变量。

Tip

A member function that does not (and will not ever) modify the state of the object should be made const, so that it can be called on both const and non-const objects. Once a member function is made const, that function can be called on const objects. Later removal of const on a member function will break any code that calls that member function on a const object.

——14.4 — Const class objects and const member functions

成员函数如果不(并且永远不会)修改对象的状态,则应该声明为 const,以便可以在 const 和非 const 对象上调用。之后从成员函数中移除 const 将破坏任何在 const 对象上调用该成员函数的代码,所以修改需要谨慎。

但是,非 const 修饰的类对象是可以调用常函数的。(光脚的不怕穿鞋的……)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream>

struct Date
{
int year {};
int month {};
int day {};

void print() const // const
{
std::cout << year << '/' << month << '/' << day;
}
};

int main()
{
Date today { 2020, 10, 14 }; // non-const

today.print(); // ok: can call const member function on non-const object

return 0;
}

如果部分成员变量确实需要常函数来修改,那么可以给成员变量加上一个 mutable 属性:

1
2
3
4
5
6
class A {
public:
mutable int test{};

void setInt(int a) const { test = a; }
}
Tip

Mutable allows the data member to be modified, even if the object containing the data member is const.

Personally, I haven’t found much use for it, but I believe it’s most often used with mutexes. See https://stackoverflow.com/a/4128689

——Alex’s reply

mutable 关键字允许常函数修改常对象里的成员变量。


Lesson30 友元

在介绍 C++ 的类时,我们提到它的访问权限控制。这一技术有很多优点,但也并非十全十美。

例如,考虑一个专注于管理某些数据集的存储类。现在假设你还想显示这些数据,但处理显示的代码将有很多选项,因此比较复杂。你可以将存储管理函数和显示管理函数放在同一个类中,但这会使事情变得杂乱无章,并导致接口复杂;你也可以将它们分开:存储类管理存储,而另一个显示类管理所有的显示功能。这创建了很好的职责分离。但是,显示类将无法访问存储类的私有成员,可能无法完成其工作。

还是拿 28.1.2 的住宅做例子。平时住宅是私人的,只有屋主本人才能进入,但如果今天有清洁工上门整理呢?

在程序中,有些私有属性需要被类外的其他函数或类对象访问。对于这个需求,C++ 的解决方案是 —— Friendship is Magic! [1]

对😂,你没听错,C++ 引入了友元 (Friend) 技术,允许一个函数或类访问另一个类中的私有或受保护成员。通过这种方式,一个类可以有选择地给予其他类或函数对其成员的完全访问权限,而不会影响其他任何内容。

友元在将要访问的类(而不是渴望访问的类或函数)的主体内,使用关键字 friend 进行声明。由于友元无视访问权限控制,因此可以在类内任意位置声明

Tip

友元关系是由执行数据隐藏的类授予的,期望友元可以访问其私有成员。这个类将友元视为其本身的扩展,拥有相同的访问权限。
——15.8 — Friend non-member functions

回应开头的例子,如果我们的存储类使显示类成为友元,那么显示类就能直接访问存储类的所有成员。显示类可以利用这种直接访问来实现存储类的显示,同时保持结构上的分离。

友元的三种实现:

  • 非成员函数作为友元
  • 类作为友元
  • 成员函数作为友元

接下来我们将一一说明。


30.1 非成员函数作为友元

友元函数是一个非成员函数,它可以像该类的成员一样访问类的私有和受保护成员。在其他所有方面,友元函数都是一个普通函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <iostream>

class Accumulator
{
private:
int m_value { 0 };

public:
void add(int value) { m_value += value; }

// Here is the friend declaration that makes non-member function void print(const Accumulator& accumulator) a friend of Accumulator
friend void print(const Accumulator& accumulator);
};

void print(const Accumulator& accumulator)
{
// Because print() is a friend of Accumulator
// it can access the private members of Accumulator
std::cout << accumulator.m_value;
}

int main()
{
Accumulator acc{};
acc.add(5); // add 5 to the accumulator

print(acc); // call the print() non-member function

return 0;
}

在这个例子中,我们声明了一个名为 print() 的非成员函数,它接受一个类 Accumulator 的对象。因为 print() 不是 Accumulator 类的成员,所以它通常无法访问私有成员 m_value 。然而,Accumulator 类有一个友元声明,使 print(const Accumulator& accumulator) 成为友元。现在 print() 可以访问到 m_value 啦!

Tip

非成员函数没有 this 指针,因此我们需要传入类对象。

如果一个成员函数被声明为友元,那么该函数将被认定为非成员函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <iostream>

class Accumulator
{
private:
int m_value { 0 };

public:
void add(int value) { m_value += value; }

// Friend functions defined inside a class are non-member functions
friend void print(const Accumulator& accumulator)
{
// Because print() is a friend of Accumulator
// it can access the private members of Accumulator
std::cout << accumulator.m_value;
}
};

int main()
{
Accumulator acc{};
acc.add(5); // add 5 to the accumulator

print(acc); // call the print() non-member function

return 0;
}

print 在类 Accumulator 内定义,但由于被声明为友元,因此它实际被视为非成员函数,无法调用 this 指针。


30.2 类作为友元

友元类是可以访问另一个类的私有和受保护成员的类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
#include <iostream>

class Storage
{
private:
int m_nValue {};
double m_dValue {};
public:
Storage(int nValue, double dValue)
: m_nValue { nValue }, m_dValue { dValue }
{ }

// Make the Display class a friend of Storage
friend class Display;
};

class Display
{
private:
bool m_displayIntFirst {};

public:
Display(bool displayIntFirst)
: m_displayIntFirst { displayIntFirst }
{
}

// Because Display is a friend of Storage, Display members can access the private members of Storage
void displayStorage(const Storage& storage)
{
if (m_displayIntFirst)
std::cout << storage.m_nValue << ' ' << storage.m_dValue << '\n';
else // display double first
std::cout << storage.m_dValue << ' ' << storage.m_nValue << '\n';
}

void setDisplayIntFirst(bool b)
{
m_displayIntFirst = b;
}
};

int main()
{
Storage storage { 5, 6.7 };
Display display { false };

display.displayStorage(storage);

display.setDisplayIntFirst(true);
display.displayStorage(storage);

return 0;
}

因为 Display 类是 Storage 的友元类, 所以 Display 的成员可以访问的任何 Storage 类对象的私有成员。

在使用友元类时,需要注意以下几点:

  • 友元只涉及成员访问权限,两个类之间相互独立。例如,Display 无法访问 Storage 对象的 *this 指针(因为 *this 实际上是一个函数参数)。
  • 友元关系不是 “双向奔赴” 的。Display 类是 Storage 的友元类不代表 Storage 类是 Display 的友元。如果想要双方互相是对方的友元,那么每个类都需要声明对方是自己的友元。
  • 友元关系不传递、不继承。AB 的友元,BC 的友元,不代表 AC 的友元;AB 的友元,但 AB 的派生类没有友元关系。
  • 类友元声明充当被友元类的先行声明。这意味着在友元化之前,我们不需要先行声明被友元化的类。

30.3 成员函数作为友元

有时我们不需要类整体成为友元,那么我们可以只声明部分成员函数为友元。这和声明非成员函数为友元有相似之处,但更复杂:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
#include <iostream>

class Storage; // forward declaration for class Storage

class Display
{
private:
bool m_displayIntFirst {};

public:
Display(bool displayIntFirst)
: m_displayIntFirst { displayIntFirst }
{
}

void displayStorage(const Storage& storage); // forward declaration for Storage needed for reference here
};

class Storage // full definition of Storage class
{
private:
int m_nValue {};
double m_dValue {};
public:
Storage(int nValue, double dValue)
: m_nValue { nValue }, m_dValue { dValue }
{
}

// Make the Display::displayStorage member function a friend of the Storage class
// Requires seeing the full definition of class Display (as displayStorage is a member)
friend void Display::displayStorage(const Storage& storage);
};

// Now we can define Display::displayStorage
// Requires seeing the full definition of class Storage (as we access Storage members)
void Display::displayStorage(const Storage& storage)
{
if (m_displayIntFirst)
std::cout << storage.m_nValue << ' ' << storage.m_dValue << '\n';
else // display double first
std::cout << storage.m_dValue << ' ' << storage.m_nValue << '\n';
}

int main()
{
Storage storage { 5, 6.7 };
Display display { false };
display.displayStorage(storage);

return 0;
}

几个注意事项:

  • 如果想要将成员函数声明为友元,编译器必须在声明前读取到该函数所属的类的完整定义。在单文件编程中,我们需要调整类定义的顺序;但在多文件编程中,更好的解决方案是将每个类的定义放入单独的头文件中,成员函数的定义放在相应的 .cpp 文件中。这样,所有的类定义都会在主文件中可用。
  • 声明友元时,注意不要忘了成员函数所属类的作用域声明。
  • 友元成员函数参数中的类也需要声明。一种方法是先行声明参数中的类,另一种方法是将这个函数独立出来(类外实现成员函数),放到要使用的类的定义之后。

Lesson31 运算符重载

在 27.3,我们学习了函数的重载。简单来讲,重载就是重新定义已有函数,增加功能以适应不同参数。运算符同样存在重载现象。

31.0 理解运算符重载

在 C++ 中,运算符由函数实现,这就是运算符重载 (operator overloading) 的底层逻辑。

接下来我们使用一组示例来说明:

  1. 两个整数相加
1
2
3
int x { 2 };
int y { 3 };
std::cout << x + y << '\n';

编译器内置了整数参数的加号运算符 (x) 版本 —— 此函数将整数 xy 相加,并返回一个整数结果。可以近似认为是编译器调用了函数 int operator+(int x, int y)

  1. 两个浮点数相加
1
2
3
double z { 2.0 };
double w { 3.0 };
std::cout << w + z << '\n';

编译器还自带了双精度浮点数参数的加号运算符 (+) 版本。表达式 w + z 变成调用函数 double operator+(double w, double z) ,编译器通过函数重载来确定应该调用该函数的双精度版本,而不是整数版本。

  1. 两个自定义类型字符串相加
1
2
3
Mystring string1 { "Hello, " };
Mystring string2 { "World!" };
std::cout << string1 + string2 << '\n';

直观上预期的结果是字符串 “Hello, World!”。然而,因为 Mystring 是一个程序定义的类型,编译器没有内置的匹配参数的加号运算符版本。所以在这种情况下,它会给我们一个错误。

为了使其按我们期望的方式工作,我们需要编写一个重载函数来告诉编译器加号运算符应该如何与两个 Mystring 类型的参数一起工作。我们有三种方式实现这种重载:常规函数方法、友元函数方法和成员函数方法

如果需要编写重载运算符,需要注意以下事项:

  • 只能重载现有的运算符,不能创建新的运算符或重命名现有的运算符。例如,我们不能创建一个 operator** 来执行指数运算。

  • C++ 中大部分现有的运算符都可以重载。例外包括:条件运算符(? :)、sizeof、作用域运算符(::)、成员选择运算符(.)、指针成员选择运算符(.*)、typeid 以及类型转换运算符。

  • 重载运算符中至少有一个操作数必须是用户定义的类型。这意味着我们可以重载 operator+(int, Mystring) ,但不能重载 operator+(int, double)

    Tip

    标准库中定义的类型(比如 std::string)会被编译器认为是用户定义的,所以有人会写 operator+(double, std::string)。然而,这并不是一个好主意,因为未来的语言标准可能会定义这个重载,这可能会破坏使用该重载的任何程序。因此,最佳实践是重载运算符至少应该作用于一个程序定义的类型。

  • 无法更改运算符支持的参数数量。

  • 所有运算符都保留其默认优先级和结合性,并且不能被更改。例如,有人试图重载按位异或运算符 (^) 来进行指数运算。然而,在 C++ 中,运算符 ^ 的优先级低于基本算术运算符,这会导致表达式解析错误。当重载运算符时,最好使运算符的功能尽可能接近运算符的原始意图

  • 重载运算符应返回与原始运算符性质一致的结果。不修改其操作数的运算符(例如算术运算符)通常应通过值返回结果;修改其最左操作数(例如前置递增、任何赋值运算符)的运算符通常应通过引用返回最左操作数。


31.1 加号运算符重载

目标:实现两个自定义数据类型的相加。

具体到接下来的示例,重载要求实现两个 Cents 类对象的相加。

  • 使用常规函数的版本:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include <iostream>

class Cents
{
private:
int m_cents{};

public:
Cents(int cents)
: m_cents{ cents }
{}

int getCents() const { return m_cents; }
};

// note: this function is not a member function nor a friend function!
Cents operator+(const Cents& c1, const Cents& c2)
{
// use the Cents constructor and operator+(int, int)
// we don't need direct access to private members here
return Cents{ c1.getCents() + c2.getCents() };
}

int main()
{
Cents cents1{ 6 };
Cents cents2{ 8 };
Cents centsSum{ cents1 + cents2 };
std::cout << "I have " << centsSum.getCents() << " cents.\n";

return 0;
}
  • 使用友元函数的版本:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include <iostream>

class Cents
{
private:
int m_cents{};

public:
Cents(int cents)
: m_cents{ cents }
{}

// add Cents + Cents using a friend function
friend Cents operator+(const Cents& c1, const Cents& c2);

int getCents() const { return m_cents; }
};

// note: this function is not a member function!
Cents operator+(const Cents& c1, const Cents& c2)
{
// use the Cents constructor and operator+(int, int)
// we can access m_cents directly because this is a friend function
return { c1.m_cents + c2.m_cents };
}

int main()
{
Cents cents1{ 6 };
Cents cents2{ 8 };
Cents centsSum{ cents1 + cents2 };
std::cout << "I have " << centsSum.getCents() << " cents.\n";

return 0;
}
  • 使用成员函数的版本:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include <iostream>

class Cents
{
private:
int m_cents {};

public:
Cents(int cents)
: m_cents { cents } { }

// Overload Cents + int
Cents operator+(int value) const;

int getCents() const { return m_cents; }
};

// note: this function is a member function!
// the cents parameter in the friend version is now the implicit *this parameter
Cents Cents::operator+ (int value) const
{
return Cents { m_cents + value };
}

int main()
{
const Cents cents1 { 6 };
const Cents cents2 { cents1 + 2 };
std::cout << "I have " << cents2.getCents() << " cents.\n";

return 0;
}

31.2 流式输出运算符重载

C++ 已经重载了 <<(左移运算符)作为流插入运算符使用。<< 支持输出基本数据类型的数据,但有时我们想要输出规定之外的数据类型。

std::basic_ostream<CharT,Traits>::operator<< - cppreference.com 提供了 << 可以输出的数据类型。

例如,我们想要直接输出类 Point 的某对象的所有成员变量,一个个写 getVar() 太烦,用成员函数 print() 输出倒是可以,但是插不进输出流里:

1
2
3
4
5
6
7
8
int main()
{
const Point point { 5.0, 6.0, 7.0 };

std::cout << "My point is: ";
point.print();
std::cout << " in Cartesian space.\n";
}

所以我们想要将类对象直接插入到输出流里,就需要重载 << 运算符。

考虑表达式 std::cout << point 。如果操作符是 << ,操作数是什么?左操作数是 std::cout 对象,右操作数是你的 Point 类对象。 std::cout 实际上是一个类型为 std::ostream 的对象。下面是 << 重载的具体利用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include <iostream>

class Point
{
private:
double m_x{};
double m_y{};
double m_z{};

public:
Point(double x=0.0, double y=0.0, double z=0.0)
: m_x{x}, m_y{y}, m_z{z}
{
}

friend std::ostream& operator<< (std::ostream& out, const Point& point);
};

std::ostream& operator<< (std::ostream& out, const Point& point)
{
// Since operator<< is a friend of the Point class, we can access Point's members directly.
out << "Point(" << point.m_x << ", " << point.m_y << ", " << point.m_z << ')'; // actual output done here

return out; // return std::ostream so we can chain calls to operator<<
}

int main()
{
const Point point1 { 2.0, 3.0, 4.0 };

std::cout << point1 << '\n';

return 0;
}

31.3 递增运算符重载

通过重载递增运算符,我们可以实现自定义数据的计算规则。

31.3.1 前缀的重载

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#include <iostream>

class Digit
{
private:
int m_digit{};
public:
Digit(int digit=0)
: m_digit{digit}
{}

Digit& operator++(); // prefix has no parameter

friend std::ostream& operator<< (std::ostream& out, const Digit& d);
};

Digit& Digit::operator++()
{
// If our number is already at 9, wrap around to 0
if (m_digit == 9)
m_digit = 0;
// otherwise just increment to next number
else
++m_digit;

return *this;
}

std::ostream& operator<< (std::ostream& out, const Digit& d)
{
out << d.m_digit;
return out;
}

int main()
{
Digit digit { 8 };

std::cout << digit;
std::cout << ++digit;
std::cout << ++digit;

return 0;
}

Digit 类包含一个介于 0 到 9 之间的数字。我们重载了递增运算符,如果数字增加 / 减少超出范围,则进行回绕。

请注意,我们返回 *this。重载的递增运算符返回当前的隐式对象,因此多个运算符可以被 “链式” 连接在一起,比如最后的重载 << 运算符。

31.3.2 后缀的重载

通常,当函数具有相同的名称但不同的参数数量和 / 或不同类型的参数时,可以进行函数重载。然而,考虑前缀和后缀递增运算符的情况。它们具有相同的名称(例如 operator++),都是一元的,并且接受相同类型的单个参数。那么在重载时如何区分这两个运算符呢?

C++ 语言规范提供了解答:编译器会检查重载运算符是否有 int 参数。如果重载运算符int 参数,则该运算符是后缀重载。如果重载运算符没有参数,则该运算符是前缀重载。这个 int 参数是一个虚拟参数,仅起到占位符作用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
class Digit
{
private:
int m_digit{};
public:
Digit(int digit=0)
: m_digit{digit}
{}

Digit operator++(int); // postfix has an int parameter

friend std::ostream& operator<< (std::ostream& out, const Digit& d);
};


// int parameter means this is postfix operator++
Digit Digit::operator++(int)
{
// Create a temporary variable with our current digit
Digit temp{*this};

// Use prefix operator to increment this digit
++(*this); // apply operator

// return temporary result
return temp; // return saved state
}

std::ostream& operator<< (std::ostream& out, const Digit& d)
{
out << d.m_digit;
return out;
}

int main()
{
Digit digit { 5 };

std::cout << digit;
std::cout << digit++; // calls Digit::operator++(int);
std::cout << digit;

return 0;
}

后缀运算符需要返回对象在自增之前的原始状态。这导致了一个小小的难题 —— 如果我们对对象进行自增,我们就无法返回对象自增之前的原始状态。另一方面,如果我们返回对象自增之前的原始状态,那么实际上自增操作没有调用。

通常解决此问题的方法是使用一个临时变量来保存对象在增减之前的值。然后对对象本身进行增减操作。最后,将临时变量返回给调用者。

这意味着重载运算符的返回值必须是非引用的,因为我们不能返回一个在函数退出时将被销毁的局部变量的引用。另外,请注意,由于需要实例化临时变量并通过值返回而不是通过引用返回,后缀运算符通常比前缀运算符效率低


31.4 赋值运算符重载

重载的赋值运算符(operator=)用于将一个对象中的值复制到另一个已存在的对象中。这和 28.2.3 的拷贝构造函数的目的相同。不过,赋值运算符会替换现有对象的内部内容,而拷贝构造函数会初始化一个新对象。

Tip

复制构造函数和复制赋值运算符之间的区别常常让新手程序员感到困惑,但实际上这并不难。总结如下:

  • 如果必须在复制之前创建一个新对象,则使用拷贝构造函数。(注意:这包括通过值传递或返回对象)
  • 如果复制发生前不需要创建新对象,则使用赋值运算符。

在编写重载时,我们需要注意一点:拷贝赋值运算符必须是成员函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
lass MyString
{
private:
char* m_data{};
int m_length{};

public:
MyString(const char* source = "" )
{
assert(source); // make sure source isn't a null string

// Find the length of the string
// Plus one character for a terminator
m_length = std::strlen(source) + 1;

// Allocate a buffer equal to this length
m_data = new char[m_length];

// Copy the parameter string into our internal buffer
for (int i{ 0 }; i < m_length; ++i)
m_data[i] = source[i];
}

void deepCopy(const MyString& source);
MyString& operator= (const MyString& source);

~MyString() // destructor
{
// We need to deallocate our string
delete[] m_data;
}

char* getString() { return m_data; }
int getLength() { return m_length; }
};

// assumes m_data is initialized
void MyString::deepCopy(const MyString& source)
{
// first we need to deallocate any value that this string is holding!
delete[] m_data;

// because m_length is not a pointer, we can shallow copy it
m_length = source.m_length;

// m_data is a pointer, so we need to deep copy it if it is non-null
if (source.m_data)
{
// allocate memory for our copy
m_data = new char[m_length];

// do the copy
for (int i{ 0 }; i < m_length; ++i)
m_data[i] = source.m_data[i];
}
else
m_data = nullptr;
}

// Assignment operator
MyString& MyString::operator=(const MyString& source)
{
// check for self-assignment
if (this != &source)
{
// now do the deep copy
deepCopy(source);
}

return *this;
}
Note

上面的例子使用深拷贝。和构造函数一样,拷贝赋值运算符也有自己的默认版本。

对于 C++ 默认使用的浅拷贝,可以参见:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Possible implementation of implicit assignment operator
Fraction& operator= (const Fraction& fraction)
{
// self-assignment guard
if (this == &fraction)
return *this;

// do the copy
m_numerator = fraction.m_numerator;
m_denominator = fraction.m_denominator;

// return the existing object so we can chain this operator
return *this;
}
Warning

对于动态分配内存的变量,我们在分配任何新内存之前需要显式地释放旧内存。对于静态分配内存的变量,我们不必担心 —— 不过是新值覆盖了旧值而已。


31.5 关系运算符重载

可以让两个自定义类型对象进行比较操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#include <iostream>
#include <string>
#include <string_view>

class Car
{
private:
std::string m_make;
std::string m_model;

public:
Car(std::string_view make, std::string_view model)
: m_make{ make }, m_model{ model }
{
}

friend bool operator== (const Car& c1, const Car& c2);
friend bool operator!= (const Car& c1, const Car& c2);
};

bool operator== (const Car& c1, const Car& c2)
{
return (c1.m_make == c2.m_make && c1.m_model == c2.m_model);
}

bool operator!= (const Car& c1, const Car& c2)
{
return (c1.m_make != c2.m_make || c1.m_model != c2.m_model);
}

int main()
{
Car corolla{ "Toyota", "Corolla" };
Car camry{ "Toyota", "Camry" };

if (corolla == camry)
std::cout << "a Corolla and Camry are the same.\n";

if (corolla != camry)
std::cout << "a Corolla and Camry are not the same.\n";

return 0;
}

上面是一个带有重载运算符 == 和运算符 !=Car 类示例。

Tip

<> 呢?那我问你,一辆车比另一辆车更大或更小意味着什么?由于操作符 < 和操作符 > 的结果不直观,最好将这些操作符定义为未定义。应该仅定义对类有直观意义的重载运算符。

有一个常见的例子是,如果我们想对汽车列表进行排序呢?在这种情况下,我们可能希望重载比较运算符以返回最可能想要排序的成员。例如,为 Car 重载的运算符 < 可能基于品牌和型号按字母顺序排序。

重载的比较运算符往往具有高度的冗余性,实现越复杂,冗余就越多,也更难维护。通过逻辑运算,我们可以降低这种冗余性:

  • 操作符 != 可以表示为 !(operator==)
  • 操作符 > 可以通过将参数顺序颠倒来实现为操作符 <,如 operator> (a, b) 可以表示为 operator< (b, a)
  • 操作符 >= 可以表示为 !(operator<)
  • 操作符 <= 可以表示为 !(operator>)

31.6 函数调用运算符重载

嘿嘿,没想到吧,() 也是一个运算符!括号运算符(operator())是一个特别有趣的运算符,因为它允许改变它接受的参数的类型和数量。

有两个要点需要注意:首先,括号运算符必须实现为成员函数;其次,在非面向对象的 C++ 中,() 运算符用于调用函数。对于类来说,operator() 只是一个普通的运算符,它像任何其他重载运算符一样调用一个函数。

操作符 () 也常被重载以实现函数对象 (Function Object) ,后者像函数一样操作,也被称为仿函数 (Functor) 。与普通函数相比,仿函数的优点是它可以在成员变量中存储数据(因为它们也是)。

一个简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <iostream>

class Accumulator
{
private:
int m_counter{ 0 };

public:
int operator() (int i) { return (m_counter += i); }

void reset() { m_counter = 0; } // optional
};

int main()
{
Accumulator acc{};
std::cout << acc(1) << '\n'; // prints 1
std::cout << acc(3) << '\n'; // prints 4

Accumulator acc2{};
std::cout << acc2(10) << '\n'; // prints 10
std::cout << acc2(20) << '\n'; // prints 30

return 0;
}

  1. LearnC++ 的作者太有幽默感了。这个句子来自 My Little Pony(《小马宝莉》) ↩︎


©2025-Present Watermelonabc | 萌ICP备20251229号

Powered by Hexo & Stellar latest & Vercel & 𝙌𝙞𝙪𝙙𝙪𝙣 𝘾𝘿𝙉 & HUAWEI Cloud
您的访问数据将由 Vercel 和自托管的 Umami 进行隐私优先分析,以优化未来的访问体验

本博客总访问量:capoo-2

| 开往-友链接力 | 异次元之旅 | 中文独立博客列表

猫猫🐱 发表了 41 篇文章 · 总计 209.8k 字