Effective C++ 读书笔记2

effective cpp读书笔记第二部分,面向对象、模版等。

29 为异常安全努力是值得的

异常安全函数提供以下三个基本保证之一:

  • 一致性:如果异常被抛出,所有对象都处于一种内部前后一致的状态。
  • 原子性:如果函数成功,就是完全成功;如果函数失败,程序回复到“调用函数之前”的状态。
  • nothrow保证:承诺不抛出异常。

我们该为我们写的函数提供哪种保证?往往想提供最强烈保证(原子性)。

copy and swap策略

简单实用的策略:当你打算修改一个对象时,先拷贝一份它的副本,然后在它的副本上应用修改,最后将对象与副本交换。

这个策略的好处是:当你的修改发生异常时,任何改动都不会影响原来的对象。

但是copy and swap策略并不能保证整个函数有强烈的异常安全性,考虑以下采用了该策略的函数:

1
2
3
4
5
6
7
void someFunc()
{
... // 拷贝副本
f1();
f2();
... // 置换副本
}

尽管f1与f2都提供了强烈的异常安全保证,但是如果f1完成任务,f2中途抛出异常,并回退到f2调用前的状态,问题是程序的状态已经被改变(f1所为)。

如果系统内有一个函数不具备异常安全性,整个系统就不具备异常安全性,因为调用那个异常不安全的函数就有可能导致资源泄漏或数据结构败坏。

30 了解inline的里里外外

(1)inline只是对编译器的一个申请,并不是强制命令。

(2)class内定义的函数,往往是隐式inline。

(3)inline造成的代码膨胀,可能导致额外的换页行为,降低指令高速缓存装置的命中率。

(4)virtrual与inline是冲突的

inline意味着在编译期,将函数的调用动作替换为函数的本体;virtual则意味着“等待,在运行期间决定调用哪个函数”。

(5)构造函数、析构函数与inline配合也是糟糕的

原因在于编译器为空白的构造函数添加了许多成员默认初始化语句,这将显著增大代码体积。

(6)inline函数无法随着程序库的升级而升级

如果f是程序库中的一个inline函数,一旦设计者决定改变f,所有用到f的客户端程序都需要重新编译;如果f是非inline函数,客户端只需要重新链接。

31 将文件间的编译依存关系降至最低

编译依存关系

1
2
3
4
5
6
7
8
9
10
11
12
#include <string>
#include "data.h"
#include "address.h"

class Person
{
...
private:
std::string theName; // 实现细目
Date theBirthDay; // 实现细目
Address theAddress; // 实现细目
};

C++并没有将“接口与实现分离”这件事情做得很好,Class的定义式不仅包含了class的接口,还包括了十足的实现细节。如此,Person定义文件便与其含入的文件之间形成了编译依存关系:

如果Person依赖的任何一个头文件改变,那么每一个含入Person Class的文件都需要重新编译。

前置声明

既然依赖的头文件改变会影响我们,那么将头文件去除,改为前向声明如何?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
namespace std{
class string; //前向声明
}
class Date; //前向声明
class Address;//前向声明

class Person
{
...
private:
std::string theName; // 实现细目
Date theBirthDay; // 实现细目
Address theAddress; // 实现细目
};

但是这并不能通过编译:编译器无法在编译期间知道类对象的大小(除非拿到实现细节)。

PImpl

虽然编译器不知道类对象的大小,但是指针大小是固定的,于是可以这么玩:将Person分割为两个class,一个只提供接口,一个负责实现接口。

1
2
3
4
5
6
7
8
9
10
#include <string> // 标准库不该被前置声明
class Date; //前向声明
class Address;//前向声明

class Person
{
...
private:
std::shared_ptr<PersonImpl> pImpl; // 指向实现的指针
};

像Person这样使用pimpl idiom的classes,被称为Handle classes。它不完成工作,只负责将工作转交给实现类。

优点:

  • 二进制兼容(ABI stability)。当 TableBuilder 类库更新时,只要其接口(.h 文件)保持不变,即使实现中 Rep 结构体增加成员,或者更改接口的实现,依赖该库的应用程序只用更新动态库文件,无需重新编译
  • 少编译依赖。如果 Rep 结构体的定义在头文件中,那么任何对 Rep 结构体的修改都会导致包含了 table_builder.h 的文件重新编译。
  • 接口与实现分离。接口(在 .h 文件中定义的公共方法)和实现(在 .cc 文件中定义的 Rep 结构体以及具体实现)是完全分开的。这使得在不更改公共接口的情况下,开发者可以自由地修改实现细节,如添加新的私有成员变量或修改内部逻辑。

