- 尽量用const, enum和inline取代#define
- 尽可能使用const
- 确定对象被使用前已先被初始化
- 了解C++默默编写并调用哪些函数
- 若不想使用编译器自动生成的函数,就该明确拒绝
- 为多态基类声明 virtual 析构函数
- 别让异常逃离析构函数
- 绝不在构造和析构过程中调用virtual函数
- 让赋值运算符返回一个 reference to *this(引向 *this 的引用)
- 复制对象时勿忘其每一个成分
- 使用对象管理资源
- 谨慎考虑资源管理类的拷贝行为
- 成对使用new和delete时要采用相同形式
- 在一个独立的语句中将 new 出来的对象存入智能指针
- 用传引用取代值传递
- 当你必须返回一个对象时不要试图返回一个引用
- 只要有可能就推迟变量定义
- 将成员变量声明为private
- 将强制类型减到最少
- 避免返回对象内部的句柄
- 争取异常安全的代码
- 理解 inline 化的介入和排除
- 最小化文件之间的编译依赖
- 确定你的public继承塑模出is-a关系
- 避免遮挡继承而来的名称
- 区分接口继承和实现继承
- 绝不要重定义一个通过继承得到的非虚函数(易错)
- 绝不重新定义继承而来的缺省参数值
- 通过复合塑模出has-a
- 明智而审慎地使用private继承
- 谨慎使用多继承
- 了解隐式接口和编译期多态
- 将与参数无关的代码抽离templates
- 写了placement new也要写placement delete
尽量用const, enum和inline取代#define
#define 定义的常量和函数都是单纯的文本替换,没有任何检查,这存在安全隐患。
定义常量选择 const,定义宏函数选择 inline,定义枚举选用 enum。
1 |
|
尽可能使用const
常量指针:指向的对象的值可以修改,但不可以更改所值对象。
1 |
|
指向常量的指针:指向的对象的值不能修改,但可以更改所值对象。
1 |
|
双重const限定的指针:既不能修改所指对象,也不能修改所指对象的值。
1 |
|
总结:根据要施加与对象的特性,选择合适的方式,可对代码起到保护作用。
确定对象被使用前已先被初始化
成员函数要进行初始化操作,避免后续使用出现未定义行为。
- 初始化顺序按照成员变量定义的顺序初始化
- 尽可能选择构造函数初始化列表初始化,而不是在构造函数函数体中初始化
- 为免除”跨编译单元之初始化次序”问题,请以 local stati 对象替换 non-local static 对象。
如何理解最后一句话?
在多个源文件中,静态对象的初始化顺序是不确定的。也就是说,如果一个源文件中的静态对象依赖于另一个源文件中的静态对象的初始化结果,就可能会出现未定义的行为,因为不同编译单元中的静态对象初始化顺序是无法预测的。
为了解决这个问题,可以使用局部静态对象(local static object)。局部静态对象是在函数内部定义的静态对象,它的初始化只会在第一次调用该函数时发生。由于局部静态对象的初始化是有序的(保证先调用函数后才初始化),因此可以避免跨编译单元间的初始化顺序问题。
了解C++默默编写并调用哪些函数
如果你定义一个空类,啥也不做,也是有相关实现的。
如果你自己不声明一个 copy constructor(拷贝构造函数),一个 copy assignment operator(拷贝赋值运算符)和一个 destructor(析构函数),编译器就会为这些东西声明一个它自己的版本。此外,如果你自己根本没有声明 constructor(构造函数),编译器就会为你声明一个 default constructor(缺省构造函数)。所有这些函数都被声明为 public 和 inline。
若不想使用编译器自动生成的函数,就该明确拒绝
选择 delete 将其拒绝,作者说用 private,那是之前的技巧。
1 |
|
为多态基类声明 virtual 析构函数
见此文:虚析构函数的场景
将基类的析构函数声明为 virtual,是为了能够回收派生类的资源,即调用派生类的析构函数。
别让异常逃离析构函数
析构函数绝对不要吐出异常。如果一个被析构函数调用的函数可能抛出异常,析构函数应该捕捉任何异常,然后吞下它们(不传播)或结束程序。
如果客户需要对某个操作函数运行期间抛出的异常做出反应,那么 class 应该提供一个普通函数(而非在析构函数中)执行该操作。
绝不在构造和析构过程中调用virtual函数
- 不确定的类型:在构造函数期间,当前对象的动态类型并不是派生类,而是正在构造的基类。调用虚函数时,实际调用的将是基类中定义的版本,而不是派生类的重写版本。这可能导致程序的行为与预期不符。
- 不完整的对象:在构造过程中,对象的成员变量尚未完全初始化,可能会导致调用虚函数时访问未初始化的成员,从而引发未定义行为。
- 资源管理和内存问题:在析构过程中调用虚函数可能导致使用已经释放或部分释放的资源,进而引发程序崩溃或内存泄漏。
让赋值运算符返回一个 reference to *this(引向 *this 的引用)
1 |
|
复制对象时勿忘其每一个成分
当你声明了你自己的拷贝函数,你就是在告诉编译器你不喜欢缺省实现中的某些东西。
因此,你每次给类添加新成员变量的时候,你就得做到更新拷贝函数。
使用对象管理资源
即选择智能指针管理对象,书中提到的 auto_ptr 已过时,抛弃即可。
谨慎考虑资源管理类的拷贝行为
拷贝构造函数和拷贝赋值运算符函数可能被编译器自动创建出来,因此除非编译器所生版本做了你想要做的事,否则你得自己编写它们。
成对使用new和delete时要采用相同形式
1 |
|
在一个独立的语句中将 new 出来的对象存入智能指针
1 |
|
processWidget(std::shared_ptr<Widget17>(new Widget17), priority())
上面这种调用包含三种行为:
- 调用 priority
- 执行 new Widge
- 调用 std::shared_ptr 的构造函数
new Widget 表达式一定在 shared_ptr 的构造函数能被调用之前执行,因为这个表达式的结果要作为一个参数传递给 shared_ptr 的构造函数,但是 priority 的调用可以被第一个,第二个或第三个执行。如果编译器选择第二个执行它(大概这样能使它们生成更有效率的代码),我们最终得到这样一个操作顺序:
- 执行 new Widge
- 调用 priority
- 调用 std::shared_ptr 的构造函数
如果对 priority 的调用引发一个异常将发生什么。在这种情况下,从 new Widget 返回的指针被丢失,因为它没有被存入我们期望能阻止资源泄漏的 std::shared_ptr。由于一个异常可能插入资源创建的时间和将资源交给一个资源管理对象的时间之间,所以调用 processWidget 可能会发生一次泄漏。
因此,在构建智能指针对象的时候,应该单独一条语句,等构建成功才作为参数传递。
用传引用取代值传递
要我说,尽可能用引用取代指针传递和值传递。
如果你是值传递,会触发拷贝构造函数,实属浪费资源。
如果你是指针传递,可是指针太容易误操作,而且极其难用。
当你必须返回一个对象时不要试图返回一个引用
不是说不能返回,而是不建议这样做,即你得确保你的这个引用指向的对象不是局部(栈上的对象)。
返回引用的目的: 避免复制,节省开销。
1 |
|
函数返回引用的要求:当以引用作为函数的返回值时,返回的变量其生命周期一定是要大于函数的生命周期的,即当函数执行完毕时,返回的变量还存在。
1 |
|
只要有可能就推迟变量定义
推迟变量定义是一种优化代码执行效率的做法,尤其在使用带有构造函数和析构函数的类时更为重要。因为在这种情况下,变量定义时会触发构造函数,而当变量离开作用域时则会触发析构函数。如果一个变量在定义后没有被使用,那么创建和销毁它就是无谓的性能开销。通过推迟变量定义,确保仅在需要使用变量时才创建它,可以有效地避免不必要的构造和析构成本,从而提升代码的运行效率。
见如下代码:
1 |
|
在这种情况下,即便 condition 不满足,MyClass 的构造和析构函数依然会被调用,这造成了不必要的开销。优化后代码如下:
1 |
|
简单来说就一句话,需要的时候再去定义对象,因为定义对象是由开销的,即内存的申请和销毁。
将成员变量声明为private
如作者所言如果你使用函数去得到和设置它的值,你就能实现禁止访问,只读访问和读写访问。嘿嘿,如果你需要,你甚至可以实现只写访问。
将强制类型减到最少
旧风格的强制转型依然合法,但是新的形式更可取。首先,在代码中它们更容易识别(无论是人还是像 grep 这样的工具都是如此),这样就简化了在代码中寻找类型系统被破坏的地方的过程。第二,更精确地指定每一个强制转型的目的,使得编译器诊断使用错误成为可能。例如,如果你试图使用一个 const_cast 以外的新风格强制转型来消除常量性,你的代码将无法编译。
尽管如此,如果通过设计能够让强制类型转换不存在,那是再好不过。
(一)const_cast
const_cast 一般用于强制消除对象的常量性。它是唯一能做到这一点的 C++ 风格的强制转型。
但我们一般建议不用,既然已经给对象一个 const 属性,就应该保留,否则你一开始就不设置对象有 const 属性。
(二)dynamic_cast
dynamic_cast 主要用于执行“安全的向下转型”,也就是说,要确定一个对象是否是一个继承体系中的一个特定类型。它是唯一不能用旧风格语法执行的强制转型。也是唯一可能有重大运行时代价的强制转型。
这种情况就说明多态存在,dynamic_cast 频繁使用,只会让性能受到影响。追求性能,还是考虑设计尽可能避免 dynamic_cast 的使用。
(三)reinterpret_cast
reinterpret_cast 是特意用于底层的强制转型,导致实现依赖(就是说,不可移植)的结果,例如,将一个指针转型为一个整数。这样的强制转型在底层代码以外应该极为罕见。在本书中我只用了一次,而且还仅仅是在讨论你应该如何为裸内存(raw memory)写一个调谐分配者(debugging allocator)的时候。
很少会被用到,特殊场景会有用,之前学《对象模型》有用到过。
(四)static_cast
static_cast 可以被用于强制隐型转换(例如,non-const 对象转型为 const 对象,int 转型为 double,等等)。它还可以用于很多这样的转换的反向转换(例如,void* 指针转型为有类型指针,基类指针转型为派生类指针),但是它不能将一个 const 对象转型为 non-const 对象。(只有 const_cast 能做到。)
避免返回对象内部的句柄
1 |
|
- 封装性破坏:upperLeft 和 lowerRight 返回了 pData->ulhc 和 pData->lrhc 的非 const 引用,允许外部客户直接修改 Rectangle 的内部数据 ulhc 和 lrhc,破坏了 Rectangle 的封装性。
- const 语义冲突:尽管 upperLeft 和 lowerRight 被声明为 const 成员函数,但由于返回的引用是非 const,客户依然可以通过这些函数修改 Rectangle 的数据,这与 const 语义不符,导致 const 限定形同虚设。
- 悬空句柄风险:当这些函数用于临时对象时,返回的引用可能指向被销毁的临时对象的成员数据,导致悬空句柄问题。
前面刚讲返回引用要慎重,着重强调避免返回临时对象。没想到还有其他需要注意点,比方说设置为私有的成员,也没有提供接口来设置值,含义就是不允许修改。但现在好了,返回非 const 引用,导致可以被外界修改,这就是不恰当的。因此,我们还要保证私有成员返回 const 引用。
1 |
|
争取异常安全的代码
异常安全函数提供下述三种保证之一:
- 函数提供基本保证,允诺如果一个异常被抛出,程序中剩下的每一件东西都处于合法状态。没有对象或数据结构被破坏,而且所有的对象都处于内部调和状态(所有的类不变量都被满足)。然而,程序的精确状态可能是不可预期的。
- 函数提供强力保证,允诺如果一个异常被抛出,程序的状态不会发生变化。调用这样的函数在感觉上是极其微弱的,如果它们成功了,它们就完全成功,如果它们失败了,程序的状态就像它们从没有被调用过一样。
- 函数提供不抛出保证,允诺决不抛出异常,因为它们只做它们答应要做的。所有对内建类型(例如,ints,指针,等等)的操作都是不抛出(nothrow)的(也就是说,提供不抛出保证)。这是异常安全代码中必不可少的基础构件。
异常安全函数必须提供上述三种保证中的一种。如果它没有提供,它就不是异常安全的。于是,选择就在于决定你写的每一个函数究竟要提供哪种保证。
有个一般化的设计策略很典型地会导致强烈保证,这个策略被称为copy and swap。原则很简单:为你打算修改的对象(原件)做出一份副本,然后在那副本身上做一切必要修改。若有任何修改动作抛出异常,原对象仍保持未改变状态。待所有改变都成功后,再将修改过的那个副本和原对象在一个不抛出异常的操作中置换(swap)。但一般而言它并不保证整个函数有强烈的异常安全性。
理解 inline 化的介入和排除
如果一个 inline 函数本体很短,为函数本体生成的代码可能比为一个函数调用生成的代码还要小。如果是这种情况,inline 化这个函数可以实际上导致更小的目标代码和更高的指令缓存命中率!
记住,inline 是向编译器发出的一个请求,而不是一个命令,即最终的决定权在编译器手里。
inline 函数一般必须在头文件内,因为大多数构建环境在编译期间进行 inline 化。为了用被调用函数的函数本体替换一个函数调用,编译器必须知道函数看起来像什么样子。即 inline 函数的头文件定义和源文件实现都在头文件中,就像模板函数一样。
最小化文件之间的编译依赖
即网上常常提及的pimpl用法,推荐文章:C++必须掌握的pimpl惯用法
没有使用 pimpl 之前:
1 |
|
我们的头文件会提供给外界,但我们肯定不希望核心的实现被别人窥探,那么我们有什么办法解决这个问题?
使用 pimpl 之后:
1 |
|
你会发现,头文件中已经不包含 Date 和 Address 的实现,而是被我们放到 Person.cpp 文件中,到时候我们提供的是库文件和头文件,是没有源文件的,起到保护作用。
1 |
|
当然,这种技法的好处可不止这一个,还能提高编译速度。
核心数据成员被隐藏;
核心数据成员被隐藏,不必暴露在头文件中,对使用者透明,提高了安全性。
降低编译依赖,提高编译速度;
由于原来的头文件的一些私有成员变量可能是非指针非引用类型的自定义类型,需要在当前类的头文件中包含这些类型的头文件,使用了 pimpl 惯用法以后,这些私有成员变量被移动到当前类的 cpp 文件中,因此头文件不再需要包含这些成员变量的类型头文件,当前头文件变“干净”,这样其他文件在引用这个头文件时,依赖的类型变少,加快了编译速度。
接口与实现分离。
使用了 pimpl 惯用法之后,即使 Address、Date 或者 PersonImpl 类的实现细节发生了变化,对使用者都是透明的,对外的类(Address、Date、PersonImpl)声明仍然可以保持不变。例如我们可以增删改 PersonImpl 的成员变量和成员方法而保持 Person.h 文件内容不变;如果不使用 pimpl 惯用法,我们做不到不改变 Person.h 文件而增删改 **PersonImpl ** 类的成员。
确定你的public继承塑模出is-a关系
推荐文章:浅析 is-a 关系
如果 class D 以 public 形式继承 class B,你的意图就是告诉编译器:每一个类型为 D 的对象同时也是一个类型为 B 的对象,反之不成立。
明确这个信息,就要求我们在进行类的设计和公开继承的时候弄清楚类和类之间的关系,不要牛头不对马嘴。
避免遮挡继承而来的名称
在派生类中定义的名字会覆盖基类中的所有同名成员,不论参数或函数类型。
若要恢复基类中被隐藏的名字,可使用 using
声明或转调函数。
区分接口继承和实现继承
作为一个 class 的设计者,有的时候你想要 派生类 只继承一个 成员函数 的 interface (声明)。有的时候你想要 派生类 既继承 interface(接口)也继承 implementation(实现),但你要允许它们替换他们继承到的 implementation。还有的时候你想要 派生类 继承一个函数的 interface(接口)和 implementation(实现),而不允许它们替换任何东西。
- 纯虚函数:只提供接口,派生类必须实现。
1 |
|
- 非纯虚函数:提供接口和缺省实现,派生类可以选择实现或使用缺省。
1 |
|
- 非虚函数:提供接口和强制实现,派生类无法更改实现。
1 |
|
绝不要重定义一个通过继承得到的非虚函数(易错)
结合前面介绍的条款,本条款其实很容易理解。
如果基类的函数是非虚函数,那我们绝不可以重定义(行为上我们规定不可以,但是语法上支持),这在设计上是不合理的。如果你要达到这个目的,你的设计应该是,如果我们的派生类要重写继承得到的函数,就应该让基类的该函数为非纯虚函数。
如果基类的函数是非虚函数,我们的派生类重定义就会造成一种错误,见如下代码:
1 |
|
同一个对象 d 通过不同的类型调用相同的函数结果却不同,造成混乱。
如果在某些情况下,D 的对象通过 D 的指针调用 mf,而通过 B 的指针调用 mf 时,行为却不同。这可能导致代码的混淆和潜在的错误,因为同一个对象的行为依赖于指针的类型,而非对象的类型。
绝不重新定义继承而来的缺省参数值
在 C++ 中,永远不要重定义继承来的默认参数值。这个建议的核心原因在于虚函数的动态绑定和默认参数值的静态绑定之间的矛盾。虚函数是动态绑定的,也就是说,在运行时根据对象的实际类型(动态类型)来决定具体调用哪个函数。然而,默认参数值是静态绑定的,在编译时根据对象的声明类型(静态类型)来决定参数的默认值。
1 |
|
在这个例子中,如果我们声明了一个 Shape*
类型的指针,并让它指向一个 Rectangle
对象:
1 |
|
即使 Rectangle::draw
中的默认参数值是 Green
,最终调用时默认参数仍是 Shape
中的 Red
。这是因为默认参数值是静态绑定的,因此 draw()
的默认参数值是编译时确定的 Shape::Red
。这可能导致难以预料的结果和代码混乱。
通过复合塑模出has-a
之前我们讲 is-a,那是继承关系下,现在讲的 has-a 指的是包含关系,即一个类包含另外的类。
1 |
|
明智而审慎地使用private继承
实际场景中,我还没有用过 private 继承方式,用的更多是 public 继承。
下面看看 private 继承方式有什么优点:
- 防止派生类被进一步继承:当使用
private
继承时,基类的所有成员在派生类中都变成私有的,派生类本身也无法进一步继承基类的接口。这种限制能确保继承关系不被公开延续,可以严格控制类的接口层次结构。 - 「is-implemented-in-terms-of」关系:
private
继承表示一种“以某种方式实现”的关系,而不是「is-a」的关系(即“某物是某种类型”)。通常我们会选择组合来实现这种关系(上一条款),但当基类含有受保护的接口,并且派生类希望访问这些接口时,private
继承提供了一种更加直接的方式,而不需要冗余的友元或接口方法。 - 实现功能复用但不想暴露接口:当一个类希望复用另一个类的实现,但不想让外界知道这个类是从该基类继承来的。例如,在某些类设计中,希望使用基类的某些功能方法,但不希望客户端能够直接访问基类的接口。这时使用
private
继承,可以将基类的公共和受保护成员都作为派生类的私有成员,不会被派生类的用户直接访问。
1 |
|
谨慎使用多继承
- 多继承比单继承更复杂。它能导致新的歧义问题和对虚拟继承的需要。
- 虚拟继承增加了大小和速度成本,以及初始化和赋值的复杂度。当虚拟基类没有数据时它是最适用的。
- 多继承有合理的用途。一种方案涉及组合从一个接口类的公有继承和从一个有助于实现的类的私有继承。
了解隐式接口和编译期多态
类和模板都支持接口和多态。
类的接口是显示的,多态则是通过 virtual 发生于运行期。
模板的接口是隐示的,多态则是模板的具现化和函数重载解析发生于编译期。
将与参数无关的代码抽离templates
模板(templates)在 C++ 编程中具有巨大的优势,它们可以避免代码重复并让代码更简洁。但如果不注意,模板也可能导致代码膨胀,即产生重复的代码或数据,使得目标代码变得臃肿。
为避免这种膨胀问题,建议对代码进行“通用性与可变性分析”。这种分析类似于在非模板代码中避免重复的方式,即将通用的代码提取出来并共享,而将特定的代码单独保留。例如,当多个实例化的模板函数几乎相同(比如仅大小不同的矩阵操作),可以将大小参数化,并在基类中创建一个通用的函数供子类调用。
在设计上,有时也可以通过传递函数参数或使用类数据成员来替代非类型模板参数,从而减少膨胀的可能性。对于类型参数引起的膨胀,可让具有相同二进制表示的不同类型实例共享实现,从而减少重复代码的生成。
需要注意的是,这些优化需要权衡。使用 size-specific 优化代码可能导致更小的可执行文件,而代码复用可能带来缓存和工作集的优化效果。不同平台和数据集可能带来不同的性能影响,因此应根据实际情况测试并决定。
写了placement new也要写placement delete
在 C++ 中编写自定义的 operator new 时,如果包含 placement 版本(带有自定义参数的版本),必须同时提供相应的 operator delete placement 版本。否则,可能导致内存泄漏,因为在对象构造失败时,系统会调用 operator delete 来释放已分配的内存,但如果未定义与之匹配的 placement operator delete,则可能无法正确释放内存,进而导致“断续”内存泄漏的问题。
同时,在编写这些版本时,需小心避免覆盖掉普通版本的 operator new 和 operator delete。特别是当 placement 版本函数的签名(包括参数类型和数量)和普通版本相似时,容易产生意外覆盖。因此,定义 placement new 和 delete 时,确保参数列表和类型与常规版本不同,以防止不小心重载掉标准的内存分配和释放逻辑。