当前位置:Linux教程 - Linux - 编写优质无错代码

编写优质无错代码

--------------------------------------------------------------------------------


一. 引言
八月上旬,深圳举办了一个讨论会,主题是""编写优质无错代码""。这个讨论会吸引了深圳各大软件公司,通信公司的程序员,系统分析员参加,并在讨论会后纷纷表示,这种讨论会很有实际价值,希望将这种形式的讨论会继续下去,形成一个论坛,以提高大家的编程水平和交换有价值的信息资料。

这个活动的发起是从网络上开始的。我偶然看到了这个讨论会的论题,发生了兴趣。本来周末的我一般是很懒的,没什么事情是不会出门的。而当我看到这论题后,就给举办者发信表示愿意参加。于是,一个周六的下午,我就坐在了讨论会的现场。参加完这个讨论后,我觉得有必要把其中的精华部分写下来,和网络上的广大程序员共享,于是就有了这篇文章。

二.主题:
编写优质无错的代码---讨论会主题。
相信每个程序员都有这种希望,谁都不愿意自己写出来的代码在release之后出错,需要不停的修改维护。但是,主持人提出了这样一个问题:""编写优质无错代码是否必要?"" 为什么呢?我稍微解释一下。在项目的时间很
紧张的时候,是按期完成任务重要,还是代码的稳定性,优质无错重要呢?
主持人提出的四个具体问题是:
1、编写优质无错的代码的代价是什么?
2、代码的质量重要还是编写效率重要?
3、在压力的情况下,你会牺牲质量来提高效率么?
4、编写优质无错的代码是否意味着效率的降低?
对于第一个问题,编写优质无错代码的代价当然是时间,不过随着编程人员的经验逐渐丰富,所需要的时间也逐渐减少。对于第二个问题,代码的质量比编写效率重要。当你花了1周时间写出来的代码需要你花一个月或者更长的时间去debug, 去修改错误,这种效率的损失是得不偿失的。对于第三个问题, 这需要看项目经理或者产品经理的态度和专业精神了。如果在一个专业的项目经理或者产品经理的指挥下,当然是首先保证质量其次提高效率。而对于某些项目经理或者产品经理来说,按时完成任务是最重要的,他们往往不在乎在软件发布之后花比开发时间长得多的时间去修改程序,维护错误。因为,对于他们来说,首先是要完成任务,好给上级领导交差,至于后期维护,就是另外一个任务了,维护花的时间多,正说明了他这个项目的复杂性和难度。而对于开发人员来讲,所希望的则正好相反。开发人员不喜欢花太多的时间在一个烂摊子上。所以,在讨论会上,大家纷纷表
示,应该让项目经理或者产品经理也来听一听这个讨论会:-)。
对于第四个问题,当然优质无错代码不是意味着效率的降低,而是正好相反,对提高效率有很好的促进作用。一个版本发布之后,如果因为错误太多,开发人员不得不去花很多时间修改bug, 甚至要从系统的体系结构方面去做
大的改动,重新编写部分代码,这种效率的降低才是更大,更不能承受的。而且,花了太多的时间在老版本的维护上,必然影响到新版本的工程进度,直接影响到整个产品线的质量和进度,严重的甚至会毁掉整个产品。

对于这一个主题,我的回答是,在时间允许的范围之内,尽量提高代码的质量,不追求慢工出细活,不追求代码的100%无错,但是要保证99%以上的无错。这样,在时间的压力下,在质量要求的束缚下,就要求程序员有一个良
好的习惯,和稳健的编程风格,以保证代码的优质无错。这就是第二个问题:什么是编写优质无错的代码的核心思想?优质无错是相对的,而不是绝对的。任何代码,都不可能说是绝对无错的,但是在绝大部分情况下,是稳定的,强健的,优质的,无错的。每次发布的时候,都会对上次的发布版本做若干修改,增强功能的同时,也要修改若干bug。那么,核心思想就是:怎样才能自动地查出这个错误。怎样才能避免这个错误。

三.编写优质无错代码的经验
在说了上面很多理论性的问题之后,来看一看具体问题。先来看一看一个具体的题目:(我本人就是先在网上看了这个题目,才对这个讨论会发生兴趣的)
题目1:
作为开发团队的一员,你需要实现一些库函数提供给其他人使用。假设你实现的一个函数原型如下:

int DoSomeThing(char* pParam)
{
...
}

你们约定好参数pParam不能为NULL,但为了防止调用者错误传递NULL,你需要在你的函数里做判断处理。
请问你会选择那种方式,并说明原因?

