6.1 陷阱

6.1.1 关于strncpy( )

C 语言使用空字符来结束字符串。

printf()strcpy()都把这一点作为默认的前提条件。此外,编译器也会在字符串常量的末尾自动加上空字符。

可是,strncpy()却令人迷惑地打破了这个规则。

strncpy()strncpy(dest, src, len);这样使用,从 src 复制最大长度为 len 的字符到 dest 中。当 src 的长度大于 len 时,dest 不会以空字符结束*

* 反过来,如果 srclen 短,就会使用空字符补足剩余的长度。这个规则也是有点奇怪的。

因此,冒冒失失地使用了 strncpy(),之后再使用 printf()sprintf()strcpy()等处理 dest,由于其末尾可能没有空字符,进而可能会发生处理越界,以至于破坏大片内存区域的数据。因此,strncpy()是危险的

要 点

如果使用 strncpy(),请注意它可能会产生没有空字符结尾的字符串。

但也有完全相反的观点:

对于 strcpy(),在 src 过长的情况下,很容易破坏内存区域。而 strncpy() 可以通过指定 len 来阻止内存的被破坏。所以, strncpy()更安全。

可是,即使是通过对 len 的设定“当场”阻止了内存破坏的发生,但作为结果,还是会产生没有空字符结尾的奇怪的字符串,依然会发生由于使用 sprintf()而导致的内存破坏。倒不如说,“bug 最终爆发的现场远离制造 bug 的地方”这种结果,在性质上更为恶劣。

对于“只是复制 len 长度的字符,即使之后的字符被切掉也没问题”这种需求,是不是应该考虑写一个 1.2.5 节补充内容中介绍的 my_strncpy()这样的函数呢?依我之见,strncpy()应该用来实现 my_strncpy()这样的函数,或者用于对大型机中常见的定长字段的操作。

下面我也来扯个闲篇儿,某日某地点,我听到两个人的对话:

有个哥们儿写了这么一行代码,

  1. strncpy(dest, src, strlen(src) + 1);

“这,这是啥啊,明明可以用 strcpy()

“说是用 strncpy()更安全……”

这真不是我自创的噱头,这可是真事儿!

6.1.2 如果在早期的C中使用float类型的参数

如今已经很少有人使用 ANSI C 以前的 C 了,但我认为还是不能绕过“早期的 C”来理解函数参数的类型。

函数的原型声明是从 ANSI C 以后开始导入的,之前的 C 中,函数的声明像

  1. double sin();

这样只能指定返回值,而不能指定参数。

其实,上面的三角函数 sin()的参数应该是 double。那么,在调用 sin() 的时候,如果传入 float,会导致什么样的结果呢?

在 ANSI C 的 math.h 中,sin()声明如下:

  1. double sin(double x);

因此,即使向形参中传递 float 类型,编译器也会自动将其转换成 double 类型。

那么,对于 ANSI C 以前的 C,究竟会发生什么呢?答案就是——float 类型的参数还是会被转换成 double

ANSI C 以前的 C,会将表达式中的 float 类型依次转换成 double。假设,我们需要做一次 float 类型的加法运算,然后将结果保存在 float 类型中,其内部过程如下,

  • 将两边的变量转换成 double

  • 进行 double 类型的加法运算

  • 将结果变换成 float 类型

因此,与直接使用 double 类型相比,肯定是 float 类型的加法运算速度比较慢,于是在不知不觉中流传起来“不要使用 float 类型”这样的说法*

* 当然,在构造较大的数组的时候,大部分的情况下还是使用 float 比较节约内存。

对于函数的参数会发生同样的问题。所以在使用 sin()的时候,应该没有必要去分辨参数是 float 还是 double。这样看上去挺方便,但对于本来就是“将 float 作为参数的函数”,又会发生什么问题呢?

在我现在使用的编译器(gcc)中,可以通过开关选项(-traditional)让编译器按照 ANSI C 以前的 C 进行处理。此外,还可以通过-S 选项输出汇编代码。通过这两个选项,尝试编译下面的两段代码(参照代码清单 6-1 和代码清单 6-2)。

代码清单 6-1 float.c

  1. 1: void sub_func();
  2. 2:
  3. 3: void func(f)
  4. 4: float f;
  5. 5: {
  6. 6: sub_func(&f);
  7. 7: }

代码清单 6-2 double.c

  1. 1: void sub_func();
  2. 2:
  3. 3: void func(d)
  4. 4: double d;
  5. 5: {
  6. 6: sub_func(&d);
  7. 7: }

对输出的汇编代码进行比较后,得到了完全相同的输出结果*1。也就是说,在形参类型为 float 的情况下,ANSI C 以前的 C 的编译器会一声不吭地将参数解释成 double(挺可气的)。

* 如果再说明得细致一些,其实作为辅助信息的文件的名称是不同的。

1 根据不同的环境,输出结果可能会有一些差异。——译者注

而且在上面的例子中,向 sub_func()中传递的是指向形参的指针。对于代码清单 6-1 的 sub_func()来说,肯定应该接受“指向 float”的指针吧。但是,f 却被擅自地解释成了 double,这种传递结果自然是不正确的。

顺便说一下,对于整型也会发生同样的事情。在 C 中,比 int 小的整型如果出现在表达式中,同样会被依次地转换成 int。但是,在整形的情况下,一旦参数被作为 int 接受,就会缩小为原来的类型,所以不会发生上面的问题。

  1. /*
  2. * 对于void func(short s)这个函数定义,
  3. * 编译器会生成和下面的C 语言代码等同的机器代码
  4. */
  5. void func(int s_temp)
  6. {
  7. short s = s_temp;
  8. }

