[C 陷阱与缺陷] (五) 库函数

C语言中没有定义输入/输出语句,任何一个有用的 C 程序(起码必须接受零个或多个输入,生成一个或多个输出)都必须调用库函数来完成最基本的输入和输出操作。ANSI C 标准毫无疑问地意识到了这一点, 因而定义了一个包含大量标准库函数的集合。从理论上说,任何一个 C 语言实现都应该提供这些标准库函数。

有关库函数的使用,我们能给出的最好建议是尽量使用系统头文件。

一 库函数

1. 返回整数的 getchar 函数

#include<stdio.h>

main(void){
char c;

while((c = getchar()) != EOF)
putchar(c);
}

getchar 函数在一般情况下返回的是标准输入文件中的下一个字符,当没有输入时返回EOF (一个在头文件stdio.h 中被定义的值,不同于任何一个字符)。这个程序乍一看似乎是把标准输入复制到标准输出,实则不然。

原因在于程序中的变量 c 被声明为 char 类型,而不是 int 类型。这意味着c无法容下所有可能的字符,特别是,可能无法容下 EOF 。

因此,最终结果存在两种可能。一种可能是,某些合法的输入字符在被“截断”后使得 c 的取值与 EOF 相同;另一种可能是, c 根本不可能取到EOF这个值。对于前一种情况,程序将在文件复制的中途终止;对于后一种情况,程序将陷入一个死循环。

实际上,还有可能存在第三种情况:程序表面上似乎能够正常工作,但完全是因为巧合。尽管函数 getchar 的返回结果在赋给 char 类型的变量 c 时会发生“截断”操作,尽管 while 语句中比较运算的操作数不是函数 getchar 的返回值,而是被“截断”的值 c,然而令人惊讶地是许多编译器对上述表达式的实现并不正确。这些编译器确实对函数 getchar 的返回值作了“截断”处理,并把低端字节部分赋给了变量c。但是,它们在比较表达式中并不是比较 c 与 EOF,而是比较 getchar 函数的返回值与 EOF ! 编译器如果采取的是这种做法,上面的例子程序看 上去就能够“正常”运行了。

2. 更新顺序文件

许多系统中的标准输入/输出库都允许程序打开一个文件,同时进行写入和读出的操作:

FILE *fp;
fp = open(file, "r+");

上面的例子代码打开了文件名由变量file 指定的文件,对于存取权限的设定表明程序希望对这个文件进行输入和输出操作。

编程者也许认为,程序一旦执行上述操作完毕,就可以自由地交错进行读出和写入的操作。遗憾的是,事实总难遂人所愿,为了保持与过去不能同时进行读写操作的程序的向下兼容性,一个输入操作不能随后直接紧跟一个输出操作,反之亦然。如果要同时进行输入和输出操作,必须在其中插入fseek 函数的调用。

下面的程序片段似乎更新了一个顺序文件中选定的记录:.

FILE *fp;

struct record rec;

...

while(fread((char*)&rec), sizeof(rec), 1, fp) == 1 ){
/* 对 rec 执行某些操纵 */
if(/* rec 必须被重新写入 */){
fseek(fp, -(long)sizeof(rec), 1);
fwrite( (char*)&rec, sizeof(rec), 1, fp );
}
}

这段代码乍看上去毫无问题: &rec 在传入 fread 和fwrite 函数时被小心翼翼地转换为字符指针类型,sizeof(rec) 被转换为 长整型(fseek 函数要求第二个参数是 long 类型,因为 int类型的整数可能无法包含一个文件的大小;sizeof 返回一个unsigned 值,因此首先必须将其转换为有符号类型才有可能将其反号)。但是这段代码仍然可能运行失败,而且出错的方式非常难于察觉。

问题出在:如果一个记录需要被重新写入文件,也就是说,fwrite 函数得到执行,对这个文件执行的下一个操作将是循环开始的 fread 函数。因为在fwrite函数调用与fread函数调用之,间缺少了一个fseek函数调用,所以无法进行上述操作。解决的办法是把这段代码改写为:

while(fread((char*)&rec), sizeof(rec), 1, fp) == 1 ){
/* 对 rec 执行某些操纵 */
if(/* rec 必须被重新写入 */){
fseek(fp, -(long)sizeof(rec), 1);
fwrite( (char*)&rec, sizeof(rec), 1, fp );
fseek(fp, 0L, 1);
}
}

第二个fseek函数虽然看上去什么也没做,但它改变了文件的状态,使得文件现在可以正常地进行读取了。

