《编写高质量代码:改善C++程序的150个建议》读书笔记(2)

《编写高质量代码——改善C++程序的150个建议》读书笔记(1)



第二部分 编码习惯和规范篇

建议81:避免无意中的内部数据裸露

对于const成员函数,不要返回内部数据的句柄,因为它会破坏封装性,违反抽象性,造成内部数据无意中的裸露,这会出现很多“不可思议”的情形,比如const对象的非常量性。

建议82:积极使用const为函数保驾护航

const的真正威力体现在几个方面:

  1. 修饰函数形式的参数:const只能修饰输入参数,对于内置数据类型的输入参数,不要将“值传递”的方式改为“const 引用传递”;
  2. 修饰函数返回值;
  3. 修饰成员函数:用const修饰成员函数的目的是提高程序的健壮性。const成员函数不允许对数据成员进行任何修改。

关于const成员函数,须遵循几个规则:

  1. const对象只能访问const成员函数,而非const对象可以访问任意的成员函数;
  2. const对象的成员是不可修改的,然而const对象通过指针维护的对象却是可以修改的;
  3. const成员函数不可以修改对象的数据,不管对象是否具有const性质。

建议83:不要返回局部变量的引用

局部变量的引用是一件不太靠谱的事儿,所以尽量避免让函数返回局部变量的引用。同时也不要返回new生成对象的引用,因为这样会让代码层次混乱,让使用者苦不堪言。

建议84:切忌过度使用传引用代替传对象

相较于传对象,传引用的优点:它减少了临时对象的构造与析构,所以更具效率。但须审慎地使用传引用替代传对象,必须传回内部对象时,就传对象,勿传引用。

建议85:了解指针参数传递内存中的玄机

用指针参数传回一块动态申请的内存,是很常见的一种需求。然而如果不甚小心,就很容易造成严重错误:程序崩溃+内存泄露,解决之道就是用指针的指针来传递,或者换种内存传递方式,用返回值来传递。

1
2
3
4
5
// 指针型变量在函数体中需要改变的写法
void f(int *&x) // 使用指针变量的引用
{
++x;
}



建议86:不要将函数参数作为工作变量

工作变量,就是在函数实现中使用的变量。应该防止将函数参数作为工作变量,而对于那些必须改变的参数,最好先用局部变量代替之,最后再将该局部变量的内容赋给该参数,这样在一定程度上保护了数据的安全。

建议87:躲过0值比较的层层陷阱

  1. 0在不在该类型数据的取值范围内?
  2. 浮点数不存在绝对0值,所以浮点零值比较需特殊处理;
  3. 区分比较操作符==与赋值操作符=,切忌混淆。
1
2
3
4
5
6
const float FLOAT_ZERO = 0.00000001f;
float f = 1.33f;
if (f > FLOAT_ZERO)
{
// ......
}


建议88:不要用reinterpret_cast去迷惑编译器

reinterpret_cast,简单地说就是保持二进制位不变,用另一种格式来重新解释,它就是C/C++中最为暴力的类型转换,所实现的是一个类型到一个毫不相关、完全不同类型的映射。

reiterpret_cast仅仅重新解释了给出对象的比特模型,它是所有类型转换中最危险的。尽量避免使用reinterpret_cast,除非是在其他转换都无效的非常情形下。

建议89:避免对动态对象指针使用static_cast

在类层次结构中,用static_cast完成基类和子类指针(或引用)的下行转换是不安全的。所以尽量避免对动态对象指针使用static_cast,可以用dynamic_cast来代替,或者优化设计,重构代码。

建议90:尽量少应用多态性数组

多态性数组一方面会涉及C++时代的基类指针与派生类指针之间的替代问题,同时也会涉及C时代的指针运算,而且常会因为这二者之间的不协调引发隐蔽的Bug。

建议91:不要强制去除变量的const属性

在C++中,const_cast<T*>(a)一般用于从一个类中去除以下这些属性:constvolatile_unaligned.强制去除变量的const属性虽然可以带来暂时的便利,但这不仅增加了错误修改变量的几率,而且还可能会引发内存故障。

建议95:为源代码设置一定的目录结构