请大致浏览一下标准库函数。在 math.h 数学运算库中,清一色地都使用了 double。在 stdio.h 和 ctype.h 中,有很多操作字符的函数(如 putchar())的参数也声明成了 int

ANSI C 以前的 C 原本不能传递 float 类型,虽然针对 charshort 做了补救措施,但由于加入了多余的处理而导致了执行效率低下。

至少对于 ANSI C 以前的 C 来说,遇到整型基本作为 int,遇到浮点型基本作为 double 来考虑。

6.1.3 printf()和scanf()

对于 ANSI C 以前的 C,比 int 小的类型会被依次地转换成 intfloat 会被依次地转换成 double,所以不会出现接受 float 类型参数的函数;针对 charshort,编译器通过别的方式做了补救措施。

因为 ANSI C 有原型声明,所以无论是 char 还是 float,都可以直接传递。

可是,对于 printf()这样的具有可变长参数的函数,原型声明对可变长部分的参数是不产生任何影响的。因此,这部分的参数同样会被编译器进行类型转换操作。也就是说,比 int 小的类型会被转换成 intfloat 会被升级成 double

结果就是,不能printf()传递 char 类型和 float 类型。

啥?对于 printf(),不是有%f 用于表示 float%lf 用于表示 double 吗?

其实,这不能不说是个误解(恐怕是来源于 scanf()的转换修饰符)。对于 printf()floatdouble 共用了%f。在 printf()中使用%lf,其实这种行为根本没有在规范中定义(如果提高 gcc 的警告级别,编译器会对%lf 的使用提出警告)。

同样,charshort 也可以用%d 来表示。

反过来,在带有可变长参数的函数一侧,

  1. va_arg(ap, char)
  2. va_arg(ap, short)
  3. va_arg(ap, float)

这样的写法也是经常发生的错误。

此外,scanf()使用了和 printf()非常相似的转换修饰符。对于那些在使用 printf()时已经习惯将 floatdouble 都用%f 表示的程序员,对 scanf()往往有着同样的期待。

可是,因为向 scanf()传递的是指针,所以并没有放入类型信息。因此,如果想在 scanf()中使用 double,必须指定%lf

6.1.4 原型声明的光和影

最近,ANSI C 以前的 C 确实已经开始慢慢绝迹了。以前经常会发生“将老的 C 转换成 ANSI C”的工作2

2 看到这里,干对日软件外包的同学笑了。对日软件外包中,这种活是特别多的,一般把这种活叫做“移行”。——译者注

ANSI C 是像下面这样进行函数定义的:

  1. int func(int hoge, int piyo)
  2. {
  3. }

ANSI C 以前的 C,却是写成下面这样:

  1. int func(hoge, piyo)
  2. int hoge;
  3. int piyo;
  4. {
  5. }

因为 ANSI 同时也允许老的写法,所以即使不修改成新的写法,也能通过编译。但是,从 ANSI C 开始导入的函数原型声明,对发现程序员的编程错误是非常有益的。所以,建议大家尽量使用新的函数定义方法。

将老的函数定义一个一个改过来,的确是一件比较繁琐的事。

那就不要去修改函数定义本身,只要在头文件中加上原型声明不就完事儿了嘛!

应该有人是这样想的吧?我也这么想过。

但是,请你再回想一下之前说明过的内容。

在没有函数原型声明的情况下,比 int 小的参数的类型会被依次地转换成 int,而 float 类型的参数会被依次地转换成 double [这种转换称为默认实参提升(default argument promotion)]。因此,在接受参数的一侧生成的代码总是处理“转换后的相对较大的数据类型”。

反过来,在提供函数原型声明的情况下,因为参数以原来的类型传递,所以在接受参数的一侧,总是生成针对原来的数据类型的机器代码。

可是,函数的定义和函数的调用可能存在于完全不同的编译单元中。那么,在编译函数定义的时候,编译器究竟根据什么来判断应该生成哪种机器代码呢?——根据函数的定义的新旧形式*。

* 几乎所有的处理环境都是这样的。对于调用没有原型定义的函数,规范完全没有规定究 竟应该怎么做。

对于旧的函数定义,一旦调用提供原型声明的函数,在函数定义的一侧期待的就是经过“默认实参扩展”后的数据类型,但实际中如果传递没有经过扩展的数据类型,就可能会发生问题。

为了防患于未然,应该在定义函数的文件中#include 声明函数自身原型的头文件。如果这么做了,但凡是正经的编译器,都会给出原型和函数定义不一致这样的警告。无论在什么情况下,只要想让编译器帮我们找出函数原型和函数定义不一致的地方,就必须在函数定义的代码中#include 函数原型*。如果原型和函数定义不一致,那么好不容易在 ANSI C 中引入的机制简直就成了摆设了(甚至可以说是有害的)。

* 实际上,以前也曾经遇到过对函数定义和原型之间的参数一致性不做检查的编译器。

要 点

在函数定义的代码文件中,必须#include 包含此函数自身原型声明的头文件。

一旦决定了使用原型声明,调用该函数所有代码文件也必须#include 包含原型声明的头文件。但人总是会犯错误的,所以还是建议提高编译器的警告级别,一旦调用没有原型声明的函数就让编译器向我们给出警告。

此外,为了提高执行速度,有些编译器往往不使用栈,而是使用寄存器来传递参数。但对于可变长参数的函数,这些编译器还是使用了栈来传递参数,但至于“是不是可变长参数的函数”,调用方在原型声明以外的地方是无法判断的。因此,在这样的处理环境中,如果不#include stdio.h,printf() 是不能顺利执行的。

要 点

如果调用提供了原型声明的函数,就必须#include 原型声明。