高质量C、C++编程指南 笔记

来源:互联网 发布:流体力学大牛 知乎 编辑:程序博客网 时间:2024/05/27 20:56
定义1:能长期稳定地编写出高质量程序的程序员称为编程老手。
定义2:能长期稳定地编写出高难度、高质量程序的程序员称为编程高手。
根据上述定义,马上得到第一推论:我既不是高手也算不上是老手。
 
做到:
(1)知错就改;
(2)经常温故而知新;
(3)坚持学习,天天向上。
 
一个程序可以从以下几个方面进行考查:
(1)编程风格;
(2)出错处理;
(3)算法复杂度分析(用于提高性能)。
 
 
正确性、健壮性、可靠性、效率、易用性、可读性(可理解性)、可扩展性、可复用性、兼容性、可移植性
 
 
每个C++/C 程序通常分为两个文件。一个文件用于保存程序的声明(declaration),称为头文件。另一个文件用于保存程序的实现(implementation),称为定义(definition)文件。
 
 
版权和版本的声明位于头文件和定义文件的开头(参见示例1-1),主要内容有:
(1)版权信息。
(2)文件名称,标识符,摘要。
(3)当前版本号,作者/修改者,完成日期。
(4)版本历史信息。
 
 
头文件由三部分内容组成:
(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 这类声明。
 
 
// 版权和版本声明
 
#ifndef GRAPHICS_H // 防止graphics.h 被重复引用
#define GRAPHICS_H
#include <math.h> // 引用标准库的头文件
 
#include“myheader.h” // 引用非标准库的头文件
 
void Function1(…); // 全局函数声明
 
class Box // 类结构声明
{
    …
};
 
#endif
 
对头文件的作用略作解释:
(1)通过头文件来调用库功能。在很多场合,源代码不便(或不准)向用户公布,只要向用户提供头文件和二进制的库即可。用户只需要按照头文件中的接口声明来调用库功能,而不必关心接口怎么实现的。编译器会从库中提取相应的代码。
(2)头文件能加强类型安全检查。如果某个接口被实现或被使用时,其方式与头文件中的声明不一致,编译器就会指出错误,这一简单的规则能大大减轻程序员调试、改错的负担。
 
 
定义文件有三部分内容:
(1) 定义文件开头处的版权和版本声明。
(2) 对一些头文件的引用。
(3) 程序的实现体(包括数据和代码)。
假设定义文件的名称为 graphics.cpp,则定义文件的结构如下:
// 版权和版本声明
 
#include “graphics.h” // 引用头文件
 
// 全局函数的实现体
void Function1(…)
{
    …
}
 
// 类成员函数的实现体
void Box::Draw(…)
{
    …
}
 
 
目录结构
如果一个软件的头文件数目比较多(如超过十个),通常应将头文件和定义文件分别保存于不同的目录,以便于维护。
例如可将头文件保存于include 目录,将定义文件保存于source 目录(可以是多级目录)。
如果某些头文件是私有的,它不会被用户的程序直接引用,则没有必要公开其“声明”。为了加强信息隐藏,这些私有的头文件可以和定义文件存放于同一个目录。
 
 
【规则2-1-1】在每个类声明之后、每个函数定义结束之后都要加空行。
【规则2-1-2】在一个函数体内,逻揖上密切相关的语句之间不加空行,其它地方应加空行分隔。
 
 
【规则2-2-1】一行代码只做一件事情,如只定义一个变量,或只写一条语句。这样的代码容易阅读,并且方便于写注释。
【规则2-2-2】if、for、while、do 等语句自占一行,执行语句不得紧跟其后。不论执行语句有多少都要加{}。这样可以防止书写失误。
 
 
【建议2-2-1】尽可能在定义变量的同时初始化该变量(就近原则)。
    如果变量的引用处和其定义处相隔比较远,变量的初始化很容易被忘记。如果引用了未被初始化的变量,可能会导致程序错误。
 
 
【规则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】赋值操作符、比较操作符、算术操作符、逻辑操作符、位域操作符,如“=”、“+=” “>=”、“<=”、“+”、“*”、“%”、“&&”、“||”、“<<”,“^”等二元操作符的前后应当加空格。
【规则2-3-6】一元操作符如“!”、“~”、“++”、“--”、“&”(地址运算符)等前后不加空格。
【规则2-3-7】象“[]”、“.”、“->”这类操作符前后不加空格。
【建议2-3-1】对于表达式比较长的for 语句和if 语句,为了紧凑起见可以适当地去掉一些空格,如for (i=0; i<10; i++)和if ((a<=b) && (c<=d))
 
 
【规则2-4-1】程序的分界符‘{’和‘}’应独占一行并且位于同一列,同时与引用它们的语句左对齐。
【规则2-4-2】{ }之内的代码块在‘{’右边数格处左对齐。
 
 
【规则2-5-1】代码行最大长度宜控制在70 至80 个字符以内。代码行不要过长,否则眼睛看不过来,也不便于打印。
【规则2-5-2】长表达式要在低优先级操作符处拆分成新行,操作符放在新行之首(以便突出操作符)。拆分出的新行要进行适当的缩进,使排版整齐,语句可读。
 
 
【规则2-6-1】应当将修饰符 * 和 & 紧靠变量名。
 
 
C 语言的注释符为“/*…*/”。C++语言中,程序块的注释常采用“/*…*/”,行注释一般采用“//…”。注释通常用于:
(1)版本、版权声明;
(2)函数接口说明;
(3)重要的代码行或段落提示。
虽然注释有助于理解代码,但注意不可过多地使用注释。
 
【规则2-7-1】注释是对代码的“提示”,而不是文档。程序中的注释不可喧宾夺主,注释太多了会让人眼花缭乱。注释的花样要少。
【规则2-7-2】如果代码本来就是清楚的,则不必加注释。否则多此一举,令人厌烦。
【规则2-7-3】边写代码边注释,修改代码同时修改相应的注释,以保证注释与代码的一致性。不再有用的注释要删除。
【规则2-7-4】注释应当准确、易懂,防止注释有二义性。错误的注释不但无益反而有害。
【规则2-7-5】尽量避免在注释中使用缩写,特别是不常用缩写。
【规则2-7-6】注释的位置应与被描述的代码相邻,可以放在代码的上方或右方,不可放在下方。
【规则2-7-8】当代码比较长,特别是有多重嵌套时,应当在一些段落的结束处加注释,便于阅读。
 
 
类可以将数据和函数封装在一起,其中函数表示了类的行为(或称服务)。类提供关键字public、protected 和private,分别用于声明哪些数据和函数是公有的、受保护的或者是私有的。
 
将public 类型的函数写在前面,而将private 类型的数据写在后面,采用这种版式的程序员主张类的设计“以行为为中心”,重点关注的是类应该提供什么样的接口(或服务)。
这是很多人的经验——“这样做不仅让自己在设计类时思路清晰,而且方便别人阅读。因为用户最关心的是接口,谁愿意先看到一堆私有数据成员!”
 
 
【规则3-1-1】标识符应当直观且可以拼读,可望文知意,不必进行“解码”。
    标识符最好采用英文单词或其组合,便于记忆和阅读。切忌使用汉语拼音来命名。
【规则3-1-2】标识符的长度应当符合“min-length && max-information”原则。
【规则3-1-3】命名规则尽量与所采用的操作系统或开发工具的风格保持一致。
    例如Windows 应用程序的标识符通常采用“大小写”混排的方式,如AddChild。而Unix 应用程序的标识符通常采用“小写加下划线”的方式,如add_child。
【规则3-1-4】程序中不要出现仅靠大小写区分的相似的标识符。
【规则3-1-5】程序中不要出现标识符完全相同的局部变量和全局变量,尽管两者的作用域不同而不会发生语法错误,但会使人误解。
【规则3-1-6】变量的名字应当使用“名词”或者“形容词+名词”。
【规则3-1-7】全局函数的名字应当使用“动词”或者“动词+名词”(动宾词组)。
    类的成员函数应当只使用“动词”,被省略掉的名词就是对象本身。
【规则3-1-8】用正确的反义词组命名具有互斥意义的变量或相反动作的函数等。
【建议3-1-1】尽量避免名字中出现数字编号,如Value1,Value2 等,除非逻辑上的确需要编号。这是为了防止程序员偷懒,不肯为命名动脑筋而导致产生无意义的名字(因为用数字编号最省事)。
 
简单的Windows 应用程序命名规则
【规则3-2-1】类名和函数名用大写字母开头的单词组合而成。
【规则3-2-2】变量和参数用小写字母开头的单词组合而成。
【规则3-2-3】常量全用大写的字母,用下划线分割单词。
【规则3-2-4】静态变量加前缀s_(表示static)。
【规则3-2-5】如果不得已需要全局变量,则使全局变量加前缀g_(表示global)。
【规则3-2-6】类的数据成员加前缀m_(表示member),这样可以避免数据成员与成员函数的参数同名。
【规则3-2-7】为了防止某一软件库中的一些标识符和其它软件库中的冲突,可以为各种标识符加上能反映软件性质的前缀。例如三维图形标准OpenGL 的所有库函数均以gl 开头,所有常量(或宏定义)均以GL 开头。
 
 
应用这个口诀的前提是你必须认识所有的C语言的运算符。
优先决
 
括号成员第一;//括号运算符[]() 成员运算符. ->
全体单目第二;//所有的单目运算符比如++ -- +(正) -(负) 指针运算*&
乘除余三,加减四;//这个“余”是指取余运算即%
移位五,关系六;//移位运算符:<< >> ,关系:> < >= <= 等
等于(与)不等排第七;//即== !=
位与、异或和位或;//这几个都是位运算:位与(&)异或(^)位或(|)
“三分天下”八九十;
逻辑或跟与;//逻辑运算符:|| 和 &&
十二和十一;//注意顺序:优先级(||) 底于 优先级(&&)
条件高于赋值;//三目运算符优先级排到 13 位只比赋值运算符和","高//需要注意的是赋值运算符很多!
逗号运算级最低!//逗号运算符优先级最低
 
位运算符的作用是按位对变量进行运算,但是并不改变参与运算的变量的值。如果要求按位改变变量的值,则要利用相应的赋值运算。还有就是位运算符是不能用来对浮点型数据进行操作的。C51中共有6种位运算符。
   位运算一般的表达形式如下:
     变量1 位运算符 变量2
   位运算符也有优先级,从高到低依次是:“~”(按位取反)→“<<”(左移) →“>>”(右移) →“&”(按位与)→“^”(按位异或)→“|”(按位或)
&:是地址运算符。当后跟一个变量名时,&得到该变量的地址
*:是间接或取值运算符。当后跟一个指针时,*得到存储在指针指向地址中的值
 
~是一元运算符,它把操作数的每一位都进行反转来得到一个值。
&是位与运算符,只有当两个操作数中对应的位都为1时,它产生的值中该位才为1。
|是位或运算符,只要两个操作数中对应的位有一位为1,它产生的值中该位就为1。
^是异或运算符,只有两个操作数中对应的位只有一位为1时,它产生的值中该位才为1。
 
“,”是逗号运算符,它把两个表达式连接到一个表达式中,并保证最左边的表达式最先被计算。整个表达式的值是左边表达式的值。
 
【规则4-1-1】如果代码行中的运算符比较多,用括号确定表达式的操作顺序,避免使用默认的优先级。
 
 
允许复合表达式存在的理由是:(1)书写简洁;(2)可以提高编译效率。
 
【规则4-2-1】不要编写太复杂的复合表达式。
【规则4-2-2】不要有多用途的复合表达式。
【规则4-2-3】不要把程序中的复合表达式与“真正的数学表达式”混淆。
 
 
【规则4-3-1】不可将布尔变量直接与TRUE、FALSE 或者1、0 进行比较。
    根据布尔类型的语义,零值为“假”(记为FALSE),任何非零值都是“真”(记为TRUE)
【规则4-3-2】应当将整型变量用“==”或“!=”直接与0 比较。
【规则4-3-3】不可将浮点变量用“==”或“!=”与任何数字比较。
    千万要留意,无论是float 还是double 类型的变量,都有精度限制。所以一定要避免将浮点变量用“==”或“!=”与数字比较,应该设法转化成“>=”或“<=”形式。
【规则4-3-4】应当将指针变量用“==”或“!=”与NULL 比较。
 
 
【建议4-4-1】在多重循环中,如果有可能,应当将最长的循环放在最内层,最短的循环放在最外层,以减少CPU 跨切循环层的次数。
【建议4-4-2】如果循环体内存在逻辑判断,并且循环次数很大,宜将逻辑判断移到循环体的外面。
 
【规则4-5-1】不可在for 循环体内修改循环变量,防止for 循环失去控制。
【建议4-5-1】建议for 语句的循环控制变量的取值采用“半开半闭区间”写法。
 
switch 是多分支选择语句,而if 语句只有两个分支可供选择。虽然可以用嵌套的if 语句来实现多分支选择,但那样的程序冗长难读。这是switch 语句存在的理由。
【规则4-6-1】每个case 语句的结尾不要忘了加break,否则将导致多个分支重叠(除非有意使多个分支重叠)。
【规则4-6-2】不要忘记最后那个default 分支。即使程序真的不需要default 处理,也应该保留语句 default : break; 这样做并非多此一举,而是为了防止别人误以为你忘了default 处理。
 
 
很多人建议废除C++/C 的goto 语句,以绝后患。但实事求是地说,错误是程序员自己造成的,不是goto 的过错。goto语句至少有一处可显神通,它能从多重循环体中咻地一下子跳到外面,用不着写很多次的break语句。就象楼房着火了,来不及从楼梯一级一级往下走,可从窗口跳出火坑。所以我们主张少用、慎用goto 语句,而不是禁用。
 
 
常量是一种标识符,它的值在运行期间恒定不变。C 语言用 #define 来定义常量(称为宏常量)。C++ 语言除了 #define 外还可以用const 来定义常量(称为const 常量)。
 
如果不使用常量,直接在程序中填写数字或字符串,将会有什么麻烦?
(1) 程序的可读性(可理解性)变差。程序员自己会忘记那些数字或字符串是什么意思,用户则更加不知它们从何处来、表示什么。
(2) 在程序的很多地方输入同样的数字或字符串,难保不发生书写错误。
(3) 如果要修改数字或字符串,则会在很多地方改动,既麻烦又容易出错。
 
【规则5-1-1】尽量使用含义直观的常量来表示那些将在程序中多次出现的数字或字符串。
 
C++ 语言可以用const 来定义常量,也可以用 #define 来定义常量。但是前者比后者有更多的优点:
(1)const 常量有数据类型,而宏常量没有数据类型。编译器可以对前者进行类型安全检查。而对后者只进行字符替换,没有类型安全检查,并且在字符替换可能会产生意料不到的错误(边际效应)。
(2)有些集成化的调试工具可以对const 常量进行调试,但是不能对宏常量进行调试。
 
【规则5-2-1】在C++ 程序中只使用const 常量而不使用宏常量,即const 常量完全取代宏常量。
 
【规则5-3-1】需要对外公开的常量放在头文件中,不需要对外公开的常量放在定义文件的头部。为便于管理,可以把不同模块的常量集中存放在一个公共的头文件中。
【规则5-3-2】如果某一常量与其它常量密切相关,应在定义中包含这种关系,而不应给出一些孤立的值。
 
 
有时我们希望某些常量只在类中有效。由于#define 定义的宏常量是全局的,不能达到目的,于是想当然地觉得应该用const修饰数据成员来实现。const 数据成员的确是存在的,但其含义却不是我们所期望的。const数据成员只在某个对象生存期内是常量,而对于整个类而言却是可变的,因为类可以创建多个对象,不同的对象其const 数据成员的值可以不同。
 
 
const 数据成员的初始化只能在类构造函数的初始化表中进行,例如
class A
{
    …
    A(int size); // 构造函数
    const int SIZE ;
};
A::A(int size) : SIZE(size) // 构造函数的初始化表
{
    …
}
A a(100); // 对象 a 的SIZE 值为100
A b(200); // 对象 b 的SIZE 值为200
 
怎样才能建立在整个类中都恒定的常量呢?别指望const 数据成员了,应该用类中的枚举常量来实现。
class A
{
    …
    enum { SIZE1 = 100, SIZE2 = 200}; // 枚举常量
    int array1[SIZE1];
    int array2[SIZE2];
};
枚举常量不会占用对象的存储空间,它们在编译时被全部求值。枚举常量的缺点是:它的隐含数据类型是整数,其最大值有限,且不能表示浮点数(如PI=3.14159)。
 
 
函数接口的两个要素是参数和返回值。C 语言中,函数的参数和返回值的传递方式有两种:值传递(pass byvalue)和指针传递(pass by pointer)。C++ 语言中多了引用传递(pass byreference)。引用传递的性质象指针传递,而使用方式却象值传递。
 
【规则6-1-1】参数的书写要完整,不要贪图省事只写参数的类型而省略参数名字。如果函数没有参数,则用void 填充。
【规则6-1-2】参数命名要恰当,顺序要合理。
【规则6-1-3】如果参数是指针,且仅作输入用,则应在类型前加const,以防止该指针在函数体内被意外修改。
【规则6-1-4】如果输入参数以值传递的方式传递对象,则宜改用“const &”方式来传递,这样可以省去临时对象的构造和析构过程,从而提高效率。
【建议6-1-1】避免函数有太多的参数,参数个数尽量控制在5 个以内。如果参数太多,在使用时容易将参数类型或顺序搞错。
【建议6-1-2】尽量不要使用类型和数目不确定的参数。
 
 
【规则6-2-1】不要省略返回值的类型。
    C 语言中,凡不加类型说明的函数,一律自动按整型处理。
    C++语言有很严格的类型安全检查,不允许上述情况发生。由于C++程序可以调用C函数,为了避免混乱,规定任何C++/ C 函数都必须有类型。如果函数没有返回值,那么应声明为void 类型。
【规则6-2-2】函数名字与返回值类型在语义上不可冲突。
【规则6-2-3】不要将正常值和错误标志混在一起返回。正常值用输出参数获得,而错误标志用return 语句返回。
 
【建议6-2-1】有时候函数原本不需要返回值,但为了增加灵活性如支持链式表达,可以附加返回值。
    例如字符串拷贝函数strcpy 的原型:char *strcpy(char *strDest,const char *strSrc);
【建议6-2-2】如果函数的返回值是一个对象,有些场合用“引用传递”替换“值传递”可以提高效率。而有些场合只能用“值传递”而不能用“引用传递”,否则会出错。
例如:
class String
{
    …
    // 赋值函数
    String & operate=(const String &other);
    // 相加函数,如果没有friend 修饰则只许有一个右侧参数
    friend String operate+( const String &s1, const String &s2);
private:
    char *m_data;
}
String 的赋值函数operate = 的实现如下:
String & String::operate=(const String &other)
{
    if (this == &other)
        return *this;
    delete m_data;
    m_data = new char[strlen(other.data)+1];
    strcpy(m_data, other.data);
    return *this; // 返回的是 *this 的引用,无需拷贝过程
}
对于赋值函数,应当用“引用传递”的方式返回String 对象。如果用“值传递”的方式,虽然功能仍然正确,但由于return 语句要把 *this 拷贝到保存返回值的外部存储单元之中,增加了不必要的开销,降低了赋值函数的效率。例如:
    String a,b,c;
    …
    a = b; // 如果用“值传递”,将产生一次 *this 拷贝
    a = b = c; // 如果用“值传递”,将产生两次 *this 拷贝
String 的相加函数operate + 的实现如下:
String operate+(const String &s1, const String &s2)
{
    String temp;
    delete temp.data; // temp.data 是仅含‘/0的字符串
    temp.data = new char[strlen(s1.data) + strlen(s2.data) +1];
    strcpy(temp.data, s1.data);
    strcat(temp.data, s2.data);
    return temp;
}
对于相加函数,应当用“值传递”的方式返回String 对象。如果改用“引用传递”,那么函数返回值是一个指向局部对象temp 的“引用”。由于temp 在函数结束时被自动销毁,将导致返回的“引用”无效。例如:
    c = a + b;
此时 a + b 并不返回期望值,c 什么也得不到,流下了隐患。
 
 
不同功能的函数其内部实现各不相同,看起来似乎无法就“内部实现”达成一致的观点。但根据经验,我们可以在函数体的“入口处”和“出口处”从严把关,从而提高函数的质量。
【规则6-3-1】在函数体的“入口处”,对参数的有效性进行检查。
    很多程序错误是由非法参数引起的,我们应该充分理解并正确使用“断言”(assert)来防止此类错误。
【规则6-3-2】在函数体的“出口处”,对return 语句的正确性和效率进行检查。
    如果函数有返回值,那么函数的“出口处”是return 语句。我们不要轻视return 语句。如果return 语句写得不好,函数要么出错,要么效率低下。
注意事项如下:
(1)return 语句不可返回指向“栈内存”的“指针”或者“引用”,因为该内存在函数体结束时被自动销毁。(结合编译原理的知识)
(2)要搞清楚返回的究竟是“值”、“指针”还是“引用”。
(3)如果函数返回值是一个对象,要考虑return 语句的效率。
例如
    return String(s1 + s2); //提倡
这是临时对象的语法,表示“创建一个临时对象并返回它”。不要以为它与“先创建一个局部对象temp 并返回它的结果”是等价的,如
    String temp(s1 + s2);
    return temp;
实质不然,上述代码将发生三件事。首先,temp 对象被创建,同时完成初始化;然后拷贝构造函数把temp拷贝到保存返回值的外部存储单元中;最后,temp在函数结束时被销毁(调用析构函数)。然而“创建一个临时对象并返回它”的过程是不同的,编译器直接把临时对象创建并初始化在外部存储单元中,省去了拷贝和析构的化费,提高了效率。
类似地,我们不要将
    return int(x + y); // 创建一个临时变量并返回它
写成
    int temp = x + y;
    return temp;
由于内部数据类型如int,float,double 的变量不存在构造函数与析构函数,虽然该“临时变量的语法”不会提高多少效率,但是程序更加简洁易读。
 
【建议6-4-1】函数的功能要单一,不要设计多用途的函数。
【建议6-4-2】函数体的规模要小,尽量控制在50 行代码之内。
【建议6-4-3】尽量避免函数带有“记忆”功能。相同的输入应当产生相同的输出。
    带有“记忆”功能的函数,其行为可能是不可预测的,因为它的行为可能取决于某种“记忆状态”。这样的函数既不易理解又不利于测试和维护。在C/C++语言中,函数中的static 局部变量是函数的“记忆”存储器。建议尽量少用static 局部变量,除非必需。
【建议6-4-4】不仅要检查输入参数的有效性,还要检查通过其它途径进入函数体内的变量的有效性,例如全局变量、文件句柄等。
【建议6-4-5】用于出错处理的返回值一定要清楚,让使用者不容易忽视或误解错误情况。
 
 
程序一般分为Debug 版本和Release 版本,Debug 版本用于内部调试,Release 版本发行给用户使用。
断言assert 是仅在Debug 版本起作用的宏,它用于检查“不应该”发生的情况。
assert 不是一个仓促拼凑起来的宏。为了不在程序的Debug 版本和Release 版本引起差别,assert不应该产生任何副作用。所以assert不是函数,而是宏。程序员可以把assert看成一个在任何系统状态下都可以安全使用的无害测试手段。如果程序在assert处终止了,并不是说含有该assert 的函数有错误,而是调用者出了差错,assert 可以帮助我们找到发生错误的原因。
很少有比跟踪到程序的断言,却不知道该断言的作用更让人沮丧的事了。你化了很多时间,不是为了排除错误,而只是为了弄清楚这个错误到底是什么。有的时候,程序员偶尔还会设计出有错误的断言。所以如果搞不清楚断言检查的是什么,就很难判断错误是出现在程序中,还是出现在断言中。幸运的是这个问题很好解决,只要加上清晰的注释即可。这本是显而易见的事情,可是很少有程序员这样做。这好比一个人在森林里,看到树上钉着一块“危险”的大牌子。但危险到底是什么?树要倒?有废井?有野兽?除非告诉人们“危险”是什么,否则这个警告牌难以起到积极有效的作用。难以理解的断言常常被程序员忽略,甚至被删除。
【规则6-5-1】使用断言捕捉不应该发生的非法情况。不要混淆非法情况与错误情况之间的区别,后者是必然存在的并且是一定要作出处理的。
【规则6-5-2】在函数的入口处,使用断言检查参数的有效性(合法性)。
【建议6-5-1】在编写函数时,要进行反复的考查,并且自问:“我打算做哪些假定?”一旦确定了的假定,就要使用断言对假定进行检查。
【建议6-5-2】一般教科书都鼓励程序员们进行防错设计,但要记住这种编程风格可能会隐瞒错误。当进行防错设计时,如果“不可能发生”的事情的确发生了,则要使用断言进行报警。
 
 
n 是m 的一个引用(reference),m 是被引用物(referent)。
    int m;
    int &n = m;
n 相当于m 的别名(绰号),对n 的任何操作就是对m 的操作。n 既不是m 的拷贝,也不是指向m 的指针,其实n 就是m 它自己。
 
引用的一些规则如下:
(1)引用被创建的同时必须被初始化(指针则可以在任何时候被初始化)。
(2)不能有NULL 引用,引用必须与合法的存储单元关联(指针则可以是NULL)。
(3)一旦引用被初始化,就不能改变引用的关系(指针则可以随时改变所指的对象)。
 
引用的主要功能是传递函数的参数和返回值。C++语言中,函数的参数和返回值的传递方式有三种:值传递、指针传递和引用传递。
//说句心里话,在看到这之前,我经常是把引用跟指针混为一谈
以下是“值传递”的示例程序。由于Func1 函数体内的x 是外部变量n 的一份拷贝,改变x 的值不会影响n, 所以n 的值仍然是0。
    void Func1(int x)
    {
        x = x + 10;
    }
    …
    int n = 0;
    Func1(n);
    cout << “n = ” << n << endl; // n = 0
以下是“指针传递”的示例程序。由于Func2 函数体内的x 是指向外部变量n 的指针,改变该指针的内容将导致n 的值改变,所以n 的值成为10。
    void Func2(int *x)
    {
        (* x) = (* x) + 10;
    }
    …
    int n = 0;
    Func2(&n);
    cout << “n = ” << n << endl; // n = 10
以下是“引用传递”的示例程序。由于Func3 函数体内的x 是外部变量n 的引用,x和n 是同一个东西,改变x 等于改变n,所以n 的值成为10。
    void Func3(int &x)
    {
        x = x + 10;
    }
    …
    int n = 0;
    Func3(n);
    cout << “n = ” << n << endl; // n = 10
对比上述三个示例程序,会发现“引用传递”的性质象“指针传递”,而书写方式象“值传递”。实际上“引用”可以做的任何事情“指针”也都能够做,为什么还要“引用”这东西?
答案是“用适当的工具做恰如其分的工作”。
指针能够毫无约束地操作内存中的任何东西,尽管指针功能强大,但是非常危险。
就象一把刀,它可以用来砍树、裁纸、修指甲、理发等等,谁敢这样用?
如果的确只需要借用一下某个对象的“别名”,那么就用“引用”,而不要用“指针”,以免发生意外。比如说,某人需要一份证明,本来在文件上盖上公章的印子就行了,如果把取公章的钥匙交给他,那么他就获得了不该有的权利。
 
 
内存分配方式有三种:
(1)从静态存储区域分配。内存在程序编译的时候就已经分配好,这块内存在程序的整个运行期间都存在。例如全局变量,static 变量。
(2)在栈上创建。在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释放。栈内存分配运算内置于处理器的指令集中,效率很高,但是分配的内存容量有限。
(3)从堆上分配,亦称动态内存分配。程序在运行的时候用malloc 或new 申请任意多少的内存,程序员自己负责在何时用free 或delete 释放内存。动态内存的生存期由我们决定,使用非常灵活,但问题也最多。
 
内存分配未成功,却使用了它。
编程新手常犯这种错误,因为他们没有意识到内存分配会不成功。常用解决办法是,在使用内存之前检查指针是否为NULL。如果指针p是函数的参数,那么在函数的入口处用assert(p!=NULL)进行检查。如果是用malloc 或new来申请内存,应该用if(p==NULL)或if(p!=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,防止产生“野指针”。
 
 
数组要么在静态存储区被创建(如全局数组),要么在栈上被创建。数组名对应着(而不是指向)一块内存,其地址与容量在生命期内保持不变,只有数组的内容可以改变。指针可以随时指向任意类型的内存块,它的特征是“可变”,所以我们常用指针来操作动态内存。指针远比数组灵活,但也更危险。
要想复制a的内容,可以先用库函数malloc 为p 申请一块容量为strlen(a)+1 个字符的内存,再用strcpy 进行字符串复制。同理,语句if(p==a) 比较的不是内容而是地址,应该用库函数strcmp 来比较。
注意函数strlen 返回的是有效字符串长度,不包含结束符‘/0’。函数strcpy 则连‘/0’一起复制。
    char a[] = "hello world";
    char *p = a;
    cout<< sizeof(a) << endl; // 12 字节
    cout<< sizeof(p) << endl; // 4 字节
用运算符sizeof 可以计算出数组的容量(字节数)。sizeof(a)的值是12(注意别忘了’/0)。
注意当数组作为函数的参数进行传递时,该数组自动退化为同类型的指针。不论数组a 的容量是多少,sizeof(a)始终等于sizeof(char *)。
 
 
编译器总是要为函数的每个参数制作临时副本,指针参数p 的副本是 _p,编译器使 _p = p。如果函数体内的程序修改了_p的内容,就导致参数p 的内容作相应的修改。这就是指针可以用作输出参数的原因。在本例中,_p 申请了新的内存,只是把_p所指的内存地址改变了,但是p 丝毫未变。
 
 
如果函数的参数是一个指针,不要指望用该指针去申请动态内存。示例7-4-1 中,Test 函数的语句GetMemory(str, 200)并没有使str 获得期望的内存,str 依旧是NULL,为什么?
    void GetMemory(char *p, int num)
    {
        p = (char *)malloc(sizeof(char) * num);
    }
    void Test(void)
    {
        char *str = NULL;
        GetMemory(str, 100); // str 仍然为 NULL
        strcpy(str, "hello"); // 运行错误
    }
示例7-4-1 试图用指针参数申请动态内存
毛病出在函数GetMemory 中。编译器总是要为函数的每个参数制作临时副本,指针参数p 的副本是 _p,编译器使 _p =p。如果函数体内的程序修改了_p 的内容,就导致参数p 的内容作相应的修改。这就是指针可以用作输出参数的原因。在本例中,_p申请了新的内存,只是把_p 所指的内存地址改变了,但是p丝毫未变。所以函数GetMemory并不能输出任何东西。事实上,每执行一次GetMemory 就会泄露一块内存,因为没有用free 释放内存。
如果非得要用指针参数去申请内存,那么应该改用“指向指针的指针”,见示例7-4-2。
    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<< str << endl;
        free(str);
    }
示例7-4-2 用指向指针的指针申请动态内存
由于“指向指针的指针”这个概念不容易理解,我们可以用函数返回值来传递动态内存。这种方法更加简单,见示例7-4-3。
    char *GetMemory3(int num)
    {
        char *p = (char *)malloc(sizeof(char) * num);
        return p;
    }
    void Test3(void)
    {
        char *str = NULL;
        str = GetMemory3(100);
        strcpy(str, "hello");
        cout<< str << endl;
        free(str);
    }
示例7-4-3 用函数返回值来传递动态内存
用函数返回值来传递动态内存这种方法虽然好用,但是常常有人把return 语句用错了。这里强调不要用return 语句返回指向“栈内存”的指针,因为该内存在函数结束时自动消亡,见示例7-4-4。
    char *GetString(void)
    {
        char p[] = "hello world";
        return p; // 编译器将提出警告
    }
    void Test4(void)
    {
        char *str = NULL;
        str = GetString(); // str 的内容是垃圾
        cout<< str << endl;
    }
示例7-4-4 return 语句返回指向“栈内存”的指针
用调试器逐步跟踪Test4,发现执行str = GetString 语句后str 不再是NULL 指针,但是str 的内容不是“hello world”而是垃圾。如果把示例7-4-4 改写成示例7-4-5,会怎么样?
    char *GetString2(void)
    {
        char *p = "hello world";
        return p;
    }
    void Test5(void)
    {
        char *str = NULL;
        str = GetString2();
        cout<< str << endl;
    }
示例7-4-5 return 语句返回常量字符串
函数Test5 运行虽然不会出错,但是函数GetString2 的设计概念却是错误的。因为GetString2 内的“helloworld”是常量字符串,位于静态存储区,它在程序生命期内恒定不变。无论什么时候调用GetString2,它返回的始终是同一个“只读”的内存块。
 
 
别看free 和delete 的名字恶狠狠的(尤其是delete),它们只是把指针所指的内存给释放掉,但并没有把指针本身干掉。
 
指针p 被free 以后其地址仍然不变(非NULL),只是该地址对应的内存是垃圾,p 成了“野指针”。如果此时不把p 设置为NULL,会让人误以为p 是个合法的指针。
 
 
我们发现指针有一些“似是而非”的特征:
(1)指针消亡了,并不表示它所指的内存会被自动释放。
(2)内存被释放了,并不表示指针会消亡或者成了NULL 指针。
 
 
“野指针”不是NULL 指针,是指向“垃圾”内存的指针。人们一般不会错用NULL指针,因为用if 语句很容易判断。但是“野指针”是很危险的,if 语句对它不起作用。
“野指针”的成因主要有两种:
(1)指针变量没有被初始化。任何指针变量刚被创建时不会自动成为NULL 指针,它的缺省值是随机的,它会乱指一气。所以,指针变量在创建的同时应当被初始化,要么将指针设置为NULL,要么让它指向合法的内存。
(2)指针p 被free 或者delete 之后,没有置为NULL,让人误以为p 是个合法的指针。
(3)指针操作超越了变量的作用范围。这种情况让人防不胜防。
 
 
malloc 与free 是C++/C 语言的标准库函数,new/delete 是C++的运算符。它们都可用于申请动态内存和释放内存。
对于非内部数据类型的对象而言,光用maloc/free 无法满足动态对象的要求。对象在创建的同时要自动执行构造函数,对象在消亡之前要自动执行析构函数。由于malloc/free是库函数而不是运算符,不在编译器控制权限之内,不能够把执行构造函数和析构函数的任务强加于malloc/free。
因此C++语言需要一个能完成动态内存分配和初始化工作的运算符new,以及一个能完成清理与释放内存工作的运算符delete。注意new/delete 不是库函数。
 
我们不要企图用malloc/free 来完成动态对象的内存管理,应该用new/delete。由于内部数据类型的“对象”没有构造与析构的过程,对它们而言malloc/free 和new/delete是等价的。
 
既然new/delete 的功能完全覆盖了malloc/free,为什么C++不把malloc/free 淘汰出局呢?这是因为C++程序经常要调用C 函数,而C 程序只能用malloc/free 管理动态内存。
 
  
 
 
通常有三种方式处理“内存耗尽”问题。
(1)判断指针是否为NULL,如果是则马上用return 语句终止本函数。
(2)判断指针是否为NULL,如果是则马上用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 下用VisualC++编写了测试程序。这个程序会无休止地运行下去,根本不会终止。因为32位操作系统支持“虚存”,内存用完了,自动用硬盘空间顶替。我只听到硬盘嘎吱嘎吱地响,Window 98 已经累得对键盘、鼠标毫无反应。
我可以得出这么一个结论:对于32 位以上的应用程序,“内存耗尽”错误处理程序毫无用处。这下可把Unix 和Windows 程序员们乐坏了:反正错误处理程序不起作用,我就不写了,省了很多麻烦。
我不想误导读者,必须强调:不加错误处理将导致程序的质量很差,千万不可因小失大。
 
 
函数malloc 的原型如下:
    void * malloc(size_t size);
我们应当把注意力集中在两个要素上:“类型转换”和“sizeof”。
malloc 返回值的类型是void *,所以在调用malloc 时要显式地进行类型转换,将void * 转换成所需要的指针类型。
malloc 函数本身并不识别要申请的内存是什么类型,它只关心内存的总字节数。我们通常记不住int, float 等数据类型的变量的确切字节数。
 
函数free 的原型如下:
    void free( void * memblock );
为什么free 函数不象malloc 函数那样复杂呢?这是因为指针p的类型以及它所指的内存的容量事先都是知道的,语句free(p)能正确地释放内存。如果p 是NULL 指针,那么free 对p无论操作多少次都不会出问题。如果p 不是NULL 指针,那么free 对p连续操作两次就会导致程序运行错误。
 
运算符new 使用起来要比函数malloc 简单得多,例如:
int *p1 = (int *)malloc(sizeof(int) * length);
int *p2 = new int[length];
这是因为new 内置了sizeof、类型转换和类型安全检查功能。对于非内部数据类型的对象而言,new 在创建动态对象的同时完成了初始化工作。如果对象有多个构造函数,那么new 的语句也可以有多种形式
 
如果用new 创建对象数组,那么只能使用对象的无参数构造函数。例如
Obj *objects = new Obj[100]; // 创建100 个动态对象
不能写成
Obj *objects = new Obj[100](1);// 创建100 个动态对象的同时赋初值1
在用delete 释放对象数组时,留意不要丢了符号‘[]’。例如
delete []objects; // 正确的用法
delete objects; // 错误的用法
 
 
对比于C 语言的函数,C++增加了重载(overloaded)、内联(inline)、const 和virtual四种新机制。其中重载和内联机制既可用于全局函数也可用于类的成员函数,const 与virtual 机制仅用于类的成员函数。
 
在C++/C 程序中,我们可以忽略函数的返回值。
 
只能靠参数而不能靠返回值类型的不同来区分重载函数。编译器根据参数为每个重载函数产生不同的内部标识符。
 
 
如果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直接引用这些头文件。注意并不是两个函数的名字相同就能构成重载。全局函数和类的成员函数同名不算重载,因为函数的作用域不同。
 
如果类的某个成员函数要调用全局函数,为了与成员函数区别,全局函数被调用时应在函数名之前加‘::’标志。
 
 
由于数字本身没有类型,将数字当作参数时将自动进行类型转换(称为隐式类型转换)。隐式类型转换在很多地方可以简化程序的书写,但是也可能留下隐患。
 
 
成员函数的重载、覆盖(override)与隐藏很容易混淆
 
成员函数被重载的特征:
(1)相同的范围(在同一个类中);
(2)函数名字相同;
(3)参数不同;
(4)virtual 关键字可有可无。
 
覆盖是指派生类函数覆盖基类函数,特征是:
(1)不同的范围(分别位于派生类与基类);
(2)函数名字相同;
(3)参数相同;
(4)基类函数必须有virtual 关键字。
在派生类与基类之间同名函数除了覆盖就是隐藏。
 
 
有一些参数的值在每次函数调用时都相同,书写这样的语句会使人厌烦。C++语言采用参数的缺省值使书写变得简洁(在编译时,缺省值由编译器自动插入)。
 
【规则8-3-1】参数缺省值只能出现在函数的声明中,而不能出现在定义体中。
为什么会这样?我想是有两个原因:
    一是函数的实现(定义)本来就与参数是否有缺省值无关,所以没有必要让缺省值出现在函数的定义体中。
    二是参数的缺省值可能会改动,显然修改函数的声明比修改函数的定义要方便。
【规则8-3-2】如果函数有多个参数,参数只能从后向前挨个儿缺省,否则将导致函数调用语句怪模怪样。
 
要注意,使用参数的缺省值并没有赋予函数新的功能,仅仅是使书写变得简洁一些。它可能会提高函数的易用性,但是也可能会降低函数的可理解性。所以我们只能适当地使用参数的缺省值,要防止使用不当产生负面效果。不合理地使用参数的缺省值将导致重载函数产生二义性。
 
 
在C++语言中,可以用关键字operator 加上运算符来表示函数,叫做运算符重载。
 
运算符与普通函数在调用时的不同之处是:对于普通函数,参数出现在圆括号内;而对于运算符,参数出现在其左、右侧。
 
如果运算符被重载为全局函数,那么只有一个参数的运算符叫做一元运算符,有两个参数的运算符叫做二元运算符。
如果运算符被重载为类的成员函数,那么一元运算符没有参数,二元运算符只有一个右侧参数,因为对象自己成了左侧参数。
由于C++语言支持函数重载,才能将运算符当成函数来用,C 语言就不行。我们要以平常心来对待运算符重载:
(1)不要过分担心自己不会用,它的本质仍然是程序员们熟悉的函数。
(2)不要过分热心地使用,如果它不能使代码变得更加易读易写,那就别用,否则会自找麻烦。
 
在C++运算符集合中,有一些运算符是不允许被重载的。这种限制是出于安全方面的考虑,可防止错误和混乱。
(1)不能改变C++内部数据类型(如int,float 等)的运算符。
(2)不能重载‘.’,因为‘.’在类中对任何成员都有意义,已经成为标准用法。
(3)不能重载目前C++运算符集合中没有的符号,如#,@,$等。原因有两点,一是难以理解,二是难以确定优先级。
(4)对已经存在的运算符进行重载时,不能改变优先级规则,否则将引起混乱。
 
 
C++ 语言支持函数内联,其目的是为了提高函数的执行效率(速度)。
在C程序中,可以用宏代码提高执行效率。宏代码本身不是函数,但使用起来象函数。预处理器用复制宏代码的方式代替函数调用,省去了参数压栈、生成汇编语言的CALL调用、返回参数、执行return等过程,从而提高了速度。使用宏代码最大的缺点是容易出错,预处理器在复制宏代码时常常产生意想不到的边际效应。
 
C++ 的“函数内联”的工作原理。
    对于任何内联函数,编译器在符号表里放入函数的声明(包括名字、参数类型、返回值类型)。如果编译器没有发现内联函数存在错误,那么该函数的代码也被放入符号表里。在调用一个内联函数时,编译器首先检查调用是否正确(进行类型安全检查,或者进行自动类型转换,当然对所有的函数都一样)。如果正确,内联函数的代码就会直接替换函数调用,于是省去了函数调用的开销。这个过程与预处理有显著的不同,因为预处理器不能进行类型安全检查,或者进行自动类型转换。假如内联函数是成员函数,对象的地址(this)会被放在合适的地方,这也是预处理器办不到的。
    C++ 语言的函数内联机制既具备宏代码的效率,又增加了安全性,而且可以自由操作类的数据成员。所以在C++ 程序中,应该用内联函数取代所有宏代码,“断言assert”恐怕是唯一的例外。assert 是仅在Debug版本起作用的宏,它用于检查“不应该”发生的情况。为了不在程序的Debug 版本和Release 版本引起差别,assert不应该产生任何副作用。如果assert 是函数,由于函数调用会引起内存、代码的变动,那么将导致Debug版本与Release版本存在差异。所以assert 不是函数,而是宏。
 
关键字inline 必须与函数定义体放在一起才能使函数成为内联,仅将inline 放在函数声明前面不起任何作用。
 
声明与定义不可混为一谈,用户没有必要、也不应该知道函数是否需要内联。
 
定义在类声明之中的成员函数将自动地成为内联函数。
 
慎用内联
内联能提高函数的执行效率,为什么不把所有的函数都定义成内联函数?
如果所有的函数都是内联函数,还用得着“内联”这个关键字吗?
内联是以代码膨胀(复制)为代价,仅仅省去了函数调用的开销,从而提高函数的执行效率。如果执行函数体内代码的时间,相比于函数调用的开销较大,那么效率的收获会很少。另一方面,每一处内联函数的调用都要复制代码,将使程序的总代码量增大,消耗更多的内存空间。以下情况不宜使用内联:
(1)如果函数体内的代码比较长,使用内联将导致内存消耗代价较高。
(2)如果函数体内出现循环,那么执行函数体内代码的时间要比函数调用的开销大。
类的构造函数和析构函数容易让人误解成使用内联更有效。要当心构造函数和析构函数可能会隐藏一些行为,如“偷偷地”执行了基类或成员对象的构造函数和析构函数。所以不要随便地将构造函数和析构函数的定义体放在类声明中。
一个好的编译器将会根据函数的定义体,自动地取消不值得的内联(这进一步说明了inline 不应该出现在函数的声明中)。
 
 
    构造函数、析构函数与赋值函数是每个类最基本的函数。它们太普通以致让人容易麻痹大意,其实这些貌似简单的函数就象没有顶盖的下水道那样危险。
    每个类只有一个析构函数和一个赋值函数,但可以有多个构造函数(包含一个拷贝构造函数,其它的称为普
通构造函数)。对于任意一个类A,如果不想编写上述函数,C++编译器将自动为A 产生四个缺省的函数,如
    A(void);                   // 缺省的无参数构造函数
    A(const A &a);             // 缺省的拷贝构造函数
    ~A(void);                  // 缺省的析构函数
    A & operate =(const A &a); // 缺省的赋值函数
 
这不禁让人疑惑,既然能自动生成函数,为什么还要程序员编写?
原因如下:
(1)如果使用“缺省的无参数构造函数”和“缺省的析构函数”,等于放弃了自主“初始化”和“清除”的机会,C++发明人Stroustrup 的好心好意白费了。
(2)“缺省的拷贝构造函数”和“缺省的赋值函数”均采用“位拷贝”而非“值拷贝”的方式来实现,倘若类中含有指针变量,这两个函数注定将出错。(位拷贝拷贝的是地址,而值拷贝则拷贝的是内容)
 
两个对象经过位拷贝之后,除了这两个对象的地址不同以外,它们成员变量的值绝对相同。
要用值拷贝就是说,要你在有指针成员变量的时候,一定要写拷贝构造函数和重载=。
 
写程序注意就行。
ASSERT(位拷贝==浅拷贝);
ASSERT(值拷贝==深拷贝);
 
 
构造函数与析构函数的起源
    作为比C更先进的语言,C++提供了更好的机制来增强程序的安全性。C++编译器具有严格的类型安全检查功能,它几乎能找出程序中所有的语法问题,这的确帮了程序员的大忙。但是程序通过了编译检查并不表示错误已经不存在了,在“错误”的大家庭里,“语法错误”的地位只能算是小弟弟。级别高的错误通常隐藏得很深,就象狡猾的罪犯,想逮住他可不容易。
    根据经验,不少难以察觉的程序错误是由于变量没有被正确初始化或清除造成的,而初始化和清除工作很容易被人遗忘。Stroustrup在设计C++语言时充分考虑了这个问题并很好地予以解决:把对象的初始化工作放在构造函数中,把清除工作放在析构函数中。当对象被创建时,构造函数被自动执行。当对象消亡时,析构函数被自
动执行。这下就不用担心忘了对象的初始化和清除工作。
    构造函数与析构函数的名字不能随便起,必须让编译器认得出才可以被自动执行。Stroustrup 的命名方法既简单又合理:让构造函数、析构函数与类同名,由于析构函数的目的与构造函数的相反,就加前缀‘~’以示区别。
    除了名字外,构造函数与析构函数的另一个特别之处是没有返回值类型,这与返回值类型为void的函数不同。构造函数与析构函数的使命非常明确,就象出生与死亡,光溜溜地来光溜溜地去。如果它们有返回值类型,那么编译器将不知所措。为了防止节外生枝,干脆规定没有返回值类型。
 
 
构造函数有个特殊的初始化方式叫“初始化表达式表”(简称初始化表)。初始化表位于函数参数表之后,却在函数体{} 之前。这说明该表里的初始化工作发生在函数体内的任何代码被执行之前。
 
成员初始化表跟在构造函数的原型后,由冒号开头。成员名是被指定的。后面是括在括号中的初始值,类似于函数调用的语法。如果成员是类对象则初始值变成被传递给适当的构造函数的实参。该构造函数然后被应用在成员类对象上。成员初始化表是由逗号分隔的成员/名字实参对。
 
构造函数初始化表的使用规则:
    如果类存在继承关系,派生类必须在其初始化表里调用基类的构造函数。
    类的const 常量只能在初始化表里被初始化,因为它不能在函数体内用赋值的方式来初始化。
    类的数据成员的初始化可以采用初始化表或函数体内赋值两种方式,这两种方式的效率不完全相同。
        非内部数据类型的成员对象应当采用“初始化表”方式初始化,以获取更高的效率。
 
const 和引用数据成员也必须是在成员初始化表中被初始化,否则就会产生编译时刻错误。
B::B(const A &a)
 : m_a(a)//对m_a初始化
{
    …
}
当构造函数体开始执行时,所有const 和引用的初始化必须都已经发生。只有将它们在成员初始化表中指定,这才有可能。
每个成员在成员初始化表中只能出现一次,初始化的顺序不是由名字在初始化表中的顺序决定而是由成员在类中被声明的顺序决定的。
我们的建议是把用一个成员对另一个成员进行初始化的代码放到构造函数体内。
 
 
构造从类层次的最根处开始,在每一层中,首先调用基类的构造函数,然后调用成员对象的构造函数。析构则严格按照与构造相反的次序执行,该次序是唯一的,否则编译器将无法自动执行析构过程。
一个有趣的现象是,成员对象初始化的次序完全不受它们在初始化表中次序的影响,只由成员对象在类中声明的次序决定。这是因为类的声明是唯一的,而类的构造函数可以有多个,因此会有多个不同次序的初始化表。如果成员对象按照初始化表的次序进行构造,这将导致析构函数无法得到唯一的逆序。
 
示例:类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;
}
 
 
拷贝构造函数是在对象被创建时调用的,而赋值函数只能被已经存在了的对象调用。
    String a(“hello”);
    String b(“world”);
    String c = a; // 调用了拷贝构造函数,最好写成 String c(a);
    c = b; // 调用了赋值函数
 
类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 拷贝构造函数与普通构造函数的区别是:在函数入口处无需与NULL 进行比较,这是因为“引用”不可能是NULL,而“指针”可以为NULL。
 
 
类String 的赋值函数比构造函数复杂得多,分四步实现:
(1)第一步,检查自赋值。你可能会认为多此一举,难道有人会愚蠢到写出 a = a 这样的自赋值语句!的确不会。但是间接的自赋值仍有可能出现。
 
也许有人会说:“即使出现自赋值,我也可以不理睬,大不了化点时间让对象复制自己而已,反正不会出错!”
 
他真的说错了。看看第二步的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 返回的将是垃圾。
 
 
如果我们实在不想编写拷贝构造函数和赋值函数,又不允许别人使用编译器生成的缺省函数,怎么办?
偷懒的办法是:只需将拷贝构造函数和赋值函数声明为私有函数,不用编写代码。
例如:
class A
{ …
 private:
    A(const A &a); // 私有的拷贝构造函数
    A & operate =(const A &a); // 私有的赋值函数
};
如果有人试图编写如下程序:
    A b(a); // 调用了私有的拷贝构造函数
    b = a; // 调用了私有的赋值函数
编译器将指出错误,因为外界不可以操作A 的私有函数。
 
 
基类的构造函数、析构函数、赋值函数都不能被派生类继承。如果类之间存在继承关系,在编写上述基本函数时。
应注意以下事项:
    派生类的构造函数应在其初始化表里调用基类的构造函数。
    基类与派生类的析构函数应该为虚(即加virtual 关键字)。
    在编写派生类的赋值函数时,注意不要忘记对基类的数据成员重新赋值。
class Base
{
 public:
    …
    Base & operate =(const Base &other); // 类Base 的赋值函数
 private:
    int m_i, m_j, m_k;
};
class Derived : public Base
{
 public:
    …
    Derived & operate =(const Derived &other); // 类Derived 的赋值函数
 private:
    int m_x, m_y, m_z;
};
Derived & Derived::operate =(const Derived &other)
{
    //(1)检查自赋值
    if(this == &other)
        return *this;
 
    //(2)对基类的数据成员重新赋值
    Base::operate =(other); // 因为不能直接操作私有数据成员
 
    //(3)对派生类的数据成员赋值
    m_x = other.m_x;
    m_y = other.m_y;
    m_z = other.m_z;
 
    //(4)返回本对象的引用
    return *this;
}
 
 
 
对象(Object)是类(Class)的一个实例(Instance)。如果将对象比作房子,那么类就是房子的设计图纸。所以面向对象设计的重点是类的设计,而不是对象的设计。
对于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 派生。
 
严格的继承规则应当是:若在逻辑上B 是A 的“一种”,并且A 的所有功能和属性对B 而言都有意义,则允许B 继承A 的功能和属性。
 
 
【规则10-2-1】若在逻辑上A 是B 的“一部分”(a part of),则不允许B 从A 派生,而是要用A 和其它东西组合出B。
 
 
一只公鸡使劲地追打一只刚下了蛋的母鸡,你知道为什么吗?
因为母鸡下了鸭蛋。
很多程序员经不起“继承”的诱惑而犯下设计错误。“运行正确”的程序不见得是高质量的程序,此处就是一个例证。
 
 
使用const 提高函数的健壮性
const 是constant 的缩写,“恒定不变”的意思。被const 修饰的东西都受到强制保护,可以预防意外的变动,能提高程序的健壮性。
 
如果参数作输出用,不论它是什么数据类型,也不论它采用“指针传递”还是“引用传递”,都不能加const 修饰,否则该参数将失去输出功能。
const 只能修饰输入参数:
    如果输入参数采用“指针传递”,那么加const 修饰可以防止意外地改动该指针,起到保护作用。
    如果输入参数采用“值传递”,由于函数将自动产生临时变量用于复制该参数,该输入参数本来就无需保护,所以不要加const 修饰。
    对于非内部数据类型的参数而言,象void Func(A a) 这样声明的函数注定效率比较底。因为函数体内将产生A 类型的临时对象用于复制参数a,而临时对象的构造、复制、析构过程都将消耗时间。
为了提高效率,可以将函数声明改为void Func(A&a),因为“引用传递”仅借用一下参数的别名而已,不需要产生临时对象。但是函数void Func(A &a)存在一个缺点:“引用传递”有可能改变参数a,这是我们不期望的。解决这个问题很容易,加const修饰即可,因此函数最终成为voidFunc(const A &a)。
用const 修饰函数的返回值:
 
如果给以“指针传递”方式的函数返回值加const 修饰,那么函数返回值(即指针)的内容不能被修改,该返回值只能被赋给加const 修饰的同类型指针。
如果函数返回值采用“值传递方式”,由于函数会把返回值复制到外部临时的存储单元中,加const 修饰没有任何价值。
如果返回值不是内部数据类型,将函数A GetA(void) 改写为const A & GetA(void)的确能提高效率。但此时千万千万要小心,一定要搞清楚函数究竟是想返回一个对象的“拷贝”还是仅返回“别名”就可以了,否则程序会出错。
函数返回值采用“引用传递”的场合并不多,这种方式一般只出现在类的赋值函数中,目的是为了实现链式表达。
 
 
任何不会修改数据成员的函数都应该声明为const 类型。如果在编写const 成员函数时,不慎修改了数据成员,或者调用了其它非const 成员函数,编译器将指出错误,这无疑会提高程序的健壮性。
const 成员函数的声明看起来怪怪的:const 关键字只能放在函数声明的尾部,大概是因为其它地方都已经被占用了。
 
 
全局效率是指站在整个系统的角度上考虑的效率,局部效率是指站在模块或函数角度上考虑的效率。
 
【规则11-2-1】不要一味地追求程序的效率,应当在满足正确性、可靠性、健壮性、可读性等质量因素的前提下,设法提高程序的效率。
【规则11-2-2】以提高程序的全局效率为主,提高局部效率为辅。
【规则11-2-3】在优化程序的效率时,应当先找出限制效率的“瓶颈”,不要在无关紧要之处优化。
【规则11-2-4】先优化数据结构和算法,再优化执行代码。
【规则11-2-5】有时候时间效率和空间效率可能对立,此时应当分析那个更重要,作出适当的折衷。例如多花费一些内存来提高性能。
【规则11-2-6】不要追求紧凑的代码,因为紧凑的代码并不能产生高效的机器码。
 
 
【建议11-3-1】当心那些视觉上不易分辨的操作符发生书写错误。我们经常会把“==”误写成“=”,象“||”、“&&”、“<=”、“>=”这类符号也很容易发生“丢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 等工具进行代码审查。
原创粉丝点击