如果一个软件所涉及的文件数目比较多,通常要将其进行划分,为其设置一定的目录结构,以便于维护,如include、 lib、 src、 doc、 release、 debug。

建议96:用有意义的标识代替Magic Numbers

用宏或常量替代信息含量较低的Magic Numbers,绝对是一个好习惯,这样可提高代码的可读性与可维护性。

建议97:避免使用“聪明的技巧”


建议98:运算符重载时坚持其通用的含义


建议99:避免嵌套过深与函数过长


建议100:养成好习惯,从现在做起


建议101:用移位实现乘除法运算

在大部分的C/C++编译器中,用移位的方法比直接调用乘除法子程序生成代码的效率要高。只要是乘以或除以一个整数常量,均可用移位的方法得到结果,如a=a*9可以拆分成a=a*(8+1),即a=a(a<<3)+a。移位只对整数运算起作用。

建议102:优化循环,提高效率

应当将最长的循环放在最内层,最短的循环放在最外层,以减少CPU跨切循环层的次数,提高效率。

建议103:改造switch语句

对于case的值,推荐按照它们发生的相对频率来排序,把最可能发生的情况放在第一位,最不可能的情况放在最后

建议104:精简函数参数

函数在调用时会建立堆栈来存储所需的参数值,因此函数的调用负担会随着参数列表的增长而增加。所以,参数的个数会影响进栈出栈的次数,当参数很多的时候,这样的操作就会花费很长的时间。因此,精简函数参数,减少参数个数可以提高函数调用的效率。如果精简后的参数还是比较多,那么可以把参数列表封装进一个单独的类中,并且可以通过引用进行传递

建议106:努力减少内存碎片

经常性地动态分配和释放内存会造成堆碎片,尤其是应用程序分配的是很小的内存块时。避免堆碎片:

  1. 尽可能少地使用动态内存,在大多数情况下,可以使用静态或自动储存,或者使用STL容器,减少对动态内存的依赖;
  2. 尽量分配和重新分配大块的内存块,降低内存碎片发生的几率。内存碎片会影响程序执行的效率。

建议108:用初始化取代赋值

以用户初始化代替赋值,可以使效率得到较大的提升,因为这样可以避免一次赋值函数operator =的调用。因此,当我们在赋值和初始化之间进行选择时,初始化应该是首选。需要注意的是,对基本的内置数据类型而言,初始化和赋值之间是没有差异的,因为内置类型没有构造和析构过程

建议109:尽可能地减少临时对象

临时对象产生的主要情形及避免方法:

  1. 参数:采用传常量引用或指针取代传值;
  2. 前缀或后缀:优先采用前缀操作
  3. 参数转换:尽量避免这种转换;
  4. 返回值:遵循single-entry/single-exit原则,避免同一个函数中存在多个return语句。

建议110:最后再去优化代码

“Premature optimization is the root of all evil.”
Donald Knuth

在大的结构还没有确定的时候,不要投入精力在一些细小的地方做“优化”。

在进行代码优化之前,需要知道:

  1. 算法是否正确;
  2. 如何在代码优化和可读性之间进行选择;
  3. 该如何优化:代码分析(profiling)工具
  4. 如何选择优化方向:先算法,再数据结构,最后才是实现细节

先粗后细。

建议111:采用相对路径包含头文件

一个“点”(“.\”)代表的是当前目录所在的路径,两个“点”(“..\”)代表的是相对于当前目录的上一次目录路径。

当写#include语句时,推荐使用相对路径;此外,要注意使用比较通用的正斜线“/”,而不要使用仅在Windows下可用的反斜线“\”。

建议112:让条件编译为开发出力

条件编译中的预处理命令主要包括:#if#ifndef#ifdef#endif#undef等,它们的主要功能是在程序编译时进行有选择性的挑选,注释掉一些指定的代码,以达到版本控制、防止对文件重复包含等目的。

建议113:使用.inl文件让代码整洁可读

.inl文件是内联函数的源文件,.inl文件还可用于模板的定义。.inl文件可以将头文件与内联函数的复杂定义隔离开来,使代码整洁可读,如果将其用于模板定义,这一优点更加明显

建议115:优先选择编译和链接错误

