软件质量是被大多数程序员挂在嘴上而不是放在心上的东西!

(1)知错就改;

(2)经常温故而知新;

(3)坚持学习,天天向上。

 

编程质量差往往是由于不良习惯造成的,与人的智力、能力没有多大关系

 

第1章 文件结构

每个C++/C程序通常分为两个文件。一个文件用于保存程序的声明(declaration),称为头文件。另一个文件用于保存程序的实现(implementation),称为定义(definition)文件。

1.2 头文件的结构

头文件由三部分内容组成:

(1)头文件开头处的版权和版本声明

(2)预处理块。

(3)函数和类结构声明等。

【规则1-2-1为了防止头文件被重复引用,应当用ifndef/define/endif结构产生预处理块。

【规则1-2-2用 #include <filename.h> 格式来引用标准库的头文件(编译器将从标准库目录开始搜索)。

【规则1-2-3用 #include “filename.h” 格式来引用非标准库的头文件(编译器将从用户的工作目录开始搜索)。

 【建议1-2-1】头文件中只存放“声明”而不存放“定义”

在C++ 语法中,类的成员函数可以在声明的同时被定义,并且自动成为内联函数。这虽然会带来书写上的方便,但却造成了风格不一致,弊大于利。建议将成员函数的定义与声明分开,不论该函数体有多么小。

【建议1-2-2】不提倡使用全局变量,尽量不要在头文件中出现象extern int value 这类声明。

 

1.3 定义文件的结构

(1) 定义文件开头处的版权和版本声明

(2) 对一些头文件的引用。

(3) 程序的实现体(包括数据和代码)。

// 全局函数的实现体

void Function1(…)

{

}

// 类成员函数的实现体

void Box::Draw(…)

{

}

1.4 头文件的作用

(1)通过头文件来调用库功能。在很多场合,源代码不便(或不准)向用户公布,只要向用户提供头文件和二进制的库即可。用户只需要按照头文件中的接口声明来调用库功能,而不必关心接口怎么实现的。编译器会从库中提取相应的代码。

(2)头文件能加强类型安全检查。如果某个接口被实现或被使用时,其方式与头文件中的声明不一致,编译器就会指出错误,这一简单的规则能大大减轻程序员调试、改错的负担。

 

1.5 目录结构

如果一个软件的头文件数目比较多(如超过十个),通常应将头文件和定义文件分别保存于不同的目录,以便于维护。

例如可将头文件保存于include目录,将定义文件保存于source目录(可以是多级目录)。

 

第2章 程序的版式

版式虽然不会影响程序的功能,但会影响可读性

2.1 空行

空行起着分隔程序段落的作用

【规则2-1-1在每个类声明之后、每个函数定义结束之后都要加空行。

【规则2-1-2在一个函数体内,逻揖上密切相关的语句之间不加空行,其它地方应加空行分隔

2.2 代码行

【规则2-2-1】一行代码只做一件事情,如只定义一个变量,或只写一条语句。这样的代码容易阅读,并且方便于写注释。

【规则2-2-2if、for、while、do等语句自占一行,执行语句不得紧跟其后。不论执行语句有多少都要加{}。这样可以防止书写失误。

【建议2-2-1尽可能在定义变量的同时初始化该变量(就近原则)

2.3 代码行内的空格