(a) if (!pParam)
return 0;

(b) if (!pParam)
return ERROR_PARAM;

(c) if (!pParam)
pParam = """";
...

(d) if (!pParam)
throw EXCEPTION_ERROR_PARAM;

(e) if (!pParam)
MessageBox(...);

(f) assert(!pParam);

(附加说明一点,基于目前开发人员技术分布情况和参与讨论会的人员的技术分布情况,这次所列举的例子都是C/C++和Java方面的,不涉及到VB, PB, Delphi等语言。不过对于这些程序员,讨论也是有借鉴作用的。)关于这个问题,大概是所有的程序员都会遇到的。所以,在网上和讨论会上,都发生了激烈的争论和意见交换。我大概把主要的几种观点记录了一下,列举在下面:

1、选择f的理由
因为非NULL是约定,所以可以确定是调用者的问题,f可以明确地指出这一点,防止错误扩散。
我的附加说明: 防止错误扩散的意思是,如果用其他方式,比如throw exception的方式,这个异常不一定会在调用此函数的上一层被捕捉到,可能会被继续抛出直到最上一层或者直到在某一层被catch到,这样的话,错误就会距离发生地点很远,扩散开来。这一观点,代表了一大部分的程序员的观点。

2、反对用f
不赞成assert, assert更重要的作用是程序体里面的一个注释, 在阅读程序的时候起作用不能依赖他来检测错误, 很大程度上assert容易使使用者依赖它本不应该依赖的东西。
这也代表了部分程序员的观点,认为assert是不可依赖的,而应该依赖于错误检测,比如返回值或者异常。

3、另外一种观点
f和d都可取。如果没有系统开销的考虑,d则更好些。可以一举两得。如果没人catch这个exception,其结果就跟f一样,按bug处理,dump core留下一stack trace。如果有人catch,那就按运行错误处理......但是返回一特初值表示错误,只是将错误上交,掩耳盗铃而已。最终总得有个人assert,messagebox,throw exception,perror+exit,或别得什么的。既然已经是约定,就干脆付起责任。

4、一种反对d的理由
不可用d, 这就像你用人,却不相信人一样,偏要try,catch防范他。其实那个错是自己造成的,如果看到异常就容易不检讨自己。

5、关于观点3的支持意见
讨论过程中,有人认为assert检查的是bug, 而异常是可以恢复的意外情况。所以,观点3的支持者说:可恢复的意外是可以理解的,但可恢复的bug就没什么意义了。既然已经约定好了,你再违背,就属于是bug而不是意外了(比如打不开文件什么的)。很多库函数都不检查指针的合法性(除了系统调用以外,因为总不能让系统dump core吧),也不检查指针是否为NULL(因为如果层层都检查,必定劳民伤财,干脆让最上面调用的人在调用之外查)。

6、选择d+f
选f+d, 好处如下:
a以最激烈的方式,充分暴露调用都的错误!能及时修改BUG
b便于调试,问题出现后,直接到事故现场。比120还快!
c对于realse版的代码没有任何副作用。
d以处理的代价来看 采用断言也是编写最小一种。
e它是多语种,多平台所通用的方式, 如:C /C++ VB,Java1.4 在win ,unix通吃, 便于移置!
如果在现实中,测试没有能找到所有的BUG,那可能就要用异常来帮忙了!

当然,我也提出了我的观点, 我支持观点6。理由如下:
assert只在debug标志的时候有用,而在编译release版本的时候不起作用。assert对于检查硬编码的错误,是非常有用的,能够及时的查处编码的错误。比如borland c++的类库源代码中就有很多这样的assert。但是assert
不是万能的,因为有很多错误的发生不是完全在编译时发生的,而是运行时的错误。在release后,assert是不可能依赖的。那么,我们就需要exception这一机制来检测运行时错误,并相应的做出处理。当然,在异常检测和处理过程中还有许多需要讨论的问题,由于不是这一题目的范围,我们没有必要继续讨论得太多,但是,提出来希望大家注意:异常不是捕获了就完成任务了,而要对于不同的情况,采取不同的处理办法,千万不能只是捕获,而不做任何处理,那样和不捕获异常没有任何区别。

在题目刚刚提出的时候,选择各种答案的人都有,所以,我有必要在这里把其他答案为什么不能选的理由说一下。

(a) if (!pParam)
return 0;
这是很多初级程序员常常采取的一种方式。返回值设为0。 因为函数的返回值往往是计算的结果,不赞成把错误标志值和计算结果混在一起使用,容易造成使用者的误会。当然,在很多unix函数中,由于历史原因,还存在很多这样子的函数,所以需要指出,不要沿用这种方式。

(b) if (!pParam)
return ERROR_PARAM;
b比a稍微好一点点,返回了一个常量或者预定义的宏。 从返回值的字面上,调用者能知道发生了什么错误,但是,这也不是一种好的方法。
(c) if (!pParam)
pParam = """";
...
这是最不好的方式。直接给pParam赋予空字符串,然后继续函数过程,这容易造成不可预料的后果,是程序不稳定的根源。
(d) if (!pParam)
throw EXCEPTION_ERROR_PARAM;
抛出异常,刚刚已经讨论过了,不再赘述。
(e) if (!pParam)
MessageBox(...);
这是一种比较可笑的方式,当然也有不少人用。MessageBox是直接弹出一个对话框,告诉使用者,出错了。但是并不做任何处理,程序继续往下执行,直到出错崩溃。呵呵
(f) assert(!pParam);
断言,刚刚已经讨论过了,不再赘述。

