C++: Concepts for OOP
Intro
C++ 通过类来实现面向对象的编程。本文将介绍一些在 C++ 中使用类的时候可能会引起一些问题的主题。
引用、const 和类
通过按值传递把对象传递给函数的效率很低。因为复制对象的操作可能需要高昂的计算代价,特别是当对象拥有大量的数据的时候。因此,在 C++ 中,最佳的实践方法是通过引用传递对象。
但是,按引用传递对象带来的一个问题是,引用允许函数修改类的参数。假设有一个用来检测两个圆是否相交的函数 Intersects
,它接收两个 Circle
对象作为参数。如果 Intersects
函数通过引用来接收这两个 Circle
对象,它就可以修改这两个 Circle
对象的中心或半径。
在 C++ 中,对这个问题的解决方案是使用常量引用(const 引用)。const 引用 保证函数只能对引用变量进行读取操作,而不能进行写入操作。使用 const 引用的 Intersects
函数的声明如下:
1 | bool Intersects(const Circle& a, const Circle& b); |
还可以把成员函数标记为 const 成员函数,以保证成员函数不会修改成员数据。例如,Circle
类的 GetRadius
函数不应该修改成员数据,这意味着它应该是 const 成员函数。如果要表示成员函数是 const 成员函数,就需要在函数声明的右括号后面添加 const 关键字,如下所示:
1 | class Circle |
总而言之,引用、const 和类的最佳实践如下:
- 通过引用、const 引用或指针来传递非基本类型数据,以避免进行复制;
- 当函数不需要修改引用参数时,通过 const 引用进行传递;
- 将不修改数据的成员函数标记为 const 成员函数。
动态分配类对象
与其他类型相同,类对象也可以动态分配。下面的代码演示了一个 Complex
类的声明,该类封装了一个实部和一个虚部。
1 | class Complex |
需要注意 Complex
类的构造函数是如何接收两个参数的。若要动态分配 Complex
类的实例,则必须传入以下参数:
1 | Complex* c = new Complex(1.0f, 2.0f); |
与其他类型的动态分配相同,new
运算符返回指向动态分配对象的指针。给定指定对象的指针,->
运算符可访问任何公共成员。例如,如果 Complex
类具有不带参数的公共 Negate
成员函数,则以下代码将调用对象 c
上的 Negate
函数:
1 | c->Negate(); |
当且仅当类具有默认构造函数(不带任何参数的构造函数)时,可以动态分配对象数组。因为在动态分配数组时,类无法指定构造函数的参数。在 C++ 中,如果没有为类定义任何构造函数,则会自动创建默认的构造函数。但是,一旦为类声明了一个接受参数的构造函数,那么 C++ 将不会为它自动创建默认构造函数。此时,如果这个类还需要默认构造函数,则必须在类代码中手动声明。
析构函数
假设在整个程序运行过程中需要多次动态分配整数数组,与其手动重复编写相关的代码,不如把这个功能封装在 DynamicArray
类中。如下代码所示:
1 | class DynamicArray |
根据上述代码,可以使用以下代码来创建一个包含 50 个元素的动态数组对象:
1 | DynamicArray scores(50); |
但是,在代码中每次调用 new
运算符,都需要有对应的 delete
运算符的调用。在上面的例子中,DynamicArray
类在它的构造函数中动态分配数组,但在类的定义中没有对应的 delete[]
的调用。在这种情况下,当 scores
对象超出作用域范围时,代码会发生内存泄漏。
对上述问题的解决方案时使用一个被称为析构函数的特殊成员函数。析构函数会在销毁类对象的时候自动运行。对于在栈上分配的对象,当对象超出作用域范围时,析构函数会自动运行。对于动态分配的对象,在对象上调用 delete
运算符时,析构函数也会自动运行。
类的析构函数总是与类具有相同的名称,但前缀为波浪号(~
),所以 DynamicArray
类的析构函数如下:
1 | DynamicArray::~DynamicArray() |
向 DynamicArray
类中添加此析构函数,则当类对象 scores
超出范围时,析构函数将释放 mArray
的空间,从而消除内存泄漏。
复制构造函数
复制构造函数是一种特殊的构造函数,用于将对象创建为相同类型的另一个对象的副本。例如,假设代码中声明以下的 Complex
对象:
1 | Complex c1 = Complex(5.0f, 3.5f); |
则可以将 Complex
类的第二个实例初始化为 c1
实例的副本:
1 | Complex c2(c1); |
在大多数情况下,如果没有声明类的复制构造函数,那么 C++ 将自动为它提供一个。这个磨人的复制构造函数直接把原始对象中的所有成员数据复制到新的对象上。对于没有指向数据的指针的类,比如 Complex
类,默认的复制构造函数非常实用。但是,如果一个类的成员包含指针,如 DynamicArray
类,直接复制成员数据可能不会产生代码所期望的结果。假设运行以下代码:
1 | DynamicArray array(50); |
使用 DynamicArray
类默认的复制构造函数,上述代码将直接复制 mArray
指针,而不是复制底层的动态分配的数组。这意味着如果程序员接下来修改 otherArray
对象,那就是同时在修改 array
对象。这种复制方式被称为浅层复制。
flowchart LR subgraph original id1["mArray"] end subgraph copy id2["mArray"] end id1 --> id3["[2, 4, 6, 8, 10]"] id2 --> id3
如果某个类的默认的复制构造函数不能满足要求,就必须在类中声明自定义复制构造函数:
1 | DynamicArray(const DynamicArray& other) |
请注意,复制构造函数的唯一参数是对该类的另一个实例的 const 引用。
在上面的例子代码中,DynamicArray
类的复制构造函数动态分配一个新数组 mArray
,然后复制 other.mArray
的数据。这种复制方式被称为深层复制,因为现在这两个对象具有底层的各自独立的动态分配的数组。
通常,使用动态分配数据的类应该实现以下成员函数:
- 用于释放动态分配内存数据的析构函数;
- 用于实现深层复制的复制构造函数;
- 复制运算符,也用于实现深层复制。
如果有必要实现上述三个函数中的任何一个,就应该实现所有的三个函数。这个问题在 C++ 程序中很常见,通常被称为“三规则”。
在 C++ 11 标准中,“三规则”被扩展为“五规则”,因为新加入了两个额外的特殊函数,它们是移动构造函数和移动赋值运算符。
运算符重载
C++ 允许程序员为自定义类型指定内置运算符。例如,程序员可以按照下面的方式定义 Complex
类的 +
运算符:
1 | friend Complex operator+(const Complex&a, const Complex& b) |
上述代码中的 friend
关键字表示 operator+
是一个独立的函数,可以访问 Complex
类的私有数据。这是二元运算符的典型声明签名。
在重载了 +
运算符之后,可以使用该运算符来对两个 Complex
对象进行相加操作:
1 | Complex result = c1 + c2; |
还可以重载二元比较运算符,唯一的区别是二元比较运算符返回了一个 bool 值。下面代码用于重载 ==
运算符:
1 | friend bool operator==(const Complex& a, const Complex& b) |
还可以重载 =
运算符(赋值运算符)。与复制构造函数一样,如果类定义中没有指定赋值运算符,C++ 将自动提供一个执行浅层复制的默认赋值运算符。因此,程序员通常只需要在“三规则”的情况下重载类的赋值运算符。
类的赋值运算符和类的复制构造函数之间有一个很大的区别:复制构造函数用于创建一个新的对象,并将其初始化为同一类的现有对象的副本;赋值运算符用于将一个对象的值赋给另一个已存在的对象。例如,在下面代码中,因为先前在第一行已经构造了 a1
对象,所以才能在第三行中调用 DynamicArray
类的赋值运算符:
1 | DynamicArray a1(50); |
由于赋值运算符会使用新的值覆盖已经存在的实例,因此需要释放先前动态分配的数据。如下代码给出的是 DynamicArray
类的赋值运算符的正确实现:
1 | DynamicArray& operator=(const DynamicArray& other) |
需要注意的是,赋值运算符是类的成员函数,而不是独立的友元函数,因此它没有 friend
关键字来修饰。此外,按照惯例,赋值运算符返回重新分配的对象的引用。赋值运算符的这种特性允许程序员能够编写链式赋值语句代码(尽管是丑陋的代码),如下所示:
1 | a = b = c; |
程序员几乎可以重载 C++ 中的每个运算符,包括下标运算符 []
、new
运算符和 delete
运算符。然而,能力越大,责任越大。程序员应该只有在清楚运算符要如何工作时才去尝试重载运算符。
过度使用运算符重载会导致生成的代码难以理解,所以程序员应尽量避免重载运算符。但是 C++ 语言库中就打破了这种最佳实践:C++ 中的 Stream 类重载了输入运算符 >>
和输出运算符 <<
(对于整数类型,>>
和 <<
是位移操作赋)。