《高质量的C_C++编程指南》读书笔记
第一章 文件结构
-
头文件由三部分构成:
- 头文件开头处的版权和版本声明。
- 预处理块。
- 函数和类结构声明等。
-
为了防止头文件被重复引用,应当用
ifndef/define/endif结构产生预处理块。 -
头文件中只存放「声明」而不存放「定义」。
-
定义文件由三部分组成:
- 定义文件开头处的版权和版本声明。
- 对一些头文件的引用。
- 程序的实现体(包括数据和代码)。
第二章 程序的版式
- 在每个类声明之后、每个函数定义结束之后都要加空行。在一个函数体内,逻揖上密切相关的语句之间不加空行,其它地方应加空行分隔。
- 尽可能在定义变量的同时初始化该变量(就近原则)。
- 应当将修饰符
*和&紧靠变量名。 - 注释的位置应与被描述的代码相邻,可以放在代码的上方或右方,不可放在下方。
- 定义类时应主张将 public 类型的函数写在前面,而将 private 类型的数据写在后面,即「以行为为中心」。
第三章 命名规则
-
标识符应当直观且可以拼读,可望文知意,不必进行「解码」。
-
标识符的长度应当符合 min-length 和 max-information 原则。
-
程序中不要出现仅靠大小写区分的相似的标识符。
-
程序中不要出现标识符完全相同的局部变量和全局变量,尽管两者的作用域不同而不会发生语法错误,但会使人误解。
-
简单 Windows 程序命名规则:
- 类名和函数名用大写字母开头的单词组合而成。
- 变量和参数用小写字母开头的单词组合而成。
- 常量全用大写的字母,用下划线分割单词。
- 静态变量加前缀
s_(表示 static)。如果不得已需要全局变量,则使全局变量加前缀g_(表示 global)。 - 类的数据成员加前缀
m_(表示 member),这样可以避免数据成员与成员函数的参数同名。
第四章 表达式和基本语句
-
一元运算符
+ - *的优先级高于对应的二元运算符。 -
如果代码行中的运算符比较多,用括号确定表达式的操作顺序,避免使用默认的优先级。
-
if语句中各种类型变量与零值比较:bool变量:不可将布尔变量直接与TRUE、FALSE或者1、0进行比较。(应为if(flag),if(!flag))。- 整型变量:应当将整型变量用
==或!=直接与0比较。 - 浮点变量:不可将浮点变量用
==或!=与任何数字比较。(浮点数有精度限制,应为if((x>=-EPSINON) && (x<=EPSINON)),其中EPSINON是允许的误差(即精度))。 - 指针变量:应当将指针变量用
==或!=与NULL比较。
-
为防止将
if(p==NULL)写成if(p=NULL),可以将其写成if(NULL==p)。 -
在多重循环中,如果有可能,应当将最长的循环放在最内层,最短的循环放在最外层,以减少 CPU 跨切循环层的次数。
-
如果循环体内存在逻辑判断,并且循环次数很大,宜将逻辑判断移到循环体的外面。(循环「流水线」作业)。
-
不可在
for循环体内修改循环变量,防止for循环失去控制。 -
建议
for语句的循环控制变量的取值采用「半开半闭区间」写法。 -
每个
case语句的结尾不要忘了加break,否则将导致多个分支重叠(除非有意使多个分支重叠)。 -
不要忘记最后那个
default分支。即使程序真的不需要default处理,也应该保留语句default: break;这样做并非多此一举,而是为了防止别人误以为你忘了default处理。
第五章 常量
-
常量是一种标识符,它的值在运行期间恒定不变。C 语言用
#define来定义常量(称为宏常量)。C++ 语言除了#define外还可以用const来定义常量(称为 const 常量)。 -
const与#define的比较:-
const常量有数据类型,而宏常量没有数据类型。编译器可以对前者进行类型安全检查。而对后者只进行字符替换,没有类型安全检查,并且在字符替换可能会产生意料不到的错误(边际效应)。 -
有些集成化的调试工具可以对
const常量进行调试,但是不能对宏常量进行调试。 -
在 C++ 程序中只使用
const常量而不使用宏常量,即 const 常量完全取代宏常量。
-
-
不能在类声明中初始化
const数据成员。const数据成员的初始化只能在类构造函数的初始化表中进行。 -
怎样才能建立在整个类中都恒定的常量呢?别指望
const数据成员了,应该用类中的枚举常量来实现(是不是只能定义为整型常量?!见下一条)
class A{
enum { SIZE1 = 100, SIZE2 = 200}; // 枚举常量
int array1[SIZE1];
int array2[SIZE2];
};
- 枚举常量不会占用对象的存储空间,它们在编译时被全部求值。枚举常量的缺点是:它的隐含数据类型是整数,其最大值有限,且不能表示浮点数(如
PI=3.14159)。
第六章 函数设计
-
如果参数是指针,且仅作输入用,则应在类型前加
const,以防止该指针在函数体内被意外修改。 -
如果输入参数以值传递的方式传递对象,则宜改用
const &方式来传递,这样可以省去临时对象的构造和析构过程,从而提高效率。 -
尽量不要使用类型和数目不确定的参数。这种风格的函数在编译时丧失了严格的类型安全检查。C 标准库函数
printf是采用不确定参数的典型代表,其原型为:int printf(const chat *format[, argument]…); -
不要省略返回值的类型:
- C 语言中,凡不加类型说明的函数,一律自动按整型处理。这样做不会有什么好处,却容易被误解为
void类型。 - C++ 语言有很严格的类型安全检查,不允许上述情况发生。
- C 语言中,凡不加类型说明的函数,一律自动按整型处理。这样做不会有什么好处,却容易被误解为
-
函数名字与返回值类型在语义上不可冲突。违反这条规则的典型代表是 C 标准库函数
getchar。 -
不要将正常值和错误标志混在一起返回。正常值用输出参数获得,而错误标志用
return语句返回。 -
有时候函数原本不需要返回值,但为了增加灵活性如支持链式表达,可以附加返回值。例如字符串拷贝函数
strcpy的原型:char *strcpy(char *strDest, const char *strSrc); -
如果函数的返回值是一个对象,有些场合用「引用传递」替换「值传递」可以提高效率。而有些场合只能用「值传递」而不能用「引用传递」,否则会出错。
-
在函数体的入口处,对参数的有效性进行检查。
-
在函数体的出口处,对
return语句的正确性和效率进行检查。注意事项如下:return语句不可返回指向「栈内存」的「指针」或者「引用」,因为该内存在函数体结束时被自动销毁。- 要搞清楚返回的究竟是「值」、「指针」还是「引用」。
- 如果函数返回值是一个对象,要考虑 return 语句的效率。eg:
return String(s1 + s2);这是临时对象的语法,表示「创建一个临时对象并返回它」。不要以为它与「先创建一个局部对象 temp 并返回它的结果」是等价的,如String temp(s1 + s2); return temp;实质不然,上述代码将发生三件事。首先,temp对象被创建,同时完成初始化;然后拷贝构造函数把temp拷贝到保存返回值的外部存储单元中;最后,temp在函数结束时被销毁(调用析构函数)。然而「创建一个临时对象并返回它」的过程是不同的,编译器直接把临时对象创建并初始化在外部存储单元中,省去了拷贝和析构的化费,提高了效率。
-
【建议 6-4-4】不仅要检查输入参数的有效性,还要检查通过其它途径进入函数体内的变量的有效性,例如全局变量、文件句柄等。
-
断言
assert是仅在 Debug 版本起作用的宏,它用于检查「不应该」发生的情况。为了不在程序的 Debug 版本和 Release 版本引起差别, assert 不应该产生任何副作用。所以 assert 不是函数,而是宏。 -
【规则 6-5-1】使用断言捕捉不应该发生的非法情况。不要混淆非法情况与错误情况之间的区别,后者是必然存在的并且是一定要作出处理的。
-
指针与引用:
-
引用被创建的同时必须被初始化(指针则可以在任何时候被初始化)。
-
不能有 NULL 引用,引用必须与合法的存储单元关联(指针则可以是 NULL)。
-
一旦引用被初始化,就不能改变引用的关系(指针则可以随时改变所指的对象)。
-
引用的主要功能是传递函数的参数和返回值。 C++ 语言中,函数的参数和返回值的传递方式有三种:值传递、指针传递和引用传递。
-
-
「引用传递」的性质象「指针传递」,而书写方式象「值传递」。
第七章 内存管理
-
内存分配方式有三种:
- 从静态存储区域分配。内存在程序编译的时候就已经分配好,这块内存在程序的整个运行期间都存在。例如全局变量, static 变量。
- 在栈上创建。在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释放。栈内存分配运算内置于处理器的指令集中,效率很高,但是分配的内存容量有限。
- 从堆上分配,亦称动态内存分配。程序在运行的时候用
malloc或new申请任意多少的内存,程序员自己负责在何时用free或delete释放内存。动态内存的生存期由我们决定,使用非常灵活,但问题也最多。
-
常见的内存错误及其对策如下:
-
内存分配未成功,却使用了它。
-
内存分配虽然成功,但是尚未初始化就引用它。
-
内存分配成功并且已经初始化,但操作越过了内存的边界。
-
忘记了释放内存,造成内存泄露。
-
释放了内存却继续使用它。有三种情况:
-
程序中的对象调用关系过于复杂,实在难以搞清楚某个对象究竟是否已经释放了内存,此时应该重新设计数据结构,从根本上解决对象管理的混乱局面。
-
函数的
return语句写错了,注意不要返回指向「栈内存」的「指针」或者「引用」,因为该内存在函数体结束时被自动销毁。 -
使用
free或delete释放了内存后,没有将指针设置为NULL。导致产生「野指针」(空悬指针)。
-
-
【规则 7-2-1】用
malloc或new申请内存之后,应该立即检查指针值是否为NULL。防止使用指针值为NULL的内存。 -
【规则 7-2-2】不要忘记为数组和动态内存赋初值。防止将未被初始化的内存作为右值使用。
-
【规则 7-2-3】避免数组或指针的下标越界,特别要当心发生「多 1」或者「少 1」操作。
-
【规则 7-2-4】动态内存的申请与释放必须配对,防止内存泄漏。
-
【规则 7-2-5】用
free或delete释放了内存之后,立即将指针设置为NULL,防止产生「野指针」。 -
指针与数组:
-
数组要么在静态存储区被创建(如全局数组),要么在栈上被创建。数组名对应着(而不是指向)一块内存,其地址与容量在生命期内保持不变,只有数组的内容可以改变。
-
指针可以随时指向任意类型的内存块,它的特征是「可变」,所以我们常用指针来操作动态内存。指针远比数组灵活,但也更危险。
-
不能对数组名进行直接复制与比较。应该用标准库函数
strcpy进行复制,用标准库函数strcmp进行比较。 -
用运算符
sizeof可以计算出数组的容量(字节数)。但是sizeof(指针)的值却是 4。C++/C 语言没有办法知道指针所指的内存容量,除非在申请内存时记住它。 -
当数组作为函数的参数进行传递时,该数组自动退化为同类型的指针。
-
-
如果函数的参数是一个指针,不要指望用该指针去申请动态内存。如果非得要用指针参数去申请内存, 那么应该改用 「指向指针的指针」,或者用函数返回值来传递动态内存。
-
free和delete只是把指针所指的内存给释放掉,但并没有把指针本身干掉。指针的地址仍然不变(非NULL),只是该地址对应的内存是垃圾。此时如果不将指针设为NULL,那么if(NULL==p)语句将不起作用。 -
我们发现指针有一些「似是而非」的特征:
-
指针消亡了,并不表示它所指的内存会被自动释放。
-
内存被释放了,并不表示指针会消亡或者成了
NULL指针。 -
「野指针」不是
NULL指针,是指向「垃圾」内存的指针。成因主要有几种:- 指针变量没有被初始化。
- 指针
p被free或者delete之后,没有置为NULL。 - 指针操作超越了变量的作用范围。
-
-
malloc/free和new/delete:前者是库函数,对于自定义的对象,不能自动调用其构造和析构函数;后者是关键字,可以调用构造函数和析构函数。对于内部数据类型两者是等价的。 -
如果用
free释放「new创建的动态对象」,那么该对象因无法执行析构函数而可能导致程序出错。 如果用delete释放「malloc申请的动态内存」,理论上讲程序不会出错,但是该程序的可读性很差。 -
malloc、free、new、delete:
malloc返回值的类型是void *,所以在调用malloc时要显式地进行类型转换,将void *转换成所需要的指针类型。malloc函数本身并不识别要申请的内存是什么类型,它只关心内存的总字节数。- 为什么
free函数不像malloc函数那样复杂呢?这是因为指针p的类型以及它所指的内存的容量事先都是知道的,语句free(p)能正确地释放内存。如果p是NULL指针,那么free对p无论操作多少次都不会出问题。如果p不是NULL指针,那么free对p连续操作两次就会导致程序运行错误。 new内置了sizeof、类型转换和类型安全检查功能。对于非内部数据类型的对象而言,new在创建动态对象的同时完成了初始化工作。如果对象有多个构造函数,那么new的语句也可以有多种形式。- 如果用
new创建对象数组,那么只能使用对象的无参数构造函数。eg:Obj *objects = new Obj[100]; // 创建 100 个动态对象不能写成Obj *objects = new Obj[100](1);// 创建 100 个动态对象的同时赋初值 1 - 在用
delete释放对象数组时,留意不要丢了符号 ' []'。
第八章 C++函数的高级特性
-
对比于 C 语言的函数, C++ 增加了重载 (overloaded)、内联( inline)、const 和 virtual 四种新机制。其中重载和内联机制既可用于全局函数也可用于类的成员函数, const 与 virtual 机制仅用于类的成员函数。
-
如果 C++程序要调用已经被编译后的 C 函数,C++ 提供了一个 C 连接交换指定符号
extern C来解决这个问题。 -
注意并不是两个函数的名字相同就能构成重载。全局函数和类的成员函数同名不算重载,因为函数的作用域不同。全局函数被调用时应加
::标志。 -
成员函数的重载、覆盖与隐藏:
-
成员函数被重载的特征:
- 相同的范围(在同一个类中);
- 函数名字相同;
- 参数不同;
virtual关键字可有可无。
-
覆盖是指派生类函数覆盖基类函数,特征是:
- 不同的范围(分别位于派生类与基类);
- 函数名字相同;
- 参数相同;
- 基类函数必须有
virtual关键字。
-
隐藏是指派生类的函数屏蔽了与其同名的基类函数,规则如下:
- 如果派生类的函数与基类的函数同名,但是参数不同。此时,不论有无
virtual关键字,基类的函数将被隐藏(注意别与重载混淆)。 - 如果派生类的函数与基类的函数同名, 并且参数也相同, 但是基类函数没有
virtual关键字。此时,基类的函数被隐藏(注意别与覆盖混淆)。
- 如果派生类的函数与基类的函数同名,但是参数不同。此时,不论有无
-
-
【规则 8-3-1】参数缺省值只能出现在函数的声明中,而不能出现在定义体中。
-
【规则 8-3-2】如果函数有多个参数,参数只能从后向前挨个儿缺省,否则将导致函数调用语句怪模怪样。
-
运算符与普通函数在调用时的不同之处是:对于普通函数,参数出现在圆括号内;而对于运算符,参数出现在其左、右侧。
-
如果运算符被重载为全局函数,那么只有一个参数的运算符叫做一元运算符,有两个参数的运算符叫做二元运算符。 如果运算符被重载为类的成员函数,那么一元运算符没有参数,二元运算符只有一个右侧参数,因为对象自己成了左侧参数。
-
在 C++ 运算符集合中,有一些运算符是不允许被重载的。这种限制是出于安全方面的考虑,可防止错误和混乱。(
sizeof、::、.、.*、?:、typeid等等) -
不能改变 C++ 内部数据类型(如
int,float等)的运算符。 -
不能重载
.,因为.在类中对任何成员都有意义,已经成为标准用法。 -
不能重载目前 C++ 运算符集合中没有的符号,如
#、@、$等。原因有两点,一是难以理解,二是难以确定优先级。 -
对已经存在的运算符进行重载时,不能改变优先级规则,否则将引起混乱。
-
在 C 程序中,可以用宏代码提高执行效率。宏代码本身不是函数,但使用起来象函数。预处理器用复制宏代码的方式代替函数调用, 省去了参数压栈、生成汇编语言的
CALL调用、返回参数、执行return等过程,从而提高了速度。使用宏代码最大的缺点是容易出错,预处理器在复制宏代码时常常产生意想不到的边际效应。使用宏代码还有另一种缺点:无法操作类的私有数据成员。 -
对于任何内联函数,编译器在符号表里放入函数的声明(包括名字、参数类型、返回值类型)。如果编译器没有发现内联函数存在错误,那么该函数的代码也被放入符号表里。在调用一个内联函数时,编译器首先检查调用是否正确(进行类型安全检查,或者进行自动类型转换,当然对所有的函数都一样)。如果正确,内联函数的代码就会直接替换函数调用,于是省去了函数调用的开销。
-
所以在 C++ 程序中,应该用内联函数取代所有宏代码,
assert恐怕是唯一的例外。assert是仅在 Debug 版本起作用的宏,它用于检查「不应该」发生的情况。为了不在程序的 Debug 版本和 Release 版本引起差别,assert不应该产生任何副作用。 如果assert是函数, 由于函数调用会引起内存、 代码的变动, 那么将导致 Debug 版本与 Release 版本存在差异。 所以assert不是函数, 而是宏。 -
关键字
inline必须与函数定义体放在一起才能使函数成为内联,仅将inline放在函数声明前面不起任何作用。所以说,inline是一种「用于实现的关键字」,而不是一种「用于声明的关键字」。 -
定义在类声明之中的成员函数将自动地成为内联函数。
-
以下情况不宜使用内联:
- 如果函数体内的代码比较长,使用内联将导致内存消耗代价较高。
- 如果函数体内出现循环,那么执行函数体内代码的时间要比函数调用的开销大。
- 类的构造函数和析构函数容易让人误解成使用内联更有效。要当心构造函数和析构函数可能会隐藏一些行为,如「偷偷地」执行了基类或成员对象的构造函数和析构函数。所以不要随便地将构造函数和析构函数的定义体放在类声明中。
第九章 类的构造函数、析构函数与赋值函数
-
每个类只有一个析构函数和一个赋值函数,但可以有多个构造函数(包含一个拷贝构造函数,其它的称为普通构造函数)。
-
既然能自动生成函数,为什么还要程序员编写?原因如下:
- 如果使用「缺省的无参数构造函数」和「缺省的析构函数」,等于放弃了自主「初始化」和「清除」的机会, C++ 发明人 Stroustrup 的好心好意白费了。
- 「缺省的拷贝构造函数」和「缺省的赋值函数」均采用「位拷贝」而非「值拷贝」的方式来实现,倘若类中含有指针变量,这两个函数注定将出错。
-
构造函数有个特殊的初始化方式叫「初始化表达式表」(简称初始化表)。初始化表位于函数参数表之后,却在函数体 {} 之前。这说明该表里的初始化工作发生在函数体内的任何代码被执行之前。
-
构造函数初始化表的使用规则:
-
如果类存在继承关系,派生类必须在其初始化表里调用基类的构造函数。
-
类的
const常量只能在初始化表里被初始化,因为它不能在函数体内用赋值的方式来初始化。 -
类的数据成员的初始化可以采用初始化表或函数体内赋值两种方式,这两种方式的效率不完全相同。
-
非内部数据类型的成员对象应当采用第一种方式初始化,以获取更高的效率。(第二种方式会先创建其对象,调用无参构造函数,再调用其赋值函数)。
-
对于内部数据类型的数据成员而言,两种初始化方式的效率几乎没有区别,但后者的程序版式似乎更清晰些。
-
-
构造从类层次的最根处开始,在每一层中,首先调用基类的构造函数,然后调用成员对象的构造函数。析构则严格按照与构造相反的次序执行,该次序是唯一的,否则编译器将无法自动执行析构过程。成员对象初始化的次序完全不受它们在初始化表中次序的影响,只由成员对象在类中声明的次序决定。
-
缺省的拷贝构造函数以「位拷贝」方式执行,以 string 类为例,将造成三个错误:
- 一是
b.m_data原有的内存没被释放,造成内存泄露; - 二是
b.m_data和a.m_data指向同一块内存,a或b任何一方变动都会影响另一方; - 三是在对象被析构时,
m_data被释放了两次。
- 一是
-
拷贝构造函数和赋值函数非常容易混淆,常导致错写、错用。拷贝构造函数是在对象被创建时调用的,而赋值函数只能被已经存在了的对象调用。
-
类 string 拷贝构造函数与普通构造函数的区别是:在函数入口处无需与
NULL进行比较,这是因为「引用」不可能是NULL,而「指针」可以为NULL。 -
string 类拷贝构造函数四部曲:
- 检查自赋值;
- 释放原有空间;
- 重新分配空间并拷贝;函数
strlen返回的是有效字符串长度,不包含结束符\0。函数strcpy则连\0一起复制。 - 返回本对象的引用。目的是为了实现象
a = b = c这样的链式表达。注意不要将return *this;错写成return this;。
-
偷懒的办法处理拷贝构造函数与赋值函数:只需将拷贝构造函数和赋值函数声明为私有函数,不用编写代码。
-
基类的构造函数、析构函数、赋值函数都不能被派生类继承。如果类之间存在继承关系,在编写上述基本函数时应注意以下事项:
- 派生类的构造函数应在其初始化表里调用基类的构造函数。
- 基类与派生类的析构函数应该为虚(即加
virtual关键字)。如果析构函数不为虚,将不会调用派生类的析构函数。 - 在编写派生类的赋值函数时,注意不要忘记对基类的数据成员重新赋值。即调用基类的赋值函数。(因为不能直接操作基类的私有成员)
第十章 类的继承与组合
- 继承:若在逻辑上 B 是 A 的「一种」,并且 A 的所有功能和属性对 B 而言都有意义,则允许 B 继承 A 的功能和属性。
- 组合:若在逻辑上 A 是 B 的「一部分」( a part of),则不允许 B 从 A 派生,而是要用 A 和其它东西组合出 B。
第十一章 其他编程经验
-
使用
const提高函数的健壮性:const不止能定义const常量,更大的魅力是它可以修饰函数的参数、返回值,甚至函数的定义体。 -
const修饰函数参数:-
如果参数作输出用,不论它是什么数据类型,也不论它采用「指针传递」还是「引用传递」,都不能加
const修饰,否则该参数将失去输出功能。 -
const只能修饰输入参数:- 如果输入参数采用「指针传递」,那么加
const修饰可以防止意外地改动该指针,起到保护作用。 - 如果输入参数采用「值传递」,由于函数将自动产生临时变量用于复制该参数,该输入参数本来就无需保护,所以不要加
const修饰。
- 如果输入参数采用「指针传递」,那么加
-
-
对于非内部数据类型的参数而言,像
void Func(A a)这样声明的函数注定效率比较底。因为函数体内将产生A类型的临时对象用于复制参数a,而临时对象的构造、复制、析构过程都将消耗时间。
-
const 修饰函数的返回值:
- 如果给以「指针传递」方式的函数返回值加
const修饰,那么函数返回值(即指针)的内容不能被修改,该返回值只能被赋给加const修饰的同类型指针。eg:const char * GetString(void); - 如果函数返回值采用「值传递方式」,由于函数会把返回值复制到外部临时的存储单元中,加
const修饰没有任何价值。 - 如果返回值不是内部数据类型,将函数
A GetA(void)改写为const A& GetA(void)的确能提高效率。但此时千万千万要小心,一定要搞清楚函数究竟是想返回一个对象的「拷贝」还是仅返回「别名」就可以了,否则程序会出错。
- 如果给以「指针传递」方式的函数返回值加
-
函数返回值采用「引用传递」的场合并不多,这种方式一般只出现在类的赋值函数中,目的是为了实现链式表达。
-
const成员函数:任何不会修改数据成员的函数都应该声明为const类型。const成员函数的声明看起来怪怪的:const关键字只能放在函数声明的尾部,大概是因为其它地方都已经被占用了。
. 【建议 11-3-2】变量(指针、数组)被创建之后应当及时把它们初始化,以防止把未被初始化的变量当成右值使用。