静态检查:编译器必须检查源程序是否符合源语言规定的语法和语义要求,静态检查的主要工作就是语义分析,它是独立于数据和控制流的,可信度相对较高,而且不会增加程序的运行时开销。

动态检查:是在运行时刻对程序的正确性、安全性等做检查,比如内存不足、溢出、数组越界、除0等,这类检查对于数据和控制流比较依赖。

C/C++语言属于一种静态语言。一个设计较好的C++程序应该是较少地依赖动态检查,更多地依赖静态检查

建议117:尽量减少文件之间的编译依赖

不要在头文件中直接包含要使用的类的头文件(除了标准库),直接包含头文件这种方式相对简单方便,但是会耗费大量的编译时间。推荐使用类的前向声明来减少文件直接的编译依赖。用对类声明的依赖替代对类定义的依赖,这是减少编译依赖的原则。

为了加快编译进程,减少时间的浪费,我们应该尽量减少头文件依赖,其中的可选方案包括前向声明柴郡猫技术等。

关于“柴郡猫(Cheshire Cat Idiom)技术”,即PImpl,Private Implementaion。主类中只定义接口,将私有数据成员封装在一个实现类中

建议118:不用在头文件中使用using

名空间是C++提供的一种机制,可以有效地避免函数名污染。然而在应用时要十分注意:任何情况下都不应在头文件中使用“using namespace XXX”这样的语句,而应该在定义时直接用全称

1
2
3
4
5
6
7
8
9
10
11
// A.h
// 头文件中绝不使用using !
// using namespace std;
class A
{
public:
A()
{
std::cout << "hello" << std::endl; // 使用名空间全称
}
}


建议119:划分全局名空间避免名污染

使用自己的名空间将全局名空间合理划分,会有效地减少名污染问题,不要简单地将所有的符号和名称统统扔进全局名空间里



第三部分 程序架构和思想篇

建议120:坚持“以行为为中心”的类设计

“以数据为中心”关注类的内部数据结构,习惯将private类型的数据写在前面,而将public类型的函数写在后面。

“以行为为中心”关注的重心放在了类的服务和接口上,习惯将public类型的函数写在前面,而将private类型的数据写在后面。

建议121:用心做好类设计

  1. 类应该如何创建和销毁呢?这会影响到类的构造函数和析构函数的设计。首先应该确定类是否需要分配资源,如果需要,还要确定这些资源又该如何释放。
  2. 类是否需要一个无参构造函数?如果需要,而恰恰此时这个类已经有了构造函数,那么我们就得显示地写一个。
  3. 类需要复制构造函数吗?其参数上加上了const修饰吗?它是用来定义这个类传值(pass-by-value)的具体实现的。
  4. 所有的数据成员是不是都已经在构造函数中完成了初始化呢?
  5. 类需要赋值操作符吗?赋值操作符能正确地将对象赋给对象本身吗?它与初始化有什么不同?其参数上加上了const修饰吗?
  6. 类的析构函数需要设置为virtual吗?
  7. 类中哪些值得组合是合法的?合法值的限定条件是什么?在成员函数内部是否对变量值得合法性做了检查?其次,类的设计是对现实对象进行抽象的一个过程。再次,数据抽象的过程其实是综合考虑各方面因素进行权衡的一个过程。

建议122:以指针代替嵌入对象或引用

设计类的数据成员时,可以有三种选择:

  1. 嵌入对象;
  2. 使用对象引用;
  3. 使用对象指针。

如果在类数据成员中使用到了自定义数据类型,使用指针是一个较为明智的选择,它有以下几方面的优点:

  1. 成员对象类型的变化不会引起包含类的重编译;
  2. 支持惰性计算,不创建不使用的对象,效率更高;
  3. 支持数据成员的多态行为。

建议123:努力将接口最小化且功能完善

类接口的目标是完整且最小。精简接口函数个数,使每一个函数都具有代表性,并且使其功能恰好覆盖class的智能,同时又可以获得接口精简所带来的好处:

  1. 利于理解、使用,维护成本也相对较低;
  2. 可以缩小头文件长度,并缩短编译时间。

建议124:让类的数据隐藏起来

坚持数据封装,坚持信息隐藏,杜绝公有、保护属性的存在(数据成员私有、柴郡猫技术)。

