More Effective C++ 35个改善编程与设计的有效方法笔记

绝对不要以多态方式处理数组(易错)

C++允许你通过基类指针和引用来操作派生类数组。不过这根本就不是一个特性,因为这样的代码几乎从不如你所愿地那样运行。数组与多态不能用在一起。

值得注意的是如果你不从一个具体类(例如 BST)派生出另一个具体类(例如 BalancedBST),那么你就不太可能犯这种使用多态性数组的错误。

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
class BST {
public:
virtual ~BST() { fprintf(stdout, "BST::~BST\n"); }
private:
int score;
};

class BalancedBST : public BST {
public:
virtual ~BalancedBST() { fprintf(stdout, "BalancedBST::~BalancedBST\n"); }
private:
int length;
int size; // 如果增加此一个int成员,执行test_item_3会segmentation fault,注释掉此变量,运行正常
};

int test_item_3()
{
fprintf(stdout, "BST size: %d\n", sizeof(BST)); // 16
fprintf(stdout, "BalancedBST size: %d\n", sizeof(BalancedBST)); // 24

BST* p = new BalancedBST[10];
delete [] p; // 如果sizeof(BST) != sizeof(BalancedBST),则会segmentation fault

return 0;
}

原因可以归纳为以下几点:

  1. 对象切片(Object Slicing)
    在 C++ 中,如果我们以基类的指针或引用存储派生类对象时,只会保留基类部分的数据,丢失派生类的数据成员和功能。这种情况称为对象切片,而对象切片在数组中非常容易发生。
  2. 数组元素大小不一致
    在多态数组中,基类和派生类对象的大小可能不同,而编译器对数组的实现假定每个元素大小一致。因此,以多态方式处理数组可能导致对数据的错误读取或写入。例如,访问数组中的第 n 个对象时,编译器会根据基类对象的大小进行偏移,而忽略派生类对象的实际大小,导致访问错误的内存区域。
  3. 析构函数不匹配
    如果使用基类指针来遍历并析构派生类对象数组,将无法正确调用派生类的析构函数,可能导致内存泄漏或资源未释放的情况。需要虚析构函数才能解决部分问题,但即便如此,数组的整体管理依然会有潜在风险。

为了解决这些问题,推荐使用智能指针STL容器(如 std::vector<std::shared_ptr<Base>>),可以安全、方便地管理多态对象的数组。这样做不仅可以避免对象切片,还可以确保派生类的析构函数被正确调用。

 

实际上上面的测试代码给人一种错觉,好像两个有继承关系的类只要 sizeof 结果相同,用数组就高枕无忧了,实际也非如此。

假设我们有一个基类 Base,以及一个继承自它的派生类 Derived

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
cpp复制代码#include <iostream>

class Base {
public:
virtual void show() const {
std::cout << "Base class\n";
}
virtual ~Base() = default;
};

class Derived : public Base {
public:
void show() const override {
std::cout << "Derived class\n";
}
};

现在,如果我们尝试创建一个 Base 类型的对象数组,并在其中放入 Derived 类型的对象,会出现对象切片的问题。

1
2
3
4
5
6
7
8
9
10
cpp复制代码int main() {
Base arr[2]; // 创建 Base 类型的数组
arr[0] = Derived(); // 尝试将 Derived 对象存入数组
arr[1] = Base();

for (const auto& obj : arr) {
obj.show(); // 调用的是 Base::show(),而不是 Derived::show()
}
return 0;
}

输出结果:

1
2
Base class
Base class

虽然我们在数组中存放了 Derived 对象,但由于对象切片,实际存储的只是 Base 类型的数据,派生类的功能被切掉了。

 

为了解决多态对象数组的问题,可以使用 std::vector 和智能指针来存储指向基类的指针。

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
cpp复制代码#include <iostream>
#include <memory>
#include <vector>

class Base {
public:
virtual void show() const {
std::cout << "Base class\n";
}
virtual ~Base() = default;
};

class Derived : public Base {
public:
void show() const override {
std::cout << "Derived class\n";
}
};

int main() {
std::vector<std::shared_ptr<Base>> arr;
arr.push_back(std::make_shared<Derived>()); // 存储 Derived 对象
arr.push_back(std::make_shared<Base>()); // 存储 Base 对象

for (const auto& obj : arr) {
obj->show(); // 调用的是对象实际类型的 show()
}
return 0;
}

