我们可以从各种途径学习计算机,学习编程,也可以很快地学会一大打的编程语言。但是,所谓会不如精,学会编写好的程序,也是非常重要的。
下面,我就以实际开发中的情况为例子,记叙一下我遇到过的各种不好的编程习惯。希望由此自省,更希望对别人有所帮助。
我是做Linux下开发的,所以本文更加侧重Linux环境下的Console C程序设计。
格式问题
我们可以任意挥洒思路,却绝对不能任意涂抹程序。因为,程序并不是写出来,编译出来,就完事了。它是要被人读的,至少,作者以后可能读到。
好的格式,无疑是快速理解程序的开端。大概有以下这几种情况。
- 语句中双目的赋值、运算、逻辑、比较等运算符号两边,保留一个空格。
- 逻辑操作中,尽量用小括号表明操作顺序。
- for语句中三个分号后边,加一个空格,并且尽量不要写得太复杂。
- #define、#ifndef、#endif等语言,最好顶格写。即使有逻辑嵌套,也不要太深。宏定长越简单越好,太复杂了建议改为函数。
- 用空行把相关度低的语句分隔开,使整体上有一种分块的感觉。这也不仅是格式问题,好的编辑器(如VI)会按空行分别段落,有利于快速地定位自己的输入位置。
- 应该缩进的地方一定要缩进,使程序看上去层次分明。
- 不要在一行中写多条语句,虽然它们非常亲近。但是,也没有亲近到睡一张床的程度。
注释也要写好
注释是以后读这些源码最重要的提示,写得好了,可以事半功倍。
- 函数功能、输入输出之类的说明,写在头文件中,因为更多地时候,别人看得是函数声明。
- 大块地for、while语句的结尾,写好注释,以便标志好是哪的循环结束了。
- 注释不要写得太多。一行一注,没有必要。注释的作用是提醒,不是手把手地教科书。画龙点睛,才是上品。
- 注释风格要统一,这个不用多说。
变量名问题
变量名的命名,直接影响着程序的易读性以及易写性。
- 变量名长度适宜。建议全局的函数、变量等稍长,易于理解;局部的变量,临时的变量,可以尽量短小。没有必要一个临时变量写成the_own_string_10_length_int这样的int类型的变量名。
- 不要一个过程里声明太多同类型的变量,如果函数开头int了一大堆i, j, k, l, m, n,检查一下程序,看看同时能要到几个,剩下的删除。
初使化问题
变量声明了,一定要注意初使化。用得好了,这里面有很多技巧。
- 如果能保证以后的操作不会直接使用默认值,就不要初使化。比如,声明了一个FILE *fp,以后肯定会用它打开文件的,那就不用再加一条fp = NULL了。初使化,多少也是要浪费一点点时间及程序语句的嘛。
- 要用到的值,会在以后用到默认值,一定要有手动地初使化。不要假定声明的整数值是零或者假定声明的指针是NULL。
- 初使化过程的位置,最好与使用过程在同一个语句块中。处于不同的地方,会加大读者的理解负担。
条件判断语句
这是出问题最多的地方。
- float、double型的变量,不要直接与0做比较,0.0也不行。因为浮点数都是用计算机的二进制表示,都是近似的,计算结果为0往往表现为一个极小值。所以,如果想比较a-b是否等于0,应该比较a-b是否比0.0001(或者更小的值)还小。
- 指针值不要与0比,要与NULL比,虽然NULL的值就是零。这样给人一种前面那个变量是整数值的错觉。
- 不要出现没有逻辑运算符的条件判断。很多人喜欢if (a),来当成a是一个非零值,甚至像if(a=b)一样的与赋值操作写在一起,导致优秀的编译器出现善意的警告。
内存问题
- 在一个语句块里,有一个malloc,就要有一个free,必须严格对应。不要在函数体里分配函数外要用的空间。这样的话,我们就不得不记好,哪个函数调用完要free,手动记载指针也会麻烦。函数体里分配,就在函数体里用,用完free掉。
认真定义函数或程序返回值
处理好函数或程序返回值,才能很方便地被程序或其它程序调用。
shell下的那么多程序,可以被方便地写进脚本,联合起来,执行强大的功能,就是因为它们一致的接口、输入输出以及返回值。
- 判断真假的函数体,一般用0表示假,用1(或者有的用非0)表示真。
- 判断执行成功与否过程,一般用0表示没有错误,正常结束,用其它值,表示错误,多种不同值,也就可以区分各种错误。
- 判断是否相等的过程,一般用0表示相等,用正数表示大于,用负数表示小于。当然,这里的大于、小于,是相对大小,不一定是真的大小。
- 计算、赋值功能的函数,最好返回的就是结果。比如,计算过程等,这样可以方便地调用。
- 处理字符串一类的函数,最好返回结果的指针。看看strcpy是多么的好用,以至弄得那么多公司笔试要用它为难刚出校门的学生。
- 无关紧要的过程,返回void。比如程序中的help(); version(); usage();等函数,puts些语句罢了。不用返回。
严格处理调用失败
打开文件或分配内存时,一定要判断是否成功,否则我们将常常发生错误以后,找不着线索。调用函数时,也要在关键之处判断成功与否,有的,要仔细检查errno。
程序结构化
结构化的程序,易于理解,易于编写,易于维护。
- 如果程序中有两个以上的地方,要用到一块功能差不多的代码,就最好把它们摘出来,写成一个函数。
- 如果一个过程太长,就要考虑把其中的功能进行分块,把重要的几块写成几个分离的过程来处理。这样,对编写、调试及维护会非常有益处。这个没有严格的定义,一般来说,超过两屏的语句,就会大大降低程序的可理解性。另外一定注意,一个函数只实现一个功能,不要一个函数的功能七勾八连,越独立越好。
关于输出
输出操作可能是程序设计中最多的操作,同时,也是目前用得最滥的操作。
- 没什么实际意义的语句,不要输出。像什么“程序正在启动”之类的话,如果程序没有慢到让人以为没启动完成就死掉了的程度,就不要打印它了。
- 输出格式一定要严谨,宽进严出。程序(或函数)可以接收的值,一定要考虑全面,输出的值,一定要严格。这样,才会方便其它程序或人的使用。
- 输出的地方要标准。正常数据到标准输出,错误或警告到标准错误,文件到文件,应该到/dev/null了到/dev/null,哪一条都不可马虎。
效率问题
- 尽量减少在循环中的语句,只要循环体中的操作不会影响的值,就不要在循环体中进行赋值、计算或比较。
- 操作系统IO时,尽量一次性进行大量的操作。因为CPU、内存,都比IO要快数百乃至数千数万倍,不应该让慢速的IO耽误快速的运行。比如计算数值写文件,不要计算一个,写入,计
算一个,写入,那样是在自杀。一次计算N个,一次写入N个,效率会大幅提升。(N视具体情况而定) - 如果不是技术问题,尽量用回溯代替递归。回溯与递归之间的效率与资源使用差异,通常是指数级的。
- 如果一个功能有现成的库函数,并且我们不能保证写出比库函数更优秀的过程,就一定记着用库函数。库函数怎么也是经过时间考验的,在通用性、跨平台性以及效率上,多少是强一些。
- 尽量不要使函数参数超过5个,越少越好。首先是易理解性的问题,另外,很多系统的实际运行中,少于5个的函数参数是放在EBX、ECX、EDX、ESI、EDI寄存器里的,而超过5个的,则要进行别的转化处理,会大大降低指令的执行效率。
- 减少分支,减少判断。流水线模式的处理器,加上强大的预读预写处理,对于顺序指令,会高速地执行。但是,一旦遇到分支,它不得不停下来,等待条件语句执行完毕。虽然现在的处理器能进行分支预取,但效率的影响还是巨大的。
编译相关的东西
我们要写好的、功能强大的、稳定高效的程序,编译是一个不得不认真对待的方面。
- 高度重视编译警告,不要编译过去就完事大吉。源码被编译通过了,仅仅代表语法上问题不大,还过得去,往往大量地有用信息,包含在警告信息中。如果用GCC,建议alias gcc='gcc -Wall’一下,保证写出的程序,没有一个错误,也没有一条警告。
- 头文件一定要有#ifndef判断,保证库文件不会重复包含。
- 不用要#ifdefine填加大量的调试信息。这种调试信息混在程序中,会大大降低程序的可理解性,以及大大增加程序的源代码行数。
- 慎用编译器的最高极别的优化,巧用编译器的调试选项。总之一句,编译选项,值得好好研究。
少用字符串复制
假设字符串有10个字节,这样,每判断一个字节是不是’\0’,复制一个字节,CPU就要至少执行两次。10个字节,就是20次。并且,由于每次都进行判断,使得CPU的指令流水线无法或者低效处理,更是雪上加霜。所以,字符串复制,是一项对内存要求不大,但是却非常浪费CPU的操作,应该尽量避免。
在一个好的C程序中,会把所有的静态变量,写到一起,程序中用指针来指定。除非有输入或者输出,否则很少很少会用字符串复制。
少动态分配空间
动态分配内存,是一项复杂的操作,涉及到很多链表甚至遍历、判断等操作。而我们在栈上分配一块空间,只是栈顶指针偏移一下。所以,应该尽量减少动态分配内存,而是采用静态的方式。这样,效率要高许多。
有人喜欢做字符串输入的时候,先判断字符串长度,再申请长度加1的内存,这是非常得不偿失的举动。
另外,千万不要在一个函数中,把本可以静态分配的结构,非要动态分配,再在末尾释放。
不要自做聪明
程序应该是在保证功能、保证可读性、保证性能的前提下,越简短越好的。并不是写得花哨一些,就有多好。相反,有的时候,就是无用而且有害的。
比如有的程序员为了防止注入,把本来的strcpy (dst, src),硬生生写成strncpy (dst, src, strlen (src))。这除了增加一轮字符遍历导致性能下降而外,没有任何用处。因为,strcpy本身就是靠末尾的0结束符来判断复制完成的,而strlen也是靠末尾的0结束符来判断长度的。这样用strncpy,就是把一项操作,调用了两次。正确的做法是,比如我们的dst只有10字节长,那么,用strncpy (dst, src, 9),然后,硬性把dst[9] = ‘\0’。
不要愚公移山似的不怕辛苦
一个好的程序员,应该是做有意义的事,尽量少做重复的事。
在一个大的函数下,根据输入参数的不同,而处理大量根本不同的操作。这样的话,可以在调用这个函数的时候,不用判断参数,而方便一些。而结果是,导致别人读起来异常吃力,并且,效率也并不高。因为要判断的,在哪都是要判断的。这种情况,最好是用不同的函数,实现不同的功能。在调用的地方,判断情况,分别调用不同的函数。
还有的正好相反,功能相似的函数,只是一点参数不同,一写一大堆(当然可能是复制出来的),这样也不好。其实这样的地方,多半可以用宏来解决。或者,用偏移地址定位。比如,我们要根据输入的enum值,来返回不同的字符串,就可以把字符串声明成一个大的二维数组,这个函数用来返回二维数组的偏移量指针,最好不要不辞辛苦地写一大片switch或者if else。
不要让常规观念影响自己
我看到一个现象,写Windows程序熟练的人,喜欢把一列上的一个node,给一个int类型的handle(id),然后在程序中,到处传递这个handle(id),每次要定位这个node的时候,就是根据它的handle(id),到列上去查找,然后返回node。这可能是受关系数据库的观念影响吧?
为什么要这样?直接在程序各处用这个node的指针不是挺好么?指针是四个字节,int也是四个字节,而用指针根本不用遍历查找。
不要动不动初使化一些东西
很多第三方库的使用,需要进行一个初使化,然后使用它的功能,最后再释放。这就使得一些保险的程序员,为了保证程序不出错,一用到库中的功能的时候,就初使化一下,没有必要。因为类似这种情形的初使化,一般来说都是资源消耗非常大的。
比如很多要求性能的程序,连系统的内存都不会每次使用时再申请,而是有自己的内存管理算法,分配出来,多次使用。
对这类的库的好的方法是,程序运行最初,初使化这些东西,退出时再释放。如果怕出问题,就加一个全局的标志,只要进行过初使化,就标志一下,以后怕出问题的地方,都查看这个标志位。
有关void*的保留意见
曾经我几个同事,喜欢上了void *, 一定要求别人,写一个库的时候,一个handle用
typedef void *handle;
来写。于是,大量的以此结构为传入参数的函数,全都变成了接收void *类型的函数调用。
这种做法的好处是:
1、handle的真正结构被隐藏在了实现文件中,做到了信息隐藏。
2、操作handle的函数,可以接受任意类型的指针。
可是,带来的问题更多。
第一个问题就是,本来好好的函数代码,全都多了一层强制类型的转换。头文件的作用是什么?就是为了类型检查,避免出现参数不配产生的错误。
比如,我们声明了
int fun (int *a);
,当传入一个非int类型的指针的时候,编译器就会警告传错参数了。可是,如果变成
int fun (void *a);
再怎么传,编译器也不管对还是错了。这是我们希望的吗?
再有,头文件做到信息隐藏,有意义吗?我们给人家用我们开发的库,却还要把结构隐藏起来,给人家看到一个不正确的结构,对我们有什么好处?
有人说这样可以防止上层再有人声明同名的结构时产生冲突,这个逻辑上也说不过去。把结构换成void *,就可以解决命名空间问题吗?
还有人说,声明成这种结构,可以让上层用户不必关心结构细节。既然不关心细节,那么我们声明成什么结构,上层也不会管的。
恰恰相反,如果上层需要关心这里的细节,我们倒要声明成void *。
比如,pthread的启动函数
extern int pthread_create (pthread_t *__restrict __newthread,
__const pthread_attr_t *__restrict __attr,
void *(*__start_routine) (void *),
void *__restrict __arg) __THROW __nonnull ((1, 3));
这里为什么第四个函数参数是void *?就是因为这个结构,pthread不会管。而是由start_routine这个过程,自己去实现它自己的处理。
所以,我们可以写出这样的代码:
int *func (int *a);
int a;
pthread_create (&pth, &attr, func, &a);
这里传入了一个int的指针,完全正确。而如果这里需要传入一个结构,一样可以这样
struct sockaddr *func2 (struct sockaddr *a);
struct sockaddr a;
pthread_create (&pth, &attr, func2, &a);
这样,pthread这个函数,就实现了多态。
当然,这个例子也可以看出来,pthread_t不必声明成void *结构,也万万不应该声明成void *结构。
所以,我上面提到,在函数开头写强转,弊大于利。
|