缺点:

  • 生命周期管理开销(Runtime Overhead): Pimpl 通常需要在堆上动态分配内存来存储实现对象(Impl 对象)。这种动态分配比在栈上分配对象(通常是更快的分配方式)慢,且涉及到更复杂的内存管理。此外,堆上分配内存,如果没有释放会造成内存泄露。不过就上面例子来说,Rep 在对象构造时分配,并在析构时释放,不会造成内存泄露。
  • 访问开销(Access Overhead): 每次通过 Pimpl 访问私有成员函数或变量时,都需要通过指针间接访问。
  • 空间开销(Space Overhead): 每个使用 Pimpl 的类都会在其对象中增加至少一个指针的空间开销来存储实现的指针。如果实现部分需要访问公共成员,可能还需要额外的指针或者通过参数传递指针

缺点:它使你在运行期间丧失若干速度,有让你为每个对象付出超额内存。

时间、空间双损 :sweat_smile:

32 确定你的public继承塑模出is-a关系

“public继承”意味着“是一个”,任何一个适用于base class的事情也一定适用于derived classs上,每一个derived class对象也都是一个base class对象。

33 避免遮掩继承而来的名称

同名覆盖的小例子

继承中同名覆盖问题的核心知识点:作用域问题,例子:

1
2
3
4
5
int a;
void dosomething(){
double a;
cin>>a; //使用double a
}

现象:内层作用域会覆盖外层作用域的同名变量,而无论变量的类型。
原因:当编译器遇到a时,首先在local作用域查找该变量,找到则停止向外查找,而无论这个变量的类型,这就是C++名称遮掩规则。

继承中的同名覆盖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Base{
public:
void func3();
void func3(int);
}

class Derived: public Base{
public:
void func3(int,int);
}


Derived d;
d.func3(5); //错误
d.func3(); //错误
d.func3(3,4); //正确,调用派生类Derived中func3(int,int)。

这是因为derived的作用域是嵌入在Base的作用域中的,对func3的查找先在Derived作用域中进行。

解决方法

(1)采用using声明

1
2
3
4
5
6
7
8
9
10
11
class Base{
public:
void func3();
void func3(int);
}

class Derived: public Base{
public:
using Base::fun3; // 让Base内名为func3的函数在Derived的作用域中可见
void func3(int,int);
}

(2)采用转交函数

using声明将使得Base类中所有fun3都在Derived中可见,但如果我们只想继承无参版本,可以利用名称掩盖的规则,声明一个同样的无参版本,去调用Base的函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Base{
private:
int x;
public:
void func3();
void func3(int);
}

class Derived: public Base{
public:
void fun3(){ //forwarding function
Base::fun3();
}
void func3(int,int);
}

34 区分接口继承与实现继承

表面上直截了断的public继承,通过更严密的检查后,实际上分为“接口继承”和“实现继承”两部分。

我们在基类中会有:

  • 纯虚函数
  • 虚函数
  • 普通函数

派生类继承基类的这几种函数分别意味着什么?

  • 纯虚函数意味着派生类只继承函数接口(因为需要派生类自己实现);

重要:虽然纯虚函数在基类可以有实现,但是派生类仍然需要自己实现一份。

  • 虚函数意味着派生类继承一份接口与一份默认实现;
  • 普通函数意味着派生类继承一份接口与强制实现。

35 考虑虚函数以外的选择

NVI(Non-Virtual interface)利用公有函数调度private虚函数,好处是可以加一些代码在虚函数调用周围。

Strategy设计模式

借由std::function完成Strategy设计模式:

  • 既能传入函数
  • 也能传入仿函数
  • 成员成员(通过std::bind绑定函数与对象)

36 绝不重新定义继承而来的non-virtual 函数

避免同名覆盖!

37 绝不重新定义继承而来的缺省参数值

virtual函数是动态绑定的,缺省参数是静态绑定的。

这点的矛盾会让你使用时出现un-expected行为。

39 关于private继承

第一条规则:如果class之间是private继承,那么将派生类对象转化为基类对象。

第二条规则:基类中的成员都会被贬为private在派生类中。

借条款34所言:private继承是纯粹继承实现,略去接口。它描述的是组合关系,其意义只在于软件实现方面。

41隐式接口与编译期多态

(1)多态

编译期多态:哪一个重载函数应该被使用。

运行期多态:哪一个虚函数应该被绑定。

(2)接口

显式接口:函数签名式(函数名称、参数类型、返回类型)

隐式接口:在模版中,T的类型并不知道,知道T需要满足的某些条件(比如必须有xx函数、支持xx operaotr),同时因为隐式转换存在,对象可被转换成另外一个对象调用。

48 认识template元编程

元编程将工作从运行期转移到编译期,从而使得某些在运行期的错误在编译期中抛出。

尽管有些代码逻辑在运行期间不会被执行,但是编译器必须确保所有源码都是符合语法规则有效的。

作者

Desirer

发布于

2025-06-15

更新于

2025-07-20

许可协议