程序圆帮你理解

  • &rec为何要强转成 char*类型:这就要理解 fread 函数(size_t fread ( void * ptr, size_t size, size_t count, FILE * stream )):fread 函数的参数有四个,简单的来说就是:从 stream 中读 count 个 size 大小的元素到 ptr 指向的内存中。而 fread 内部在读取一个 size 大小的元素时会调用 size 次 fputc 函数,所以我猜测是每次用 fputc 函数读一个字节然后将该值赋给 ptr 指向的那个地址。既然 fputc 每次只能读一个,那也应该将 ptr 强转为 char* 类型。(但是函数原型是 void* 类型,会发生实参提升,转成 void*,这又是个问题了)。

  • 其实上面的程序可以简化为:

    fread();
    fseek();
    fwrite();
    fread();

    我们知道,读写之间需要调用一次 fseek,这就是为什么要在 fwrite 后调用 fseek 了。

3.缓冲输出 与内存分配

当一个程序生成输出时,是否有必要将输出立即展示给用户?这个问题的答案根据不同的程序而定。

程序输出有两种方式:一种是即时处理方式,另一种是先暂存起来,然后再大块写入的方式,前者往往造成较高的系统负担。因此,C语言实现通常都允许程序员进行实际的写操作之前控制产生的输出数据量。

这种控制能力一般是通过库函数 setbuf 实现的。如果buf是一个大小适当的字符数组,那么

setbuf(stdout, buf);

语句将通知输入/输出库,所有写入到 stdout 的输出都应该使用 buf 作为输出缓冲区,直到 buf 缓冲区被填满或者程序员直接调用 flush (译注:对于由写操作打开的文件,调用 fflush 将导致输出缓冲区的内容被实际地写入该文件),buf 缓冲区中的内容才实际写入到stdout 中。缓冲区的大小由系统头文件<stdio.h>中的 BUFSIZ 定义。

程序圆帮你理解: setbuf 比较老,现在可以用 C99 引入的函数 setvbuf

下面的程序的作用是把标准输入的内容复制到标准输出中,演示了setbuf 库函数最显而易见的用法:

#include <stdio.h>

main()
int C;

char buf [BUFSIZ];
setbuf(stdout, buf) ;

while((c = getchar()) != EOF)
putchar(c) ;

)

遗憾的是,这个程序是错误的,仅仅是因为一个细微的原因。程序中对库函数 setbuf 的调用,通知了输入输出库所有字符的标准输出应该首先缓存在 buf 中。要找到问题出自何处,我们不妨思考一下buf缓冲区最后一次被清空是在什么时候?答案是在 main 函数结束之后,作为程序交回控制给操作系统之前 C 运行时库所必须进行的清理工作的一部分。但是,在此之前 buf 字符数组已经被释放!

要避免这种类型的错误有两种办法。第一种办法是让缓冲数组成为静态数组,即可以直接显式声明 buf 为静态:

static char buf[BUFSIZ];

也可以把 buf 声明完全移到 main 函数之外。

第二种办法是动态分配缓冲区,在程序中并不主动释放分配的缓冲区(译注:由于缓冲区是动态分配的,所以 main 函数结束时并不会释放该缓冲区,这样 C 运行时库进行清理工作时就不会发生缓冲区已释放的情况):

char *malloc() ;

setbuf(stdout, malloc(BUFSIZ));

如果读者关心一些编程“小技巧”,也许会注意到这里其实并不需要检查 malloc 函数调用是否成功。如果 malloc 函数调用失败,将返回一个 NULL 指针。setbuf 函数的第二个参数取值可以为 NULL,此时标准输出不需要进行缓冲。这种情况下,
程序仍然能够工作,只不过速度较慢而已。

4. 使用errno检测错误

很多库函数,特别是那些与操作系统有关的,当执行失败时会通过一个名称为 errno 的外部变量,通知程序该函数调用失败。下面的代码利用这一 特性进行错误处理,似乎再清楚明白不过,然而却是错误的:

/*调用库函数*/
if (errno)
/*处理错误*/

出错原因在于,在库函数调用没有失败的情况下,并没有强制要求库函数一定要设置 errno 为0,这样errno 的值就可能是前一个执行失败的库函数设置的值。

下面的代码作了更正,似乎能够工作,很可惜还是错误的:

errno = 0;
/*调用库函数*/
if (errno)
/*处理错误*/