以上这个题目,引发了所有与会者的兴趣,讨论异常热烈,最后,主持人也给出了自己的观点:d+f。当然这并不是标准答案,因为编程这一门课程本来就没有什么标准答案,大家见仁见智,这个答案只是经验的积累。

主持人紧接着列出了""编写优质无错代码的经验"":
a.理想的编译器和实际的编译器
b.使用断言
c.函数的界面设计
d.考虑风险
e.态度的问题

以上是本节的主要内容。断言,刚刚的问题中已经讨论过了,来看看其他的内容。

理想的编译器和实际的编译器:

题目二:
下面memcpy函数实现有什么问题:
Void *memcpy(void *pvTo,void *pvFrom,size_t size){
byte *pbTo=(byte *) pvTo;
byte *pbFrom=(byte *)pvFrom;
while(size -- >0);
*pbTo++= *pbFrom++;
return pvTo;
}

呵呵,粗略一看,这函数还真找不出问题来。但是仔细看看,你就会发现while(size -- >0);
这里多了一个分号,导致下面的*pbTo++= *pbFrom++;不是在while循环中执行多次,而是只执行了一次。当然这不是函数设计者的预期结果,而编译器却不会报告错误,因为while(size -- >0);从语法上来讲,并没有错误。这就是理想的编译器和实际的编译器的区别所在。

那么,该怎么检查这种错误呢?主持人提出了如下办法:
while(size -- >0) NULL; 可以加入NULL来解决空语句. 这样子,当你需要 while(size -- >0)
*pbTo++= *pbFrom++;
这种形式的时候,就不会发生错误了,只需要用眼睛看看,就能发现了。两点好处 1 无冗余代码,2 使人更明白。减少风险.

还有人会这么写
if( (n=read(....)) == 1) ....
在这里,赋值符号=和判断相等的符号==容易敲错,而编译器又检查不出来,可能就会有如下错误:
If(ch = ‘ ’)...;这也是需要注意的问题。

理想的编译器和实际的编译器小结:
a.把屡次出错的合法的C习惯用法看成程序中的错误
b.增强编译器的警告级别
c.使用其它的工具来检查代码 如 Lint 等
d.进行单元测试
e.消除程序错误的最好方法是尽可能早、尽可能容易地发现错误,要寻求费力最小的自动查错的方法
f.努力减少程序员查错所需的技巧

使用断言
题目三
下面函数实现,哪一个好,为什么?
a.
char Uptolower(char ch){
if(ch >= ‘A’ && ch <= ‘Z’)
return ch+=‘a’-’A’;
return -1;
}
b.
char Uptolower(char ch){
assert(ch >= ‘A’ && ch <= ‘Z’);
if(ch >= ‘A’ && ch <= ‘Z’)
return ch+=‘a’-’A’;
return ch;
}
c.
char Uptolower(char ch){
assert(ch >= ‘A’ && ch <= ‘Z’);
return ch+(‘a’-’A’);
}
分析:
a.该函数检查ch是否在A..Z之间,如果是,则返回相应的小写字符,如果不是,则返回-1。
缺点在于:把错误标志值和计算结果混在一起使用,容易造成使用者的误会。

