学过 C++ 的你,你不得不知的这 10 条细节

发布于 2020-9-2 17:15
浏览
0收藏

 

前言

我在阅读 《Effective C++ (第三版本)》 书时做了不少笔记,从中收获了非常多,也明白为什么会书中前言的第一句话会说:

学过 C++ 的你,你不得不知的这 10 条细节-开源基础软件社区

对于书中的「条款」这一词,我更喜欢以「细节」替换,毕竟年轻的我们在打 LOL 或 王者的时,总会说注意细节!细节!细节~ —— 细节也算伴随我们的青春的字眼

 

针对书中的前两个章节,我筛选了 10 个 细节(条款)作为了本文的内容,这些细节也相对基础且重要。

针对这 10 细节我都用较简洁的例子来加以阐述,同时也把本文所提及细节中的「小结」总结绘画成了一副思维导图,便于大家的阅读。

 

1 让自己习惯C++

 

细节 01:尽量以const,enum,inline 替换 #define

 

 #define 定义的常量有什么不妥?

首先我们要清楚程序的编译重要的三个阶段:预处理阶段,编译阶段和链接阶段。

 

#define 是不被视为语言的一部分,它在程序编译阶段中的预处理阶段的作用,就是做简单的替换。

 

如下面的 PI 宏定义,在程序编译时,编译器在预处理阶段时,会先将源码中所有 PI 宏定义替换成 3.14:
#define PI 3.14

 

程序编译在预处理阶段后,才进行真正的编译阶段。在有的编译器,运用了此 PI 常量,如果遇到了编译错误,那么这个错误信息也许会提到 3.14 而不是 PI,这就会让人困惑哪里来的3.14,特别是在项目大的情况下。

 

