Lesson29 C++ 对象模型
29.1 this
指针
创建类对象时,只有非静态成员变量才占用该对象的空间。静态成员变量和所有成员函数均共享一个实例,不会重复占用空间。
对于空对象,C++ 编译器会分配 1 个字节的空间,这是为了区分类对象的内存位置。换句话说,每个类对象都拥有一个独一无二的地址。
1 | class Person { |
正如上文所言,所有类对象的成员函数仅有一份,那么当我们调用成员函数时,C++ 又是怎么知道是哪个类对象调用的呢?比如下面的示例:
1 |
|
当程序调用 23
行的 setID
函数时,m_id
到底是 a
的,还是 b
的?函数只接受到参数 id
,至于运算结果属于谁…… 不造啊,定义里面没写啊!
因此 C++ 使用了 this
指针来解决这个问题。
每一个非静态成员函数都拥有 this
指针。它是指向当前成员函数所属的类对象的地址的 const
指针。大多数时候,我们会省略 this
,因为编译器会帮我们补充这个指针,这时还写就有点多余了。
下面介绍编译器对 this
指针的处理思路(以上面的例子为例):
- 为了让函数明确是哪个对象在调用它,编译器会重写函数调用,比如
a.setID(3)
(可能)会被重写为Simple::setID(&a, 3)
。此时类对象的地址会作为参数一并传入。 - 为了使用类对象的地址,编译器还会重写函数的一部分定义。最终的函数定义应类似于:
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
2
3
4
5
6
7
8
9
10
11class 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
指针。 -
成员函数返回类对象本身。
这样做的目的是使成员函数 “链式” 调用,即在单个表达式中对同一对象调用多个成员函数。这被称为函数链式调用 (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
12class 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; }
};在连续使用
add
、sub
、mult
三个函数时:1
2
3
4
5
6
7
8
9
10
11
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
,因此calc
的m_value
现在包含的值是(((0 + 5) - 3) * 4)
,即8
。 -
将类对象重置为初始状态。
如果类有一个默认构造函数,那么将类重置为默认状态的最佳方法是创建一个
reset()
成员函数,让该函数创建一个新的对象(使用默认构造函数),然后将该新对象分配给当前的隐式对象,如下所示:1
2
3
4void reset()
{
*this = {}; // value initialize a new object and overwrite the implicit object
}
29.2 空指针访问成员函数
在 C++ 中,空指针也可以访问成员函数:
1 | class Person { |
1 | This is Person class |
但需要注意成员函数里是否使用了 this
指针:
1 | class Person { |
此时调试报错:
1 0x00007FF635A9103E 处引发的异常: 0xC0000005: 读取位置 0x0000000000000000 时发生访问冲突。因为编译器理解为:
1
2
3 void getAge() {
std::cout << this -> m_age << std::endl;
}此时
this
是一个空指针,没有对应的可读的类对象。
如果使用指针调用成员函数,需要考虑到空指针下代码的健壮性。最简单的解决方法就是加一个判断,遇到空指针时让函数避免使用 this
指针:
1 | void getAge() { |
29.3 const
修饰类对象
在 C 语言中,我们可以通过 const
关键字设置基本数据类型对象的常量,常量必须在创建时被初始化。
现在我们也可以使用 const
修饰类对象,后者称为常对象。常对象同样在创建时被初始化。一旦初始化了常对象,任何修改对象成员变量的尝试都是不允许的:
1 | struct Date |
常对象不能调用非 const
修饰的成员函数,因为后者仍有可能破坏常对象的 const
属性。所以我们需要为成员函数也加上 const
属性,使之成为常函数。常函数保证不会修改对象或调用任何非 const
修饰的成员函数,尝试调用的话编译器会抛出编译错误:
1 |
|
构造函数不能被设置为常函数,因为它必定修改成员变量。
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 |
|
如果部分成员变量确实需要常函数来修改,那么可以给成员变量加上一个 mutable
属性:
1 | class A { |
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
mutable
关键字允许常函数修改常对象里的成员变量。
Lesson30 友元
在介绍 C++ 的类时,我们提到它的访问权限控制。这一技术有很多优点,但也并非十全十美。
例如,考虑一个专注于管理某些数据集的存储类。现在假设你还想显示这些数据,但处理显示的代码将有很多选项,因此比较复杂。你可以将存储管理函数和显示管理函数放在同一个类中,但这会使事情变得杂乱无章,并导致接口复杂;你也可以将它们分开:存储类管理存储,而另一个显示类管理所有的显示功能。这创建了很好的职责分离。但是,显示类将无法访问存储类的私有成员,可能无法完成其工作。
还是拿 28.1.2 的住宅做例子。平时住宅是私人的,只有屋主本人才能进入,但如果今天有清洁工上门整理呢?
在程序中,有些私有属性需要被类外的其他函数或类对象访问。对于这个需求,C++ 的解决方案是 —— Friendship is Magic! [1]
对😂,你没听错,C++ 引入了友元 (Friend) 技术,允许一个函数或类访问另一个类中的私有或受保护成员。通过这种方式,一个类可以有选择地给予其他类或函数对其成员的完全访问权限,而不会影响其他任何内容。
友元在将要访问的类(而不是渴望访问的类或函数)的主体内,使用关键字 friend
进行声明。由于友元无视访问权限控制,因此可以在类内任意位置声明。
友元关系是由执行数据隐藏的类授予的,期望友元可以访问其私有成员。这个类将友元视为其本身的扩展,拥有相同的访问权限。
——15.8 — Friend non-member functions
回应开头的例子,如果我们的存储类使显示类成为友元,那么显示类就能直接访问存储类的所有成员。显示类可以利用这种直接访问来实现存储类的显示,同时保持结构上的分离。
友元的三种实现:
- 非成员函数作为友元
- 类作为友元
- 成员函数作为友元
接下来我们将一一说明。
30.1 非成员函数作为友元
友元函数是一个非成员函数,它可以像该类的成员一样访问类的私有和受保护成员。在其他所有方面,友元函数都是一个普通函数:
1 |
|
在这个例子中,我们声明了一个名为 print()
的非成员函数,它接受一个类 Accumulator
的对象。因为 print()
不是 Accumulator 类的成员,所以它通常无法访问私有成员 m_value
。然而,Accumulator
类有一个友元声明,使 print(const Accumulator& accumulator)
成为友元。现在 print()
可以访问到 m_value
啦!
非成员函数没有 this
指针,因此我们需要传入类对象。
如果一个成员函数被声明为友元,那么该函数将被认定为非成员函数:
1 |
|
print
在类 Accumulator
内定义,但由于被声明为友元,因此它实际被视为非成员函数,无法调用 this
指针。
30.2 类作为友元
友元类是可以访问另一个类的私有和受保护成员的类。
1 |
|
因为 Display
类是 Storage
的友元类, 所以 Display
的成员可以访问的任何 Storage
类对象的私有成员。
在使用友元类时,需要注意以下几点:
- 友元只涉及成员访问权限,两个类之间相互独立。例如,
Display
无法访问Storage
对象的*this
指针(因为*this
实际上是一个函数参数)。 - 友元关系不是 “双向奔赴” 的。
Display
类是Storage
的友元类不代表Storage
类是Display
的友元。如果想要双方互相是对方的友元,那么每个类都需要声明对方是自己的友元。 - 友元关系不传递、不继承。
A
是B
的友元,B
是C
的友元,不代表A
是C
的友元;A
是B
的友元,但A
和B
的派生类没有友元关系。 - 类友元声明充当被友元类的先行声明。这意味着在友元化之前,我们不需要先行声明被友元化的类。
30.3 成员函数作为友元
有时我们不需要类整体成为友元,那么我们可以只声明部分成员函数为友元。这和声明非成员函数为友元有相似之处,但更复杂:
1 |
|
几个注意事项:
- 如果想要将成员函数声明为友元,编译器必须在声明前读取到该函数所属的类的完整定义。在单文件编程中,我们需要调整类定义的顺序;但在多文件编程中,更好的解决方案是将每个类的定义放入单独的头文件中,成员函数的定义放在相应的 .cpp 文件中。这样,所有的类定义都会在主文件中可用。
- 声明友元时,注意不要忘了成员函数所属类的作用域声明。
- 友元成员函数参数中的类也需要声明。一种方法是先行声明参数中的类,另一种方法是将这个函数独立出来(类外实现成员函数),放到要使用的类的定义之后。
Lesson31 运算符重载
在 27.3,我们学习了函数的重载。简单来讲,重载就是重新定义已有函数,增加功能以适应不同参数。运算符同样存在重载现象。
31.0 理解运算符重载
在 C++ 中,运算符由函数实现,这就是运算符重载 (operator overloading) 的底层逻辑。
接下来我们使用一组示例来说明:
- 两个整数相加
1 | int x { 2 }; |
编译器内置了整数参数的加号运算符 (x
) 版本 —— 此函数将整数 x
和 y
相加,并返回一个整数结果。可以近似认为是编译器调用了函数 int operator+(int x, int y)
。
- 两个浮点数相加
1 | double z { 2.0 }; |
编译器还自带了双精度浮点数参数的加号运算符 (+
) 版本。表达式 w + z
变成调用函数 double operator+(double w, double z)
,编译器通过函数重载来确定应该调用该函数的双精度版本,而不是整数版本。
- 两个自定义类型字符串相加
1 | Mystring string1 { "Hello, " }; |
直观上预期的结果是字符串 “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 |
|
- 使用友元函数的版本:
1 |
|
- 使用成员函数的版本:
1 |
|
31.2 流式输出运算符重载
C++ 已经重载了 <<
(左移运算符)作为流插入运算符使用。<<
支持输出基本数据类型的数据,但有时我们想要输出规定之外的数据类型。
std::basic_ostream<CharT,Traits>::operator<< - cppreference.com 提供了
<<
可以输出的数据类型。
例如,我们想要直接输出类 Point
的某对象的所有成员变量,一个个写 getVar()
太烦,用成员函数 print()
输出倒是可以,但是插不进输出流里:
1 | int main() |
所以我们想要将类对象直接插入到输出流里,就需要重载 <<
运算符。
考虑表达式 std::cout << point
。如果操作符是 <<
,操作数是什么?左操作数是 std::cout
对象,右操作数是你的 Point
类对象。 std::cout
实际上是一个类型为 std::ostream
的对象。下面是 <<
重载的具体利用:
1 |
|
31.3 递增运算符重载
通过重载递增运算符,我们可以实现自定义数据的计算规则。
31.3.1 前缀的重载
1 |
|
Digit
类包含一个介于 0 到 9 之间的数字。我们重载了递增运算符,如果数字增加 / 减少超出范围,则进行回绕。
请注意,我们返回 *this
。重载的递增运算符返回当前的隐式对象,因此多个运算符可以被 “链式” 连接在一起,比如最后的重载 <<
运算符。
31.3.2 后缀的重载
通常,当函数具有相同的名称但不同的参数数量和 / 或不同类型的参数时,可以进行函数重载。然而,考虑前缀和后缀递增运算符的情况。它们具有相同的名称(例如 operator++
),都是一元的,并且接受相同类型的单个参数。那么在重载时如何区分这两个运算符呢?
C++ 语言规范提供了解答:编译器会检查重载运算符是否有 int
参数。如果重载运算符有 int
参数,则该运算符是后缀重载。如果重载运算符没有参数,则该运算符是前缀重载。这个 int
参数是一个虚拟参数,仅起到占位符作用。
1 | class Digit |
后缀运算符需要返回对象在自增之前的原始状态。这导致了一个小小的难题 —— 如果我们对对象进行自增,我们就无法返回对象自增之前的原始状态。另一方面,如果我们返回对象自增之前的原始状态,那么实际上自增操作没有调用。
通常解决此问题的方法是使用一个临时变量来保存对象在增减之前的值。然后对对象本身进行增减操作。最后,将临时变量返回给调用者。
这意味着重载运算符的返回值必须是非引用的,因为我们不能返回一个在函数退出时将被销毁的局部变量的引用。另外,请注意,由于需要实例化临时变量并通过值返回而不是通过引用返回,后缀运算符通常比前缀运算符效率低。
31.4 赋值运算符重载
重载的赋值运算符(operator=
)用于将一个对象中的值复制到另一个已存在的对象中。这和 28.2.3 的拷贝构造函数的目的相同。不过,赋值运算符会替换现有对象的内部内容,而拷贝构造函数会初始化一个新对象。
复制构造函数和复制赋值运算符之间的区别常常让新手程序员感到困惑,但实际上这并不难。总结如下:
- 如果必须在复制之前创建一个新对象,则使用拷贝构造函数。(注意:这包括通过值传递或返回对象)
- 如果复制发生前不需要创建新对象,则使用赋值运算符。
在编写重载时,我们需要注意一点:拷贝赋值运算符必须是成员函数。
1 | lass MyString |
上面的例子使用深拷贝。和构造函数一样,拷贝赋值运算符也有自己的默认版本。
对于 C++ 默认使用的浅拷贝,可以参见:
1 | // Possible implementation of implicit assignment operator |
对于动态分配内存的变量,我们在分配任何新内存之前需要显式地释放旧内存。对于静态分配内存的变量,我们不必担心 —— 不过是新值覆盖了旧值而已。
31.5 关系运算符重载
可以让两个自定义类型对象进行比较操作。
1 |
|
上面是一个带有重载运算符 ==
和运算符 !=
的 Car
类示例。
<
和 >
呢?那我问你,一辆车比另一辆车更大或更小意味着什么?由于操作符 <
和操作符 >
的结果不直观,最好将这些操作符定义为未定义。应该仅定义对类有直观意义的重载运算符。
有一个常见的例子是,如果我们想对汽车列表进行排序呢?在这种情况下,我们可能希望重载比较运算符以返回最可能想要排序的成员。例如,为 Car
重载的运算符 <
可能基于品牌和型号按字母顺序排序。
重载的比较运算符往往具有高度的冗余性,实现越复杂,冗余就越多,也更难维护。通过逻辑运算,我们可以降低这种冗余性:
- 操作符
!=
可以表示为!(operator==)
- 操作符
>
可以通过将参数顺序颠倒来实现为操作符<
,如operator> (a, b)
可以表示为operator< (b, a)
- 操作符
>=
可以表示为!(operator<)
- 操作符
<=
可以表示为!(operator>)
31.6 函数调用运算符重载
嘿嘿,没想到吧,()
也是一个运算符!括号运算符(operator()
)是一个特别有趣的运算符,因为它允许改变它接受的参数的类型和数量。
有两个要点需要注意:首先,括号运算符必须实现为成员函数;其次,在非面向对象的 C++ 中,()
运算符用于调用函数。对于类来说,operator()
只是一个普通的运算符,它像任何其他重载运算符一样调用一个函数。
操作符 ()
也常被重载以实现函数对象 (Function Object) ,后者像函数一样操作,也被称为仿函数 (Functor) 。与普通函数相比,仿函数的优点是它可以在成员变量中存储数据(因为它们也是类)。
一个简单的例子:
1 |
|
LearnC++ 的作者太有幽默感了。这个句子来自 My Little Pony(《小马宝莉》) ↩︎