Skip to content

C 陷阱与缺陷

C Traps and Pitfalls

出版社 作者 原书年份
人民邮电出版社 Andrew Koening 1989

想要学好一门编程语言,应该阅读什么样的图书?在大多数场合下,我都会向他推荐市面上最新出版的图书。原因就是:以现在计算机领域内技术的发展速度,几乎每隔一段时间,我们就需要对自己现有的知识进行更新。这样看来,使用一本比较新的图书,里面的东西会比较贴近当前技术的发展,因而也就能够让你更容易掌握所要学的东西。

Ch0. 导读

回忆:词法、语法、语义

在进入接下来的章节前,让我们回顾一些知识。

《计算机科学导论》第 9 章中我们学到,程序设计语言的编译步骤由以下四个步骤组成:

  • 词法分析器
  • 语法分析器
  • 语义分析器
  • 编译模块

请回忆它们的工作和作用。

本章简单介绍了全书各个部分将要讨论的内容。

Ch1. 词法陷阱

本章我们关注字符是如何组成符号的。

  • 术语 token (符号)是语言的基本表意单元。字符组成符号。例子: ->file 都是符号.
    • 同一组字符序列在不同上下文中可能属于不同符号。

我们来看几个例子,请思考这些程序的行为(注意运算符优先级)。

=== 混用

while(c = ' ' || c == '\t')
    c = getc(f);
if((filedesc == open(argv[i], 0)) < 0)
    error();

open() 函数

这是一个系统调用:

#include <fcntl.h>
int open(const char *pathname, int flags)

我们将在 Linux 进阶中学习到与系统调用有关的知识。

贪心法

C 的词法分析遵循贪心法

  • 如果该字符可能组成符号,那么再读入下一个字符,直到读入的字符串已经不可能再组成一个有意义的符号。

我们举几个可能不恰当的例子来说明贪心法:

a---b
a -- - b
a - -- b

y = x/*p /* p point to the dividen */
y = x / *p

n-->0
n-- >0
n- -> 0

a+++++b

请思考上面几个表达式的行为。

整形常量

  • 10010 的含义截然不同。
  • ANSI C 以后禁止在八进制数中使用字符 89

混用的情况可能在需要格式对齐时出现,此时不要为数字添 0,可能造成八进制结果。

字符串和字符常量

单引号引起的字符实际上代表一个整数,而整数的存储空间(16 位或 32 位)可以同时容纳多个字符。因此,有些 C 编译器允许在一个字符常量中包括多个字符。但对于这样的行为没有准确的定义

'yes'

有的编译器可能忽略后面的字符,有的编译器可能用后面的字符逐个覆盖前面的字符。GCC 和 Clang 采用后一种方法。

Ch2. 语法陷阱

本章我们关注符号是如何组成声明、表达式、语句和程序的。

函数声明

Question

请设计一个 C 语句,调用首地址为 0 的函数。

对于上面这个问题,标准答案是 (*(void(*)())0)()

接下来我们一步步解释这一函数调用。

声明的组成

本书将声明看做由:类型和一组类似表达式的声明符组成。简单的逻辑就是:对声明符求值时应当返回指定类型的结果。

  • float f, gfg 求值应返回 float
  • float ff()ff() 求值应返回 float
  • float *pf*pf 求值应返回 float
  • float *g(), (*h)()*(g())h() 求值应返回 float (本例中,注意运算符优先级和函数指针的意义)

函数指针

应当始终理解:函数指针实际表达的意义是 (*fp)()fp() 只是简写。在声明中,必须使用前一种形式。这是因为 () 的优先级比 * 高,所以必须用圆括号将指针 (*fp) 括起来。事实上,K&R C 不允许第二种形式的使用。

再回忆一些相关的知识:

  • 函数名可以代表函数的地址
  • 函数指针类型名最好这样声明:V_FP_CHARP,即说明返回值、名称、参数列表。

类型转换符

把声明中的变量名去掉,再用括号封装,就变成了类型转换符,比如对上面的每一例:

  • (float)
  • (float (*)())
  • (float *)
  • (float *()), (float (*)())

我们要想调用地址为 0 的函数,可能会这样想:

(*0)()

但是,0 应当使用强制类型转换为我们需要的类型,类型转换符是:(void(*)())

使用 typedef 可以更清晰地解决该问题:

typedef void (*funcptr)();
(*(funcptr)0)();

理解 typedef

本书给出的理解声明的方式也能让我们更好地理解 typedef:直接在声明语句前面加上 typedef 就将该声明符作为该声明符的类型的替代。

  • typedef float *pfpf 代表 float *
  • typedef float *g()g 代表 float *()

例子:signal()

signal() 函数

声明在 <signal.h> 中,它接受一个代表需要被捕获的特定 signal 的整数值,和一个用户提供的用于处理 signal 函数的指针。

我们先从用户定义的函数开始设计。用户可能会定义这样的处理函数:

void sigfunc(int);

因此,我们将 signal 函数声明为:

void (*signal(int, void(*)(int)))(int);

解释:传递 (int, void(*)(int)) 参数以调用 signal 函数,对 signal 的返回值(是一个函数指针类型)解引用得到一个函数,然后传递一个整数给这个函数,最后返回值是 void

运算符优先级

(略)

分号

有几种情况下的分号会导致重大问题:

  • ifwhile 等语句的结尾
  • 上面语句和 return 的结合,如:
if(n<3)
    return
logrec.date = x[0];
logrec.time = x[1];
  • 结构声明的结尾,再碰到函数定义:
struct logrec{
    int date;
}
main ()
{
    //...
}

这种情况下结构会被当做函数的返回值。

switch 语句

活用 switch 语句处理步骤比较相近的分支。

悬挂的 else 语句

牢记:else 与括号内最近的一个 if 匹配,并在多重选择、嵌套的情况下用好花括号即可避免该错误。

Ch3. 语义陷阱

本章考察看起来一切都显得合情合理,但在所有 C 实现中都是未定义的行为。

指针与数组

本节重点理解数组名与指针的概念、数组与指针的转化。

  • 除了被用作 sizeof 的参数这一情形,其他情况下数组名都代表指向数组首元素地址的指针。

    • 注意,这个首元素可能还是数组,让我们来看一个多维数组的情况:

      int calendar[12][31];
      p = calendar;
      

      这里的 calendar 是一个指向数组的指针。

  • 还有 &array_name 的情况,得到一个指向数组的指针。

  • 因为 *(a+i)*(i+a) 含义相同,因此 a[i]i[a] 相同。

对于上面的日历多维数组,我们写一个你可能从来不会这样写的循环:

int (*montp)[31];
for (monthp = calendar; monthp < &calendar[12]; monthp++){
    int *dayp;
    for (dayp = *monthp; dayp < &(*monthp)[31]; dayp++)
        *dayp = 0;
}

这个例子仅用于揭示数组与指针的关系,在实际写代码时,千万不要这样做。

非数组的指针

本节主要讨论字符串的大小、空字符的影响,略过。

作为参数的数组声明

将数组作为参数毫无意义,将被自动转换为指向首元素的指针。

但是,请不要假设在其他情况下也有这样的转换。以下两个语句有着天壤之别:

extern char *hello;
extern char hello[];

Note

编写函数声明时,选择能最清晰表达自己意图的方式。

避免“举隅法”

举隅法的意思是,以隐喻表示指代物与被指代物之间的关系。本节主要讨论混淆指针和指针所指向数据的情况,略过。

空指针与空串

清楚这两者的不同即可。

边界计算与不对称边界