解决之道:以 const 定义一个常量替换上述的宏(#define)

作为一个语言变量,下面的 const 定义的常量 Pi 肯定会被编译器看到,出错的时候可以很清楚知道,是这个变量导致的问题:

const doule Pi = 3.14;

 

如果是定义常量字符串,则必须要 const 两次,目的是为了防止指针所指内容和指针自身不能被改变:

const char* const myName = "小林coding";

 

如果是定义常量 string,则只需要在最前面加一次 const,形式如下:

const std::string myName("小林coding");
 

#define 不重视作用域,所以对于 calss 的专属常量,应避免使用宏定义。

还有另外一点宏无法涉及的,就是我们无法利用 #define 创建一个 class 专属常量,因为 #define 并不重视作用域。

 

对于类里要定义专属常量时,我们依然使用 static + const。

 

如果不想外部获取到 class 专属常量的内存地址,可以使用 enum 的方式定义常量

enum 会帮你约束这个条件,因为取一个 enum 的地址是不合法的。

 

#define 实现的函数容易出错,并且长相丑陋不易阅读。

另外一个常见的 #define 误用情况是以它实现宏函数,它不会招致函数调用带来的开销,但是用 #define 编写宏函数容易出错。

 

解决的方式:用 inline 替换 #define 定义的函数

用 inline 修饰的函数,也是可以解决函数调用的带来的开销,同时阅读性较高,不会让人困惑。


细节 01 小结 - 请记住
对于单纯常量,最好以 const 对象或 enum 替换 #define;
对于形式函数的宏,最好改用 inline 函数替换 #define。
 

细节 02:尽可能使用 const

const 的一件奇妙的事情是:它允许你告诉编译器和其他程序员某值应该保持不变。

1. 面对指针,你可以指定指针自身、指针所指物,或两者都(或都不)是 const。

如果关键词const出现在星号(*)左边,表示指针所指物是常量(不能改变 *p 的值);
如果关键词const出现在星号(*)右边,表示指针自身是常量(不能改变 p 的值);
如果关键词const出现在星号(*)两边,表示指针所指物和指针自身都是常量;

2. 面对迭代器,你也指定迭代器自身或自迭代器所指物不可被改变:

如果你希望迭代器自身不可被改动,像指针声明为 const 即可(即声明一个 T* const 指针); —— 这个不常用
如果你希望迭代器所指的物不可被改动,你需要的是 const_iterator(即声明一个  const T* 指针)。—— 这个常用

 

const 最具有威力的用法是面对函数声明时的应用。在一个函数声明式内,const 可以和函数返回值、各参数、成员函数自身产生关联。

1. 令函数返回一个常量值,往往可以降低因程序员错误而造成的意外。

2. 将 const 实施于成员函数的目的,是为了确认该成员函数可作用于 const 对象。理由如下两个:

理由 1 :它们使得 class 接口比较容易理解,因为可以得知哪个函数可以改动对象而哪些函数不行。

理由 2 :它们使操作 const 对象成为可能,这对编写高效代码是个关键,因为改善 C++ 程序效率的一个根本的方法是以 pass by referenc-to-const(const T& a) 方式传递对象。

 

没有发生复制构造函数,说明 pass by referenc-to-const 比 pass by value 性能高。

 

在 const 和 non-const 成员函数中避免代码重复

假设 MyString 内的 operator[] 在返回一个引用前,先执行边界校验、打印日志、校验数据完整性。把所有这些同时放进 const 和 non-const operator[]中,就会导致代码存在一定的重复,可以有一种解决方法,避免代码的重复:将 MyString& 转换成 const MyString&,可让其调用 const operator[] 兄弟;将 const char & 转换为 char &,让其是 non-const operator[] 的返回类型。

 

虽然语法有一点点奇特,但「运用 const 成员函数实现 non-const 孪生兄弟 」的技术是值得了解的。

需要注意的是:我们可以在 non-const 成员函数调用 const 成员函数,但是不可以反过来,在 const 成员函数调用 non-const 成员函数调用,原因是对象有可能因此改动,这会违背了 const 的本意。

 

细节 02 小结 - 请记住
将某些东西声明为 const 可帮助编译器探测出错误用法。const 可以被施加于任何作用域内的对象、函数参数、函数返回类型、成员函数本体。
当 const 和 non-const 成员函数有着实质等价的实现时,令 non-const 版本调用 const 版本可避免代码重复。

 

细节 03:确定对象被使用前先被初始化

 

内置类型初始化

在某些语境下 x 保证被初始化为 0,但在其他语境中却不保证。那么可能在读取未初始化的值会导致不明确的行为。为了避免不确定的问题,最佳的处理方法就是:永远在使用对象之前将它初始化。

 

对于内置类型以外的任何其他东西,初始化责任落在构造函数。

规则很简单:确保每一个构造函数都将对象的每一个成员初始化。但是别混淆了赋值和*初始化*。考虑用一个表现学生的class,其构造函数如下:

学过 C++ 的你,你不得不知的这 10 条细节-开源基础软件社区

上面的做法并非初始化,而是赋值,这不是最佳的做法。因为 C++ 规定,对象的成员变量的初始化动作发生在进入构造函数本体之前,在构造函数内,都不算是被初始化,而是被赋值。

 

初始化的写法是使用成员初值列,如下:

学过 C++ 的你,你不得不知的这 10 条细节-开源基础软件社区

这个构造函数和上一个构造函数的最终结果是一样的,但是效率较高,凸显在:

上一个构造函数(赋值版本)首先会先自动调用 m_Name 和 m_Score 对象的默认构造函数作为初值,然后在构造函数体内立刻再对它们进行赋值操作,这期间经历了两个步骤。


这个构造函数(成员初值列)避免了这个问题,只会发生了一次复制构造函数,本例中的 m_Name 以 name 为初值进行复制构造,m_Score 以 score 为初值进行复制构造。

 

另外一个注意的是初始化次序(顺序),初始化次序(顺序):

先是基类对象,再初始化派生类对象(如果存在继承关系);
在类里成员变量总是以声明次序被初始化,如本例中 m_Id 先被初始化,再是 m_Name,最后是 m_Score,否则会出现编译出错。

 

避免「跨编译单元之初始化次序」的问题

现在,我们关系的问题涉及至少两个以上源码文件,每一个内含至少一个 non-local static 对象。

 

存在的问题是:如果有一个 non-local static 对象需要等另外一个 non-local static 对象初始化后,才可正常使用,那么这里就需要保证次序的问题。

 

细节 03 小结 - 请记住
为内置类型进行手工初始化,因为 C++ 不保证初始化它们。


构造函数最好使用成员初值列,而不要在构造函数本体内使用赋值操作。初值列列出的成员变量,其排列次序应该和它们在 class 中的声明次序(顺序)相同。
为避免“跨编译单元之初始化次序”的问题,请以 local static 对象替代 non-local static 对象。

 

2 构造/析构/赋值运算

细节 04:了解 C++ 默默编写并调用哪些函数

 

当你写了如下的空类:
class Student { };

 

编译器就会它声明,并且这些函数都是 public 且 inline:

复制构造函数
赋值操作符函数
析构函数
默认构造函数(如果没有声明任何构造函数)

 

唯有当这些函数被需要调用时,它们才会被编译器创建出来

 

编译器为我们写的函数,来说说这些函数做了什么?

 

默认构造函数和析构函数主要是给编译器一个地方用来放置隐藏幕后的代码,像是调用基类和非静态成员变量的构造函数和析构函数。注意,编译器产出的析构函数是个 non-virtual,除非这个 class 的 base class 自身声明有 virtual 析构函数。


复制构造函数和赋值操作符函数,编译器创建的版本只是单纯地将来源对象的每一个非静态成员变量拷贝到目标对象。

 

编译器拒绝为 class 生出 operator= 的情况

对于赋值操作符函数,只有当生出的代码合法且有适当机会证明它有意义,才会生出 operator= ,若万一两个条件有一个不符合,则编译器会拒绝为 class 生出 operator= 。

 

细节 04 小结 - 请记住
编译器可以暗自为 class 创建默认构造函数(如果没有声明任何构造函数)、复制构造函数、赋值操作符函数,以及析构函数。
编译器拒绝为 class 创建 operator= 函数情况:(1) 内含引用的成员、(2) 内含 const 的成员、(3)基类将 operator= 函数声明为 private。

 

细节 05:若不想使用编译器自动生成的函数,就该明确拒绝

 

在不允许存在一模一样的两个对象的情况下,可以把复制构造函数和赋值操作符函数声明为 private,这样既可防止编译器自动生成这两个函数。

 

细节 05 小结 - 请记住

如果不想编译器自动生成函数,可将相应的成员函数声明为 private 并且不予实现。使用像 Uncopyale 这样的基类也是一种做法。

 

细节 06:为多态基类声明 virtual 析构函数

 

多态性质基类需声明 virtual 析构函数

如果在多态性质的基类,没有声明一个 virtual 析构函数,那么在 delete 基类指针对象的时候,只会调用基类的析构函数,而不会调用派生类的析构函数,这就是存在了泄漏内存和其他资源的情况。

 

为了避免泄漏内存和其他资源,需要把基类的析构函数声明为 virtual 析构函数。

 

非多态性质基类无需声明 virtual 函数

当类的设计目的不是被当做基类,令其析构函数为 virtual 往往是个馊主意。

 

若类里声明了 virtual 函数,对象必须携带某些信息。主要用来运行期间决定哪一个 virtual 函数被调用。

 

这份信息通常是由一个所谓 vptr(virtual table pointer —— 虚函数表指针)指针指出。vptr 指向一个由函数指针构成的数组,称为 vtbl(virtual table —— 虚函数表);每一个带有 virtual 函数的类都有一个相应的 vtbl。当对象调用某一 virtual 函数,实际被调用的函数取决于该对象的 vptr 所指向的那个 vtbl,接着编译器在其中寻找适当的函数指针,从而调用对应类的函数。

 

既然内含 virtual 函数的类的对象必须会携带信息,那么必然其对象的体积是会增加的。

在 32 位计算机体系结构中将多占用 4个字节(存放 vptr );
在 64 位计算机体系结构则将多占用 8 个字节(存放 vptr )。

 

因此,无端地将所有类的析构函数声明为 virtual ,是错误的,原因是会增加不必要的体积。

 

许多人的心得是:只有当 class 内含至少一个 virtual 函数,才为它声明 virtual 析构函数。

 

细节 06 小结 - 请记住
在多态性质的基类,应该声明一个 virtual 析构函数。如果 class 带有任何 virtual 函数,它就应该拥有一个 virtual 析构函数。
类的设计目的如果不是为基类使用的,或不是为了具备多态性,就不该声明 virtual 析构函数。

 

细节 07:绝不在构造和析构过程中调用 virtual 函数


我们不该在构造函数和析构函数体内调用 virtual 函数,因为这样的调用不会带来你预想的结果。

 

基类构造期间 virtual 函数绝不会下降到派生类阶层。取而代之的是,对象的作为就像隶属于基类类型一样。

 

非正式的说法或许比较传神:在基类构造期间,virtual 函数不是 virtual 函数。相同的道理,也适用于析构函数。

 

细节 07 小结 - 请记住

在构造和析构期间不要调用 virtual,因为这类调用不会下降至派生类。

 

细节 08:令 operator= 返回一个 reference to *this

 

细节 09:在 operator= 中处理「自我赋值」

 

「自我赋值」发生在对象被赋值给自己时:

下面是operator = 实现代码,表面上看起来合理,但自我赋值出现时并不安全:

学过 C++ 的你,你不得不知的这 10 条细节-开源基础软件社区

这里的自我赋值的问题是, operator= 函数内的 *this(赋值的目的端)和 rhs 有可能是同一个对象。果真如此 delete 就不只是销毁当前对象的 pb,它也销毁 rhs 的 pb。

 

相当于发生了自我销毁(自爆/自灭)过程,那么此时 A 类对象持有了一个指向一个被销毁的 B 类对象。非常的危险,请勿模仿!

 

下面来说说如何规避这种问题的方式。

 

方式一:比较来源对象和目标对象的地址

要想阻止这种错误,传统的做法是在 operator= 函数最前面加一个 if 判断,判断是否是自己,不是才进行赋值操作。

 

这样错虽然行得通,但是不具备自我赋值的安全性,也不具备异常安全性:

如果「 new B 」这句发生了异常(申请堆内存失败的情况),A 最终会持有一个指针指向一块被删除的 B,这样的指针是有害的。

 

方式二:精心周到的语句顺序

把代码的顺序重新编排以下就可以避免此问题,例如一下代码,我们只需之一在赋值 pb 所指东西之前别删掉 pb :

学过 C++ 的你,你不得不知的这 10 条细节-开源基础软件社区

现在,如果「 new B 」这句发生了异常,pb 依然保持原状。即使没有加 if 自我判断,这段代码还是能够处理自我赋值,因为我们对原 B 做了一份副本、删除原 B 、然后返回引用指向新创造的那个副本。它或许不是处理自我赋值的最高效的方法,但它行得通。

 

方式三:copy and swap 

更高效的方式使用所谓的 copy and swap 技术,实现方法如下:

学过 C++ 的你,你不得不知的这 10 条细节-开源基础软件社区

 

当类里 operator= 函数被声明为「以 by value 方式接受实参」,那么由于 by value 方式传递东西会造成一份复件(副本),则直接 swap 交换即可,如下:

学过 C++ 的你,你不得不知的这 10 条细节-开源基础软件社区

细节 09 小结 - 请记住

确保当对象自我赋值时,operator= 有良好行为。其中技术包括比较来源对象和目标对象的地址、精心周到的语句顺序、以及 copy-and-swap。
确保任何函数如果操作一个以上的对象,而其中多个对象是同个对象时,其行为忍然正常。

 

细节 10:复制对象时勿忘其每一个成分

在以下我把复制构造函数和赋值操作符函数,称为「copying 函数」。

 

如果你声明自己的 copying 函数,那么编译器就不会创建默认的 copying 函数。但是,当你在实现 copying 函数,遗漏了某个成分没被 copying,编译器却不会告诉你。

 

确保对象内的所有成员变量 copying

如果你为 class 添加一个成员变量,你必须同时修改 copying 函数。

 

确保所有 base class (基类) 成分 copying

我们不仅要确保复制所有类里的成员变量,还要调用所有 base classes 内的适当的 copying 函数。

 

消除 copying 函数之间的重复代码

还要一点需要注意的:不要令复制「构造函数」调用「赋值操作符函数」,来减少代码的重复。这么做也是存在危险的,假设调用赋值操作符函数不是你期望的。—— 错误行为。

 

同样也不要令「赋值操作符函数」调用「构造函数」。如果你发现你的「复制构造函数和赋值操作符函数」有近似的代码,消除重复代码的做法是:建立一个新的成员函数给两者调用。

 

细节 10 小结 - 请记住

Copying 函数(复制构造函数和赋值操作符函数)应该确保复制「对象内的所有成员变量」及「所有 base class(基类) 成分」。
不要尝试以某个 copying 函数实现另外一个 coping 函数。应该将共同地方放进第三个函数中,并由两个 copying 函数共同调用。

标签
收藏
回复
举报
回复
添加资源
添加资源将有机会获得更多曝光,你也可以直接关联已上传资源 去关联
    相关推荐