就从避免语法语义错误,使用指针注意事项,以及做好可移植三个方面,来给出 C 程序员的防身指南。

轻松赢了一顿饭
有一回,一位同事让我帮忙看个问题,她写了段百十来行的代码,但运行总是不达预期,却又找不到原因在哪里。
观察她编译、运行演示一遍,看起来代码都对,但运行结果就是不对。我干脆拉过代码来一行一行地看。
忽然眼前似有一道金光闪过,我看到了问题所在,故意开个玩笑:“我找到了,要是解决了,请我吃饭吧。”
同事爽快地答应了,于是我把光标拉到下面的代码上:
  1. for (int i=0; i<N; i++);
  2.   do_something();
同事认真一看,捂脸认输,感叹道:“就这么个问题,走,请你吃饭。”
问题就出在for语句最后的分号上。单看这一句,问题很显眼,for 循环语句后的分号是多余的,它造成循环空转,do_something()方法得不到运行。
这种手误带来的麻烦其实可以避免,就是改变一下代码风格。
在写所有 for 循环执行体时都加上花括号,不用为此分心,办法就是这么简单。
  1. for (int i=0; i<N; i++) { //有了花括号,即使多打分号,也容易看得出来
  2. do_something();}
除了语法、语义问题,结合书中的内容,现给出下面三条良心建议:
  • 包括if, switch,while等条件判断或者循环语句时,都给执行体加上花括号。
  • 做比较判断时,将字面量写在变量之前。例如if(100 == x),这可以避免写成if(x = 100)这样的手误仍然通过编译。
  • 使用边界明确的内存复制方法,避开缓冲区溢出问题。例如禁止使用sprintf(),strcpy(),strcat()这类方法,而替换为snprintf(),strncpy(),strncat()。

做好这两件事,从此不再被指针虐
一个 C 程序员成长起来的标志,就是他真切懂得了被指针虐过的痛。指针这个概念在 C 语言中并不难理解,但用起来的时候,却是各种问题满天飞。
指针使用相关的问题有这么三类:
野指针:指针未初始化就使用,或者指向未知区域。
悬垂指针:指针指向的内存被释放,但却未置空。
越界访问:遍历数组或者链表时,超出了边界,造成非法访问。
上述问题导致的后果,要么数据内容异常,要么直接报Segmentation fault (core dumped)崩溃退出,这个让人闻风丧胆的提示,曾是多少 C 程序员心底的痛。
要在编程中避开上面这些坑,我们要做好两件事情,一是指针与数组的操作;二是指针对动态内存的操作
先说指针与数组的操作。
指针经常会用来对数组进行访问,以获得操作上的便利性。如果对数组的特性不熟悉,就会出错却不自知。我们先了解一下数组的基本特点:
C 语言中只有一维数组,数组元素可以是任意类型的对象。
对于一个数组,只能做两件事:确定该数组的大小,以及获得指向该数组下标为0的元素的指针。需要注意的是,数组首元素指针是个指针常量。
这给我们的启示就是,C 语言中的多维数组,是通过一维数组中包含另一个数组元素的方式给模拟出来的;对于数组的任何下标式操作,都能转化为同等功能的指针操作。
下面通过代码说明指针对数组的操作:
  1. int array[100]; // 声明有100个元素的整型数组,下标是0~99
  2. int *p = array; // 声明整型指针p,指向数组首元素array
  3. *p = 123; // 等同于array[0] = 123
  4. p++; // 指针移动到数组下一个元素,即a[1]的位置
  5. *p = 456; // 等同于array[1] 456
使用指针操作数组易出错的地方,是p++这一步可能会被误解为是只偏移一个字节,有同学会错误地使用p = p + sizeof(int)来实现。
接下来,再说指针对动态内存的操作。
指针对动态内存的操作引发的问题,是 C 语言中最为幽深晦暗的深坑,结合书中的内容,我总结出以下几条代码编写的原则:
  • 内存在哪里分配,就在哪里释放。即将malloc()与free()尽量置于同一个模块或者抽象层之内,而不要分散于不同层级中。
  • 调用malloc()之后立即对内存初始化。即不要假设内存分配函数会有初始化动作,小心驶得万年船。
  • 调用free()之后,立即给指针变量赋值NULL。不管后面的代码用不用这个变量,简单的一个操作,就能有效避免野指针的问题。
  • 要检测内存分配错误。即调用malloc()之后,要判断指针变量是否为NULL。不要假设内存分配永远会成功,发生内存泄漏时,它的调用就可能会失败。


