晓雅数码读《编写高质量代码:改善C++程序的150个建议》摘录的重点
本人主要从事底层驱动的编写,因此主要阅读了《编写高质量代码:改善C++程序的150个建议》其中的C语言的部分,对于自己认为重要的点记录于此(呵呵,其实每点都重要)
1.
不要让main函数写成void main
要想保证程序具有良好的可移植性能,就要标明 main 函数返回 int,而不是
void。强烈建议使用以下形式:
int
main()
{
// some processing codes
return 0;
}
2.
赋值符“=”和运算符“= =”的区别
下面的代码就是一个典型的例子:
if(nValue
= 0)
{
//
do something if nValue is not zero.
}
显然,程序员的本意是要写 if( nValue
= = 0 )。不幸的是,上述语句虽未达成程序员的本意,但它却完全是合法的,编译器不会给出任何错误提示。C++ 语句首先会将 nValue
赋值为0,然后再判断
nValue 是否为非零。结果就是 if
条件始终不能被满足,大括号中的代码永远不会被执行。
针对 = 和 == 之间的问题,通过良好的代码习惯可以避免,代码如下所示:
if(0 ==
nValue)
{
//
do something if nValue is not zero.
}
其他几对容易弄错的运算符是 &(按位与)和
&&(与)
,以及 |(按位或)和
||(或)
。对于这两对运算符,能够避免错误的只有细心。
3.
对表达式计算顺序不要想当然
不要过分自信地认为自己已经熟悉了所有运算符的优先级、结合律,多写几个括号确实是个好主意。例如:
COLOR rgb
= (red<<16) | (green<<8) | blue;
bool
isGradeOne = ((nGrade & MASK) == GRADE_ONE);
《The C Programming
Lauguage》
(程序员亲切地称此书为“K
& R”
)中反复强调,函数参数也好,某个操作符中的操作数也罢,表达式求值次序是不一定的,每个特定机器、操作系统、编译器也都不一样。
例如:
a = p() + q() *
r();
三个函数 p()、q() 和 r()
可能以 6
种顺序中的任何一种被评估求值。乘法运算符的高优级只能保证 q() 和 r()
的返回值首先相乘,然后再加到 p() 的返回值上。所以,就算加上再的括号依旧不能解决问题。
幸运的是,使用显式的、手工指定的中间变量可以解决这一问题,从而保证固定的子表式评估求值顺序:
int para1 =
p();
int para2 =
q();
a = para1 + para2 *
r();
这样,上述代码就为 p()、q() 和 r()
三个函数指定了唯一的计算顺序:p() → q()
→ r()。
4.
小心宏 #define
使用中的陷阱
(1)
用宏定义表达式时,要使用完备的括号。
错误的定义:
#define ADD( a, b
) a +
b
#define MULTIPLE(
a, b ) (a
* b)
正确的定义应为:
#define ADD( a, b
)
((a)+(b))
#define MULTIPLE(
a, b )
((a)*(b))
(2)
使用宏时,不允许参数发生变化。
#define
SQUARE( a ) ((a) * (a))
int
Square(int a)
{
return a*a;
}
int
nValue1 = 10, nValue2 = 10;
int
nSquare1 = SQUARE(nValue1++); // nSquare1=110,
nValue1=12
int
nSquare2 = Square(nValue2++);// nSquare2=100, nValue2=11
类似的定义,却产生了不同的结果,究其原因还是宏的字符替换问题。正如上面的示样,两处的 a 都被参数 nValue1++
替换了,所以 nValue1
自增操作也就被执行了两回。这就是宏在展开时对其参数的多次取值替换所带来的副作用。为了避免出现这样的副作用。最简单有效的方法就是保证宏参数不发生变化,如下所示。
#define
SQUARE( a ) ((a) * (a))
int
nValue1 = 10;
int
nSquare1 = SQUARE(nValue1); // nSquare1=100
nValue1++;
//
nValue1=11
(3)
用大括号将宏所定义的多条表达式括起来。
如果宏定义包含多条表达式,一定要用大括号将其括起来。如果没有这个大括号,宏定义的多条表达式很有可能只有第一句会被执行,正如下面的代码片段:
#define
CLEAR_CUBE_VALUE( l, w, h )\
l = 0;\
w = 0;\
h = 0;
正确的做法应该是用大括号将多条表达式括起来,这样就能保证多条表达式全部执行了,如下面的代码片段所示:
#define
CLEAR_CUBE_VALUE( l, w, h )\
{\
l = 0;\
w = 0;\
h = 0;\
}
5.
不要忘记指针变量的初始化
指针是 C/C++
语言编程中最给力的工具。指针,让我们直接去面对最为神秘的内存空间,赋予我们对内存进行直接操作的能力。
为对于全局变量来说,在声明的同时,编译器会悄悄完成对变量的初始化。
使用未初始化的局部指针变量是件很危险的事,所以,在使用局部指针变量时,一定要及时将其初始化。
6.
时刻提防内存溢出
在内存空间中,当要表示的数据超出了计算机为该数据分配的空间范围时,就产生了溢出,而溢出的多余数据则可以作为指令在计算机中大摇大摆地运行,这样很容易造成程序崩溃。
C
语言中的字符串库没有采用相应的安全保护措施,所以在使用时要特别小心。例如在执行 strcpy、strcat 等函数操作时没有检查缓冲区大小,就会很容易引起安全问题。
现在分析下面的代码片段:
const int
MAX_DATA_LENGTH = 32;
void DataCopy (char
*szSrcData)
{
char
szDestData[MAX_DATA_LENGTH];
strcpy(szDestData,
szSrcData);
//原文这里有笔误strcpy(cDest,szData);
// processing
codes
...
}
似乎这段代码不存在什么问题,但是细心的读者还是会发其中的危险。如果数据源szSrcDatar 的长度不超过规定的长度,那么这段代码确实没什么问题。strcpy() 不会在乎数据来源,也不会检查字符串长度,唯一能让它停下来的只有字符串结束符 '\0'。不过,如果没有遇到这个结束符,它就会一个字节一个字节地复制
szSrcData 的内容,在填满 32
字节的预设空间后,溢出的字符就会取代缓冲区后面的数据。如果这些溢出的数据恰好覆盖了后面DataCopy 函数的返回地址,在该函数调用完毕后,程序就会转入攻击者设定的“返回地址”中,乖乖地进入预先设定好的陷阱。
为了避免落入这样的圈套,给作恶者留下可乘之机,当 C/C++ 代码处理来自用户的数据时,应该处处留意。如果一个函数的数据来源不可靠,又要用到内存缓冲区,那么必须提高警惕,必须知道内存缓冲区的总长度,并检验内存缓冲区。
const int
MAX_DATA_LENGTH = 32;
void DataCopy (char
*szSrcData, DWORD nDataLen)
{
char
szDestData[MAX_DATA_LENGTH];
if(nDataLen < MAX_DATA_LENGTH)
strcpy(szDestData, szSrcData); //原文这里笔误strcpy(cDest,szData);
szDestData[nDataLen] =
'\0'; //
0x42;
// processing
code
...
}
首先,要获得 szSrcData
的长度,保证数据长度不大于最大缓冲区长度 MAX_DATA_LENGTH ;其次,要保证参数传来的数据长度真实有效,方法就是向内存缓冲区的末尾写入数据。因为,当缓冲区溢出时,一旦向其中写入常量值,代码就会出错,终止运行。与其落入阴谋家的陷阱,还不如及时终止程序运行。
7.
访问边界数据可能引起缓冲区溢出
从上面第6点分离出(因为内存溢出这个问题太重要了)
在这种情况下的内存溢出不会像第一种那么危险,但同样令人讨厌。就如下面的代码片段:
const int
DATA_LENGTH = 16;
Int
data[16] = {1,9,8,4,0,9,1,7,1,9,8,7,0,3,0,9};
void
PrintData()
{
for(int i=0; a[i]!=0&&i
{
cout<
}
}
这也是一个隐藏很深、难以发现的问题:当 i==16 的时候,在判断 i<
DATA_LENGTH
的同时需要判断 data[16]。而
data[16] 已经访问到了非法区域,可能引起缓冲区溢出。正确的方式应该是不要将索引号 i 与数据本身 data[i]
的判断放在一起,而是将判断条件分成
两句:
const int
DATA_LENGTH = 16;
int
data[16] = {1,9,8,4,0,9,1,7,1,9,8,7,0,3,0,9};
void
PrintData()
{
for(int i=0; i
{
if(a[i]!=0)
cout<
}
}
类似的问题还有可能发生在访问未初始化指针或失效指针时。未初始化的指针和失效后未置 NULL 的指针指向的是未知的内存空间,所以对这样的指针进行操作很有可能访问或改写未知的内存区域,也就可能引起缓冲区溢出的问题了。
请记住:因为内存溢出潜在的危害很大,所以必须注意和面对这个问题,特别是在网络相关的应用程序中。在调用
C 语言字符串经典函数(如 strcpy、strcat、gets 等)时,要从源代码开始就提高警惕,尽量追踪传入数据的流向,向代码中的每一个假设提出质疑。在访问数据时,注意对于边界数据要特殊情况特殊处理,还要对杜绝使用未初始化指针和失效后未置
NULL的“野指针”
。
8.
防止重复包含头文件
为了避免重复包含头文件,建议在声明每个头文件时采用“头文件卫士”加以保护,比如采用如下的形式:
#ifndef
_PROJECT_PATH_FILE_H_
#define
_PROJECT_PATH_FILE_H_
... ...
// 声明、定义语句
#endif
9.
优化结构体中元素的布局
在编程应用中,如果空间紧张,需要考虑节约空间,那么就需要将结构体中的各个变量按照上面的原则进行排列。基本的原则是
:把结构体中的变量按照类型大小从小到大依次声明,尽量减少中间的填充字节。
也可以采用保留字节的形式显式地进行字节填充实现对齐,以提高存取效率。其实这就是时间与空间的博弈。
10.
将强制转型减到最少
在 C/C++
语言中,强制转型是“一个你必须全神贯注才能正确使用”的特性。所以一定慎用强制转型。
将较大的整数转换为较短的数据类型时,会产生无意义的结果,而程序员可能被蒙在鼓里。正如下面的代码片段所示:
unsigned i =
65535;
int j = (int)
i;
输出结果竟然成了- 1。
较长的无符号类型在转换为较短的有符号类型时,其数值很可能会超出较短类型的数值表示范围。
类似的问题还会发生在有符号负数转化为无符号数、双精度类型转化为单精度类型、浮点数转化为整型等时候。以上这些情况都属于数值的强制转型,在转换过程中,首先生成临时变量,然后会进行数值截断。
11.
提防隐式转换带来的麻烦
在 C/C++
语言的表达式中,允许在不同类型的数据之间进行某一操作或混合运算。当对不同类型的数据进行操作时,首先要做的就是将数据转换成为相同的数据类型。C/C++ 语言中的类型转换可以分为两种,一种为隐式转换,而另一种则为上面提及的显式强制转换。显式强制转换在某种程度上还有一定的优点,对于编写代码的人来说使用它能够很容易地获得所需类型的数据,对于阅读代码的人来说可以从代码中获知作者的意图。而隐式转换则不然,它让发生的一切变得悄无声息,在编译时这一切由编译程序按照一定规则自动完成,不需任何的人为干预。
存在大量的隐式转换也是 C/C++
常受人诟病的焦点之一。隐式转换虽然带来了一定的便利,使编码更加简洁,减少了冗余,但是这些并不足以让我们完全接受它,因为隐式转换所带来的副作用不可小觑,它通常会使我们在调试程序时毫无头绪。如下所示:
void
Function(char c);
int
main()
{
long para = 256;
Function(para);
return 0;
}
上述代码片段中的函数调用不会出现任何错误,编译器给出的仅仅是一个警告。可细心的程序员一眼就能看出问题
:函数 Function(char c)的参数
c 是一个 char
型,绝不会出现在其取值区间内。但是编译器会自动地完成数据截断处理。。但是编译器会自动地完成数据截断处理。编译器悄悄完成的这种转换存在着很大的不确定性
:一方面它可能是合理的,因为尽管类型 long 大于char,但
para 中很可能存放着 char
类型范围内的数值 ;另一方面 para
的值的确可能是char
无法容纳的数据,这种“暗地里的勾当”一不小心便会造成一个非常隐蔽、难以捉摸的错误。
C
语言规定的转换规则是由低级向高级转换。两个通用的转换原则是:
(1)为防止精度损失,类型总是被提升为较宽的类型。
(2)所有含有小于整型类型的算术表达式在计算之前其类型都会被转换成整型。
12.
活用特殊指针类型 void*
众所周知,如果存在两个类型相同的指针pInt1 和 pInt2,那么我们可以直接在二者间互相赋值;如果是两个指向不同数据类型的指针pInt 和 pFloat,直接相互赋值则会编译出错。使用强制转型运算符把赋值运算符右侧的指针类型转换为左侧的指针类型,这一点在上面中已经解释得很清晰,代码如下所示:
int
*pInt;
float
*pFloat;
pInt =
pFloat;
//编译出错,提示“'=' : cannot convert from 'int *' to 'float
*'”
pInt = (int
*)pFloat;
//原文笔误pInt = (float *)pFloat; //正确,需强制转型
而 void *
则不同,任何类型的指针都可以直接赋值给它,无须强制转型,如下所示:
void
*pVoid;
float
*pFloat;
pVoid =
pFloat;
//正确,无需强制转型
如果函数的参数可以是任意类型指针,那么应声明其参数为 void *,最典型的例子就是我们熟知的内存操作函数
memcpy 和 memset
的原型:
void * memcpy(void
*dest, const void *src, size_t len);
void * memset (
void * buffer, int c, size_t num );
仔细品味,就会发现这样的函数设计是多么富有学问,任何类型的指针都可以传入memcpy 和 memset
中,传出的则是一块没有具体数据类型规定的内存,这也真实地体现了内存操作函数的意义。如果类型不是 void *,而是
char *,那么这样的
memcpy 和 memset函数就会与数据类型产生明显联系,纠缠不清,就是一个“纯粹的、脱离低级趣味”的函数设计!