第一篇 程序的排版
a) 空行
i. 函数间空3行
ii. 逻辑不相关空1行
b) 代码行
i. 一行代码只做一件事情 如:定义一个变量
ii. If、for、while、do等语句自占一行,必须加{}
iii. 尽量定义变量时初始化变量,就近原则
c) 代码行内的空格
i. 关键字后要留个空格,包括while、for、if
ii. ‘(’向后紧跟,‘)’、‘,’、‘;’向前紧跟,紧跟处不留空格。
iii. ‘,’之后要留空格,如Function(x, y, z)。如果‘;’不是一行的结束符号,其后要留空格,如for (initialization; condition; update)。
iv. 赋值操作符、比较操作符、算术操作符、逻辑操作符、位域操作符,如“=”、“+=” “>=”、“<=”、“+”、“*”、“%”、“&&”、“||”、“<<”,“^”等二元操作符的前后应当加空格。
v. 一元操作符如“!”、“~”、“++”、“--”、“&”(地址运算符)等前后不加空格。
vi. 象“[]”、“.”、“->”这类操作符前后不加空格。
vii. 对于表达式比较长的for语句和if语句,为了紧凑起见可以适当地去掉一些空格,如for (i=0; i<10; i++)和if ((a<=b) && (c<=d))
d) 长行拆分
i. 代码行最大长度宜控制在70至80个字符以内。
ii. 长表达式要在低优先级操作符处拆分成新行,操作符放在新行之首(以便突出操作符)
e) 修饰符的位置
i. 应当将修饰符 * 和 & 紧靠变量名
f) 注释
i. C语言的注释符为“/*…*/”。C++语言中,程序块的注释常采用“/*…*/”,行注释一般采用“//…”。
ii. 注释的位置应与被描述的代码相邻,可以放在代码的上方或右方,不可放在下方。
iii. 当代码比较长,特别是有多重嵌套时,应当在一些段落的结束处加注释,便于阅读。
g) 类的版式
i. 建议读者采用“以行为为中心”的书写方式,即首先考虑类应该提供什么样的函数。这是很多人的经验——“这样做不仅让自己在设计类时思路清晰,而且方便别人阅读。因为用户最关心的是接口,谁愿意先看到一堆私有数据成员!”
第二篇 命名规则
a) 标识符的长度应当符合“min-length && max-information”原则。
单字符的名字也是有用的,常见的如i,j,k,m,n,x,y,z等,它们通常可用作函数内的局部变量。
b) 命名规则尽量与所采用的操作系统或开发工具的风格保持一致。
例如Windows应用程序的标识符通常采用“大小写”混排的方式,如AddChild。而Unix应用程序的标识符通常采用“小写加下划线”的方式,如add_child。别把这两类风格混在一起用
c) 变量的名字应当使用“名词”或者“形容词+名词”。
d) 全局函数的名字应当使用“动词”或者“动词+名词”(动宾词组)。类的成员函数应当只使用“动词”,被省略掉的名词就是对象本身。
e) 用正确的反义词组命名具有互斥意义的变量或相反动作的函数等。
f) 简单的Windows应用程序命名规则
i. 类名和函数名用大写字母开头的单词组合而成。
ii. 变量和参数用小写字母开头的单词组合而成。
iii. 常量全用大写的字母,用下划线分割单词
iv. 静态变量加前缀s_
v. 如果不得已需要全局变量,则使全局变量加前缀g_(表示global)
vi. 类的数据成员加前缀m_(表示member),这样可以避免数据成员与成员函数的参数同名
vii. 为了防止某一软件库中的一些标识符和其它软件库中的冲突,可以为各种标识符加上能反映软件性质的前缀。例如三维图形标准OpenGL的所有库函数均以gl开头,所有常量(或宏定义)均以GL开头。
第三篇 表达式和基本语句
a) 不可将布尔变量直接与TRUE、FALSE或者1、0进行比较
建议 if (flag) , 杜绝:if (flag == ture) or if (flag == 0)
b) 应当将整型变量用“==”或“!=”直接与0比较
禁止if (value) or if (!value)
c) 不可将浮点变量用“==”或“!=”与任何数字比较。
千万要留意,无论是float还是double类型的变量,都有精度限制。
d) 应当将指针变量用“==”或“!=”与NULL比较。
e) 建议使用if (value == variant),即使开始会不习惯
f) 循环语句的效率
i. 在多重循环中,如果有可能,应当将最长的循环放在最内层,最短的循环放在最外层,以减少CPU跨切循环层的次数
ii. 如果循环体内存在逻辑判断,并且循环次数很大,宜将逻辑判断移到循环体的外面
g) 建议for语句的循环控制变量的取值采用“半开半闭区间”写法。
选择半开区间的一部分原因是因为计算机从0开始。
h) switch语句
i. 每个case语句的结尾不要忘了加break,否则将导致多个分支重叠(除非有意使多个分支重叠)
ii. 不要忘记最后那个default分支。即使程序真的不需要default处理,也应该保留语句 default : break; 这样做并非多此一举,而是为了防止别人误以为你忘了default处理
i) 很多人建议废除C++/C的goto语句,以绝后患。但实事求是地说,错误是程序员自己造成的,不是goto的过错。goto 语句至少有一处可显神通,它能从多重循环体中咻地一下子跳到外面,用不着写很多次的break语
第四篇 常量
a) 在C++ 程序中只使用const常量而不使用宏常量,即const常量完全取代宏常量。
b) 需要对外公开的常量放在头文件中,不需要对外公开的常量放在定义文件的头部。为便于管理,可以把不同模块的常量集中存放在一个公共的头文件中
c) 类常量
i. 不能在类声明中初始化const数据成员
如果使用static const呢?
ii. 怎样才能建立在整个类中都恒定的常量呢?别指望const数据成员了,应该用类中的枚举常量来实现
iii. 枚举常量不会占用对象的存储空间,它们在编译时被全部求值。枚举常量的缺点是:它的隐含数据类型是整数,其最大值有限,且不能表示浮点数(如PI=3.14159)
第五篇 函数设计
a) 参数规则
i. 如果函数没有参数,则用void填充
ii. 参数的顺序要遵循程序员的习惯。一般地,应将目的参数放在前面,源参数放在后面 (如strcat)
iii. 避免函数有太多的参数,参数个数尽量控制在5个以内。如果参数太多,在使用时容易将参数类型或顺序搞错
iv. 尽量不要使用类型和数目不确定的参数。
如:int printf(const chat *format[, argument]…)
b) 返回值规则
C语言中,凡不加类型说明的函数,一律自动按整型处理。这样做不会有什么好处,却容易被误解为void类型。
C++语言有很严格的类型安全检查,不允许上述情况发生。
由于C++程序可以调用C函数,为了避免混乱,规定任何C++/ C函数都必须有类型。如果函数没有返回值,那么应声明为void类型。
i. 函数名字与返回值类型在语义上不可冲突
如:getchar() 返回int类型
ii. 不要将正常值和错误标志混在一起返回。正常值用输出参数获得,而错误标志用return语句返回
可能更高效、更直观,省去构造、析构、拷贝
iii. 有时候函数原本不需要返回值,但为了增加灵活性如支持链式表达
iv. 如果函数的返回值是一个对象,有些场合用“引用传递”替换“值传递”可以提高效率。而有些场合只能用“值传递”而不能用“引用传递”,否则会出错
c) 函数内部实现的规则
根据经验,我们可以在函数体的“入口处”和“出口处”从严把关,从而提高函数的质量
i. 在函数体的“入口处”,对参数的有效性进行检查
ii. 在函数体的“出口处”,对return语句的正确性和效率进行检查
1. return语句不可返回指向“栈内存”的“指针”或者“引用”,因为该内存在函数体结束时被自动销毁
2. 要搞清楚返回的究竟是“值”、“指针”还是“引用”
3. 如果函数返回值是一个对象,要考虑return语句的效率
创建一个临时对象并返回它”。不要以为它与“先创建一个局部对象temp并返回它的结果”是等价的
编译器直接把临时对象创建并初始化在外部存储单元中,省去了拷贝和析构的化费,提高了效率。
d) 其它建议
i. 函数的功能要单一,不要设计多用途的函数
ii. 函数体的规模要小,尽量控制在50行代码之内
iii. 尽量避免函数带有“记忆”功能。相同的输入应当产生相同的输出
iv. 不仅要检查输入参数的有效性,还要检查通过其它途径进入函数体内的变量的有效性,例如全局变量、文件句柄等
v. 用于出错处理的返回值一定要清楚,让使用者不容易忽视或误解错误情况
vi. 关于返回0还是-1,表示错误好呢?还是返回0或者-1都表示错误?
e) 使用断言
断言assert是仅在Debug版本起作用的宏,它用于检查“不应该”发生的情况。
i. 使用断言捕捉不应该发生的非法情况。不要混淆非法情况与错误情况之间的区别,后者是必然存在的并且是一定要作出处理的
ii. 在函数的入口处,使用断言检查参数的有效性(合法性)
iii. 在编写函数时,要进行反复的考查,并且自问:“我打算做哪些假定?”一旦确定了的假定,就要使用断言对假定进行检查
iv. 一般教科书都鼓励程序员们进行防错设计,但要记住这种编程风格可能会隐瞒错误。当进行防错设计时,如果“不可能发生”的事情的确发生了,则要使用断言进行报警
f) 引用与指针的比较
引用的主要功能是传递函数的参数和返回值。
实际上“引用”可以做的任何事情“指针”也都能够做,为什么还要“引用”这东西?
i. 引用被创建的同时必须被初始化(指针则可以在任何时候被初始化)
ii. 一旦引用被初始化,就不能改变引用的关系(指针则可以随时改变所指的对象)
第六篇 内存管理
有一个很重要的现象要告诉大家。对于32位以上的应用程序而言,无论怎样使用malloc与new,几乎不可能导致“内存耗尽”。我在Windows 98下用Visual C++编写了测试程序,见示例7-9。这个程序会无休止地运行下去,根本不会终止。因为32位操作系统支持“虚存”,内存用完了,自动用硬盘空间顶替。我只听到硬盘嘎吱嘎吱地响,Window 98已经累得对键盘、鼠标毫无反应。
a) 常见的内存错误
i. 内存分配未成功,却使用了它
ii. 内存分配虽然成功,但是尚未初始化就引用它
iii. 内存分配成功并且已经初始化,但操作越过了内存的边界
iv. 忘记了释放内存,造成内存泄露
v. 释放了内存却继续使用它 (野指针)
b) 指针与数组的对比
i. char *p = “world”; p[0] = ‘X’; 编译器无法发现该错误,修改常量字符串
ii. 计算内存容量:用运算符sizeof可以计算出数组的容量(字节数)
iii. 如果函数的参数是一个指针,不要指望用该指针去申请动态内存。
如果非得要用指针参数去申请内存,那么应该改用“指向指针的指针”
void GetMemory(char *p, int num)
{
p = (char *)malloc(sizeof(char) * num);
}
解释:p是地址的拷贝,malloc返回地址,改变地址的值,不是传指针参数的
目的,它的目的是改变指针所指向数据的改变
但char* GetMemory(int num)
{
return (char *)malloc(sizeof(char) * num);
}是可以的
iv. 发现指针p被free、delete以后其地址仍然不变(非NULL),只是该地址对应的内存是垃圾,p成了“野指针”。如果此时不把p设置为NULL,会让人误以为p是个合法的指针
v. “野指针”的成因主要有两
1. 指针变量没有被初始化
2. 指针p被free或者delete之后,没有置为NULL,让人误以为p是个合法的指针
3. 指针操作超越了变量的作用范围。
vi. 有了malloc/free为什么还要new/delete ?
1. malloc/free 是库函数,而new/delete是运算符
2. new/delete 运用于结构体、类比较方便
vii. malloc / free 使用要点
void * malloc(size_t size);
int *p = (int *) malloc(sizeof(int) * length)
void free( void * memblock )
1. malloc返回值的类型是void *,所以在调用malloc时要显式地进行类型转换,将void * 转换成所需要的指针类型
2. malloc函数本身并不识别要申请的内存是什么类型,它只关心内存的总字节数。我们通常记不住int, float等数据类型的变量的确切字节数。
viii. new/delete 的使用要点
int *p2 = new int[length];
delete p2;
1. new内置了sizeof、类型转换和类型安全检查功能。对于非内部数据类型的对象而言,new在创建动态对象的同时完成了初始化工作。
2. 如果用new创建对象数组,那么只能使用对象的无参数构造函数。
c) 经验总结
i. 越是怕指针,就越要使用指针。不会正确使用指针,肯定算不上是合格的程序员
ii. 必须养成“使用调试器逐步跟踪程序”的习惯,只有这样才能发现问题的本质
第七篇 C++函数的高级特性
a) 函数重载的概念
i. 在C++程序中,可以将语义、功能相似的几个函数用同一个名字表示,即函数重载。
ii. C++语言采用重载机制的另一个理由是:类的构造函数需要重载机制。因为C++规定构造函数与类同名(请参见第9章),构造函数只能有一个名字。如果想用几种不同的方法创建对象该怎么办?别无选择,只能用重载机制来实现。
b) 重载是如何实现的?
i. 问题是在C++/C程序中,我们可以忽略函数的返回值。
所以只能靠参数而不能靠返回值类型的不同来区分重载函数。
ii. 如果C++程序要调用已经被编译后的C函数,该怎么办?
extern “C”{ ………… }
c) 成员函数的重载、覆盖与隐藏
i. 成员函数被重载的特征:(1)相同的范围(在同一个类中);(2)函数名字相同;(3)参数不同;(4)virtual关键字可有可无
ii. 覆盖是指派生类函数覆盖基类函数,特征是:(1)不同的范围(分别位于派生类与基类);(2)函数名字相同;(3)参数相同;(4)基类函数必须有virtual关键字
iii. 隐藏的特征:
(1)如果派生类的函数与基类的函数同名,但是参数不同。此时,不论有无virtual关键字,基类的函数将被隐藏(注意别与重载混淆)。
(2)如果派生类的函数与基类的函数同名,并且参数也相同,但是基类函数没有virtual关键字。此时,基类的函数被隐藏(注意别与覆盖混淆)。
d) 参数的缺省值
i. 参数缺省值只能出现在函数的声明中,而不能出现在定义体中
ii. 如果函数有多个参数,参数只能从后向前挨个儿缺省,否则将导致函数调用语句怪模怪样
iii. 避免不当使用默认值
不合理地使用参数的缺省值将导致重载函数output产生二义性。
void output( int x);
void output( int x, float y=0.0);
e) 运算符重载
i. 如果运算符被重载为全局函数,那么只有一个参数的运算符叫做一元运算符,有两个参数的运算符叫做二元运算符。
ii. 如果运算符被重载为类的成员函数,那么一元运算符没有参数,二元运算符只有一个右侧参数,因为对象自己成了左侧参数。
iii. 从语法上讲,运算符既可以定义为全局函数,也可以定义为成员函数。
总结了表8-4-1的规则
运算符 | 规则 |
所有的一元运算符 | 建议重载为成员函数 |
= () [] -> | 只能重载为成员函数 |
+= -= /= *= &= |= ~= %= >>= <<= | 建议重载为成员函数 |
所有其它运算符 | 建议重载为全局函数 |
iv. 不能被重载的运算符
1. 不能重载‘.’,因为‘.’在类中对任何成员都有意义,已经成为标准用法。
2. 不能重载目前C++运算符集合中没有的符号,如#,@,$等。原因有两点,一是难以理解,二是难以确定优先级。
3. 对已经存在的运算符进行重载时,不能改变优先级规则,否则将引起混乱。
f) 函数内联
让我们看看C++ 的“函数内联”是如何工作的。在调用一个内联函数时,编译器首先检查调用是否正确(进行类型安全检查,或者进行自动类型转换,当然对所有的函数都一样)。如果正确,内联函数的代码就会直接替换函数调用,于是省去了函数调用的开销。这个过程与预处理有显著的不同,因为预处理器不能进行类型安全检查,或者进行自动类型转换。假如内联函数是成员函数,对象的地址(this)会被放在合适的地方,这也是预处理器办不到的。
“断言assert” 是宏,而不是inline
i. 关键字inline必须与函数定义体放在一起才能使函数成为内联,仅将inline放在函数声明前面不起任何作用。
ii. 我认为inline不应该出现在函数的声明中。这个细节虽然不会影响函数的功能,但是体现了高质量C++/C程序设计风格的一个基本原则:声明与定义不可混为一谈,用户没有必要、也不应该知道函数是否需要内联
iii. 将成员函数的定义体放在类声明之中虽然能带来书写上的方便,但不是一种良好的编程风格
iv. 内联是以代码膨胀(复制)为代价,仅仅省去了函数调用的开销,从而提高函数的执行效率。如果执行函数体内代码的时间,相比于函数调用的开销较大,那么效率的收获会很少。
v. 类的构造函数和析构函数容易让人误解成使用内联更有效。要当心构造函数和析构函数可能会隐藏一些行为,如“偷偷地”执行了基类或成员对象的构造函数和析构函数。所以不要随便地将构造函数和析构函数的定义体放在类声明中。
第八篇 类的构造函数、析构函数与赋值函数
每个类只有一个析构函数和一个赋值函数,但可以有多个构造函数(包含一个拷贝构造函数,其它的称为普通构造函数)。
a) 构造函数与析构函数的起源
根据经验,不少难以察觉的程序错误是由于变量没有被正确初始化或清除造成的,而初始化和清除工作很容易被人遗忘。Stroustrup在设计C++语言时充分考虑了这个问题并很好地予以解决:把对象的初始化工作放在构造函数中,把清除工作放在析构函数中。当对象被创建时,构造函数被自动执行。当对象消亡时,析构函数被自动执行。这下就不用担心忘了对象的初始化和清除工作。、
b) 构造函数的初始化表
初始化列表工作发生在函数体内的任何代码被执行之前。
i. 如果类存在继承关系,派生类必须在其初始化表里调用基类的构造函数
ii. 类的const常量只能在初始化表里被初始化,因为它不能在函数体内用赋值的方式来初始化
iii. 类的数据成员的初始化可以采用初始化表或函数体内赋值两种方式,这两种方式的效率不完全相同
c) 构造和析构的次序
i. 一个有趣的现象是,成员对象初始化的次序完全不受它们在初始化表中次序的影响,只由成员对象在类中声明的次序决定。
d) 不要轻视拷贝构造函数与赋值函数
i. 本章开头讲过,如果不主动编写拷贝构造函数和赋值函数,编译器将以“位拷贝”的方式自动生成缺省的函数。
ii. 拷贝构造函数和赋值函数非常容易混淆,常导致错写、错用。拷贝构造函数是在对象被创建时调用的,而赋值函数只能被已经存在了的对象调用。
String a(“hello”);
String c = a; // 调用了拷贝构造函数,最好写成 c(a);风格较差
c = b; // 调用了赋值函
iii. “引用”不可能是NULL,而“指针”可以为NULL。
iv. 分析步骤
1. (1)第一步,检查自赋值
2. 第二步,用delete释放原有的内存资源。如果现在不释放,以后就没机会了,将造成内存泄露。
3. 第三步,分配新的内存资源,并复制字符串。
4. 第四步,返回本对象的引用,目的是为了实现象 a = b = c 这样的链式表达。
e) 偷懒的办法处理拷贝构造函数与赋值函数
偷懒的办法是:只需将拷贝构造函数和赋值函数声明为私有函数,不用编写代码。
f) 如何在派生类中实现类的基本函数
基类的构造函数、析构函数、赋值函数都不能被派生类继承。如果类之间存在继承关系,在编写上述基本函数时应注意以下事项
i. 派生类的构造函数应在其初始化表里调用基类的构造函数
ii. 基类与派生类的析构函数应该为虚(即加virtual关键字)
iii. 在编写派生类的赋值函数时,注意不要忘记对基类的数据成员重新赋值
g) 心得体会
第九篇 类的继承与组合
注意,当前面向对象技术的应用热点是COM和CORBA,请阅读COM和CORBA相关论著。
a) 继承
i. 如果A是基类,B是A的派生类,那么B将继承A的数据和函数。
class A{public:void Func1(void);void Func2(void);};
class B : public A{public:void Func3(void);void Func4(void);};
main()
{
B b;
b.Func1(); // B从A继承了函数Func1
b.Func2(); // B从A继承了函数Func2.
b.Func3();
b.Func4();
}
1. 如果类A和类B毫不相关,不可以为了使B的功能更多些而让B继承A的功能和属性。
2. 若在逻辑上B是A的“一种”(a kind of ),则允许B继承A的功能和属性。
3. 所以更加严格的继承规则应当是:若在逻辑上B是A的“一种”,并且A的所有功能和属性对B而言都有意义,则允许B继承A的功能和属性。即要求A的所有属性对B而言都有意义。
ii. 组合
1. 若在逻辑上A是B的“一部分”(a part of),则不允许B从A派生,而是要用A和其它东西组合出B。
第十篇 其它编程经验
a) 使用const提高函数的健壮性
i. 如果输入参数采用“指针传递”,那么加const修饰可以防止意外地改动该指针,起到保护作用
ii. 如果给以“指针传递”方式的函数返回值加const修饰,那么函数返回值(即指针)的内容不能被修改,该返回值只能被赋给加const修饰的同类型指针
iii. 函数返回值采用“引用传递”的场合并不多,这种方式一般只出现在类的赋值函数中,目的是为了实现链式表达。
iv. 任何不会修改数据成员的函数都应该声明为const类型。
b) 提高程序的效率
i. 不要一味地追求程序的效率,应当在满足正确性、可靠性、健壮性、可读性等质量因素的前提下,设法提高程序的效率。
ii. 以提高程序的全局效率为主,提高局部效率为辅。
iii. 在优化程序的效率时,应当先找出限制效率的“瓶颈”,不要在无关紧要之处优化。
iv. 先优化数据结构和算法,再优化执行代码。
v. 有时候时间效率和空间效率可能对立,此时应当分析那个更重要,作出适当的折衷。
vi. 不要追求紧凑的代码,因为紧凑的代码并不能产生高效的机器码。
c) 一些有益的建议
i. 当心数据类型转换发生错误。尽量使用显式的数据类型转换(让人们知道发生了什么事),避免让编译器轻悄悄地进行隐式的数据类型转换。
ii. 当心忘记编写错误处理程序,当心错误处理程序本身有误
iii. 当心文件I/O有错误
iv. 避免编写技巧性很高代码。
v. 不要设计面面俱到、非常灵活的数据结构
vi. 如果原有的代码质量比较好,尽量复用它。但是不要修补很差劲的代码,应当重新编写
vii. 尽量使用标准库函数,不要“发明”已经存在的库函数
viii. 尽量不要使用与具体硬件或软件环境关系密切的变量
ix. 把编译器的选择项设置为最严格状态
x. 如果可能的话,使用PC-Lint、LogiScope等工具进行代码审查