首页 > 代码库 > C陷阱和缺陷整理四

C陷阱和缺陷整理四

1.assert宏的定义
#define assert(e) \
              ((void)((e) || _assert_error(__FILE__, __LINE__)))
    库里面对这个宏做了这样的定义,当宏参数(或表达式)e为真的时候由||运算符的运算规则会执行_assert_error(__FILE__, __LINE__)从而打印一条报警信息。所以整个表达式的最终会变为(void)0或者(void)1这种形式,这种形式确实有点奇怪?
    系统这样定义的目的是当一个值被转换为void类型之后,没有一个类型的变量可以接收这个值,因为C语言中不能定义一个void类型的变量,所以这样可以防止(void)0或者(void)1被作为右值赋给其他变量。

2.宏定义的危险
    虽然宏定义可以为我们提供很多方便,但是其中也可能隐藏着许多bug,现在C语言中的typedef完全可以替代宏定义的功能,同时具有更高的安全性,所以尽量使用typedef来替代#define宏定义。看下面的例子:
#define T1 struct foo *
typedef struct foo *T2;
从上面这两个定义来看,T1和T2从概念上来说完全相同,都是指向foo的指针,但是当我们用他们来定义多个变量时,宏定义就会出现问题:
T1 a, b;
T2 a, b;
第一个定义语句会被展开为:
struct foo *a, b;
然而第二个语句却等价于:
struct foo *a, *b;

3.字符型像整型提升

    如果编程者关注一个最高位是1的字符其数值究竟是正还是负,可以将这个字符声明为无符号字符(unsigned char)。这样,无论是什么编译器,在将该字符转换为整数时都只需要将多余的位填充0即可。而如果声明为一般的字符变量,那么在某些编译器上可能会作为有符号数处理,而在另一些编译器上又会作为无符号数处理。看下面一个例子:
#include <stdio.h>
int main(void)
{
    char i = -1;
    unsigned int j = (unsigned int)i;
    unsigned int k = (unsigned char)i;
    printf("%u %u", j, k);
}
这个例子的输出是多少呢?
分析:在我的PC上面输出为4294967295 255.现在来分析一下其中的原因,首先需要知道的是强制类型转换的过程是首先将单元中的值取出来,然后按照需要转换的类型进行一定的扩展,最终得到强制转换类型数据。上述代码中将i设置为-1,在二进制上面的8个bits的表示方式为全1,而我的编译器将这8位数值当做有符号数处理,所以强制类型转换的时候分别会被扩展为32位的有符号数和8位的有符号数,最后再按照无符号数解释这个值,所以最终的打印结果是int类型无符号最大值和unsigned char类型无符号最大值。

4.移位运算

(1)对于无符号数的左移或者右移都是在移入的位置填充0,所以在该无符号数表示的范围内,都相当于乘2或者除2的操作。所以一个非负的数据除2的运算完全可以使用右移一位来替代,且替代之后执行效率更高。
(2)对于有符号数来说,左移依旧是填入0,而右移大多数编译器都会填充该数的符号位。所以有符号数的右移是不能够看做除2运算的,而有符号数的左移是可以看做乘2运算的。例如:
    int i = -1;
    printf("%d ", i>>1);
程序段的输出一般都为-1,而并不是像C语言实现i /= 2那样的结果为0。因为在C语言实现i /= 2的时候,先进行纯值的运算,即先算的是 1/2的值为0,最后再为其添加负号。
(3)移位长度的限制,一般来说,都需要严格要求移位长度小于等于该类型的长度,因为这样硬件上就可以高效的实现移位运算。
(4)如果你写出下面这样的程序:
    int i = 1;
    i >>= -2;
不要期望能够实现你所期望的功能,虽然i >>= -2这样的表达式很多编译器不会报错,但是编译器也不会真的理会,只是会默默丢弃这条语句。

5.内存位置0
    NULL指针并不指向任何对象,因此,除非是用于赋值或者比较运算,出于其他任何目的使用NULL指针都是非法的。
    在其他非法情况下究竟会得到什么结果呢?不同的编译器会有不同的结果。一些C语言实现对内存位置0强加了硬件级的读保护,在其上工作的程序如果错误的使用了一个NULL指针,将立即终止其执行。其他一些C语言实现对内存位置0只允许读,不允许写,在这种情况下,一个NULL指针似乎指向的是某个字符串,但其内容通常不过是一堆“垃圾信息”。还有一些C语言实现对内存位置0既允许读,也允许写,在这种实现上面工作的程序如果错误的使用了一个NULL指针,和可能覆盖了操作系统的那部分内容,造成彻底的灾难。

6.printf族函数

    printf、fprintf和sprintf的返回值都是已传送的字符数。对于sprintf的情形,作为输出数据结束标志的空字符并不计入总的字符数。如果printf或fprintf在试图写入时出现一个I/O错误,将返回一个负值。在这种情况下,我们就无从得知究竟有多少字符已经被写出。因为sprintf函数并不进行I/O操作,因此它不会返回负值。当然,也不排除有的C语言实现会因为某种原因,而令sprintf函数返回一个负值。
    因为格式字符串决定了其余参数的类型,而且可以到运行时才建立格式字符串,所以C语言实现要检查printf函数的参数类型是否正确是异常困难的。下面的程序:
printf("%d\n", 0.1);
最后得到的结果可能毫无意义,而且在程序运行之前,这些错误很难被编译器检测到,成为漏网之鱼。

7.使用varargs.h来实现可变参数列表
    varargs.h头文件中定义了宏名va_list, va_dcl, va_start, va_end以及va_arg。然而va_alist一般由编程者来定义,注意却分va_list和va_alist。
    对于可变参数列表的第n个参数,在已知其类型的情况下,要对其进行存取还需要一些额外的信息。这些信息是通过已经存取的第一个参数到第n-1个参数而间接得到的,可以把这些信息看做是一个指向参数列表内部的指针。
    这些信息存储在一个类型为va_list的对象中,因此,当我们声明了一个名称为ap的类型为va_list的对象后,只需要给定ap的第一个参数的类型就可以确定第1个参数的值。通过va_list存取一个参数之后,va_list将被更新,指向参数列表中的下一个参数,va_list中包括了存取全部参数的所有必要信息。
    ANSI中的stdarg.h完成varargs.h的功能,使用stdarg.h实现的printf函数如下:
#include<stdarg.h>
int printf(char *format, ...)
{
    va_list ap;
    int n;
    
    va_start(ap, format);
    n = vprintf(format, ap);
    va_end(ap);
    return n;
}
    基本思想:printf函数接收的第一个参数固定是一个字符指针,后面的参数不固定。在调用printf函数的时候,一定会生成一个参数列表存放在内存中某个位置,然后通过va_start找出字符串后面的真正的值部分的起始地址,然后调用vprintf函数输出,vprintf函数和printf函数类似,只不过它会将格式输出符替换为后面的参数。vprintf函数相比于printf函数参数更为具体。在得到真实的值的地址之后,通过格式输出符每次使用相应的指针强制取出相应类型的值,然后将指针后移,下次再将指针强制转换为另一个类型,取出另一个类型的值,这就是参数输出的过程。这个过程必须要保证参数值在内存中是连续存放的才能够成功。同时需要注意的是,参数类型的提升:char、short会被强制提升为int,float会被强制提升为double,所以从参数值地址处取出的不可能为一个char类型的参数,因为实际传输过程中这个char已经被提升为int类型,这或许也是为了程序处理的方便。本书(C陷阱和缺陷)中对于va_alist的实现以及vprintf的实现并未提及,以后有机会再做补充。

C陷阱和缺陷整理四