输出结果:

1
2
Derived class
Base class

在这种方式下,std::vector 中存储的是 std::shared_ptr<Base> 类型的智能指针。由于智能指针能够正确管理派生类对象的生命周期和多态调用,因此可以正确调用 Derived 类的 show 方法,也不会发生对象切片。

自增、自减操作符前缀形式与后缀形式的区别

由于重载函数是基于参数列表来定位,而前缀++和后缀++或前缀–和后缀–去重载的时候,会出现难以定位的问题。那么 C++ 之父如何解决的?下面以 ++ 运算符重载举例。

1
2
3
4
5
6
7
8
9
10
11
12
UPInt & operator--() // --前缀
{
i -= 1;
return * this;
}

const UPInt operator--(int) // --后缀
{
UPInt oldValue = *this;
--( * this);
return oldValue;
}

后缀 ++ 的重载会添加一个形参来和前缀 ++ 的重载进行区分,但你要记住只能有一个形参且必须是 int。

当处理用户定义的类型时,尽可能地使用前缀++,因为它的效率较高。

不要重载 &&、||、或 ,

重载 && 与 || 的风险

  • 你以函数调用法替代了短路求值法。函数调用法与短路求值法是绝对不同的。首先当函数被调用时,需要运算其所有参数。
  • C++语言规范没有定义函数参数的计算顺序,所以没有办法知道表达式1与表达式2哪一个先计算。完全可能与具有从左参数到右参数计算顺序的短路计算法相反。

因此如果你重载 && 或 ||,就没有办法提供给程序员他们所期望和使用的行为特性,所以不要重载 && 和 ||。

重载 , 的风险

  • 一个包含逗号的表达式首先计算逗号左边的表达式,然后计算逗号右边的表达式;整个表达式的结果是逗号右边表达式的值。如果你写一个非成员函数operator,你不能保证左边的表达式先于右边的表达式计算,因为函数(operator)调用时两个表达式作为参数被传递出去。但是你不能控制函数参数的计算顺序。所以非成员函数的方法绝对不行。
  • 成员函数operator,你也不能依靠于逗号左边表达式先被计算的行为特性,因为编译器不一定必须按此方法去计算。

因此你不能重载逗号操作符,保证它的行为特性与其被料想的一样。重载它是完全轻率的行为。

new操作符(new operator)和new操作(operator new)的区别

new 操作符就像 sizeof 一样是语言内置的,你不能改变它的含义,它的功能总是一样的。它要完成的功能分成两部分。第一部分是分配足够的内存以便容纳所需类型的对象。第二部分是它调用构造函数初始化内存中的对象。new 操作符总是做这两件事情,你不能以任何方式改变它的行为。你所能改变的是如何为对象分配内存。new 操作符调用一个函数来完成必须的内存分配,你能够重写或重载这个函数来改变它的行为。new 操作符为分配内存所调用函数的名字是 operator new。

函数 operator new 通常声明:返回值类型是 void*,因为这个函数返回一个未经处理的指针,未初始化的内存。参数 size_t 确定分配多少内存。你能增加额外的参数重载函数 operator new,但是第一个参数类型必须是 size_t。就像 malloc 一样,operator new 的职责只是分配内存。它对构造函数一无所知。把 operator new 返回的未经处理的指针传递给一个对象是 new 操作符的工作。

new operator = operator new + 构造函数。

通过引用(reference)捕获异常

通过指针捕获异常不符合C++语言本身的规范。

四个标准的异常—- bad_alloc(当operator new不能分配足够的内存时被抛出);bad_cast(当dynamic_cast针对一个引用操作失败时被抛出);bad_typeid(当dynamic_cast对空指针进行操作时被抛出);bad_exception(用于unexpected异常)—-都不是指向对象的指针,所以你必须通过值或引用来捕获它们。

了解异常处理的系统开销

采用不支持异常的方法编译的程序一般比支持异常的程序运行速度更快所占空间也更小。

为了减少开销,你应该避免使用无用的 try 块。如果使用 try 块,代码的尺寸将增加并且运行速度也会减慢。