编程体会
作者:孟永辉
soeml@hotmail.com
因为我从事的Windows编程,开发工具主要是VC6.0,所以以下的观点有可能有Windows编程习惯的痕迹,但我会尽量是以C/C++的观点来说明。除非进行了特别注明,否则与具体的开发工具无关。
关于编程规范,大家可以参考编程规范。本文的内容是我认为编程规范需要强调的和一些个人的编程经验。由于C++/C编程是众所周知的技术,没有秘密可言。编程的好经验应该大家共享,若有自认为好的编程习惯或方法请告诉我,我会收录到这篇文章或后续的文章中。谢谢!
如果有时间,我也愿意再继续写一些东西。
因为个人的水平有限,所以会存在错误或不当之处。如果有任何问题请与我联系。
对文中提到的<编程规范>,指<XXXX>和<XX编程规范>。
1 引言软件质量是被大多数程序员挂在嘴上而不是放在心上的东西!
作为软件工程师,我们首先要树立质量和责任意识。我认为在公司现有的开发流程下(质量管理部主要做功能测试)测试最终软件产品的质量主要取决于开发人员。也可以说,产品出现BUG的主要责任应由开发人员来承担。这不是为测试工程师和其他人员开脱责任,而是说我们一定要有这个意识。
编程质量差往往是由于不良习惯造成的,与人的智力、能力没有多大关系。
关于C++/C编程风格,难度不高,但是细节比较多。别小看了,提高质量就是要从这些点点滴滴做起。世上不存在最好的编程风格,一切因需求而定。团队开发讲究风格一致,如果制定了大家认可的编程风格,那么所有组员都要遵守。如果你觉得某些编程风格比较合你的工作,那么就采用它,不要只看不做。人在小时候说话发音不准,写字潦草,如果不改正,总有后悔的时候。编程也是同样道理。
与代码质量的相关的重要因素有:
l 设计文档
l 编程风格
l 编程经验
l 编程语言
l 编程工具
l 操作系统
l 错误处理
l 安全处理
参考资料:
<高质量C++编程指南>
<应用程序调试技术>
<C语言编程常见问题解答>
<Effective C++中文版>
<C++ Primer>
...
2 注意注释作为软件工程师,我们的工作是双重的,即为用户开发解决方案,同是还要使该解决方案在将来是可维护的。使我们编写的代码是可维护的唯一方法是对代码进行注释。对于“注释”,我的意思不只是简单地编写代码功用的注释,而是将假设、方法以及选择所使用的方法的原因写进文件。同时,注释必需与代码相符。通常情况下,当维护程序设计者们试图更新那些功用与注释描述的功能不一致的代码时,工作效率往往是极低的。
对于关于注释的规范,请参见<编程规范>。
2.1 单入口单出口原则结构化程序设计技术的定义是结构化程序设计技术是一种程序设计技术,它采用自顶向下逐步求精的设计方法和单入口单出口的控制结构,并且只包含顺序、选择和循环三种结构。结构化程序设计的核心思想是Algorithms+Data Structures=Programs。结构化程序设计虽然不是最好的的程序设计方法并且好像已经过时。我认为结构化程序设计应该和其它的程序设计技术结合起来使用。但对于它的单入口和单出口原则无论是在详细设计和编码阶段都应该坚决贯彻的。
2.1.1 详细设计在详细设计阶段基本上确定了程序的数据和控制的流向。在进行详细设计时一定要确保程序执行相同的功能调用的函数和顺序是一致的,也就是单入口和单出口,这有利于程序的实现、测试和维护。另外,在程序维护时也要确保不要为了增加某个功能而另外建立一条数据流通道,而是要仔细分析是否可利用现有的入口和出口。
有关例子此处不列举了,但相信每一个编程老手都会受到在程序结构不是单入口和单出口的问题的困扰。
2.1.2 函数设计2.1.2.1 函数只有一个出口以下是从WebKeeper中摘录的一段代码,为了保证函数只有一个出口,利用了tryfinally终止处理结构,这避免了函数内部的多个return语句。试想一下,如果改用return或其它方式来改写以下的模块,会是多么复杂和难以维护。假设函数体中再有对同步量的占有和释放过程,则拥有多个出口的函数将是更难编写和维护。
__try
{
//创建目录内存映射文件
{
HANDLE hFileMap = NULL;
……
g_hDir = CreateFileW(lpszFileName, ….);
if(INVALID_HANDLE_VALUE == g_hDir)
__leave;
if((GetFileSize(g_hDir, NULL) == 0)
&& (AddFileHeader(g_hDir) != ERROR_SUCCESS))
__leave;
if(!(hFileMap = CreateFileMapping(g_hDir, …, 0, 0, NULL)))
__leave;
if(!(g_pDir = (char*)MapViewOfFile(hFileMap, FILE_MAP_ALL_ACCESS, 0, 0, 0)))
__leave;
CloseHandle(hFileMap);
}
//创建索引内存映射文件
{
…
}
//创建数据内存映射文件
{
}
g_bLoaded = TRUE;
}
__finally
{
if(!g_bLoaded)
CloseGlobalObject();
else if(IsBadBackupLib())
RepairBackupLib();
}
2.1.2.2 语句块只有一个出口程序流应该清晰,避免使用goto语句和其它(比如
break,continue)跳转语句
例如:
for(int a = 0; a < 100; a++)
{
Func1(a);
if(a == 2)
continue;
Func2(a);
}
它可以被改写成如下的形式:
for(int a = 0; a < 100; a++)
{
Func1(a);
if(a != 2)
Func2(a);
}
这段程序更易于调试,因为循环体内的代码清楚地显示了应该执行和不应该执行什么。假设现在要加入一些在每次循环的最后都要被执行的代码,在第一段程序中如果维护者注意到了continue语句,就不得不对这段程序做的修改;如果没有注意到continue语句,那么恐怕就要犯一个难以发现的错误了。在第二段代码中,要做的修改很简单,只需把新的代码加到循环体的末尾。
当使用
break语句时,可能会发生另外两种错误。
for(int a = 0; a < 100; a++)
{
if(Func1(a) == 2)
break;
Func2(a);
}
假设Func1()的返回值永远不会等于2,上面循环就会从1进行到100;反之,循环在到达100以前就会结束。假设因维护的需要要在循环体中加入代码,一种危险是我们可能认为它确实能从0循环到99;另一种危险可能来自对a值的使用,因为当循环结束后,a的值并不一定就是100。
但我们可能按以下形式编写这个for循环:
for(a = 0; (a < 100) && (Func1(a) != 2); a++)
这样当维护这段代码时就很难犯前面的那些错误了。
2.1.2.3 多个出口还可能导致程序代码膨胀内联析构函数可能是程序代码膨胀的一个源泉,因为它被插入到函数中的每个退出点,以析构每一个活动的局部类对象。《C++Primer 3 P579》。
3 命名规则比较著名的命名规则当推Microsoft公司的“匈牙利”法,该命名规则的主要思想是“在变量和函数名中加入前缀以增进人们对程序的理解”。例如所有的字符变量均以ch为前缀,若是指针变量则追加前缀p。如果一个变量由ppch开头,则表明它是指向字符指针的指针。
但是也没有必要对每个变量的命名都用这个方法,在某些情况下单字符的名字也是有用的,常见的如i,j,k,m,n,x,y,z等,它们通常可用作函数内的局部变量。
1. 命名规则尽量与所采用的操作系统或开发工具的风格保持一致。例如Windows应用程序的标识符通常采用“大小写”混排的方式,如AddChild。而Unix应用程序的标识符通常采用“小写加下划线”的方式,如add_child。别把这两类风格混在一起用。
2. 为了防止某一软件库中的一些标识符和其它软件库中的冲突,可以为各种标识符加上能反映软件性质的前缀。例如三维图形标准OpenGL的所有库函数均以gl开头,所有常量(或宏定义)均以GL开头;又如动态链接库AutoRecover.dll的所有引出函数名均以ar开头。
4 杂项4.1 new和delete操作符4.1.1 尽量以new和delete取代malloc和free原因很简单:malloc和free函数对构造函数和析构函数一无所知。
4.1.2 new很多程序员习惯这样用new操作符:
#include <new>
……
if(char* p = new char[100])
{
…;
}
这样写的目的是判断分配内存是否成功,然后针对分配成功与否进行相应的处理。但这样写代码达不到我们的目的。因为当分配不成功时是触发异常(std::bad_alloc)而不是返回NULL(这依赖于编译器和相关编译设置),当分配不成功的情况出现以后如果没有异常处理将会弹出异常对话框,导致程序终止运行。有关这个问题在很多书籍和资料中都认为new在失败后返回NULL,其实这种观点是不正确的。
若是让new在分配失败时返回NULL,可用如下形式:
#include <new>
……
if(char* p = new (std::nothrow) char[100])
{
…;
}
说明:在用VC6.0的main和WinMain项目时在DEBUG和RELEASE两种方式都没有问题。在MFC的项目下使用时在RELEASE下编译和运行都没有问题,但在DEBUG下编译通不过。通过研究发现在源文件的开头有这样几行代码:
#ifdef _DEBUG
#define new DEBUG_NEW
#undef THIS_FILE
static char THIS_FILE[] = __FILE__;
#endif
原来是DEBUG环境下已经把new定义成了DEBUG_NEW,问题就出在这里。通过进一步的研究发现在AFX.H中有如下代码:
// Memory tracking allocation
void* AFX_CDECL operator new(size_t nSize, LPCSTR lpszFileName, int nLine);
#define DEBUG_NEW new(THIS_FILE, __LINE__)
原来是为了内存跟踪和保存调试信息,AFX小组已经把new进行了重载,而这个重载过的new是不支持nothrow的。难道是AFX开发小组忽略了与C++标准的兼容吗?也许是,但也许这样做也可能是该小组成员认为这样可以在DEBUG环境下更快的发现问题,而不允许使用new的nothrow版本。
其实CObject类也把new进行了重载了三个版本,但没有重载nothrow版本。这说明了分配一个以CObject为基类的时,不能使用nothrow版本,只能使用抛出异常版本。以下是CObject的声明片断:
class CObject
{
public:
……
// Diagnostic allocations
void* PASCAL operator new(size_t nSize);
void* PASCAL operator new(size_t, void* p);
void PASCAL operator delete(void* p);
#if _MSC_VER >= 1200
void PASCAL operator delete(void* p, void* pPlace);
#endif
#if defined(_DEBUG) && !defined(_AFX_NO_DEBUG_CRT)
// for file name/line number tracking using DEBUG_NEW
void* PASCAL operator new(size_t nSize, LPCSTR lpszFileName, int nLine);
#if _MSC_VER >= 1200
void PASCAL operator delete(void *p, LPCSTR lpszFileName, int nLine);
#endif
#endif
……
};
不论使用正常形式(抛出exception)的new,或是“nothrow”形式的new,重要的是我们已经准备好处理内存配置失败的情况了。最简单的办法就是利用set_new_handler,因为它在两种形式中都可用。
4.1.3 deletedelete的作用是确保每个一对象的析构函数被调用。且new[]和delete[]一定要成对使用。
例如:
…
CObject* pObj = new CObject[5];
…
delete oObj;
这段代码能够把所分配的内存全部释放掉,不会出现内存泄漏问题。但只调用了第一个对象的析构函数,而不是全部的5个。对基本类型(比如分配字符串数组),没有析构函数可调用,但为了规范和维护的不出问题,也一定要保证delete和new的形式要匹配。
4.2 边编码边调试被动式的程序设计是提示有错误出现的错误处理代码,而主动式的程序设计则会告诉我们错误出现的原因。被动式的编码只是修改和解决错误工作的一部分。软件工程师们通常会努力制定出明确的被动策略,例如,校验指向某一字符串的指针不是NULL,但是通常不采纳主动式程序设计所要示的另外一个步骤,即检查这个相同的参数,查看是否有足够的内存来支持字符串所允许的最大字符数。主动式程序设计同时这意味着在编写代码的同时,查找存在问题的部分,因此编码一开始,调试过程也就开始了。
实际上,错误并不会无缘无故地出现在代码中。其原因实际上是我们在编写代码的同时也输入了错误,而且这些令人讨厌的错误可以来源于任何地方。但是,必需接受这样一个事实:错误是由人为引起的。
由于开发者要对代码中的任何错误负责,问题就成了找出一种方法来创建一个检查和对比的系统,以便在编码的过程中找出错误。这种方法称为“信任,但要校验”。为了避免错误我检验其它开发者传送到我编写的代码中的数据、校验我编写的代码的内部处理功能、校验我在代码中所作的任何假设、校验我编写的代码传送给他人的数据、并且校验我编写的代码进行的调用所返回的数据。
需要强调软件开发哲学中的一个重要原则:代码质量仅是软件开发工程师的责任,而不是测试工程师、技术资料编写者或管理者的责任。
4.2.1 声明声明有很多函数,大家可以先用自己喜欢的一个或几个。例如ASSERT、assert、_ASSERT、_ASSERTE、ASSERT_KINDOF、ASSERT_VALID和VERIFY。
使用声明除了能帮助我们更快地发现问题并定位BUG点以外还有一个附加的好处:就是它起到了注释的作用,帮助自己以后或其他维护工程师更快更清楚的知道该函数的假设条件。
使用声明的注意事项:
1.声明的第一个原则是每次只检查单独的一项。如果只用一个声明检查多个条件,我们将无法知道故障是哪个条件造成的。
2. 声明一个条件时,需要尽可能全面地检查该条件。
C/C++和API提供了各种各样的函数帮助我们尽可能以描述性的语言编写声明。
a) GetObjectType
b) IsBadCodePtr
c) IsBadReadPtr
d) IsBadStringPtr
e) IsBadWritePtr
f) IsWindow
g) AfxIsValidAddress
h) AfxIsMemoryBlock
i) AfxIsValidString
注意:IsBadStringPtrt和IsBadWritePtr不是线程安全的。详见<MSDN>。
3. 需要声明所有出现在函数中的参数。对于接口函数和开发组中其他成员调用的公用类来说,声明参数是尤其重要的。因为这是进入自己所编写代码的入口点,需要确保各个参数和假设是有效的。进入到自己的模块中以后,对模块的专用函数的参数不用进行太多的检查,因为问题主要取决于参数的起源地。只需一些经验即可判断需要声明哪些参数。
4. 另一个经常使用声明的地方,是在普通的处理流程中,调用的函数的返回值。
5. 使用声明的最后一个地方是需要对假设进行检查的地方。例如,如果某一函数需要3MB的磁盘空间,就使用一个声明检查该假设。另一个例子是,如果函数使用了一个指向某一特定数据结构的指针数组,应遍历该数据结构,验证每个独立的项都是有效的。
6. 对每一个声明还要进行if语句判断,声明不能代替判断。
一个使用声明的代码片段:
BOOL BUGSUTIL_DLLINTERFACE __stdcall
HookOrdinalExport ( HMODULE hModule ,
LPCTSTR szImportMod ,
DWORD dwOrdinal ,
PROC pHookFunc ,
PROC * ppOrigAddr )
{
ASSERT ( NULL != hModule ) ;
ASSERT ( FALSE == IsBadStringPtr ( szImportMod , MAX_PATH ) ) ;
ASSERT ( 0 != dwOrdinal ) ;
ASSERT ( FALSE == IsBadCodePtr ( pHookFunc ) ) ;
// Perform the error checking for the parameters.
if ( ( NULL == hModule ) ||
( TRUE == IsBadStringPtr ( szImportMod , MAX_PATH ) ) ||
( 0 == dwOrdinal ) ||
( TRUE == IsBadCodePtr ( pHookFunc ) ) )
{
SetLastErrorEx ( ERROR_INVALID_PARAMETER , SLE_ERROR ) ;
return ( FALSE ) ;
}
if ( NULL != ppOrigAddr )
{
ASSERT ( FALSE ==
IsBadWritePtr ( ppOrigAddr , sizeof ( PROC ) ) ) ;
if ( TRUE == IsBadWritePtr ( ppOrigAddr , sizeof ( PROC ) ) )
{
SetLastErrorEx ( ERROR_INVALID_PARAMETER , SLE_ERROR ) ;
return ( FALSE ) ;
}
}
}
4.3 线程一般情况下创建线程应该用_beginthreadex而不是CreateThread,因为后一个函数为API,当线程终结时在大多数情况下会有内存和资源泄漏。
只有在万不得已的情况下才用TerminateThread,但一定要注意,除了内存和资源会释放不了以外,还可能会导致某些同步量释放不了。经过测试被TerminateThread掉线程占有的临界段(CRITICAL_SECTION)和事件(Event)释放不了,但互斥量(Mutex)能够释放。但不管怎样,杀掉线程以前知道将要发生的结果是有益的。详见<MSDN>关于TerminateThread的说明。
4.4 MFC的数据类CString、CList和CArray等最好只在一个模块内部使用,若在不同模块间进行通信时的接口参数或结构中的成员是这些类型则很可能发生内存(运行不正常)问题。原因是对这些数据类的赋值等操作都伴随着内存的释放和分配等操作,问题就转化成了在一个模块分配内存而在另一个模块释放。而在一个二进制模块分配内存又在另一个二进制模块释放内存是我们要尽量避免的。原因是:在一个项目的不同模块中分别链接不同的C运行库是可以的,但不同的C运行库对内存的处理是不一样的(比如RELEASE和DEBUG,MultiThreaded和MultiThreaded DLL等)。这就是在一个模块中分配的内存而在另一个模块释放是不安全的原因,也是我们要统一各个模块的编译设置的原因之一。
另外,在结构中也最好不要使用这些类作为成员。因为有些程序员为了操作方便和认为这样不会浪费空间而习惯这样做。不要让它们作为结构成员的原因之一是,因为要常常利用结构对象进行模块间的通信。原因之二是我们常常把对其他结构惯用的一些操作应用到采用了CString等作为成员之一的结构上面,例如赋值、内存拷贝等。因为CString等数据成员只是一个指针,而它们的析构函数一般都要进行内存释放操作所以会非常容易造成对一块内存释放两次的内存错误。当然,第二种错误可以通过重载赋值操作符和拷贝构造函数来避免。但为什么不用其他方法替代呢?现在的程序运行环境下,空间对我们来说非常重要吗?使用CString一定比char [MAX_PATH]节省空间吗,不一定,这要视具体情况而定,因为每一个new操作都会占用额外的空间(大概至少是12-32字节,这和具体的库实现版本有关),当然也要多花费一定的时间。这样做了至少造成两个后果:a)这样开发出来的产品运行易出错;b)难于开发、调试和维护。
4.5 表达式和基本语句
1. 如果代码行中的运算符比较多,用括号确定表达式的操作顺序,避免使用默认的优先级。
由于将运算符的优先级熟记是比较困难的,为了防止产生歧义并提高可读性,应当用括号确定表达式的操作顺序。例如:
word = (high << 8) | low
if ((a | b) && (a & c))
2.不可将布尔变量直接与TRUE、FALSE或者1、0进行比较。
根据布尔类型的语义,零值为“假”(记为FALSE),任何非零值都是“真”(记为TRUE)。TRUE的值究竟是什么并没有统一的标准。例如Visual C++ 将TRUE定义为1,而Visual Basic则将TRUE定义为-1。
假设布尔变量名字为flag,它与零值比较的标准if语句如下:
if (flag) // 表示flag为真
if (!flag) // 表示flag为假
其它的用法都属于不良风格,例如:
if (flag == TRUE)
if (flag == 1 )
if (flag == FALSE)
if (flag == 0)
3.if语句
有时候我们可能会看到 if (NULL == p) 这样古怪的格式。不是程序写错了,是程序员为了防止将 if (p == NULL) 误写成 if (p = NULL),而有意把p和NULL颠倒。编译器认为 if (p = NULL) 是合法的,但是会指出 if (NULL = p)是错误的,因为NULL不能被赋值。
把if(p == NULL)误写成if(p = NULL)的情形是很可能发生的,也可能成为一个难以发现的BUG。依赖编译器也许能够发现这一点,比如把VC的警告设置成4级,就可以发现这个问题,但你得有这样的习惯。总而言之,if(NULL == p)这样的写法应是我们提倡的。
4. case语句的结尾不要忘了加
break,否则将导致多个分支重叠(除非有意使多个分支重叠)。
5. 不要忘记最后那个default分支。即使程序真的不需要default处理,也应该保留语句 default : break; 这样做并非多此一举,而是为了防止别人误以为你忘了default处理。
6. 尽管switch语句的最后一个分支不一定需要
break语句,但最好还是在switch的每个分支后面加上
break语句,包括最后一个分支。这样做的原因是:程序很可能要让另一个来维护,他可能要增加一些新的分支,但没有注意到最后一个分支没有
break语句,结果使原来的最后一个分支受到其后新增分支的干扰而失效。在每个分支后面加上
break语句将防止发生这种错误并增强程序的安全性。此外,目前大多数优化编译程序都会忽略最后一条
break语句,所以加入这条语句不会影响程序的性能。
7. for语句
可能的情况下,要用某一标志而不是固定的次数作为循环终止条件。这有利于程序的扩展和维护。例如,为了遍历某一个结构数组(最后一个元素全0或结构中的重要值是0代表结构数组的终结)则终止条件可为判断相应值是否标志结束。这要比用固定次数为终止条件有更好的扩展和维护性能。
为了保证for语句只有一个出口,不要在循环体内部使用跳转(continue,break)语句。
4.6 使用const常量1. C++ 程序中只使用const常量而不使用宏常量,即const常量完全取代宏常量。
2. 如果参数是指针,且仅作输入用,则应在类型前加const,以防止该指针在函数体内被意外修改。例如char *strcpy( char *strDestination, const char *strSource )。
这样做还会有附加的好处,例如:
{
char szNewFileName[MAX_PATH];
CString strOldFileName;
strOldFileName = “abc.doc”;
strcpy(szNewFileName, strOldFileName);
}
但若strcpy的原型是char *strcpy( char *strDestination, char *strSource ),则要完成类似功能需编写如下代码:
{
char szNewFileName[MAX_PATH];
CString strOldFileName;
strOldFileName = “abc.doc”;
strOldFileName.GetBuffer(strOldFileName.GetLength());
strcpy(szNewFileName, strOldFileName);
strOldFileName.ReleaseBuffer();
}
3. 建议对函数内部不进行修改的所有参数声明为const。这有利于编译器帮助我们发现错误,并可生成效率稍高的二进制文件。
例如:假如strcpy函数内部实现若不更改指针值则可声明为:char *strcpy( char *const strDestination, const char * const strSource ),但C函数库的声明不是这样的,这也说明了在这个函数实现中应该对这两个指针值进行了更改。以下是该函数的一种可能的实现方式:
char *strcpy(char *strDest, const char *strSrc);
{
assert(strDest!=NULL);
assert(strSrc !=NULL);
char *address = strDest;
while( (*strDest++ = * strSrc++) != ‘\0’ );
return address;
}
请大家注意:const char* const psz这样写法的含义,并习惯这样的写法。
4. 如果输入参数以值传递的方式传递对象,则宜改用“const &”方式来传递,这样可以省去临时对象的构造和析构过程,从而提高效率。
4.7 函数设计1. 对C++程序而言,函数内部的局部变量应该最近定义。这会使程序看起来更直观,同时也方便维护和排错。
2. 避免函数有太多的参数,参数个数尽量控制在5个以内。如果参数太多,在使用时容易将参数类型或顺序搞错。
3. 在函数体的“入口处”,对参数的有效性进行检查。
很多程序错误是由非法参数引起的,我们应该充分理解并正确使用“断言”(assert)来防止此类错误。详见“边编码边调试”。
4. 在函数体的“出口处”,对return语句的正确性和效率进行检查。
如果函数有返回值,那么函数的“出口处”是return语句。我们不要轻视return语句。如果return语句写得不好,函数要么出错,要么效率低下。
注意事项如下:
(1)return语句不可返回指向“栈内存”的“指针”或者“引用”,因为该内存在函数体结束时被自动销毁。例如
char * Func(void)
{
char str[] = “hello world”; // str的内存位于栈上
…
return str; // 将导致错误
}
(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的变量不存在构造函数与析构函数,虽然该“临时变量的语法”不会提高多少效率,但是程序更加简洁易读。
5. 仅要检查输入参数的有效性,还要检查通过其它途径进入函数体内的变量的有效性,例如全局变量、文件句柄等。
5.用于出错处理的返回值一定要清楚,让使用者不容易忽视或误解错误情况。
4.8 使用安全的C/C++库函数和Windows API使用strcpy,strcat,sprintf,fgets等函数有缓冲区溢出的危险,每一个合格的程序员都会非常谨慎地使用它们。但最好禁用这些函数。
为了自己会在某一天忘记了这条约束,可在公共头文件中进行如下定义:
#define strcpy Unsafe_strcpy
这样如果使用了strcpy,编译器会提醒我们。
可用strncpy版本的函数替代strcpy函数:
有如下的语句块:
{
char szBuf[MAX_PATH];
strcpy(szBuf, lpszInput); //lpszInput为用户键盘输入的字符串
}
上面的代码是不安全的,可用如下语语句代替:
{
char szBuf[MAX_PATH];
strncpy(szBuf, lpszInput, sizeof(szBuf) / sizeof(szBuf[0]));
if(szBuf[sizeof(szBuf) / sizeof(szBuf[0]) – 1])
{
//szBuf缓冲区长度不够,进行合适的处理
szBuf[0] = 0;
}
}
应该用_snprintf代替sprintf,用法与strncpy有点不同,如下:
{
char szBuf[MAX_PATH];
szBuf[sizeof(szBuf) / sizeof(szBuf[0]) – 1] = 0;
_snprintf(szBuf, sizeof(szBuf) / sizeof(szBuf[0]), “%s%d”, lpszInout, 0x233323);
if(szBuf[sizeof(szBuf) / sizeof(szBuf[0]) – 1])
{
//szBuf缓冲区长度不够,进行合适的处理
szBuf[0] = 0;
}
}
或
{
char szBuf[MAX_PATH];
if(_snprintf(szBuf, sizeof(szBuf) / sizeof(szBuf[0]), “%s%d”, lpszInout, 0x233323) < 0)
{
//szBuf缓冲区长度不够,进行合适的处理
szBuf[0] = 0;
}
}
不安全的C/C++库函数和API还有很多,大家可以多看一下这方面的书籍或资料。
CreateProcess,WinExec等使用不当也可能造成一些漏洞,参见<MSDN>。
4.9 杜绝“野指针”和“野句柄”“野指针”不是NULL指针,是指向“垃圾”内存的指针。人们一般不会错用NULL指针,因为用if语句很容易判断。但是“野指针”是很危险的,if语句对它不起作用。
在使用任何一个指针或句柄等前先进行判断是非常有益的。否则你认为目前的程序代码中某个指针一定是有效且可用的,于是就没有进行判断而直接使用,但随着时间的推移和代码量的增大,在维护程序时你有很大的可能注意不到这一点,而出现一个难以发现的BUG。这也是改正了一个BUG而又增加了一个或几个BUG的原因之一。也有可能是自己没有考虑到某一个边界情况,而自认为该指针或句柄等一定是有效的。出现了这种情况更难调试,有可能还要依靠测试工程师的帮助。因为这个边界条件是很难重现的,外在表现是程序在绝大多数情况下运行正常,而某刻又不正常。寻找这样的BUG真是太困难了。为什么不在开始写代码时就做严格的检查呢?
“野指针”的成因主要有两种:
(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 << “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就成了“野指针”。
4.10 使用const提高函数的健壮性看到const关键字,C++程序员首先想到的可能是const常量。这可不是良好的条件反射。如果只知道用const定义常量,那么相当于把火药仅用于制作鞭炮。const更大的魅力是它可以修饰函数的参数、返回值,甚至函数的定义体。
const是constant的缩写,“恒定不变”的意思。被const修饰的东西都受到强制保护,可以预防意外的变动,能提高程序的健壮性。所以很多C++程序设计书籍建议:“Use const whenever you need”。
11.1.1 用const修饰函数的参数
如果参数作输出用,不论它是什么数据类型,也不论它采用“指针传递”还是“引用传递”,都不能加const修饰,否则该参数将失去输出功能。
const只能修饰输入参数:
u 如果输入参数采用“指针传递”,那么加const修饰可以防止意外地改动该指针,起到保护作用。
例如StringCopy函数:
void StringCopy(char *strDestination, const char *strSource);
其中strSource是输入参数,strDestination是输出参数。给strSource加上const修饰后,如果函数体内的语句试图改动strSource的内容,编译器将指出错误。
u 如果输入参数采用“值传递”,由于函数将自动产生临时变量用于复制该参数,该输入参数本来就无需保护,所以不要加const修饰。
例如不要将函数void Func1(int x) 写成void Func1(const int x)。同理不要将函数void Func2(A a) 写成void Func2(const A a)。其中A为用户自定义的数据类型。
u 对于非内部数据类型的参数而言,象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)。
“const &”修饰输入参数的规则
11.1.2 用const修饰函数的返回值
u 如果给以“指针传递”方式的函数返回值加const修饰,那么函数返回值(即指针)的内容不能被修改,该返回值只能被赋给加const修饰的同类型指针。
例如函数
const char * GetString(void);
如下语句将出现编译错误:
char *str = GetString();
正确的用法是
const char *str = GetString();
u 如果函数返回值采用“值传递方式”,由于函数会把返回值复制到外部临时的存储单元中,加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节“返回值的规则”。
u 函数返回值采用“引用传递”的场合并不多,这种方式一般只出现在类的赋值函数中,目的是为了实现链式表达。
例如
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 则是非法的。
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关键字只能放在函数声明的尾部,大概是因为其它地方都已经被占用了。
4.11 BOOL的使用1. 如果可能应使用int代替 BOOL作为函数传入参数。说明:原因有二,其一是BOOL参数值TRUE/FALSE的含义是非常模糊的,在调用时很难知道该参数到底传达的是什么意思;其二是BOOL参数值不利于扩充。
2. bool占用一个字节,BOOL占用4个字节。所以在一个系统中不同模块间进行通信或参数传递时应保证一致,否则在某些情况下会出现缓冲区溢出问题。建议使用BOOL,因为:a)BOOL是一个宏,便于移植;b)把bool变量作为结构成员时,因为结构对齐的需要,不一定会节省空间。
3. 一个项目的开发中是使用bool还是BOOL一定要统一。否则极易产生缓冲区溢出。
4.12 提高程序的效率程序的时间效率是指运行速度,空间效率是指程序占用内存或者外存的状况。
全局效率是指站在整个系统的角度上考虑的效率,局部效率是指站在模块或函数角度上考虑的效率。
1. 不要一味地追求程序的效率,应当在满足正确性、可靠性、健壮性、可读性等质量因素的前提下,设法提高程序的效率。
2. 以提高程序的全局效率为主,提高局部效率为辅。
3. 在优化程序的效率时,应当先找出限制效率的“瓶颈”,不要在无关紧要之处优化。
4. 先优化数据结构和算法,再优化执行代码。
5. 有时候时间效率和空间效率可能对立,此时应当分析那个更重要,作出适当的折衷。例如多花费一些内存来提高性能。
6. 不要追求紧凑的代码,因为紧凑的代码并不一定能产生高效的机器码。
4.13 一些有益的建议1.当心那些视觉上不易分辨的操作符发生书写错误。
我们经常会把“==”误写成“=”,象“||”、“&&”、“<=”、“>=”这类符号也很容易发生“丢1”失误。然而编译器却不一定能自动指出这类错误。
2. 变量(指针、数组)被创建之后应当及时把它们初始化,以防止把未被初始化的变量当成右值使用。
3. 当心变量的初值、缺省值错误,或者精度不够。
4. 当心数据类型转换发生错误。尽量使用显式的数据类型转换(让人们知道发生了什么事),避免让编译器轻悄悄地进行隐式的数据类型转换。
5. 当心变量发生上溢或下溢,数组的下标越界。
6. 当心忘记编写错误处理程序,当心错误处理程序本身有误。
7. 当心文件I/O有错误。
8. 避免编写技巧性很高代码。
9. 不要设计面面俱到、非常灵活的数据结构。
10. 代码质量比较好,尽量复用它。但是不要修补很差劲的代码,应当重新编写。
11. 尽量使用标准库函数,不要“发明”已经存在的库函数。
12. 尽量不要使用与具体硬件或软件环境关系密切的变量。
13. 把编译器的选择项设置为最严格状态。
14. 如果可能的话,使用PC-Lint、LogiScope、BoundsChecker/SmartCheck(一种错误检测工具)、TrueTime(一种性能工具)、TureCoverage(一种代码覆盖工具)等工具进行代码审查。我这儿只有BoundsChecker,如果谁有上面提到的工具或其它编程工程工具请共享一下。
5 调试1. 当我们发现一个BUG后,应该先尝试用多种方法复制这一错误,然后再开始调试。以多种方式复制错误以保证你看到的是它本身,而不是多个错误相互掩盖和混合。如果繁重的调试工作以前,先进行“创见性的思考”,则情况会好些。
2. 一般情况下(特殊情况除外),边编码边测试,在代码覆盖率达到80%以前提交给质量管理部是一种不负责任的做法。
3. 进行项目开发时,若项目被划分成若干个二进制模块,把开发时使用的编译链接设置设置成完全一样(尤其是其中的某些关键项)的是有利的,这能避免一些难以调试的莫名其妙的问题。
4. 把警告设置成最高级别且设置Warnings as errors,并把每一个警告消灭掉是一个非常好的习惯。很多程序在VC的3级警告下甚至还有几个警告,很难让人相信这是一个质量合格的程序。以下是摘选代码,它们对警告设置进行操作,在编译选项中已经设置成最高级别4级(以下示例均以VC6.0为例):
a)某些库函数的头文件在最高警告级别下编译出来警告较多,以下为处理方式
……
#pragma warning(push, 3)
#include <iostream>
#pragma warning(pop)
….
b)有未使用的函数参数但以后会用到,或是系统提供的函数的参数目前未用
……
UNREFERENCED_PARAMETER(hModule);
UNREFERENCED_PARAMETER(lpReserved);
……
c)对某一程序段有类或几类警告我们认为是合理的,但要用以下方式进行显式处理
……
#pragma warning(push)
#pragma warning(disable:4706)
……
#pragma warning(pop)
…
相关警告的更多用法和解释说明,请参考<MSDN>。
5. 在基于MFC的开发中,派生自CObject的类都会继承下来AssertValid和Dump两个虚函数。这两个函数是MFC的内嵌调试函数。
a) AssertValid()。是一个虚成员函数,检查传给它的类对象中的每个成员,从而判断该对象是否合法。直接使用AssertValid存在两个问题:其一是只有定义了_DEBUG之后,AssertValid才存在;其二是因为AsserValid是虚成员函数。使用非法指针调用虚拟成员函数总是致命的错误。使用ASSERT_VALID宏可以解决这两个问题。
b) Dump()。也是一个虚成员函数,当一个对象存在时该函数提供给程序员尽可能多的信息,但必须准备好输出。MFC会倾尽所有已知的关于出错对象的信息,仅仅一个对象的信息就有几十行。调用的方式可为Dump(AfxDump)。
值得一提的是要利用这两个函数,需要我们在自己的类中进行重载。把自己想检查的内容和想输出的内容写成代码,这样才有实用性。例如:
void CTgetsView::AssertValid() const
{
CView::AssertValid();
ASSERT_VALID(&m_cMousePositions);
ASSERT_VALID(&m_cMouseButtons);
ASSERT_VALID(m_cMousePositions.GetSize() == m_cMouseButtons.GetSize());
for(int i = 0; i < m_cMousePositions.GetSize(); i++)
{
...;
ASSERT(...);
}
}
void CTgetsView::Dump(CDumpContext& dc) const
{
dc << "\nDump of CTgetsView@" << (void*)this << "\n";
dc << "m_nPrevIndex = " << m_nPrevIndex << "\n";
CView::Dump(dc);
}
6. CMemoryState是MFC的内存泄漏调试类。有兴趣的话可以学习并应用一下。例如:
CMemoryState msObject;
//…Code Continues…
msObject.CheckPoint(); //update the msObject
//…Keep on tracking…
msObject.DumpAllObjectsSince();
Dump()现在被限定在CheckPoint()调用后的范围内。可以用Difference()比较一下两个检查CmemoryState对象的区别。
7. 很多内容只能点到为止,因为a)每一个主题都可以阐述成某个书籍一章,而这方面的书籍和资料多得很且都比要我认识的要全面和深刻,好多内容我也是东拼西凑出来的。;b)我拣了一点有些书籍不太注意的细节进行了较详细的解释和说明,比如有关警告的部分;c)好多内容我也只能用到了或看书时才会想到。d)本文的任务主要是对编程过程中的一些易忽视的方面引起大家的重视,“勿以善小而不为”,否则不管是自己或他人维护你的程序是非常困难的。
8. 对本文的某些观点大家同意,以后的工作中可以多加注意。如果认为没必要,则可以置之不理。但若发现有任何错误或不当之处请一定要告诉我。
9. 谢谢大家能有耐心看完这篇文章!
6 其它1. 使用MFC向导创建的MFC 共享方式的DLL工程中编译设置中,两个宏_MBCS,_USRDLL默认中有的,但使用RESET功能后会没有,没有_USRDLL会导致问题,所以要手动加上。这应该是MS的一个BUG。
2. 以下是ScanMain工程中出现的一个链接冲突的问题描述及解决方法,解决的途径是修改预编译头文件的设置。在这个工程中的StdAfx.h文件中也有相应的文字说明。
3. 以下代码示例摘自ScanMain.dll的GetScanMethodByID()函数
//-:)以下语句写成如下写法编译器会出错:SM_MTDMAP_ENTRY e(WORD(dwVulnID));
//但写成SM_MTDMAP_ENTRY e((WORD)dwVulnID);再以e作为lower_bound的第三个参数则没有问题。这应该是VC6的一个BUG。
//目前的做法是去掉了e的显式定义,直接给lower_bound传递了一个临时对象。
vector<SM_MTDMAP_ENTRY>::const_iterator
it = lower_bound(pve->begin(),
pve->end(),
SM_MTDMAP_ENTRY((WORD)dwVulnID));
4. 扫描引擎运行已经正常运行了一年多,大概从6.0.0.9升级包以后,变得不稳定。经过调试发现在写结果时的同步没有起作用。因为CMutexHelper类在很多程序中都有使用,所以认为是出现的内存方面的问题,才导致同步没有生效。调试了大概2周左右,没有任何进展。后来李杰发现CMutexHelper的构造函数中使用了一个未初始化的成员变量,只不过因为该成员变量一般情况下的随机值非0,所以才会运行正常。正确代码如下:
class CMutexHelper
{
public:
CMutexHelper(DWORD hMutex, DWORD dwMilliseconds = INFINITE)
{
if(hMutex)//正确
{
m_hMutex = HANDLE(hMutex);
m_dwResult = WaitMutex(dwMilliseconds);
}
else
{
m_hMutex = NULL;
m_dwResult = DWORD(-1);
}
}
~CMutexHelper()
{
…
}
private:
DWORD WaitMutex(DWORD dwMilliseconds)
{
…
}
CMutexHelper(const CMutexHelper&);
CMutexHelper& operator=(const CMutexHelper& rhs);
HANDLE m_hMutex;
DWORD m_dwResult;
};
错误代码如下:
class CMutexHelper
{
public:
CMutexHelper(DWORD hMutex, DWORD dwMilliseconds = INFINITE)
{
if(m_hMutex) //错误。该成员变量没有初始化
{
m_hMutex = HANDLE(hMutex);
m_dwResult = WaitMutex(dwMilliseconds);
}
else
{
m_hMutex = NULL;
m_dwResult = DWORD(-1);
}
….. //其它代码段相同。
}
本次调试,历时很长,断断续续一个月的时间,于2005-6-23结束。经验如下:1}调试程序一定要静下心来,怀疑一切。其实这个问题应该很好发现,就因为方向错了,所以导致一直发现不了问题;2)使用未初始化的类的成员变量在最高警告级别(4级)警告下没有警告。3)其实最开始也怀疑此处了,当m_hMutex为0时,弹出一个对话框,但因为该工程中包含的CommonFunction.h有两个,分别位于Kng10Lib\Common和CommonFiles\Include下,只改了其中一个,且这个不是在应该修改的。所以造成了调试的方向错误。之后把该工程下的所有包含位置统一定位于CommonFiles\Include。4)十分钦佩STL的线程安全特性,通过打印TRACE文件发现重入非常严重,但被同步的vector的状态和数据仍然是完整的。这个程序能够稳定运行这么长时间,显然也受益于STL的线程安全。