建议125:不要让成员函数破坏类的封装性

小心类的成员函数返回属性变量的“直接句柄”,它会破坏辛辛苦苦搭建维护的封装性,一种方法,将函数的返回值加上const修饰。

建议126:理解“virtual + 访问限定符”的深层含义

virtual关键字是C++中用于实现多态的重要机制,其核心理念就是通过基类访问派生类定义的函数。

  1. 基类中的一个虚拟私有成员函数,表示实现细节是可以被派生类修改的;
  2. 基类中的一个虚拟保护成员函数,表示实现细节是必须被派生类修改的;
  3. 基类中的一个虚拟公有成员函数,则表示这是一个接口,不推荐,建议用protected virtual来替换。

经典设计模式:Template Method

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class CBaseTemplate
{
private:
void Step_1() {...} // 不可被派生类修改
virtual void Step_2() {...} // 可以被派生类修改
protected:
virtual void Step_3() = 0; // 必须被派生类修改
public:
void Function() // 算法骨架函数
{
Step_1();
Step_2();
Step_3();
}
};


建议127:谨慎恰当地使用友元机制

通常说来,类中的私有成员一般是不允许外面访问的。但是友元可以超脱这条禁令,它可以访问该类的私有成员。所带来的最大好处就是避免了类成员函数的频繁调用,节约了处理器的开销,提高了程序的效率。但是,通常,大家认为“友元破坏了类的封装性”。采用友元机制,一般是基于这样的需求:一个类的部分成员需要对个别其他类公开。

建议128:控制对象的创建方式

栈和堆是对象的主要分布区,它们对应着两种基本的对象创建方式:以new方式手动管理的堆创建和只需声明就可使用的栈创建。

控制对象的创建方式:

  1. 要求在堆中建立对象:为了执行这种限制,必须找到一种方法保证调用new是建立对象的唯一手段。非堆对象是在定义它时自动构造的,而且是在生存期结束时自动释放的。将析构函数声明为private,而构造函数保持为public
  2. 禁止在堆中建立对象:要禁止调用new来建立对象,可以通过operator new函数声明为private来实现

建议129:控制实例化对象的个数

当实例化对象唯一时,采用设计模式中的单件模式;当实例化对象为N(N>0)个时,设置计数变量是一个思路。

单件模式:Singleton,将constructor声明为private,提供一个static对象及获取该static对象的方法。

建议130:区分继承与组合

  1. 继承:C++的“继承”特性可以提高程序的可复用性。继承规则:若在逻辑上B是一种A,并且A的所有功能和属性对B而言都有意义,则允许B继承A的功能和属性。继承易于修改或扩展那些被复用的实现。但它的这种“白盒复用”却容易破坏封装性,因为这会将父类的实现细节暴露给子类。当父类实现更改时,子类也不得不随之更改,所以,从父类继承来的实现将不能在运行期间进行改变;
  2. 组合:在逻辑上表示的是“有一个(Hase-A)”的关系,即A是B的一部分。组合属于“黑盒”复用,被包含对象的内部细节对外是不可见的。所以,它的封装性相对较好,实现上的相互依赖性比较小。并且可以通过获取指向其他的具有相同类型的对象引用,在运行期间动态地定义组合。而其缺点就是致使系统中的对象过多

Is-A关系用继承表达,Has-A关系用组合表达。

优先使用对象组合,而不是类继承

建议131:不要将对象的继承关系扩展至对象容器

A是B的基类,B是一种A,但是B的容器却不能是这种A的容器。

建议132:杜绝不良继承

在继承体系中,派生类对象必须是可以取代基类对象的。

典型问题:“圆是不是椭圆?”

圆继承自椭圆是不良继承!

建议133:将RAII作为一种习惯

RAII(ResourceAcquisition Is Initialization),资源获取即初始化,RAII是C++语言的一种管理资源、避免泄露的惯用方法。RAII的做法是使用一个对象,在其构造时获取资源,在对象生命周期中控制对象资源的访问,使之始终保持有效,最后再对象析构时释放资源。实现这种功能的类即采用了RAII方式,这样的类被称为封装类。

建议134:学习使用设计模式