做好可移植,要小心的几件事
C 程序员要说服自己相信一件事,就是自己写的程序如果在一种平台上运行良好,那它移植到另一个平台上,肯定会有问题。
这就是可移植工作的特点,完美地符合墨菲定律。最诡异的现象就是“明明在我这儿运行得很正常,跑你那去怎么就出错了呢?”
我在工作中就掉过一次坑。那是我将一个功能库移植到嵌入式平台上去,这个库在服务端已经稳定运行一段时间了。我想应该没什么问题,就直接交叉编译,然后测试。
结果它总是跑不稳定,有时候正常,有时候出错,飘忽不定,不可捉摸。没办法,问题要解决,嵌入式环境受限,gdb无法运行,只得在代码里塞满了打印语句。最后发现是个整型变量的值不对劲。
这个声明为unsigned long类型的变量,值超过4294967295就不对。结果用sizeof一探测,才发现服务器环境是64位,sizeof(long) = 8,嵌入式环境是32位的,sizeof(long) = 4。(这是个很low的问题,早期懵懂无知,各位见笑了)
尽管 ANSI C 竭力保证在不同平台上,相同的类型和方法,预期使用结果是一致的。但架不住 C 编译器厂商的各自为战,总在一些很小的细节上存在差别。这样的差别,就是名副其实的陷阱。
一个螺栓没对准,宇宙飞船都会爆炸。幸好《C 陷阱与缺陷》中将可移植会遇到的暗坑列举出来,我挑选比较有代表性的进行说明。

标识符名称不要与库函数重名
书中以malloc()方法为例,即为了追踪内存分配而自定义Malloc()方法又封装了一次。虽然大小写有差别,但在某些特殊平台上可能真就不区分大小写,这就不是个好主意。
解决办法是在自定义方法名前,添加独特不易重复的前缀名。例如工程名全称是cactus_project,那么取其缩写再和方法名拼接,即可为cac_malloc()。

统一预定义整型数类型
这就是为了解决前文中我所犯过的错误,在不同平台上long的类型是不一样的问题。可以使用#define进行宏定义,更建议使用typedef进行类型预定义。
以下示例则可以确保在所有平台上,int64就是64位有符号整型数,而uint64就是64位无符号整型数:
  1. typedef long long int int64;
  2. typedef unsigned long long int uint64;

小心处理NULL
如果字符型指针置为NULL,表示空指针状态,对其进行读访问时存在不确定性行为。这种情况一般出现在打印调试信息的语句里。
简单示例如下:
  1. char *p = NULL;
  2. printf("result: %s\n", p);
有的平台能正常工作,只是显示result: (null),而有的平台则会直接提示段错误并退出。
最好的应对方法还是在使用指针之前,要进行一次非空判断。当然,我也能理解在处理打印语句时,直接显示null也是有意义的,这时候又要增加判断分支,还要额外处理,C 程序员们不一定会乐意。
不过从程序可移植性来考虑,写代码时谨慎一些,就不用掉到这样无谓的坑里,节约了大量的修改调试成本,还是相当值得的。

结语
《C 陷阱与缺陷》的作者是 Andrew Koenig ,他在1977年加入贝尔实验室,之后开始从事 C 语言研究。在 C/C++ 领域,Andrew 大神星光闪耀,他编写的数百篇研究论文,给多少程序员带来启示,也帮助他们避开一个又一个深坑。
他将自己在使用 C 语言时遇到的问题,进行整理之后在1985年通过论文发表出来。出乎意料的是,随后共有2000多人,向贝尔实验室的图书馆索取该论文的副本。Andrew 大神便将这篇论文扩充,于是就有了历经37年而长盛不衰的《C 陷阱与缺陷》。
刚入行的 C 程序员,如果你正掉在坑里苦苦挣扎,那么赶紧拿起这本书帮自己爬出来吧;入行多年还没看过这本书的老鸟们,那更不要等了,相信拿起来随便一翻都能想起一段血泪往事,要以此书警醒自己不要再掉到更幽暗的坑里了。
人在江湖漂,防身指南常在手,不怕会挨刀!

来源:异步社区