电子书链接: 高质量 C++/C 编程指南
1. 文件结构
每个 C++/C 程序通常分为两个文件。一个文件用于保存程序的声明(declaration),称为头文件,后缀为 .h
。另一个文件用于保存程序的实现(implementation),称为定义文件(definition),后缀为 .c
或 .cpp
。
1.1 头文件
1.1.1 头文件的结构
- 版权和版本的声明位于头文件和定义文件的开头。
- 预处理块。
- 对一些头文件的引用。
- 函数和类的结构声明。
1.1.2 版权和版本的声明
(1)版权信息。
(2)文件名称,标识符,摘要。
(3)当前版本号,作者/修改者,完成日期。
(4)版本历史信息。
/*
* Copyright (c) 2001,上海贝尔有限公司网络应用事业部
* All rights reserved.
*
* 文件名称:filename.h
* 文件标识:见配置管理计划书
* 摘 要:简要描述本文件的内容
*
* 当前版本:1.1
* 作 者:输入作者(或修改者)名字
* 完成日期:2001年7月20日
*
* 取代版本:1.0
* 原 作 者:输入原作者(或修改者)名字
* 完成日期:2001年5月10日
*/
1.1.3 规则
-
为了防止头文件被重复引用,应当用
ifndef/define/endif
结构产生预处理块。#ifndef GRAPHICS_H // 防止 graphics.h 被重复引用 #define GRAPHICS_H ...... #endif
-
用
#include <filename.h>
格式来引用标准库的头文件(编译器将从标准库目录开始搜索)。 -
用
#include “filename.h”
格式来引用非标准库的头文件(编译器将从用户的工作目录开始搜索)。 -
头文件中只存放“声明”而不存放“定义”。
-
不提倡使用全局变量,尽量不要在头文件中出现象
extern int value
这类声明。
1.1.4 头文件的内容示例
// 版权和版本的声明
#ifndef GRAPHICS_H // 防止 graphics.h 被重复引用
#define GRAPHICS_H
#include <math.h> // 引用标准库的头文件
⋯
#include “myheader.h” // 引用非标准库的头文件
⋯
void Function1(⋯); // 全局函数声明
⋯
class Box // 类结构声明
{
⋯
};
#endif
1.1.5 头文件的作用
- 源代码不便(或不准)向用户公布,只要向用户提供头文件和二进制的库即可。
- 用户只需要按照头文件中的接口声明来调用库功能,而不必关心接口怎么实现的。
- 能加强类型安全检查。如果某个接口被实现或被使用时,其方式与头文件中的声明不一致,编译器就会指出错误,这一简单的规则能大大减轻程序员调试、改错的负担。
1.2 源文件(定义文件)
1.2.1 源文件的结构
- 源文件开头处的版权和版本声明。
- 对一些头文件的引用。
- 程序的实现体(包括数据和代码)。
1.2.2 源文件的内容示例
// 版权和版本的声明
#include “graphics.h” // 引用头文件
⋯
// 全局函数的实现体
void Function1(⋯)
{
⋯
}
// 类成员函数的实现体
void Box::Draw(⋯)
{
⋯
}
1.3 目录结构
- 如果一个软件的头文件数目比较多(如超过十个),通常应将头文件和定义文件分别保存于不同的目录,以便于维护。
- 将头文件保存于
include
目录,将定义文件保存于source
目录(可以是多级目录)。 - 如果某些头文件是私有的,它不会被用户的程序直接引用,则没有必要公开其“声明”。为了加强信息隐藏,这些私有的头文件可以和源文件存放于同一个目录。
2. 程序的版式
版式虽然不会影响程序的功能,但会影响可读性。程序的版式追求清晰、美观。
2.1 空行
空行不会浪费内存。空行得体(不过多也不过少)将使程序的布局更加清晰。
-
在每个类声明之后、每个函数定义结束之后都要加空行。
// 空行 void Function1(⋯) { ⋯ } // 空行 void Function2(⋯) { ⋯ }
-
在一个函数体内,逻揖上密切相关的语句之间不加空行,其它地方应加空行分隔。
// 空行 while (condition) { statement1; // 空行 if (condition) { statement2; } else { statement3; } // 空行 statement4; }
2.2 代码行
- 一行代码只做一件事情,如只定义一个变量,或只写一条语句。这使得代码容易阅读,并且方便写注释。
if
、for
、while
、do
等语句自占一行,执行语句不得紧跟其后。不论执行语句有多少都要加{}
。- 在定义变量的同时初始化该变量(就近原则)。如果变量的引用处和其定义处相隔比较远,变量的初始化很容易被忘记。如果引用了未被初始化的变量,可能会导致程序错误。
// 不好的代码:int width, height, depth; // 宽度高度深度
int width; // 宽度
int height; // 高度
int depth; // 深度
// 不好的代码:X = a + b; y = c + d; z = e + f;
x = a + b;
y = c + d;
z = e + f;
// 不好的代码:if (width < height) dosomething();
if (width < height)
{
dosomething();
}
// 不好的代码:for (initialization; condition; update)
// dosomething();
// other();
for (initialization; condition; update)
{
dosomething();
}
// 空行
other();
2.3 代码行内的空格
-
关键字之后要留空格。像
const
、virtual
、inline
、case
等关键字之后至少要留一个空格,否则无法辨析关键字。像if
、for
、while
等关键字之后应留一个空格再跟左括号(
,以突出关键字。if (year >= 2000) // 良好的风格 if ((a>=b) && (c<=d)) // 良好的风格 if(year>=2000) // 不良的风格 if(a>=b&&c<=d) // 不良的风格
-
函数名之后不要留空格,紧跟左括号
(
,以与关键字区别。void Func1(int x, int y, int z); // 良好的风格 void Func1 (int x,int y,int z); // 不良的风格
-
(
向后紧跟,)
、,
、;
’向前紧跟,紧跟处不留空格。 -
,
之后要留空格,如Function(x, y, z)
。如果;
不是一行的结束符号,其后要留空格,如for (initialization; condition; update)
。 -
对于表达式比较长的
for
语句和if
语句,为了紧凑起见可以适当地去掉一些空格,如for (i=0; i<10; i++)
和if ((a<=b) && (c<=d))
for (i=0; i<10; i++) // 良好的风格 for(i=0;i<10;i++) // 不良的风格 for (i = 0; i < 10; i ++) // 过多的空格
-
赋值操作符、比较操作符、算术操作符、逻辑操作符、位域操作符,如
=
、+=
、>=
、+
、*
、%
、&&
、||
、<<
等二元操作符的前后应当加空格。x = a < b ? a : b; // 良好的风格 x=a<b?a:b; // 不好的风格
-
一元操作符如
!
、~
、++
、--
、&
(地址运算符)等前后不加空格。int *x = &y; // 良好的风格 int * x = & y; // 不良的风格
-
[]
、.
、->
这类操作符前后不加空格。array[5] = 0; // 不要写成 array [ 5 ] = 0; a.Function(); // 不要写成 a . Function(); b->Function(); // 不要写成 b -> Function();
-
修饰符
*
和&
紧靠变量名。char *name; int *x, y; // 此处 y 不会被误解为指针
2.4 对齐
- 程序的分界符
{
和}
应独占一行并且位于同一列,同时与引用它们的语句左对齐。 { }
之内的代码块在{
右边数格处左对齐。
void Function(int x)
{
⋯ // program code
}
//不好的代码
/*
void Function(int x){
⋯ // program code
}
*/
2.5 长行拆分
长表达式要在低优先级操作符处拆分成新行,操作符放在新行之首(以便突出操作符)。拆分出的新行要进行适当的缩进,使排版整齐,语句可读。
if ((very_longer_variable1 >= very_longer_variable12)
&& (very_longer_variable3 <= very_longer_variable14)
&& (very_longer_variable5 <= very_longer_variable16))
{
dosomething();
}
virtual CMatrix CMultiplyMatrix (CMatrix leftMatrix,
CMatrix rightMatrix);
for (very_longer_initialization;
very_longer_condition;
very_longer_update)
{
dosomething();
}
2.6 注释
- 如果代码本来就是清楚的,则不必加注释。否则多此一举,令人厌烦。如
i++; // i加1
- 边写代码边注释,修改代码同时修改相应的注释,以保证注释与代码的一致性。不再有用的注释要删除。
- 注释的位置应与被描述的代码相邻,可以放在代码的上方或右方,不可放在下方。
- 当代码比较长,特别是有多重嵌套时,应当在一些段落的结束处加注释,便于阅读。
/*
* 函数介绍:
* 输入参数:
* 输出参数:
* 返回值 :
*/
void Function(float x, float y, float z)
{
…
}
if (…)
{
…
while (…)
{
…
} // end of while
…
} // end of if
2.7 类的版式
类的版式主要有两种方式:
- 将
private
类型的数据写在前面,而将public
类型的函数写在后面。采用这种版式的程序员主张类的设计 “以数据为中心” ,重点关注类的内部结构。 - 将 public 类型的函数写在前面,而将 private 类型的数据写在后面。采用这种版式的程序员主张类的设计 “以行为为中心” ,重点关注的是类应该提供什么样的接口(或服务)。
建议读者采用 “以行为为中心” 的书写方式,即首先考虑类应该提供什么样的函数。这是很多人的经验—— “这样做不仅让自己在设计类时思路清晰,而且方便别人阅读。因为用户最关心的是接口,谁愿意先看到一堆私有数据成员!”
class A
{
public:
void Func1(void);
void Func2(void);
…
private:
int i, j;
float x, y;
…
}
3. 命名规则
没有一种命名规则可以让所有的程序员赞同,不要花太多精力试图发明世界上最好的命名规则,而应当制定一种令大多数项目成员满意的命名规则,并在项目中贯彻实施。
3.1 共性规则
匈牙利法:在变量和函数名中加入前缀以增进人们对程序的理解。
int iI, iJ, ik; // 前缀 i 表示 int 类型
float fX, fY, fZ; // 前缀 f 表示 float 类型
-
标识符应当直观且可以拼读,可望文知意。
-
命名规则尽量与所采用的操作系统或开发工具的风格保持一致。
Windows 应用程序的标识符通常采用“大小写”混排的方式,如
AddChild
。Unix 应用程序的标识符通常采用“小写加下划线”的方式,如
add_child
。 -
不要出现仅靠大小写区分的相似的标识符。
int x, X; // 变量 x 与 X 容易混淆 void foo(int x); // 函数 foo 与 FOO 容易混淆 void FOO(float x);
-
不要出现标识符完全相同的局部变量和全局变量,尽管两者的作用域不同而不会发生语法错误,但会使人误解。
-
变量的名字应当使用“名词”或者“形容词+名词”。
float value; float oldValue; float newValue;
-
全局函数的名字应当使用“动词”或者“动词+名词”;类的成员函数应当只使用“动词”,被省略掉的名词就是对象本身。
DrawBox(); // 全局函数 box->Draw(); // 类的成员函数
-
用正确的反义词组命名具有互斥意义的变量或相反动作的函数。
int minValue; int maxValue; int SetValue(…); int GetValue(…);
-
尽量避免名字中出现数字编号,如
Value1
,Value2
等,除非逻辑上的确需要编号。这是为了防止程序员偷懒,不肯为命名动脑筋而导致产生无意义的名字。
3.2 Windows 应用程序命名规则
-
类名和函数名用大写字母开头的单词组合而成。
class Node; // 类名 class LeafNode; // 类名 void Draw(void); // 函数名 void SetValue(int value); // 函数名
-
变量和参数用小写字母开头的单词组合而成。
bool flag; int drawMode;
-
常量全用大写的字母,用下划线分割单词。
const int MAX = 100; const int MAX_LENGTH = 100;
-
静态变量加前缀 s_(表示 static)。
void Init(…) { static int s_initValue; // 静态变量 … }
-
全局变量加前缀 g_(表示 global)。
int g_howManyPeople; // 全局变量 int g_howMuchMoney; // 全局变量
-
类的数据成员加前缀 m_(表示 member),这样可以避免数据成员与成员函数的参数同名。
void Object::SetValue(int width, int height) { m_width = width; m_height = height; }
-
为了防止某一软件库中的一些标识符和其它软件库中的冲突,可以为各种标识符加上能反映软件性质的前缀。例如三维图形标准 OpenGL 的所有库函数均以 gl 开头,所有常量(或宏定义)均以 GL 开头。
4. 表达式和语句
4.1 运算符和优先级
下表的优先级从高到低排列。其中一元运算符 + - *
的优先级高于对应的二元运算符。
运算符 | 结合律 |
---|---|
() [] -> . | 从左至右 |
! ~ ++ -- (类型) sizeof + - * & (单目运算符) | 从右至左 |
* / % | 从左至右 |
+ - (双目运算符) | 从左至右 |
<< >> | 从左至右 |
< <= > >= | 从左至右 |
== != | 从左至右 |
& (按位与) | 从左至右 |
^ | 从左至右 |
` | ` |
&& | 从左至右 |
` | |
?: | 从右至左 |
`= += -= *= /= %= &= ^= | = <<= >>=` |
, | 从左至右 |
将上表熟记是比较困难的。如果代码行中的运算符比较多,为了防止产生歧义并提高可读性,应当用括号确定表达式的操作顺序,避免使用默认的优先级。
word = (high << 8) | low
if ((a | b) && (a & c))
4.2 复合表达式
允许复合表达式存在的理由是:
- 书写简洁。
- 可以提高编译效率。但要防止滥用复合表达式。
注意事项:
-
不要编写太复杂的复合表达式。
i = a >= b && c < d && c + f <= g + h ; // 复合表达式过于复杂
-
不要有多用途的复合表达式。
d = (a = b + c) + r ; //该表达式既求 a 值又求 d 值。应该拆分为两个独立的语句: a = b + c; d = a + r;
-
不要把程序中的复合表达式与“真正的数学表达式”混淆。
if (a < b < c) // a < b < c 是数学表达式而不是程序表达式 //并不表示 if ((a<b) && (b<c)) //而是成了令人费解的 if ( (a<b)<c )
4.3 if 语句
-
不可将布尔变量直接与
TRUE
、FALSE
或者 1、0 进行比较。根据布尔类型的语义,零值为“假”(记为FALSE
),任何非零值都是“真”(记为TRUE
)。//布尔变量名字为 flag,它与零值比较的标准 if 语句如下: if (flag) // 表示 flag 为真 if (!flag) // 表示 flag 为假 //不良风格,例如: if (flag == TRUE) if (flag == 1 ) if (flag == FALSE) if (flag == 0)
-
应当将整型变量用
==
或!=
直接与 0 比较。不然会让人误解是布尔变量。//整型变量的名字为 value,它与零值比较的标准 if 语句 if (value == 0) if (value != 0) //不良风格,例如: if (value) // 会让人误解 value 是布尔变量 if (!value)
-
不可将浮点变量用
==
或!=
与任何数字比较。无论是float
还是double
类型的变量,都有精度限制。所以一定要避免将浮点变量用==
或!=
与数字比较,应该设法转化成>=
或<=
形式。//浮点变量的名字为 x,标准比较语句 if ((x>=-EPSINON) && (x<=EPSINON)) //其中 EPSINON 是允许的误差(即精度) //隐含错误的比较 if (x == 0.0)
-
应当将指针变量用
==
或!=
与 NULL 比较。尽管 NULL 的值与 0 相同,但是两者意义不同。//指针变量的名字为 p,与零值比较的标准 if 语句如下: if (p == NULL) // p 与 NULL 显式比较,强调 p 是指针变量 if (p != NULL) //不要写成 if (p == 0) // 容易让人误解 p 是整型变量 if (p != 0) //或者 if (p) // 容易让人误解 p 是布尔变量 if (!p)
注:
-
有时可能会看到
if (NULL == p)
这样古怪的格式。不是程序写错了,是程序员为了防止将if (p == NULL)
误写成if (p = NULL)
,而有意把 p 和 NULL 颠倒。编译器认为if (p = NULL)
是合法的,但是会指出if (NULL = p)
是错误的,因为 NULL不能被赋值。 -
有时会遇到
if/else/return
的组合,应注意程序的书写风格。
//应该将如下不良风格的程序
if (condition)
return x;
return y;
//改写为
if (condition)
{
return x;
}
else
{
return y;
}
//或者改写成更加简练的
return (condition ? x : y);
4.4 循环语句的效率
C++/C 循环语句中,for
语句使用频率最高,while
语句其次,do
语句很少用。提高循环体效率的基本办法是降低循环体的复杂性。
-
在多重循环中,如果有可能,应当将最长的循环放在最内层,最短的循环放在最外层,以减少 CPU 跨循环层的次数。
//高效率:长循环在最内层 for (col=0; col<5; col++ ) { for (row=0; row<100; row++) { sum = sum + a[row][col]; } } //低效率:长循环在最外层 for (row=0; row<100; row++) { for ( col=0; col<5; col++ ) { sum = sum + a[row][col]; } }
-
如果循环体内存在逻辑判断,并且循环次数很大,宜将逻辑判断移到循环体的外面。
//效率高但程序不简洁 N很大的时候使用 if (condition) { for (i=0; i<N; i++) DoSomething(); } else { for (i=0; i<N; i++) DoOtherthing(); } //效率低但程序简洁 N很小的时候使用 for (i=0; i<N; i++) { if (condition) DoSomething(); else DoOtherthing(); }
4.5 for 语句的循环控制变量
-
不可在
for
循环体内修改循环变量,防止for
循环失去控制。 -
建议
for
语句的循环控制变量的取值采用“前开后闭区间”写法。//两者的功能是相同的,但该写法更加直观 for (int x=0; x<N; x++) { ⋯ } for (int x=0; x<=N-1; x++) { ⋯ }
4.6 switch语句
- 每个 case 语句的结尾不要忘了加
break
,否则将导致多个分支重叠(除非有意使多个分支重叠)。 - 即使程序真的不需要
default
处理,也应该保留语句default : break
; 这样做并非多此一举,而是为了防止别人误以为你忘了default
处理。
4.7 goto 语句
goto
语句至少有一处可显神通,它能从多重循环体中咻地一下子跳到外面,用不着写很多次的 break
语句;
{ ⋯
{ ⋯
{ ⋯
goto error;
}
}
}
error:
⋯
主张少用、慎用 goto 语句,而不是禁用。
5 常量
5.1 常量的作用
如果不使用常量,直接在程序中填写数字或字符串,将会有什么麻烦?
- 程序的可读性(可理解性)变差。程序员自己会忘记那些数字或字符串是什么意思,用户则更加不知它们从何处来、表示什么。
- 在程序的很多地方输入同样的数字或字符串,难保不发生书写错误。
- 如果要修改数字或字符串,则会在很多地方改动,既麻烦又容易出错。
尽量使用含义直观的常量来表示那些将在程序中多次出现的数字或字符串。
#define MAX 100 /* C 语言的宏常量 */
const int MAX = 100; // C++ 语言的 const 常量
const float PI = 3.14159; // C++ 语言的 const 常量
5.2 const 与 #define 的比较
C++ 语言可以用 const
来定义常量,也可以用 #define
来定义常量。const 的优点:
const
常量有数据类型,而宏常量没有数据类型。编译器可以对前者进行类型安全检查。而对后者只进行字符替换,没有类型安全检查,并且在字符替换可能会产生意料不到的错误(边际效应)。- 有些集成化的调试工具可以对
const
常量进行调试,但是不能对宏常量进行调试。
在 C++ 程序中只使用 const
常量而不使用宏常量,即 const
常量完全取代宏常量。
5.3 常量定义规则
-
需要对外公开的常量放在头文件中,不需要对外公开的常量放在定义文件的头部。为便于管理,可以把不同模块的常量集中存放在一个公共的头文件中。
-
如果某一常量与其它常量密切相关,应在定义中包含这种关系,而不应给出一些孤立的值。
const float RADIUS = 100; const float DIAMETER = RADIUS * 2;
5.4 类中的常量
某些常量只在类中有效。由于 #define
定义的宏常量是全局的,不能达到目的。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
用类中的枚举常量来实现在整个类中都恒定的常量。
class A
{⋯
enum { SIZE1 = 100, SIZE2 = 200}; // 枚举常量
int array1[SIZE1];
int array2[SIZE2];
};
枚举常量不会占用对象的存储空间,它们在编译时被全部求值。枚举常量的缺点是:它的隐含数据类型是整数,其最大值有限,且不能表示浮点数(如 PI=3.14159)。
6. 函数设计
函数接口的两个要素是参数和返回值。C 语言中,函数的参数和返回值的传递方式有两种:值传递和指针传递;C++ 语言中多了引用传递。
6.1 参数的规则
-
参数的书写要完整,不要贪图省事只写参数的类型而省略参数名字。如果函数没有参数,则用 void 填充。
void SetValue(int width, int height); // 良好的风格 float GetValue(void); // 良好的风格 void SetValue(int, int); // 不良的风格 float GetValue(); // 不良的风格
-
参数命名要恰当,顺序要合理。应将目的参数放在前面,源参数放在后面。
-
如果参数是指针,且仅作输入用,则应在类型前加
const
,以防止该指针在函数体内被意外修改。void StringCopy(char *strDestination,const char *strSource);
-
如果输入参数以值传递的方式传递对象,则宜改用
const &
方式来传递,这样可以省去临时对象的构造和析构过程,从而提高效率。 -
尽量不要使用类型和数目不确定的参数。
int printf(const chat *format[, argument]⋯) //在编译时丧失了严格的类型安全检查。
6.2 返回值的规则
-
不要省略返回值的类型。如果函数没有返回值,那么应声明为
void
类型。 -
函数名字与返回值类型在语义上不可冲突。
违反这条规则的典型代表是 C 标准库函数
getchar
。原型:int getchar(void);
-
不要将正常值和错误标志混在一起返回。正常值用输出参数获得,而错误标志用
return
语句返回。 -
有时候函数原本不需要返回值,但为了增加灵活性如支持链式表达,可以附加返回值。
字符串拷贝函数 strcpy 的原型:
char *strcpy(char *strDest,const char *strSrc);
strcpy 函数将 strSrc 拷贝至输出参数 strDest 中,同时函数的返回值又是 strDest。这样做并非多此一举,可以获得如下灵活性:
char str[20]; int length = strlen( strcpy(str, “Hello World”) );
6.3 函数内部实现的规则
-
在函数体的“入口处”,对参数的有效性进行检查。使用
assert
来防止此类错误。 -
在函数体的“出口处”,对
return
语句的正确性和效率进行检查。-
return
语句不可返回指向 “栈内存”的“指针”或者“引用”,因为该内存在函数体结束时被自动销毁。char * Func(void) { char str[] = “hello world”; // str 的内存位于栈上 … return str; // 将导致错误 }
-
要搞清楚返回的究竟是“值”、“指针”还是“引用”。
-
6.4 其他建议
-
函数的功能要单一,不要设计多用途的函数。
-
函数体的规模要小,尽量控制在 50 行代码之内。
-
尽量避免函数带有“记忆”功能。相同的输入应当产生相同的输出。
带有“记忆”功能的函数,其行为可能是不可预测的,因为它的行为可能取决于某种“记忆状态”。这样的函数既不易理解又不利于测试和维护。在 C/C++语言中,函数的
static
局部变量是函数的“记忆”存储器。建议尽量少用static
局部变量,除非必需。 -
不仅要检查输入参数的有效性,还要检查通过其它途径进入函数体内的变量的有效性,例如全局变量、文件句柄等。
-
用于出错处理的返回值一定要清楚,让使用者不容易忽视或误解错误情况。
6.5 使用断言
断言 assert
是仅在 Debug 版本起作用的宏,它用于检查“不应该”发生的情况。
为了不在程序的 Debug 版本和 Release 版本引起差别,assert
不应该产生任何副作用。所以 assert
不是函数,而是宏。
如果程序在 assert
处终止了,并不是说含有该 assert
的函数有错误,而是调用者出了差错,assert
可以帮助我们找到发生错误的原因。
在运行过程中,如果
assert
的参数为假,那么程序就会中止(一般地还会出现提示对话,说明在什么地方引发了assert
)。
void *memcpy(void *pvTo, const void *pvFrom, size_t size)
{
assert((pvTo != NULL) && (pvFrom != NULL)); // 使用断言
byte *pbTo = (byte *) pvTo; // 防止改变 pvTo 的地址
byte *pbFrom = (byte *) pvFrom; // 防止改变 pvFrom 的地址
while (size -- > 0 )
*pbTo ++ = *pbFrom ++ ;
return pvTo;
}
- 使用断言捕捉不应该发生的非法情况。不要混淆非法情况与错误情况之间的区别,后者是必然存在的并且是一定要作出处理的。
- 在函数的入口处,使用断言检查参数的有效性(合法性)。
- 当进行防错设计时,如果“不可能发生”的事情的确发生了,则要使用断言进行报警。
7. 内存管理
7.1 内存分配方式
内存分配方式有三种:
- 从静态存储区域分配。内存在程序编译的时候就已经分配好,这块内存在程序的整个运行期间都存在。例如全局变量,
static
变量。 - 在栈上创建。在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释放。栈内存分配运算内置于处理器的指令集中,效率很高,但是分配的内存容量有限。
- 从堆上分配,亦称动态内存分配。程序在运行的时候用
malloc
或new
申请任意多少的内存,程序员自己负责在何时用 free 或 delete 释放内存。动态内存的生存期由我们决定,使用非常灵活,但问题也最多。
7.2 常见的内存错误及其对策
常见内存错误:
-
内存分配未成功,却使用了它。
常用解决办法:在使用内存之前检查指针是否为
NULL
。如果指针 p 是函数的参数,那么在函数的入口
处用assert(p!=NULL)
进行检查。如果是用malloc
或new
来申请内存,应该用if(p==NULL)
或if(p!=NULL)
进行防错处理。 -
内存分配虽然成功,但是尚未初始化就引用它。
无论用何种方式创建数组,都别忘了赋初值,即便是赋零值也不可省略,不要嫌麻烦。
犯这种错误主要有两个起因:
- 没有初始化的观念。
- 误以为内存的缺省初值全为零,导致引用初值错误(例如数组)。
-
内存分配成功并且已经初始化,但操作越过了内存的边界。
例如在使用数组时经常发生下标“多 1”或者“少 1”的操作。特别是在 for 循环语句中,循环次数很容易搞错,导致数组操作越界。
-
忘记了释放内存,造成内存泄露。
动态内存的申请与释放必须配对,程序中
malloc
与free
的使用次数一定要相同,否则肯定有错误(new/delete
同理)。 -
释放了内存却继续使用它。
- 程序中的对象调用关系过于复杂,实在难以搞清楚某个对象究竟是否已经释放了内存,此时应该重新设计数据结构,从根本上解决对象管理的混乱局面。
- 函数的
return
语句写错了,注意不要返回指向“栈内存”的“指针”或者“引用”,因为该内存在函数体结束时被自动销毁。 - 使用
free
或delete
释放了内存后,没有将指针设置为NULL
。导致产生“野指针”。
对策:
- 用
malloc
或new
申请内存之后,应该立即检查指针值是否为NULL
。防止使用指针值为NULL
的内存。 - 不要忘记为数组和动态内存赋初值。防止将未被初始化的内存作为右值使用。
- 避免数组或指针的下标越界,特别要当心发生“多 1”或者“少 1”操作。
- 动态内存的申请与释放必须配对,防止内存泄漏。
- 用
free
或delete
释放了内存之后,立即将指针设置为NULL
,防止产生“野指针”。
7.3 数组和指针的对比
指针远比数组灵活,但也更危险。
-
修改内容
char a[] = “hello”; a[0] = ‘X’; cout << a << endl; char *p = “world”; // 注意 p 指向常量字符串 p[0] = ‘X’; // 编译器不能发现该错误 cout << p << endl;
字符数组 a 的容量是 6 个字符,其内容为
hello\0
。a 的内容可以改变,如a[0]= ‘X’
。指针 p 指向常量字符串“world”(位于静态存储区,内容为world\0
),常量字符串的内容是不可以被修改的。从语法上看,编译器并不觉得语句p[0]= ‘X’
有什么不妥,但是该语句企图修改常量字符串的内容而导致运行错误。 -
内容复制与比较
//数组 char a[] = "hello"; char b[10]; strcpy(b, a); // 不能用 b = a; if (strcmp(b, a) == 0) // 不能用 if (b == a)
不能对数组名进行直接复制与比较。 想把数组 a 的内容复制给数组 b,不能用语句 b = a ,否则将产生编译错误。应该用标准库函数 strcpy 进行复制。同理,比较 b 和 a 的内容是否相同,不能用 if(b==a) 来判断,应该用标准库函数 strcmp进行比较。
//指针 int len = strlen(a); char *p = (char *)malloc(sizeof(char)*(len+1)); strcpy(p,a); // 不要用 p = a; if (strcmp(p, a) == 0) // 不要用 if (p == a)
要想复制 a 的内容,可以先用库函数
malloc
为 p 申请一块容量为strlen(a)+1
个字符的内存,再用 strcpy 进行字符串复制。同理,语句if(p==a)
比较的不是内容而是地址,应该用库函数 strcmp 来比较。 -
计算内存容量
用运算符 sizeof 可以计算出数组的容量(字节数),但没有办法知道指针所指的内存容量,除非在申请内存时记住它。
char a[] = "hello world"; char *p = a; cout<< sizeof(a) << endl; // 12 字节 cout<< sizeof(p) << endl; // 4 字节
7.4 指针参数是如何传递内存的?
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"); // 运行错误 -> 只改变形参没有改变实参
}
事实上,每执行一次 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<< str << endl;
free(str);
}
//函数返回值来传递动态内存
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);
}
return不可返回指向栈内存的指针。
char *GetString(void)
{
char p[] = "hello world";
return p; // 编译器将提出警告
}
void Test4(void)
{
char *str = NULL;
str = GetString(); // str 的内容是垃圾
cout<< str << endl;
}
局部数组p[]是分配在栈上的。即
hello world
保存在栈内存上,栈内存在函数调用结束时会自动销毁,因此此时的p里的内容是未知的,所以结果无输出。
7.5 free 和 delete 把指针怎么啦?
free
和 delete
(尤其是 delete
),它们只是把指针所指的内存给释放掉,但并没有把指针本身干掉。
char *p = (char *) malloc(100);
strcpy(p, “hello”);
free(p); // p 所指的内存被释放,但是 p 所指的地址仍然不变
...
if(p != NULL) // 没有起到防错作用
{
strcpy(p, “world”); // 出错
}
指针 p 被 free
以后其地址仍然不变(非 NULL
),只是该地址对应的内存是垃圾,p 成了“野指针”。如果此时不把 p 设置为 NULL
,会让人误以为 p 是个合法的指针。
释放内存后要立马将指针设置为 NULL
!
如果程序比较长,我们有时记不住 p 所指的内存是否已经被释放,在继续使用 p 之前,通常会用语句
if (p != NULL)
进行防错处理。很遗憾,此时 if 语句起不到防错作用,因为即便 p 不是NULL
指针,它也不指向合法的内存块。
7.6 动态内存会被自动释放吗?
void Func(void)
{
char *p = (char *) malloc(100); // 动态内存会自动释放吗? -> 不会
}
指针有一些“似是而非”的特征:
- 指针消亡了,并不表示它所指的内存会被自动释放。
- 内存被释放了,并不表示指针会消亡或者成了
NULL
指针。
7.7 杜绝“野指针”
“野指针”不是 NULL 指针
,是指向“垃圾”内存的指针。“野指针”是很危险的,if 语句对它不起作用。
“野指针”的成因主要有两种:
-
指针变量没有被初始化。 任何指针变量刚被创建时不会自动成为 NULL 指针,它的缺省值是随机的,它会乱指一气。所以,指针变量在创建的同时应当被初始化,要么将指针设置为
NULL
,要么让它指向合法的内存。char *p = NULL; char *str = (char *) malloc(100);
-
指针 p 被 free 或者 delete 之后,没有置为 NULL,让人误以为 p 是个合法的指针。
-
指针操作超越了变量的作用范围。
class A { public: void Func(void){ cout << “Func of class A” << endl; } }; void Test(void) { A *p; { A a; p = &a; // 注意 a 的生命期 } p->Func(); // p 是“野指针” }
函数 Test 在执行语句
p->Func()
时,对象 a 已经消失,而 p 是指向 a 的,所以 p 就成了“野指针”。
7.9 内存耗尽怎么办?
如果在申请动态内存时找不到足够大的内存块,malloc
和 new
将返回 NULL
指针,宣告内存申请失败。
通常有三种方式处理“内存耗尽”问题。
-
判断指针是否为
NULL
,如果是则马上用return
语句终止本函数。void Func(void) { A *a = new A; if (a == NULL) { return; } … }
-
判断指针是否为 NULL,如果是则马上用
exit(1)
终止整个程序的运行。void Func(void) { A *a = new A; if (a == NULL) { cout << “Memory Exhausted” << endl; exit(1); } … }
-
为
new
和malloc
设置异常处理函数。
上述 (1)(2)方式使用最普遍。如果一个函数内有多处需要申请动态内存,那么方式(1)就显得力不从心(释放内存很麻烦),应该用方式(2)来处理。
如果发生“内存耗尽”这样的事情,一般说来应用程序已经无药可救。如果不用 exit(1)
把坏程序杀死,它可能会害死操作系统。
对于 32 位以上的应用程序,“内存耗尽”错误处理程毫无用处。这下可把 Unix 和 Windows 程序员们乐坏了:反正错误处理程序不起作用,我就不写了,省了很多麻烦。
不加错误处理将导致程序的质量很差,千万不可因小失大。
7.10 malloc/free 的使用要点
函数 malloc 的原型:void * malloc(size_t size);
用 malloc 申请一块长度为 length 的整数类型的内存:
int *p = (int *) malloc(sizeof(int) * length);
- malloc 返回值的类型是
void *
,所以在调用 malloc 时要显式地进行类型转换,将void *
转换成所需要的指针类型。 - malloc 函数本身并不识别要申请的内存是什么类型,它只关心内存的总字节数。
函数 free 的原型:void free( void * memblock );
指针 p 的类型以及它所指的内存的容量事先都是知道的,语句 free(p)
能正确地释放内存。
如果 p 是
NULL
指针,那么free
对 p 无论操作多少次都不会出问题。如果 p 不是NULL
指针,那么free
对 p 连续操作两次就会导致程序运行错误。
7.12 一些心得体会
- 越是怕指针,就越要使用指针。
- 必须养成“使用调试器逐步跟踪程序”的习惯,只有这样才能发现问题的本质。
11. 其他编程经验
11.1 使用 const 提高函数的健壮性
const
修饰的东西都受到强制保护,可以预防意外的变动,能提高程序的健壮性。
const
只能修饰输入参数。
-
如果输入参数采用“指针传递” ,那么加
const
修饰可以防止意外地改动该指针,起到保护作用。//给 strSource 加上 const修饰后,如果函数体内的语句试图改动 strSource 的内容,编译器将指出错误。 void StringCopy(char *strDestination, const char *strSource);
-
如果输入参数采用“值传递”,由于函数将自动产生临时变量用于复制该参数,该输入参数本来就无需保护,所以不要加 const 修饰。
不要将函数
void Func1(int x)
写成void Func1(const int x)
。 -
对于非内部数据类型的输入参数,应该将“值传递”的方式改为“
const
引用传递”,目的是提高效率。void Func(A a)
改为void Func(const A &a)
。对象的构造、复制、析构过程都将消耗时间。 -
以“指针传递”方式的函数返回值加
const
修饰,那么函数返回值(即指针)的内容不能被修改,该返回值只能被赋给加const
修饰的同类型指针。//函数定义 const char * GetString(void); //如下语句将出现编译错误: char *str = GetString(); //正确的用法是 const char *str = GetString();
-
函数返回值采用“值传递方式”,由于函数会把返回值复制到外部临时的存储单元中,加
const
修饰没有任何价值。 -
函数返回值采用“引用传递”的场合并不多,这种方式一般只出现在类的赋值函数中,目的是为了实现链式表达。
class A {⋯ A & operate = (const A &other); // 赋值函数 }; A a, b, c; // a, b, c 为 A 的对象 ⋯ a = b = c; // 正常的链式赋值 (a = b) = c; // 不正常的链式赋值,但合法
如果将赋值函数的返回值加
const
修饰,那么该返回值的内容不允许被改动。上例中,语句a = b = c
仍然正确,但是语句(a = b) = c
则是非法的。 -
任何不会修改数据成员的函数都应该声明为
const
类型。const
关键字只能放在函数声明的尾部。如果在编写const
成员函数时,不慎修改了数据成员,或者调用了其它非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; }
11.2 提高程序的效率
- 应当在满足正确性、可靠性、健壮性、可读性等质量因素的前提下,设法提高程序的效率。
- 以提高程序的全局效率为主,提高局部效率为辅。
- 在优化程序的效率时,应当先找出限制效率的“瓶颈”,不要在无关紧要之处优化。
- 先优化数据结构和算法,再优化执行代码。
- 有时候时间效率和空间效率可能对立,此时应当分析那个更重要,作出适当的折衷。例如多花费一些内存来提高性能。
- 不要追求紧凑的代码,因为紧凑的代码并不能产生高效的机器码。
总而言之,先总体再局部,先主要再次要。
11.3 一些有益的建议
-
当心那些视觉上不易分辨的操作符发生书写错误。
- 经常会把
==
误写成=
||
、&&
、<=
、>=
这类符号也很容易发生“丢 1”失误。然而编译器却不一定能自动指出这类错误。
- 经常会把
-
变量(指针、数组)被创建之后应当及时把它们初始化,以防止把未被初始化的变量当成右值使用。
-
当心变量的初值、缺省值错误,或者精度不够。
-
当心数据类型转换发生错误。尽量使用显式的数据类型转换,避免让编译器轻悄悄地进行隐式的数据类型换。
-
当心变量发生上溢或下溢,数组的下标越界。
-
当心忘记编写错误处理程序,当心错误处理程序本身有误。
-
当心文件 I/O 有错误。
-
避免编写技巧性很高代码。技巧性越高,可读性越差。
-
不要设计面面俱到、非常灵活的数据结构。
-
如果原有的代码质量比较好,尽量复用它。但是不要修补很差劲的代码,应当重新编写。
-
尽量使用标准库函数,不要“发明”已经存在的库函数。
-
尽量不要使用与具体硬件或软件环境关系密切的变量。
-
把编译器的选择项设置为最严格状态。
-
如果可能的话,使用 PC-Lint、LogiScope 等工具进行代码审查。