库函数在调用成功时,既没有强制要求对 errno 清零,但同时也没有禁止设置 errno。既然库函数已经调用成功,为什么还有可能设置 errno 呢? 要理解这一点,我们不妨假想一下库函数 fopen 在调用时可能会发生什么情况。

当 fopen 函数被要求新建一个文件以供程序输出时,如果已经存在一个同名文件,fopen 函数将先删除它,然后新建一个文件。 这样,fopen 函数可能需要调用其他的库函数,以检测同名文件是否已经存在。(译注:假设用于检测文件的库函数在文件不存在时,会设置 errno 。那么,fopen 函数每次新建一个事先并不存在的文件时,即使没有任何程序错误发生,errmo 也仍然可能被设置。)

因此,在调用库函数时,我们应该首先检测作为错误指示的返回值,确定程序执行已经失败。然后,再检查 errno,来搞清楚出错原因:

/*调用库函数*/
if (返回的错误值)
/* 检查errno */

5. 库函数 signal

关于 signal 函数使用需要避免的情况:

  • 信号处理函数不应该调用复杂的库函数(例如:malloc)

    例如,假设malloc函数的执行过程被一个信号中断。 此时,malloc 函数用来跟踪可用内存的数据结构很可能只有部分被更新。如果 signal 处理函数再调用 malloc 函数,结果可能是 malloc 函数用到的数据结构完全崩溃,后果不堪设想!

  • 从 siganl 函数中使用 longjup 退出

    基于同样的原因,从 signal 处理函数中使用 longjmp 退出,通常情况下也是不安全的:因为信号可能发生在 malloc 或者其他库函数开始更新某个数据结构,却又没有最后完成的过程中。因此,signal 处理函数能够做的安全的事情,似乎就只有设置一个标志然后返回,期待以后主程序能够检查到这个标志,发现一个信号已经发生。

  • 算数运算错误

    然而,就算这样做也并不总是安全的。当一个算术运算错误(例如溢出或者零作除数)引发一个信号时,某些机器在signal 处理函数返回后还将重新执行失败的操作。而当这个算术运算重新执行时,我们并没有一个可移植的办法来改变操作数。这种情况下,最可能的结果就是马上又引发一个同样的信号。因此,对于算术运算错误,signal 处理函数的惟一安全、 可移植的操作就是打印一条出错消息,然后使用 longjmp 或 exit 立即退出程序。

由此,我们得到的结论是:信号非常复杂棘手,而且具有一些从本质上而言不可移植的特性。解决这个问题我们最好采取“守势”,让signal处理函数尽可能地简单,并将它们组织在一起。这样,当需要适应一个新系统时,我们可以很容易地进行修改。

练习

练习5-1

当一个程序异常终止时,程序输出的最后几行常常会去失,原因是什么?我们能够采取怎样的措施来解决这个问题?

一个异常终止的程序可能没有机会来清空其输出缓冲区。

解决方案就是在调试时强制不允许对输出进行缓冲。要做到这一点,不同的系统有不同的做法,这些做法虽然存在细微差别,但大致如下:

setbuf(stdout, (char *)0);

这个语句必须在任何输出被写入到 stdout(包括任何对 printf 函数的调用)之前执行。该语句最恰当的位置就是作为main函数的第一个语句。

练习5-2

下 面程序的作用是把它的输入复制到输出:

#include <stdio.h>
main()
register int c;

while ((c = getchar()) != EOF)
putchar(c);
}

从这个程序中去掉 #include 语句,将导致程序不能通过编译,因为这时 EOF 是未定义的。假定我们手工定义了EOF (当然,这是一种不好的做法):

#define EOP -1
main()
{
register int c;

while ((c = getchar()) != EOF)
putchar (c) ;
}

这个程序在许多系统中仍然能够运行,但是在某些系统运行起来却慢得多。这是为什么?

函数调用需要花费较长的程序执行时间,因此getchar经常被实现为宏。这个宏在stdio.h头文件中定义,因此如果一个程序没有包含 stdio.h 头文件,编译器对 getchar 的定义就一无所知。 在这种情况下,编译器会假定 getchar 是一个返回类型为整型的函数。

实际上,很多C语言实现在库文件中都包括有 getchar 函数,原因部分是预防编程者粗心大意,部分是为了方便那些需要得到 getchar 地址的编程者。因此,程序中忘记包含 stdio.h 头文件的效果就是,在所有 getchar 宏出现的地方,都getchar 函数调用来替换 getchar 宏。这个程序之所以运行变慢,就是因为函数调用所导致的开销增多。同样的依据也完全适用于putchar 。

参考资料《C 缺陷与陷阱》