b.该函数使用了断言,如果ch在A..Z之间则返回相应的小写字符,如果不是,断言会起作用,程序发生错误并退出。而最后一个return ch;则是在release的时候,如果不是A..Z之间,则返回原来的字符。但是,从书写效率上来说,这个函数稍微罗嗦了一点。因为它重复使用了断言和if判断。

c.该函数也使用了断言,返回相应大写字母的小写字母。

使用断言的好处:
a.暴露了调用者的错误
b.便于调试
c.对代码没有代价
d.最少的处理代价

断言使用举例:
void memcpy(void * pvTo,void *pvFrom,size_t size){
void *pbTo= (byte *)pvTo;
void *pbFrom= (byte * pvFrom);
assert(pvTo !=NULL && pvFrom !=NULL);
assert(pbTo >= pbFrom +size'' ''pbFrom >= pbTo+size);

}

使用断言的规则:
a.要使用断言对函数参数进行确认
b.要从程序中删去无定义的特性或者在程序中使 用断言来检查出无定义特性的非法使用
c.不要浪费别人的时间-详细说明不清楚的断言
d.消除所做的隐式假定,或者利用检查其正确性
e.在进行防错性程序设计时,不要隐瞒错误防错性程序设计虽然被誉为有较好的编码风格,但它却隐瞒了错误。
要记住,我们正在谈论的错误决不应该再发生,而对这些错所进行的安全处理又编写无错代码变得更加困难
f.要利用不同的算法对程序的结果进行确认
g.不要等待错误发生,要使用初始检查程序

断言小结:
a.要同时维护交付和调试两个版本。封装交付的版本,应尽可能地使用调试版本进行自动查错。
b.断言是进行调试检查的简单方法。要使用断言捕捉不应该发生的非法情况。不要混淆非法情况与错误情况之间的区别,后者是在最终产品中必须处理的。
c.使用断言对函数的参数进行确认,并且在程序员使用了无定义的特性时向程序员报警。涵数定义得越严格,确认其参数就越容易。
d.防错性程序设计会隐瞒错误。在进行防错编码时,如果”不可能发生”的情况确实发生了,要使用断言进行报警。


写到这里,我们初步探讨了编写优质无错代码的必要性,原则,和相关经验。
留几个练习题目,大家也参与一下讨论吧。
练习题目1:
下面的memset函数实现有什么问题?

void *memset(void *pv, byte b, size_t size)
{
byte *pb = (byte *)pv;
unsigned long l;
size_t sizeSize;

l = (b << 8) | b; /* 用4个字节拼成一个long */
l = (l << 16) | l;
pb = (byte *)longfill((long *)pb, l, size/4);
size = size % 4;

while (size-- > 0)
*pb++ = b;
return (pv);
}

练习题目2:

下面的代码用memset将三个局部变量置为0,请问可能会有什么问题?
void DoSomeThing(...)
{
int i;
int j;
int k;

memset(&k, 0, 3*sizeof(int)); // 将i,j,k置为0
...
}

练习题目3:

定义结构如下:
typedef struct
{
char c1;
char c2;
int n;
} stru;
请问sizeof(stru)等于多少?并说明理由。

练习题目4:

下面是C语言中两种if语句判断方式。请问哪种写法更好?为什么?
int n;

if (n == 10) // 第一种判断方式
if (10 == n) // 第二种判断方式

练习题目5:

下面的代码有什么问题?
void DoSomeThing(...)
{
char* p;
...
p = malloc(1024); // 分配1K的空间
if (NULL == p)
return;
...
p = realloc(p, 2048); // 空间不够,重新分配到2K
if (NULL == p)
return;
...
}

练习题目6:

下面的代码有什么问题?
char *DoSomeThing(...)
{
char str[16];

...
return str;
}

练习题目7:
下面的代码有什么问题?

char *_strdup( const char *strSource )
{
static char str[MAX_STR_LEN];

strcpy(str, strSource);
return str;
}

练习题目8:

下面的代码有什么问题?并请给出正确的写法。
try{
FILE* fp = fopen(""c:1.dat"");
if (NULL != fp)
{
...
}
fclose(fp);
}
except(EXCEPTION_EXECUTE_HANDLER){
}

我敲字敲累了,告一段落吧。不过,讨论会并不止讨论了这些内容,还有很多内容我没有写完,比如,函数的界面, 编写代码的风险, 编程的态度等等问题。作为补充,我把讨论会的幻灯片修改成了文本版本,作为另外一篇文章放在这里,以便对这个话题感兴趣的网友们参考。有什么问题,欢迎来信 [email protected] 继续探讨。