【规则2-3-1】关键字之后要留空格。象const、virtual、inline、case 等关键字之后至少要留一个空格,否则无法辨析关键字。象if、for、while等关键字之后应留一个空格再跟左括号‘(’,以突出关键字。

【规则2-3-2】函数名之后不要留空格,紧跟左括号‘(’,以与关键字区别。

【规则2-3-3‘(’向后紧跟,‘)’、‘,’、‘;’向前紧跟,紧跟处不留空格。

【规则2-3-4】‘,’之后要留空格,如Function(x, y, z)。如果‘;’不是一行的结束符号,其后要留空格,如for (initialization; condition; update)。

【规则2-3-5赋值操作符、比较操作符、算术操作符、逻辑操作符、位域操作符,如“=”、“+=” “&gt;=”、“<=”、“+”、“*”、“%”、“&&”、“||”、“&lt;&lt;”,“^”等二元操作符的前后应当加空格。

【规则2-3-6一元操作符如“!”、“~”、“++”、“--”、“&”(地址运算符)等前后不加空格。

【规则2-3-7象“[]”、“.”、“->”这类操作符前后不加空格。

【建议2-3-1对于表达式比较长的for语句和if语句,为了紧凑起见可以适当地去掉一些空格,如for (i=0; i<10; i++)和if ((a&lt;=b) && (c&lt;=d))

2.4 对齐

【规则2-4-1程序的分界符‘{’和‘}’应独占一行并且位于同一列,同时与引用它们的语句左对齐

【规则2-4-2{ }之内的代码块在‘{’右边数格处左对齐

 

2.6 修饰符的位置

【规则2-6-1应当将修饰符 * 和 & 紧靠变量名

例如:

char *name;

int *x, y; // 此处y不会被误解为指针

2.8 类的版式

类可以将数据和函数封装在一起,其中函数表示了类的行为(或称服务)。类提供关键字public、protected和private,分别用于声明哪些数据和函数是公有的、受保护的或者是私有的。这样可以达到信息隐藏的目的,即让类仅仅公开必须要让外界知道的内容,而隐藏其它一切内容。我们不可以滥用类的封装功能,不要把它当成火锅,什么东西都往里扔

类的版式主要有两种方式:

(1)将private类型的数据写在前面,而将public类型的函数写在后面,采用这种版式的程序员主张类的设计“以数据为中心”,重点关注类的内部结构

(2)将public类型的函数写在前面,而将private类型的数据写在后面,采用这种版式的程序员主张类的设计“以行为为中心”,重点关注的是类应该提供什么样的接口(或服务)。

 

我建议读者采用“以行为为中心”的书写方式,即首先考虑类应该提供什么样的函数。这是很多人的经验——“这样做不仅让自己在设计类时思路清晰,而且方便别人阅读。因为用户最关心的是接口,谁愿意先看到一堆私有数据成员!”

 

第3章 命名规则

比较著名的命名规则当推Microsoft公司的“匈牙利”法,该命名规则的主要思想是“在变量和函数名中加入前缀以增进人们对程序的理解”。

3.2 简单的Windows应用程序命名规则

【规则3-2-1类名和函数名用大写字母开头的单词组合而成 class Node; // 类名

【规则3-2-2变量和参数用小写字母开头的单词组合而成。BOOL flag;

【规则3-2-3常量全用大写的字母,用下划线分割单词。const int MAX_LENGTH = 100;

【规则3-2-4静态变量加前缀s_(表示static)。static int s_initValue; // 静态变量

【规则3-2-5如果不得已需要全局变量,则使全局变量加前缀g_  int g_howManyPeople; // 全局变量

【规则3-2-6类的数据成员加前缀m_(表示member),这样可以避免数据成员与成员函数的参数同名。

 

第4章 表达式和基本语句

【规则4-1-1】如果代码行中的运算符比较多,用括号确定表达式的操作顺序,避免使用默认的优先级。

4.3 if 语句

【规则4-3-1不可将布尔变量直接与TRUE、FALSE或者1、0进行比较。

【规则4-3-2应当将整型变量用“==”或“!=”直接与0比较。

【规则4-3-3不可将浮点变量用“==”或“!=”与任何数字比较。

【规则4-3-4应当将指针变量用“==”或“!=”与NULL比较。

4.4 循环语句的效率

C++/C循环语句中,for语句使用频率最高,while语句其次,do语句很少用

【建议4-4-2】如果循环体内存在逻辑判断,并且循环次数很大,宜将逻辑判断移到循环体的外面

4.5 for 语句的循环控制变量

【规则4-5-1不可在for 循环体内修改循环变量,防止for 循环失去控制。

4.6 switch语句

【规则4-6-1每个case语句的结尾不要忘了加break,否则将导致多个分支重叠(除非有意使多个分支重叠)

【规则4-6-2不要忘记最后那个default分支。即使程序真的不需要default处理,也应该保留语句 default : break; 这样做并非多此一举,而是为了防止别人误以为你忘了default处理。

第5章 常量

5.2 const 与 #define的比较

C++ 语言可以用const来定义常量,也可以用 #define来定义常量。但是前者比后者有更多的优点:

(1) const常量有数据类型,而宏常量没有数据类型

(2) 有些集成化的调试工具可以对const常量进行调试,但是不能对宏常量进行调试

 

5.4 类中的常量

有时我们希望某些常量只在类中有效。由于#define定义的宏常量是全局的,不能达到目的

const数据成员只在某个对象生存期内是常量,而对于整个类而言却是可变的,因为类可以创建多个对象,不同的对象其const数据成员的值可以不同。

不能在类声明中初始化const数据成员

const数据成员的初始化只能在类构造函数的初始化表中进行

怎样才能建立在整个类中都恒定的常量呢     应该用类中的枚举常量来实现

enum { SIZE1 = 100, SIZE2 = 200}; // 枚举常量

枚举常量不会占用对象的存储空间,它们在编译时被全部求值。枚举常量的缺点是:它的隐含数据类型是整数,其最大值有限,且不能表示浮点数(如PI=3.14159)。

 

第6章 函数设计

函数接口的两个要素是参数和返回值

C语言中,函数的参数和返回值的传递方式有两种:值传递(pass by value)和指针传递(pass by pointer)。C++ 语言中多了引用传递(pass by reference)。由于引用传递的性质象指针传递,而使用方式却象值传递,初学者常常迷惑不解,容易引起混乱

6.1 参数的规则

规则6-1-1参数的书写要完整,不要贪图省事只写参数的类型而省略参数名字。如果函数没有参数,则用void填充

【规则6-1-2参数命名要恰当,顺序要合理 一般地,应将目的参数放在前面,源参数放在后面

【规则6-1-3如果参数是指针,且仅作输入用,则应在类型前加const,以防止该指针在函数体内被意外修改

【规则6-1-4如果输入参数以值传递的方式传递对象,则宜改用“const &”方式来传递,这样可以省去临时对象的构造和析构过程,从而提高效率。

【建议6-1-1避免函数有太多的参数,参数个数尽量控制在5个以内。如果参数太多,在使用时容易将参数类型或顺序搞错。

【建议6-1-2尽量不要使用类型和数目不确定的参数。

 

6.2 返回值的规则

【规则6-2-1不要省略返回值的类型。

【规则6-2-3不要将正常值和错误标志混在一起返回。正常值用输出参数获得,而错误标志用return语句返回。

6.3 函数内部实现的规则

【规则6-3-1在函数体的“入口处”,对参数的有效性进行检查。

【规则6-3-2在函数体的“出口处”,对return语句的正确性和效率进行检查。

return语句不可返回指向“栈内存”的“指针”或者“引用”,因为该内存在函数体结束时被自动销毁

6.5 使用断言

程序一般分为Debug版本和Release版本,Debug版本用于内部调试,Release版本发行给用户使用。

 

断言assert是仅在Debug版本起作用的宏,它用于检查“不应该”发生的情况

assert不是一个仓促拼凑起来的宏。为了不在程序的Debug版本和Release版本引起差别,assert不应该产生任何副作用。所以assert不是函数,而是宏。程序员可以把assert看成一个在任何系统状态下都可以安全使用的无害测试手段。如果程序在assert处终止了,并不是说含有该assert的函数有错误,而是调用者出了差错,assert可以帮助我们找到发生错误的原因。

6.6 引用与指针的比较

引用是C++中的概念

int m;

int &n = m;

n是m的一个引用(reference),m是被引用物(referent)。

n相当于m的别名(绰号),对n的任何操作就是对m的操作。n既不是m的拷贝,也不是指向m的指针,其实n就是m它自己。

引用的一些规则如下:

(1)引用被创建的同时必须被初始化(指针则可以在任何时候被初始化)。

(2)不能有NULL引用,引用必须与合法的存储单元关联(指针则可以是NULL)。

(3)一旦引用被初始化,就不能改变引用的关系(指针则可以随时改变所指的对象)。

引用的主要功能是传递函数的参数和返回值

以下是“值传递”的示例程序。由于Func1函数体内的x是外部变量n的一份拷贝,改变x的值不会影响n, 所以n的值仍然是0。

void Func1(int x)

{

x = x + 10;

}

int n = 0;

Func1(n);

cout &lt;&lt; “n = ” &lt;&lt; n &lt;&lt; endl; // n = 0

 

以下是“指针传递”的示例程序。由于Func2函数体内的x是指向外部变量n的指针,改变该指针的内容将导致n的值改变,所以n的值成为10。

void Func2(int *x)

{

(* x) = (* x) + 10;

}

 

以下是“引用传递”的示例程序。由于Func3函数体内的x是外部变量n的引用,x和n是同一个东西,改变x等于改变n,所以n的值成为10。

void Func3(int &x)

{

x = x + 10;

}

int n = 0;

Func3(n);

cout &lt;&lt; “n = ” &lt;&lt; n &lt;&lt; endl; // n = 10

 

实际上“引用”可以做的任何事情“指针”也都能够做,为什么还要“引用”这东西?

指针能够毫无约束地操作内存中的如何东西,尽管指针功能强大,但是非常危险

如果的确只需要借用一下某个对象的“别名”,那么就用“引用”,而不要用“指针”,以免发生意外。比如说,某人需要一份证明,本来在文件上盖上公章的印子就行了,如果把取公章的钥匙交给他,那么他就获得了不该有的权利。

 

第7章 内存管理

 

7.1内存分配方式

内存分配方式有三种:

(1) 从静态存储区域分配。内存在程序编译的时候就已经分配好,这块内存在程序的整个运行期间都存在。例如全局变量,static变量。

(2) 在栈上创建。在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释放。栈内存分配运算内置于处理器的指令集中,效率很高,但是分配的内存容量有限。

(3) 从堆上分配,亦称动态内存分配。程序在运行的时候用mallocnew申请任意多少的内存,程序员自己负责在何时用free或delete释放内存。动态内存的生存期由我们决定,使用非常灵活,但问题也最多。

7.2常见的内存错误及其对策

发生内存错误是件非常麻烦的事情。编译器不能自动发现这些错误,通常是在程序运行时才能捕捉到

常见的内存错误及其对策如下:

  • 内存分配未成功,却使用了它
    常用解决办法是,在使用内存之前检查指针是否为NULL
  • 内存分配虽然成功,但是尚未初始化就引用它
  • 内存分配成功并且已经初始化,但操作越过了内存的边界。
  • 忘记了释放内存,造成内存泄露
    动态内存的申请与释放必须配对,程序中malloc与free的使用次数一定要相同,否则肯定有错误(new/delete同理)。
  • 释放了内存却继续使用它。

【规则7-2-1用malloc或new申请内存之后,应该立即检查指针值是否为NULL。防止使用指针值为NULL的内存。

【规则7-2-2不要忘记为数组和动态内存赋初值。防止将未被初始化的内存作为右值使用。

【规则7-2-3避免数组或指针的下标越界,特别要当心发生“多1”或者“少1”操作。

【规则7-2-4动态内存的申请与释放必须配对,防止内存泄漏。

【规则7-2-5用free或delete释放了内存之后,立即将指针设置为NULL,防止产生“野指针”。

 

7.3指针与数组的对比

 

数组要么在静态存储区被创建(如全局数组),要么在栈上被创建。数组名对应着(而不是指向)一块内存,其地址与容量在生命期内保持不变,只有数组的内容可以改变。

指针可以随时指向任意类型的内存块,它的特征是“可变”,所以我们常用指针来操作动态内存。指针远比数组灵活,但也更危险。

 

7.3.2 内容复制与比较

 

不能对数组名进行直接复制与比较

若想把数组a的内容复制给数组b,不能用语句 b = a ,否则将产生编译错误。应该用标准库函数strcpy进行复制。同理,比较b和a的内容是否相同,不能用if(b==a) 来判断,应该用标准库函数strcmp进行比较。

 

7.3.3 计算内存容量

用运算符sizeof可以计算出数组的容量(字节数) C++/C语言没有办法知道指针所指的内存容量,除非在申请内存时记住它。

指针p指向a,但是sizeof(p)的值却是4。这是因为sizeof(p)得到的是一个指针变量的字节数,相当于sizeof(char*),而不是p所指的内存容量

char a[] = "hello world";

char *p = a;

cout&lt;&lt; sizeof(a) &lt;&lt; endl; // 12字节

cout&lt;&lt; sizeof(p) &lt;&lt; endl; // 4字节

注意当数组作为函数的参数进行传递时,该数组自动退化为同类型的指针。示例中,不论数组a的容量是多少,sizeof(a)始终等于sizeof(char *)。

void Func(char a[100])

{

cout&lt;&lt; sizeof(a) &lt;&lt; endl; // 4字节而不是100字节

}

 

7.4指针参数是如何传递内存的?

如果函数的参数是一个指针,不要指望用该指针去申请动态内存

每执行一次GetMemory就会泄露一块内存,因为没有用free释放内存。

如果非得要用指针参数去申请内存,那么应该改用“指向指针的指针”

void GetMemory2(char **p, int num)

{

*p = (char *)malloc(sizeof(char) * num);

}

void Test2(void)

{

char *str = NULL;

GetMemory2(&str, 100); // 注意参数是 &str,而不是str

strcpy(str, "hello");

cout&lt;&lt; str &lt;&lt; endl;

free(str);

}

用函数返回值来传递动态内存这种方法虽然好用,但是常常有人把return语句用错了。这里强调不要用return语句返回指向“栈内存”的指针,因为该内存在函数结束时自动消亡

7.5 free和delete把指针怎么啦?

它们只是把指针所指的内存给释放掉,但并没有把指针本身干掉。

7.6 动态内存会被自动释放吗?

函数体内的局部变量在函数结束时自动消亡。很多人误以为示例7-6是正确的。理由是p是局部的指针变量,它消亡的时候会让它所指的动态内存一起完蛋。这是错觉!

void Func(void)

{

char *p = (char *) malloc(100); // 动态内存会自动释放吗?

}

(1)指针消亡了,并不表示它所指的内存会被自动释放。

(2)内存被释放了,并不表示指针会消亡或者成了NULL指针

 

这表明释放内存并不是一件可以草率对待的事。也许有人不服气,一定要找出可以草率行事的理由:

如果程序终止了运行,一切指针都会消亡,动态内存会被操作系统回收。既然如此,在程序临终前,就可以不必释放内存、不必将指针设置为NULL了。终于可以偷懒而不会发生错误了吧?想得美。

7.7 杜绝“野指针”

“野指针”不是NULL指针,是指向“垃圾”内存的指针。人们一般不会错用NULL指针,因为用if语句很容易判断。但是“野指针”是很危险的,if语句对它不起作用。

“野指针”的成因主要有两种:

(1)指针变量没有被初始化。任何指针变量刚被创建时不会自动成为NULL指针,它的缺省值是随机的,它会乱指一气。所以,指针变量在创建的同时应当被初始化,要么将指针设置为NULL,要么让它指向合法的内存。例如

char *p = NULL;

char *str = (char *) malloc(100);

2)指针p被free或者delete之后,没有置为NULL,让人误以为p是个合法的指针。参见7.5节。

(3)指针操作超越了变量的作用范围。这种情况让人防不胜防,示例程序如下:

class A

{

public:

void Func(void){ cout &lt;&lt; “Func of class A” &lt;&lt; endl; }

};

void Test(void)

{

A *p;

{

A a;

p = &a; // 注意 a 的生命期

}

p->Func(); // p是“野指针”

}

函数Test在执行语句p-&gt;Func()时,对象a已经消失,而p是指向a的,所以p就成了“野指针”。但奇怪的是我运行这个程序时居然没有出错,这可能与编译器有关。

 

7.8 有了malloc/free为什么还要new/delete ?

malloc与free是C++/C语言的标准库函数,new/delete是C++的运算符。它们都可用于申请动态内存和释放内存。

对于非内部数据类型的对象而言,光用maloc/free无法满足动态对象的要求。对象在创建的同时要自动执行构造函数,对象在消亡之前要自动执行析构函数。由于malloc/free是库函数而不是运算符,不在编译器控制权限之内,不能够把执行构造函数和析构函数的任务强加于malloc/free。

因此C++语言需要一个能完成动态内存分配和初始化工作的运算符new,以及一个能完成清理与释放内存工作的运算符delete。注意new/delete不是库函数。

 

class Obj

{

public :

Obj(void){ cout <&lt; “Initialization” &lt;&lt; endl; }

~Obj(void){ cout &lt;&lt; “Destroy” &lt;&lt; endl; }

void Initialize(void){ cout &lt;&lt; “Initialization” &lt;&lt; endl; }

void Destroy(void){ cout &lt;&lt; “Destroy” &lt;&lt; endl; }

};

void UseMallocFree(void)

{

Obj *a = (obj *)malloc(sizeof(obj)); // 申请动态内存

a->Initialize(); // 初始化

//…

a-&gt;Destroy(); // 清除工作

free(a); // 释放内存

}

void UseNewDelete(void)

{

Obj *a = new Obj; // 申请动态内存并且初始化

//…

delete a; // 清除并且释放内存

}

 

类Obj的函数Initialize模拟了构造函数的功能,函数Destroy模拟了析构函数的功能。函数UseMallocFree中,由于malloc/free不能执行构造函数与析构函数,必须调用成员函数Initialize和Destroy来完成初始化与清除工作。函数UseNewDelete则简单得多。

所以我们不要企图用malloc/free来完成动态对象的内存管理,应该用new/delete。由于内部数据类型的“对象”没有构造与析构的过程,对它们而言malloc/free和new/delete是等价的。

既然new/delete的功能完全覆盖了malloc/free,为什么C++不把malloc/free淘汰出局呢?这是因为C++程序经常要调用C函数,而C程序只能用malloc/free管理动态内存。

如果用free释放“new创建的动态对象”,那么该对象因无法执行析构函数而可能导致程序出错。如果用delete释放“malloc申请的动态内存”,理论上讲程序不会出错,但是该程序的可读性很差。所以new/delete必须配对使用,malloc/free也一样。

既然new/delete的功能完全覆盖了malloc/free,为什么C++不把malloc/free淘汰出局呢?这是因为C++程序经常要调用C函数,而C程序只能用malloc/free管理动态内存

如果用free释放“new创建的动态对象”,那么该对象因无法执行析构函数而可能导致程序出错。如果用delete释放“malloc申请的动态内存”,理论上讲程序不会出错,但是该程序的可读性很差。所以new/delete必须配对使用,malloc/free也一样。

 

7.9 内存耗尽怎么办?

如果在申请动态内存时找不到足够大的内存块,malloc和new将返回NULL指针,宣告内存申请失败。通常有三种方式处理“内存耗尽”问题。

(1)判断指针是否为NULL,如果是则马上用return语句终止本函数。例如:

void Func(void)

{

A *a = new A;

if(a == NULL)

{

return;

}

}

(2)判断指针是否为NULL,如果是则马上用exit(1)终止整个程序的运行。例如:

void Func(void)

{

A *a = new A;

if(a == NULL)

{

cout <&lt; “Memory Exhausted” &lt;&lt; endl;

exit(1);

}

}

(3)为new和malloc设置异常处理函数。例如Visual C++可以用_set_new_hander函数为new设置用户自己定义的异常处理函数,也可以让malloc享用与new相同的异常处理函数。详细内容请参考C++使用手册。

上述(1)(2)方式使用最普遍。如果一个函数内有多处需要申请动态内存,那么方式(1)就显得力不从心(释放内存很麻烦),应该用方式(2)来处理。

很多人不忍心用exit(1),问:“不编写出错处理程序,让操作系统自己解决行不行?”

不行。如果发生“内存耗尽”这样的事情,一般说来应用程序已经无药可救。如果不用exit(1) 把坏程序杀死,它可能会害死操作系统。道理如同:如果不把歹徒击毙,歹徒在老死之前会犯下更多的罪。

 

有一个很重要的现象要告诉大家。对于32位以上的应用程序而言,无论怎样使用malloc与new,几乎不可能导致“内存耗尽”。我在Windows 98下用Visual C++编写了测试程序,见示例7-9。这个程序会无休止地运行下去,根本不会终止。因为32位操作系统支持“虚存”,内存用完了,自动用硬盘空间顶替。我只听到硬盘嘎吱嘎吱地响,Window 98已经累得对键盘、鼠标毫无反应。

我可以得出这么一个结论:对于32位以上的应用程序,“内存耗尽”错误处理程序毫无用处。这下可把Unix和Windows程序员们乐坏了:反正错误处理程序不起作用,我就不写了,省了很多麻烦。

 

7.10 malloc/free 的使用要点

函数malloc的原型如下:

void * malloc(size_t size);

用malloc申请一块长度为length的整数类型的内存,程序如下:

int *p = (int *) malloc(sizeof(int) * length);

我们应当把注意力集中在两个要素上:“类型转换”和“sizeof”。

 

malloc返回值的类型是void *,所以在调用malloc时要显式地进行类型转换,将void * 转换成所需要的指针类型。

malloc函数本身并不识别要申请的内存是什么类型,它只关心内存的总字节数。我们通常记不住int, float等数据类型的变量的确切字节数。例如int变量在16位系统下是2个字节,在32位下是4个字节;而float变量在16位系统下是4个字节,在32位下也是4个字节。

在malloc的“()”中使用sizeof运算符是良好的风格,但要当心有时我们会昏了头,写出 p = malloc(sizeof(p))这样的程序来。

函数free的原型如下:

void free( void * memblock );

为什么free函数不象malloc函数那样复杂呢?这是因为指针p的类型以及它所指的内存的容量事先都是知道的,语句free(p)能正确地释放内存。如果p是NULL指针,那么free对p无论操作多少次都不会出问题。如果p不是NULL指针,那么free对p连续操作两次就会导致程序运行错误。

 

7.11 new/delete 的使用要点

运算符new使用起来要比函数malloc简单得多,例如:

int *p1 = (int *)malloc(sizeof(int) * length);

int *p2 = new int[length];

这是因为new内置了sizeof、类型转换和类型安全检查功能。对于非内部数据类型的对象而言,new在创建动态对象的同时完成了初始化工作。如果对象有多个构造函数,那么new的语句也可以有多种形式。例如

class Obj

{

public :

Obj(void); // 无参数的构造函数

Obj(int x); // 带一个参数的构造函数

}

void Test(void)

{

Obj *a = new Obj;

Obj *b = new Obj(1); // 初值为1

delete a;

delete b;

}

如果用new创建对象数组,那么只能使用对象的无参数构造函数。例如

Obj *objects = new Obj[100]; // 创建100个动态对象

不能写成

Obj *objects = new Obj[100](1);// 创建100个动态对象的同时赋初值1

在用delete释放对象数组时,留意不要丢了符号‘[]’。例如

delete []objects; // 正确的用法

delete objects; // 错误的用法  相当于delete objects[0],漏掉了另外99个对象。

 

7.12 一些心得体会

我最初学习C语言时特别怕指针,导致我开发第一个应用软件(约1万行C代码)时没有使用一个指针,全用数组来顶替指针,实在蠢笨得过分。躲避指针不是办法,后来我改写了这个软件,代码量缩小到原先的一半。

我的经验教训是:

(1)越是怕指针,就越要使用指针。不会正确使用指针,肯定算不上是合格的程序员。

(2)必须养成“使用调试器逐步跟踪程序”的习惯,只有这样才能发现问题的本质。

 

第8章 C++函数的高级特性

 

对比于C语言的函数,C++增加了重载(overloaded)、内联(inline)、const和virtual四种新机制。其中重载和内联机制既可用于全局函数也可用于类的成员函数,const与virtual机制仅用于类的成员函数

重载和内联肯定有其好处才会被C++语言采纳,但是不可以当成免费的午餐而滥用。本章将探究重载和内联的优点与局限性,说明什么情况下应该采用、不该采用以及要警惕错用。

8.1 函数重载的概念

C++语言采用重载机制的另一个理由是:类的构造函数需要重载机制。因为C++规定构造函数与类同名(请参见第9章),构造函数只能有一个名字。如果想用几种不同的方法创建对象该怎么办?别无选择,只能用重载机制来实现。所以类可以有多个同名的构造函数。

 

如果C++程序要调用已经被编译后的C函数,该怎么办?

假设某个C函数的声明如下:

void foo(int x, int y);

该函数被C编译器编译后在库中的名字为_foo,而C++编译器则会产生像_foo_int_int之类的名字用来支持函数重载和类型安全连接。由于编译后的名字不同,C++程序不能直接调用C函数。C++提供了一个C连接交换指定符号extern“C”来解决这个问题。例如:

extern “C”

{

void foo(int x, int y);

… // 其它函数

}

或者写成

extern “C”

{

#include “myheader.h”

… // 其它C头文件

}

这就告诉C++编译译器,函数foo是个C连接,应该到库中找名字_foo而不是找_foo_int_int。C++编译器开发商已经对C标准库的头文件作了extern“C”处理,所以我们可以用#include 直接引用这些头文件

注意并不是两个函数的名字相同就能构成重载。全局函数和类的成员函数同名不算重载,因为函数的作用域不同。例如:

void Print(…); // 全局函数

class A

{…

void Print(…); // 成员函数

}

不论两个Print函数的参数是否不同,如果类的某个成员函数要调用全局函数Print,为了与成员函数Print区别,全局函数被调用时应加‘::’标志。如

::Print(…); // 表示Print是全局函数而非成员函数

 

# include &lt;iostream.h>

void output( int x); // 函数声明

void output( float x); // 函数声明

void output( int x)

{

cout <&lt; " output int " &lt;&lt; x &lt;&lt; endl ;

}

void output( float x)

{

cout &lt;&lt; " output float " &lt;&lt; x &lt;&lt; endl ;

}

void main(void)

{

int x = 1;

float y = 1.0;

output(x); // output int 1

output(y); // output float 1

output(1); // output int 1

// output(0.5); // error! ambiguous call, 因为自动类型转换

output(int(0.5)); // output int 0

output(float(0.5)); // output float 0.5

}

8.1.3 当心隐式类型转换导致重载函数产生二义性

示例,第一个output函数的参数是int类型,第二个output函数的参数是float类型。由于数字本身没有类型,将数字当作参数时将自动进行类型转换(称为隐式类型转换)。语句output(0.5)将产生编译错误,因为编译器不知道该将0.5转换成int还是float类型的参数。隐式类型转换在很多地方可以简化程序的书写,但是也可能留下隐患。

 

8.2 成员函数的重载、覆盖与隐藏

成员函数的重载、覆盖(override)与隐藏很容易混淆,C++程序员必须要搞清楚概念,否则错误将防不胜防。

8.2.1 重载与覆盖

成员函数被重载的特征:

(1)相同的范围(在同一个类中);

(2)函数名字相同;

(3)参数不同;

(4)virtual关键字可有可无。

覆盖是指派生类函数覆盖基类函数,特征是:

(1)不同的范围(分别位于派生类与基类);

(2)函数名字相同;

(3)参数相同;

(4)基类函数必须有virtual关键字

示例8-2-1中,函数Base::f(int)与Base::f(float)相互重载,而Base::g(void)被Derived::g(void)覆盖。

 

#include &lt;iostream.h>

class Base

{

public:

void f(int x){ cout <&lt; "Base::f(int) " &lt;&lt; x &lt;&lt; endl; }

void f(float x){ cout &lt;&lt; "Base::f(float) " &lt;&lt; x &lt;&lt; endl; }

virtual void g(void){ cout &lt;&lt; "Base::g(void)" &lt;&lt; endl;}

};

class Derived : public Base

{

public:

virtual void g(void){ cout &lt;&lt; "Derived::g(void)" &lt;&lt; endl;}

};

void main(void)

{

Derived d;

Base *pb = &d;

pb->f(42); // Base::f(int) 42

pb-&gt;f(3.14f); // Base::f(float) 3.14

pb-&gt;g(); // Derived::g(void)

}

 

8.2.2 令人迷惑的隐藏规则

本来仅仅区别重载与覆盖并不算困难,但是C++的隐藏规则使问题复杂性陡然增加。这里“隐藏”是指派生类的函数屏蔽了与其同名的基类函数,规则如下:

(1)如果派生类的函数与基类的函数同名,但是参数不同。此时,不论有无virtual关键字,基类的函数将被隐藏(注意别与重载混淆)。

(2)如果派生类的函数与基类的函数同名,并且参数也相同,但是基类函数没有virtual关键字。此时,基类的函数被隐藏(注意别与覆盖混淆)。

示例程序8-2-2(a)中:

(1)函数Derived::f(float)覆盖了Base::f(float)。

(2)函数Derived::g(int)隐藏了Base::g(float),而不是重载。

(3)函数Derived::h(float)隐藏了Base::h(float),而不是覆盖。

 

#include <iostream.h>

class Base

{

public:

virtual void f(float x){ cout <&lt; "Base::f(float) " &lt;&lt; x &lt;&lt; endl; }

void g(float x){ cout &lt;&lt; "Base::g(float) " &lt;&lt; x &lt;&lt; endl; }

void h(float x){ cout &lt;&lt; "Base::h(float) " &lt;&lt; x &lt;&lt; endl; }

};

class Derived : public Base

{

public:

virtual void f(float x){ cout &lt;&lt; "Derived::f(float) " &lt;&lt; x &lt;&lt; endl; }

void g(int x){ cout &lt;&lt; "Derived::g(int) " &lt;&lt; x &lt;&lt; endl; }

void h(float x){ cout &lt;&lt; "Derived::h(float) " &lt;&lt; x &lt;&lt; endl; }

};

示例8-2-2(a)成员函数的重载、覆盖和隐藏

 

示例8-2-2(b)中,pb和pd指向同一地址,按理说运行结果应该是相同的,可事实并非这样。

void main(void)

{

Derived d;

Base *pb = &d;

Derived *pd = &d;

// Good : behavior depends solely on type of the object

pb->f(3.14f); // Derived::f(float) 3.14

pd-&gt;f(3.14f); // Derived::f(float) 3.14

// Bad : behavior depends on type of the pointer

pb-&gt;g(3.14f); // Base::g(float) 3.14

pd-&gt;g(3.14f); // Derived::g(int) 3 (surprise!)

// Bad : behavior depends on type of the pointer

pb-&gt;h(3.14f); // Base::h(float) 3.14 (surprise!)

pd-&gt;h(3.14f); // Derived::h(float) 3.14

}

示例8-2-2(b) 重载、覆盖和隐藏的比较

8.2.3 摆脱隐藏

隐藏规则引起了不少麻烦。示例8-2-3程序中,语句pd-&gt;f(10)的本意是想调用函数Base::f(int),但是Base::f(int)不幸被Derived::f(char *)隐藏了。由于数字10不能被隐式地转化为字符串,所以在编译时出错。

class Base

{

public:

void f(int x);

};

class Derived : public Base

{

public:

void f(char *str);

};

void Test(void)

{

Derived *pd = new Derived;

pd-&gt;f(10); // error

}

示例8-2-3 由于隐藏而导致错误

从示例8-2-3看来,隐藏规则似乎很愚蠢。但是隐藏规则至少有两个存在的理由:

写语句pd-&gt;f(10)的人可能真的想调用Derived::f(char *)函数,只是他误将参数写错了。有了隐藏规则,编译器就可以明确指出错误,这未必不是好事。否则,编译器会静悄悄地将错就错,程序员将很难发现这个错误,留下祸根。

假如类Derived有多个基类(多重继承),有时搞不清楚哪些基类定义了函数f。如果没有隐藏规则,那么pd-&gt;f(10)可能会调用一个出乎意料的基类函数f。尽管隐藏规则看起来不怎么有道理,但它的确能消灭这些意外。

示例8-2-3中,如果语句pd-&gt;f(10)一定要调用函数Base::f(int),那么将类Derived修改为如下即可。

class Derived : public Base

{

public:

void f(char *str);

void f(int x) { Base::f(x); }

};

 

8.3 参数的缺省值 【规则8-3-1】参数缺省值只能出现在 函数的声明中,而不能出现在定义体中。

例如:

void Foo(int x=0, int y=0); // 正确,缺省值出现在函数的声明中

void Foo(int x=0, int y=0) // 错误,缺省值出现在函数的定义体中

{

}

为什么会这样?我想是有两个原因:一是函数的实现(定义)本来就与参数是否有缺省值无关,所以没有必要让缺省值出现在函数的定义体中。二是参数的缺省值可能会改动,显然修改函数的声明比修改函数的定义要方便。

【规则8-3-2】如果函数有多个参数,参数只能从后向前挨个儿缺省,否则将导致函数调用语句怪模怪样。

正确的示例如下:

void Foo(int x, int y=0, int z=0);

错误的示例如下:

void Foo(int x=0, int y, int z=0);

要注意,使用参数的缺省值并没有赋予函数新的功能,仅仅是使书写变得简洁一些。它可能会提高函数的易用性,但是也可能会降低函数的可理解性。所以我们只能适当地使用参数的缺省值,要防止使用不当产生负面效果。示例8-3-2中,不合理地使用参数的缺省值将导致重载函数output产生二义性。

 

8.4 运算符重载 8.5 函数内联

8.5.1 用内联取代宏代码

C++ 语言支持函数内联,其目的是为了提高函数的执行效率(速度)。

在C程序中,可以用宏代码提高执行效率。宏代码本身不是函数,但使用起来象函数。预处理器用复制宏代码的方式代替函数调用,省去了参数压栈、生成汇编语言的CALL调用、返回参数、执行return等过程,从而提高了速度。使用宏代码最大的缺点是容易出错,预处理器在复制宏代码时常常产生意想不到的边际效应。例如

#define MAX(a, b) (a) &gt; (b) ? (a) : (b)

语句

result = MAX(i, j) + 2 ;

将被预处理器解释为

result = (i) &gt; (j) ? (i) : (j) + 2 ;

由于运算符‘+’比运算符‘:’的优先级高,所以上述语句并不等价于期望的

result = ( (i) &gt; (j) ? (i) : (j) ) + 2 ;

如果把宏代码改写为

#define MAX(a, b) ( (a) &gt; (b) ? (a) : (b) )

则可以解决由优先级引起的错误。但是即使使用修改后的宏代码也不是万无一失的,例如语句

result = MAX(i++, j);

将被预处理器解释为

result = (i++) &gt; (j) ? (i++) : (j);

对于C++ 而言,使用宏代码还有另一种缺点:无法操作类的私有数据成员

 

让我们看看C++ 的“函数内联”是如何工作的。对于任何内联函数,编译器在符号表里放入函数的声明(包括名字、参数类型、返回值类型)。如果编译器没有发现内联函数存在错误,那么该函数的代码也被放入符号表里。在调用一个内联函数时,编译器首先检查调用是否正确(进行类型安全检查,或者进行自动类型转换,当然对所有的函数都一样)。如果正确,内联函数的代码就会直接替换函数调用,于是省去了函数调用的开销。这个过程与预处理有显著的不同,因为预处理器不能进行类型安全检查,或者进行自动类型转换。假如内联函数是成员函数,对象的地址(this)会被放在合适的地方,这也是预处理器办不到的。

C++ 语言的函数内联机制既具备宏代码的效率,又增加了安全性而且可以自由操作类的数据成员。所以在C++ 程序中,应该用内联函数取代所有宏代码,“断言assert”恐怕是唯一的例外。assert是仅在Debug版本起作用的宏,它用于检查“不应该”发生的情况。为了不在程序的Debug版本和Release版本引起差别,assert不应该产生任何副作用。如果assert是函数,由于函数调用会引起内存、代码的变动,那么将导致Debug版本与Release版本存在差异。所以assert不是函数,而是宏。(参见6.5节“使用断言”)

 

8.5.2 内联函数的编程风格

关键字inline必须与函数定义体放在一起才能使函数成为内联,仅将inline放在函数声明前面不起任何作用。如下风格的函数Foo不能成为内联函数:

inline void Foo(int x, int y); // inline仅与函数声明放在一起

void Foo(int x, int y)

{

}

而如下风格的函数Foo则成为内联函数:

void Foo(int x, int y);

inline void Foo(int x, int y) // inline与函数定义体放在一起

{

}

 

8.5.3 慎用内联

内联能提高函数的执行效率,为什么不把所有的函数都定义成内联函数?

如果所有的函数都是内联函数,还用得着“内联”这个关键字吗?

内联是以代码膨胀(复制)为代价,仅仅省去了函数调用的开销,从而提高函数的执行效率。如果执行函数体内代码的时间,相比于函数调用的开销较大,那么效率的收获会很少。另一方面,每一处内联函数的调用都要复制代码,将使程序的总代码量增大,消耗更多的内存空间。以下情况不宜使用内联:

(1)如果函数体内的代码比较长,使用内联将导致内存消耗代价较高

(2)如果函数体内出现循环,那么执行函数体内代码的时间要比函数调用的开销大。

类的构造函数和析构函数容易让人误解成使用内联更有效。要当心构造函数和析构函数可能会隐藏一些行为,如“偷偷地”执行了基类或成员对象的构造函数和析构函数。所以不要随便地将构造函数和析构函数的定义体放在类声明中。

一个好的编译器将会根据函数的定义体,自动地取消不值得的内联(这进一步说明了inline不应该出现在函数的声明中)。

8.6 一些心得体会

C++ 语言中的重载、内联、缺省参数、隐式转换等机制展现了很多优点,但是这些优点的背后都隐藏着一些隐患。正如人们的饮食,少食和暴食都不可取,应当恰到好处。我们要辨证地看待C++的新机制,应该恰如其分地使用它们。虽然这会使我们编程时多费一些心思,少了一些痛快,但这才是编程的艺术。

 

第9章 类的构造函数、析构函数与赋值函数

构造函数、析构函数与赋值函数是每个类最基本的函数。它们太普通以致让人容易麻痹大意,其实这些貌似简单的函数就象没有顶盖的下水道那样危险。

每个类只有一个析构函数和一个赋值函数,但可以有多个构造函数(包含一个拷贝构造函数,其它的称为普通构造函数)。对于任意一个类A,如果不想编写上述函数,C++编译器将自动为A产生四个缺省的函数,如

A(void); // 缺省的无参数构造函数

A(const A &a); // 缺省的拷贝构造函数

~A(void); // 缺省的析构函数

A & operate =(const A &a); // 缺省的赋值函数

这不禁让人疑惑,既然能自动生成函数,为什么还要程序员编写?

原因如下:

(1)如果使用“缺省的无参数构造函数”和“缺省的析构函数”,等于放弃了自主“初始化”和“清除”的机会,C++发明人Stroustrup的好心好意白费了。

(2)“缺省的拷贝构造函数”和“缺省的赋值函数”均采用“位拷贝”而非“值拷贝”的方式来实现,倘若类中含有指针变量,这两个函数注定将出错。

 

对于那些没有吃够苦头的C++程序员,如果他说编写构造函数、析构函数与赋值函数很容易,可以不用动脑筋,表明他的认识还比较肤浅,水平有待于提高。

本章以类String的设计与实现为例,深入阐述被很多教科书忽视了的道理。String的结构如下:

class String

{

public:

String(const char *str = NULL); // 普通构造函数

String(const String &other); // 拷贝构造函数

~ String(void); // 析构函数

String & operate =(const String &other); // 赋值函数

private:

char *m_data; // 用于保存字符串

};

9.1 构造函数与析构函数的起源

作为比C更先进的语言,C++提供了更好的机制来增强程序的安全性。C++编译器具有严格的类型安全检查功能,它几乎能找出程序中所有的语法问题

根据经验,不少难以察觉的程序错误是由于变量没有被正确初始化或清除造成的,而初始化和清除工作很容易被人遗忘。

把对象的初始化工作放在构造函数中,把清除工作放在析构函数中。当对象被创建时,构造函数被自动执行。当对象消亡时,析构函数被自动执行。这下就不用担心忘了对象的初始化和清除工作。

构造函数与析构函数的名字不能随便起,必须让编译器认得出才可以被自动执行。Stroustrup的命名方法既简单又合理:让构造函数、析构函数与类同名,由于析构函数的目的与构造函数的相反,就加前缀‘~’以示区别。

除了名字外,构造函数与析构函数的另一个特别之处是没有返回值类型,这与返回值类型为void的函数不同。构造函数与析构函数的使命非常明确,就象出生与死亡,光溜溜地来光溜溜地去。

 

9.2 构造函数的初始化表

构造函数有个特殊的初始化方式叫“初始化表达式表”(简称初始化表)。初始化表位于函数参数表之后,却在函数体 {} 之前。这说明该表里的初始化工作发生在函数体内的任何代码被执行之前

构造函数初始化表的使用规则:

如果类存在继承关系,派生类必须在其初始化表里调用基类的构造函数

例如

class A

{…

A(int x); // A的构造函数

};

class B : public A

{…

B(int x, int y);// B的构造函数

};

B::B(int x, int y)

: A(x) // 在初始化表里调用A的构造函数

{

}

9.3 构造和析构的次序

构造从类层次的最根处开始,在每一层中,首先调用基类的构造函数,然后调用成员对象的构造函数。析构则严格按照与构造相反的次序执行,该次序是唯一的,否则编译器将无法自动执行析构过程。

一个有趣的现象是,成员对象初始化的次序完全不受它们在初始化表中次序的影响,只由成员对象在类中声明的次序决定。这是因为类的声明是唯一的,而类的构造函数可以有多个,因此会有多个不同次序的初始化表。如果成员对象按照初始化表的次序进行构造,这将导致析构函数无法得到唯一的逆序。[Eckel, p260-261]

9.4 示例:类String的构造函数与析构函数

// String的普通构造函数

String::String(const char *str)

{

if(str==NULL)

{

m_data = new char[1];

*m_data = ‘\0’;

}

else

{

int length = strlen(str);

m_data = new char[length+1];

strcpy(m_data, str);

}

}

// String的析构函数

String::~String(void)

{

delete [] m_data;

// 由于m_data是内部数据类型,也可以写成 delete m_data;

}

9.6 示例:类String的拷贝构造函数与赋值函数

// 拷贝构造函数

String::String(const String &other)

{

// 允许操作other的私有成员m_data

int length = strlen(other.m_data);

m_data = new char[length+1];

strcpy(m_data, other.m_data);

}

// 赋值函数

String & String::operate =(const String &other)

{

// (1) 检查自赋值

if(this == &other)

return *this;

// (2) 释放原有的内存资源

delete [] m_data;

// (3)分配新的内存资源,并复制内容

int length = strlen(other.m_data);

m_data = new char[length+1];

strcpy(m_data, other.m_data);

// (4)返回本对象的引用

return *this;

}

类String拷贝构造函数与普通构造函数(参见9.4节)的区别是:在函数入口处无需与NULL进行比较,这是因为“引用”不可能是NULL,而“指针”可以为NULL。

类String的赋值函数比构造函数复杂得多,分四步实现:

(1)第一步,检查自赋值。你可能会认为多此一举,难道有人会愚蠢到写出 a = a 这样的自赋值语句!的确不会。但是间接的自赋值仍有可能出现,例如

// 内容自赋值

b = a;

c = b;

a = c;

// 地址自赋值

b = &a;

a = *b;

也许有人会说:“即使出现自赋值,我也可以不理睬,大不了化点时间让对象复制自己而已,反正不会出错!”

他真的说错了。看看第二步的delete,自杀后还能复制自己吗?所以,如果发现自赋值,应该马上终止函数。注意不要将检查自赋值的if语句

if(this == &other)

错写成为

if( *this == other)

(2)第二步,用delete释放原有的内存资源。如果现在不释放,以后就没机会了,将造成内存泄露。

(3)第三步,分配新的内存资源,并复制字符串。注意函数strlen返回的是有效字符串长度,不包含结束符‘\0’。函数strcpy则连‘\0’一起复制。

(4)第四步,返回本对象的引用,目的是为了实现象 a = b = c 这样的链式表达。注意不要将 return *this 错写成 return this 。那么能否写成return other 呢?效果不是一样吗?

不可以!因为我们不知道参数other的生命期。有可能other是个临时对象,在赋值结束后它马上消失,那么return other返回的将是垃圾。

9.7 偷懒的办法处理拷贝构造函数与赋值函数

如果我们实在不想编写拷贝构造函数和赋值函数,又不允许别人使用编译器生成的缺省函数,怎么办?

偷懒的办法是:只需将拷贝构造函数和赋值函数声明为私有函数,不用编写代码。

例如:

class A

{ …

private:

A(const A &a); // 私有的拷贝构造函数

A & operate =(const A &a); // 私有的赋值函数

};

如果有人试图编写如下程序:

A b(a); // 调用了私有的拷贝构造函数

b = a; // 调用了私有的赋值函数

编译器将指出错误,因为外界不可以操作A的私有函数。

 

9.8 如何在派生类中实现类的基本函数

基类的构造函数、析构函数、赋值函数都不能被派生类继承。如果类之间存在继承关系,在编写上述基本函数时应注意以下事项:

派生类的构造函数应在其初始化表里调用基类的构造函数。

基类与派生类的析构函数应该为虚函数(即加virtual关键字)。例如

虚函数是不会调用基类的虚函数,只调用一个,除非你在派生类中显式调用基类的虚函数,但是析够函数是会调用基类的析够函数(不是虚函数的原因),这是析够函数特有的。

 

#include <iostream.h>

class Base

{

public:

virtual ~Base() { cout<&lt; "~Base" &lt;&lt; endl ; }

};

class Derived : public Base

{

public:

virtual ~Derived() { cout&lt;&lt; "~Derived" &lt;&lt; endl ; }

};

void main(void)

{

Base * pB = new Derived; // upcast

delete pB;

}

输出结果为:

~Derived

~Base

如果析构函数不为虚,那么输出结果为

~Base

第10章 类的继承与组合

对象(Object)是类(Class)的一个实例(Instance)。如果将对象比作房子,那么类就是房子的设计图纸。所以面向对象设计的重点是类的设计,而不是对象的设计。

对于C++程序而言,设计孤立的类是比较容易的,难的是正确设计基类及其派生类。

注意,当前面向对象技术的应用热点是COM和CORBA

C++的“继承”特性可以提高程序的可复用性。正因为“继承”太有用、太容易用,才要防止乱用“继承”。我们应当给“继承”立一些使用规则。

【规则10-1-1如果类A和类B毫不相关,不可以为了使B的功能更多些而让B继承A的功能和属性。不要觉得“白吃白不吃”,让一个好端端的健壮青年无缘无故地吃人参补身体。

【规则10-1-2若在逻辑上B是A的“一种”(a kind of ),则允许B继承A的功能和属性。例如男人(Man)是人(Human)的一种,男孩(Boy)是男人的一种。那么类Man可以从类Human派生,类Boy可以从类Man派生。

注意事项

【规则10-1-2看起来很简单,但是实际应用时可能会有意外,继承的概念在程序世界与现实世界并不完全相同。

例如从生物学角度讲,鸵鸟(Ostrich)是鸟(Bird)的一种,按理说类Ostrich应该可以从类Bird派生。但是鸵鸟不能飞,那么Ostrich::Fly是什么东西?

例如从数学角度讲,圆(Circle)是一种特殊的椭圆(Ellipse),按理说类Circle应该可以从类Ellipse派生。但是椭圆有长轴和短轴,如果圆继承了椭圆的长轴和短轴,岂非画蛇添足?

所以更加严格的继承规则应当是:若在逻辑上B是A的“一种”,并且A的所有功能和属性对B而言都有意义,则允许B继承A的功能和属性。

10.2 组合

【规则10-2-1】若在逻辑上A是B的“一部分”(a part of),则不允许B从A派生,而是要用A和其它东西组合出B

例如眼(Eye)、鼻(Nose)、口(Mouth)、耳(Ear)是头(Head)的一部分,所以类Head应该由类Eye、Nose、Mouth、Ear组合而成,不是派生而成。如示例10-2-1所示。

 

// 正确的设计,虽然代码冗长。

class Head

{

public:

void Look(void) { m_eye.Look(); }

void Smell(void) { m_nose.Smell(); }

void Eat(void) { m_mouth.Eat(); }

void Listen(void) { m_ear.Listen(); }

private:

Eye m_eye;

Nose m_nose;

Mouth m_mouth;

Ear m_ear;

};

如果允许Head从Eye、Nose、Mouth、Ear派生而成,那么Head将自动具有Look、 Smell、Eat、Listen这些功能。示例10-2-2十分简短并且运行正确,但是这种设计方法却是不对的。

// 功能正确并且代码简洁,但是设计方法不对。

class Head : public Eye, public Nose, public Mouth, public Ear

{

};

一只公鸡使劲地追打一只刚下了蛋的母鸡,你知道为什么吗?

因为母鸡下了鸭蛋。

很多程序员经不起“继承”的诱惑而犯下设计错误。“运行正确”的程序不见得是高质量的程序,此处就是一个例证。

第11章 其它编程经验 11.1 使用const提高函数的健壮性

看到const关键字,C++程序员首先想到的可能是const常量。这可不是良好的条件反射。如果只知道用const定义常量,那么相当于把火药仅用于制作×××。const更大的魅力是它可以修饰函数的参数、返回值,甚至函数的定义体。

const是constant的缩写,“恒定不变”的意思。被const修饰的东西都受到强制保护,可以预防意外的变动,能提高程序的健壮性。所以很多C++程序设计书籍建议:“Use const whenever you need”。

 

11.1.1 用const修饰函数的参数

如果参数作输出用,不论它是什么数据类型,也不论它采用“指针传递”还是“引用传递”,都不能加const修饰,否则该参数将失去输出功能。

const只能修饰输入参数

  • 如果输入参数采用“指针传递”,那么加const修饰可以防止意外地改动该指针起到保护作用

例如StringCopy函数:

void StringCopy(char *strDestination, const char *strSource);

其中strSource是输入参数,strDestination是输出参数。给strSource加上const修饰后,如果函数体内的语句试图改动strSource的内容,编译器将指出错误。

  • 如果输入参数采用“值传递”,由于函数将自动产生临时变量用于复制该参数,该输入参数本来就无需保护,所以不要加const修饰。

例如不要将函数void Func1(int x) 写成void Func1(const int x)。同理不要将函数void Func2(A a) 写成void Func2(const A a)。其中A为用户自定义的数据类型。

  • 对于非内部数据类型的参数而言,象void Func(A a) 这样声明的函数注定效率比较低。因为函数体内将产生A类型的临时对象用于复制参数a,而临时对象的构造、复制、析构过程都将消耗时间。

为了提高效率,可以将函数声明改为void Func(A &a),因为“引用传递”仅借用一下参数的别名而已,不需要产生临时对象。但是函数void Func(A &a) 存在一个缺点:“引用传递”有可能改变参数a,这是我们不期望的。解决这个问题很容易,加const修饰即可,因此函数最终成为void Func(const A &a)。

以此类推,是否应将void Func(int x) 改写为void Func(const int &x),以便提高效率?完全没有必要,因为内部数据类型的参数不存在构造、析构的过程,而复制也非常快“值传递”和“引用传递”的效率几乎相当

问题是如此的缠绵,我只好将“const &”修饰输入参数的用法总结一下:

  • 对于非内部数据类型的输入参数,应该将“值传递”的方式改为“const引用传递”,目的是提高效率。例如将void Func(A a) 改为void Func(const A &a)。
  • 对于内部数据类型的输入参数,不要将“值传递”的方式改为“const引用传递”。否则既达不到提高效率的目的,又降低了函数的可理解性。例如void Func(int x) 不应该改为void Func(const int &x)。

11.1.2 用const修饰函数的返回值

  • 如果给以“指针传递”方式的函数返回值加const修饰,那么函数返回值(即指针)的内容不能被修改,该返回值只能被赋给加const修饰的同类型指针。

例如函数

const char * GetString(void);

如下语句将出现编译错误:

char *str = GetString();

正确的用法是

const char *str = GetString();

  • 如果函数返回值采用“值传递方式”,由于函数会把返回值复制到外部临时的存储单元中,加const修饰没有任何价值。

例如不要把函数int GetInt(void) 写成const int GetInt(void)。

同理不要把函数A GetA(void) 写成const A GetA(void),其中A为用户自定义的数据类型。

如果返回值不是内部数据类型,将函数A GetA(void) 改写为const A & GetA(void)的确能提高效率。但此时千万千万要小心,一定要搞清楚函数究竟是想返回一个对象的“拷贝”还是仅返回“别名”就可以了,否则程序会出错。见6.2节“返回值的规则”。

  • 函数返回值采用“引用传递”的场合并不多,这种方式一般只出现在类的赋值函数中,目的是为了实现链式表达。

11.1.3 const成员函数

任何不会修改数据成员的函数都应该声明为const类型。如果在编写const成员函数时,不慎修改了数据成员,或者调用了其它非const成员函数,编译器将指出错误,这无疑会提高程序的健壮性。

以下程序中,类stack的成员函数GetCount仅用于计数,从逻辑上讲GetCount应当为const函数。编译器将指出GetCount函数中的错误。

class Stack

{

public:

void Push(int elem);

int Pop(void);

int GetCount(void) const; // const成员函数

private:

int m_num;

int m_data[100];

};

int Stack::GetCount(void) const

{
++ m_num; // 编译错误,企图修改数据成员m_num

Pop(); // 编译错误,企图调用非const函数

return m_num;

}

const成员函数的声明看起来怪怪的:const关键字只能放在函数声明的尾部,大概是因为其它地方都已经被占用了。

11.2 提高程序的效率

程序的时间效率是指运行速度,空间效率是指程序占用内存或者外存的状况。

 

全局效率是指站在整个系统的角度上考虑的效率,局部效率是指站在模块或函数角度上考虑的效率。

【规则11-2-1不要一味地追求程序的效率,应当在满足正确性、可靠性、健壮性、可读性等质量因素的前提下,设法提高程序的效率。

【规则11-2-2以提高程序的全局效率为主,提高局部效率为辅。

【规则11-2-3在优化程序的效率时,应当先找出限制效率的“瓶颈”,不要在无关紧要之处优化。

【规则11-2-4】先优化数据结构和算法,再优化执行代码。

【规则11-2-5有时候时间效率和空间效率可能对立,此时应当分析那个更重要,作出适当的折衷。例如多花费一些内存来提高性能。

【规则11-2-6不要追求紧凑的代码,因为紧凑的代码并不能产生高效的机器码。

11.3 一些有益的建议

【建议11-3-1当心那些视觉上不易分辨的操作符发生书写错误。

我们经常会把“==”误写成“=”,象“||”、“&&”、“&lt;=”、“>=”这类符号也很容易发生“丢1”失误。然而编译器却不一定能自动指出这类错误。

【建议11-3-2变量(指针、数组)被创建之后应当及时把它们初始化,以防止把未被初始化的变量当成右值使用。

【建议11-3-3当心变量的初值、缺省值错误,或者精度不够。

【建议11-3-4当心数据类型转换发生错误。尽量使用显式的数据类型转换(让人们知道发生了什么事),避免让编译器轻悄悄地进行隐式的数据类型转换。

【建议11-3-5当心变量发生上溢或下溢,数组的下标越界。

【建议11-3-6当心忘记编写错误处理程序,当心错误处理程序本身有误。

【建议11-3-7当心文件I/O有错误。

【建议11-3-8避免编写技巧性很高代码。

【建议11-3-9不要设计面面俱到、非常灵活的数据结构。

【建议11-3-10如果原有的代码质量比较好,尽量复用它。但是不要修补很差劲的代码,应当重新编写。

 【建议11-3-11尽量使用标准库函数,不要“发明”已经存在的库函数。

【建议11-3-12尽量不要使用与具体硬件或软件环境关系密切的变量。

【建议11-3-13把编译器的选择项设置为最严格状态。

【建议11-3-14如果可能的话,使用PC-Lint、LogiScope等工具进行代码审查。