设计模式是用来“封装变化、降低耦合”的工具,它是面向对象设计时代的产物,其本质就是充分运用面向对象的三个特性(即:封装、继承和多态),并进行灵活的组合。

建议135:在接口继承和实现继承中做谨慎选择

在接口继承和实现继承之间进行选择时,需要考虑的一个因素就是:基类的默认版本。对于那些无法提供默认版本的函数接口我们选择函数接口继承;而对于那些能够提供默认版本的,函数实现继承就是最佳选择。

1
2
3
4
5
6
7
8
9
10
11
12
class CShape
{
public:
virtual void Draw() = 0; // 用于接口继承
virtual void SetColor(const COLOR &color); // 用于实现继承
private:
COLOR m_color;
}
class CCircle : public CShape {...}
class CRectangle : public CShape {...}


建议136:遵循类设计的五项基本原则

  1. 单一职责原则(SRP):一个类,最好只做一件事。SRP可以看作是低耦合、高内聚在面向对象原则上的引申;
  2. 开闭原则(OCP):对扩展开放,对更改关闭,应该能够不用修改原有类就能扩展一个类的行为
  3. 替换原则(LSP ):子类应当可以替换父类并出现在父类能够出现的任何地方。反过来则不成立,子类可以替换基类,但是基类不一定能替换子类;
  4. 依赖倒置原则(DIP):高层模块不依赖于底层模块,而是二者都依赖于抽象,即抽象不依赖于具体,具体依赖于抽象。依赖一定会存在类与类、模块与模块之间。当两个模块之间存在紧密的耦合关系时,最好的方法就是分离接口和实现:在依赖之间定义一个抽象的接口使得高层模块调用接口,底层模块实现接口的定义,从而有效控制耦合关系,达到依赖于抽象的设计目的;
  5. 接口分离原则(ISP):使用多个小的专门的接口,而不要使用一个大的总接口。接口有效地将细节和抽象隔离开来,体现了对抽象编程的一切好处,接口隔离强调接口的单一性。分离的手段主要有两种方式:一个是利用委托分离接口,另一个是利用多重继承分离接口。

建议137:用表驱动取代冗长的逻辑选择

表驱动法(Table drivenmethod),是一种不必用很多的逻辑语句(ifcase)就可以把表中信息找出来的方法。它是一种设计模式,可用来代替复杂的if/elseswitch-case逻辑判断。

一个简单的例子:

1
2
3
4
5
6
7
static int s_nMonthDays[12] =
{31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
// 使用表驱动可以代替冗长的逻辑选择
int GetMonthDays(int iMonth)
{
return s_nMonthDays[iMonth - 1];
}

MFC中的消息映射机制也使用了表驱动。

表驱动的好处在于将冗长的逻辑选择改为维护表,符合”元编程思想”。

建议139:编码之前需三思

在让电脑运行你的程序之前,先让你的大脑编译运行。
程序员最重要的工作是在远离键盘时完成的。
Want to write some code? Get away from your computer!
Think first, program later.
你开始编码的时间越早,项目持续的时间越长。
三思而后码。


建议140:重构代码

重构无止境,重构你的代码,精雕细琢,千锤百炼。
重构是一门艺术。


建议142:在未来时态下开发C++程序

在未来时态下开发C++程序,需要考虑代码的可重用性、可维护性、健壮性,以及可移植性。

建议143:根据你的目的决定造不造轮子

在编程语言中这些轮子表现为大量的通用类和库。在工程实践中,不要重复造轮子;而在学习研究中,鼓励重复造轮子

建议144:谨慎在OO与GP之间选择

面向对象(OO)和泛型编程(GP)是C++提供给程序员的两种矛盾的思考模式。OO是我们难以割舍的设计原则,世界是对象的,我们面向对象分析、设计、编程;而泛型编程则关注于产生通用的软件组件,让这些组件在不同的应用场合都能很容易的重用。

建议145:让内存管理理念与时俱进

学习STL allocator,更新内存管理理念。

建议146:从大师的代码中学习编程思想与技艺

阅读代码需要方法:刚开始不要纠结于代码的细节,将关注的重点放在代码的高层结构上,理解代码的构建过程;之后,再有重点的深入研究,理解对象的构造,明晰算法的步骤,尝试着深入理解其中的繁杂细节。