第8章 库函数

即使我们编写一个只打印出“hello world”的最简单的C 程序,最后生成的可执行程序也会有几k 的大小,这是为什么呢?别忘了,C 语言要经过编译和链接两个阶段。在链接阶段,你的程序会把一些你从库中调用的函数链接进程序中。库的尺寸现在越来越大,尤其是有的时候,一个库中的函数还有可能调用其它库中的函数,这样你的程序尺寸会更大。关于编译和链接的过程中到底发生了什么,可以参考《程序员的自我修养:链接、装载与库》[18]。市面上C 语言的书铺天盖地,但是讲解C 语言背后故事的书却凤毛麟角,如果你想加深对C 语言的理解并了解一些系统内核部分的知识,这本书绝对不要错过。

好在 C 语言标准库中的函数的数量还不是太多。下面的各个小节分别给予简单的介绍。注意,本书毕竟不是一本参考手册,读者从这本书的厚度上也能猜到这一点,无论是垫桌脚还是当枕头,都太薄。所以对于标准的库函数,我只是给出简单的函数名、常见的功能和使用这些函数的注意事项。本书只是告诉你有这些函数,至于如何具体地使用,你一定要仔细查看函数本身的参考文档,在Windows 下可以使用MSDN,在Linux 下可以使用man 命令,当然,最方便的一种方法就是直接上网Google。

8.1 数学相关

C 语言的数学函数主要包含初等函数,如绝对值函数abs 和fabs,幂函数exp 和pow,对数函数log 和log10,开平方函数sqrt,上下界函数ceil 和floor,以及三角函数sin、cos、tan 和反三角函数asin、acos、atan 等。

下面我主要说说使用这些函数的注意事项。

第一,不同的函数,可能要求不同的头文件。使用数学函数的时候,大部分需要包含math.h。但是也有特例,例如处理整型数的abs 函数被包含在stdlib.h 头文件中,所以你使用这些数学函数的时候,一定要包含正确的头文件。

第二,三角函数和反三角函数,都是以弧度为参数,而不是以角度为参数。如果你要求45°的sin 值,你可以写为sin(45*pi/180);来完成。

第三,不要以为数学函数就这么多,事实上,很多数学函数对于我一个理科出身的学生来说都显得陌生而新鲜,例如双曲正弦函数。比较全的索引可以参考本书网站“扩展内容”网页中的“C mathematical functions”链接。在需要使用某个数学功能的时候,你可以先到这里看一看有没有你需要的函数。另外要注意,这里列出的只是标准中规定的,不同的编译器可能有不同的实现程度,最终还要看你的编译器是否支持。

8.2 字符串相关

首先介绍一些字符类型判断函数,如isalnum、isalpha、islower、isupper、isspace 等函数,他们在ctype.h 头文件中声明。

处理字符串相关的函数主要包含String manipulation 类和String examination 两大类。manipulation 类中包含strcpy、strcat 等函数,而examination 包含strlen、strcmp 等函数。比较全的索引可以参考本书网站“扩展内容”网页中的“C string handling”。

具体使用的时候,你可以上网参考每个函数的详细说明。下面我主要说说使用这些函数的注意事项。

第一,strcpy、strcat 函数会改变传入的字符串的内容。你需要确保传入的地址有足够的空间容纳改变后的字符串,也需要确保源地址和目标地址不会发生重叠。如果这个空间是通过alloc 函数动态申请的,你还需要在函数外面释放这些内存。

也就是说 strcpy、strcat 函数对传入的空间不做任何的容量检查。在程序8-1中,虽然des 并没有足够的空间容纳连接后的整个字符串,但是strcat 函数对此并不负责。它只会把src 中所有的字符加到des 字符串的后面,最终造成des 数组溢出(overflow)的严重错误。

程序8-1 字符串函数发生溢出

include<string.h>

main(){

char src[8]={" world!"};

char des[10] ={"hello"};

strcat(des,src); / 溢出 /

}

第二,为了解决字符串函数的溢出问题,C 语言引入了strncpy 和strncat 两个n 族函数。这两个函数允许我们指定复制或追加的字符的个数,这样就可以有效避免数组溢出的问题。我们可以调用strncat(des,src,4)这一语句,这个函数从src 字符串中取出前4 个字符,然后连接到des 的尾部,最后des 字符串为“hello wor”,最后还有一个作为字符串结尾的'\'0 字符。

第三,标准库函数中并没有substr(char des, char src, int pos, int len)函数,不过我们可以自己简单地用strncat(des, src + pos, len);来实现。如果真的是这样,我感觉真的没必要再添加substr 这个函数了。

第四,strtok 函数用于将一个字符串根据分割符进行分割,他的原型是char strtok ( char str, const char * delimiters),函数中的tok 代表tokenizing 的意思。strtok 函数具体的使用可以参考程序8-2。

程序8-2 strtok 函数演示程序

include <stdio.h>

include <string.h>

int main (){

char str[] ="This, a sample string.";

char * pch;

printf ("Splitting string \"%s\" into tokens:\n",str);

pch = strtok (str," ,.");

while (pch != NULL){

printf ("%s\n",pch);

pch = strtok (NULL, " ,.-");

}

Printf("%s\n",str);

}

这个函数在使用上有两点要注意。首先,需要反复调用这个函数,并且第二次调用的时候需要传递NULL 值给参数str;其次,这个函数会修改传入的str 的内容,它会把str 字符串中的每个delimiters 都替换成’\0’字符,这一点一定要注意。例如,程序8-2 的最后一个printf 语句会打印出字符串“This”。这是因为第一个delimiters 符逗号被替换成了’\0’字符,所以printf 打印str 字符串的时候,只输出字符串“This”。

字符串相关函数是世界500 强公司面试程序员的时候最容易出的面试题。如果你正在找工作,你最好准备应付一下面试官“请用指针实现strcpy 这个函数”或者“请实现字符串反转函数”等面试题。好在C 语言库函数的源码都是公开的,程序8-3给出了Windows 平台下strlen 函数的实验源码,可以看出源码很少,但是包含了很多的知识,确实有高手的“范儿”。比如说*eos++语句,我们在4.1 节中就已经解释过了。

程序8-3 strlen 函数源码

size_t strlen (const char * str){

const char *eos = str;

while( *eos++ ) ;

return( eos - str - 1 );

}

8.3 字符和数字相互转换

这里没有什么需要解释的,就是一个“菜谱”而已。如果以后你需要用到这部分的功能,直接拿来用就可以了。字符串转成数字的功能见程序8-4。与atof 函数类似的还有atoi 和atol。理解这些函数名的关键就在于理解中间的两个字符“to”,在英语中“to”是指从什么到什么的一个介词。另外,你在使用这三个函数的时候,一定要包含stdlib.h 头文件。

程序8-4 字符串换成数字

include<stdlib.h>

int main(void){

double d;

char numstr[10] = "765.4321";

d = atof(numstr);

printf("%f\n",d);

}

有的时候,你需要把一个大文件分成几个小文件,每个小文件都需要采用顺序的数字进行编号。这个时候,将数字转换成字符串的功能就是非常必要的了。数字转成字符串的功能见程序8-5。大家可能对sprintf 不太熟悉,但是大家一定对它的大表哥printf 非常熟悉。printf 把内容输出到stdout 中,而sprintf 是把内容输出到第一个参数指定的字符串str 中,其余的功能都是一样的。

程序8-5 数字换成字符串

1 float f = 1234.56789f; int i = 123;

2 char str[20];

3 sprintf(str, "%d",i);

4 sprintf(str, "%10.3f",f);

5 printf("%s\n",str);

对 sprintf 函数,一个比较有趣的地方就在于,你可以使用格式控制符控制生成的字符串。程序8-5 中第4 行最后会生成“图1234.568”这样一个字符串,其中一个“图”代表一个空格。

既然 printf 有大表弟sprintf,那么scanf 也应该有大表弟sscanf 吧?没错!sscanf 这个函数经常用来从一个字符串中按格式提取出对应的子字符串。具体用法可以上Google 自己查一下。如果不确定输入的格式,那么我们先调用fgets 函数读入一行,并把读入的内容保存到一个字符数组中,然后再利用sscanf 函数,配合格式控制字符串,分别进行读取。基本的思想可以参考程序8-6。sscanf 函数在第5章介绍过,这里再重新温习一遍。

程序8-6 sscanf 函数的使用实例

int main ()

{

char sentence []="sep. 12 1993";

char month [20];

int day,year;

sscanf (sentence,"%s %d %d",month, &day, &year);

printf ("month -> %s\n",month);

printf ("day -> %d\n",day);

printf ("year -> %d\n",year);

return 0;

}

8.4 时间函数

C 语言的时间函数有点乱,为了了解它们,你必须抓住一个中心,两个基本点。一个中心就是time_t time(time_t* timer)函数,两个基本点分别为系统时间time_t 和日历时间(或称为分解时间)struct tm。系统时间为从1970年1月1日0时0分0秒到你看书的这一刻所经过时间的秒数。日历时间顾名思义,就是指分别表示的年、月、日、时、分、秒和星期等,它们分别在结构struct tm 中被定义。

time_t 通过typedef 来定义,其实是一个long 型的整型变量,用来保存的就是系统时间。由于long 型数据容纳的数范围有限,在32位机器上,time_t所表示的时间不能晚于2038年1月18日19时14分07秒。不过我感觉你不用太担心,在2038年,我猜所有的电脑都将是64位机器了,应该不会存在溢出的问题;还有一种可能,在2038年电脑已经撤底消失了,人们改用结绳记事。

time_t 很少单独使用,基本上就是用在time_t time(time_t* timer)函数中,有两种利用time 函数取得系统时间的方法,分别为利用函数参数或利用函数的返回值。如程序8-7 第5 行所示。取得的系统时间为time 函数运行的那一刻。

程序8-7 时间函数演示程序

1 #include<time.h>

2 #include<stdio.h>

3 main(){

4 char a[100]; time_t now;

5 time(&now); / 或者 now = time(NULL); /

6 printf("%s",ctime(&now));

7 / 利用time返回值构建表达式 /

8 printf("%d", time(NULL)+606024*7));

9 strftime(a,100,"%d-%m-%Y",localtime(&now));

10 printf("%s",a);

11 }

有些读者可能对这两种方式感到有点迷惑,既然能够通过函数参数返回,为什么还要提供函数返回值呢?其实,通过函数提供的返回值,可以把函数直接写到一个表达式中。这种用法在字符串函数中非常常见,如:strcat(str3, strcpy(str1,str2));。除了用在字符产函数中,在其他场合也可为程序员提供很大的便利。如我们可以直接把time 函数用到函数srand(time(NULL));中。srand 函数在8.5节将给予介绍,它用来设置随机数的种子。

几乎所有的时间函数都是围绕time_t 展开的。具体的细节可以参考图8-1。通过系统时间time_t,我们可以利用ctime 函数得到时间字符串,不过这个字符串是固定格式的。为了得到自定义的时间字符串,我们可以通过localtime 来得到本地的日历时间,或者通过gmtime 得到格林威治日历时间。然后把日历时间传入strftime 函数,利用strftime 函数中的格式控制字符串,来得到自定义的时间字符串。其中,从日历时间通过函数mktime 也能得到系统时间time_t。这里我并没有给出每个函数的准确定义,在互联网的时代,所有函数的相关定义和用法都可以通过网络在不到一秒的时间内获得,比你翻书的速度还要快。所以我真的没有什么必要给出这些定义。就是一个剪刀手,Ctrl+C、Ctrl+V 而已。但是对读者来说,可能就要为此多花费一些钱来支付纸张和打印等费用。我一直尽力想把本书的价格控制在一定的水平下,毕竟,哪怕就算三块钱,也有可能是一位同学一天的饭钱。

图

图8-1 时间函数之间的关系

8.5 随机数探讨

C 语言有自己的随机数生成函数,《C Programming Language》的2.7 节给出了一个随机数的实现。虽然不同的编译器实现的方法可能不一致,但是基本原理却是一样的,我们可以通过程序8-8 来说明。

程序8-8 随机数函数的实现

unsigned long int next = 1;

int rand(void){

next = next * 1103515245 + 12345;

return (unsigned int)(next/65536) % 32768;

}

void srand(unsigned int seed){

next = seed;

}

首先,我们可以看到,next 的初始值为1,所以当我们首次调用rand 函数的时候,他会生成一个随机数,并改变next 的值;当我们第二次调用rand 函数的时候,因为next 的值改变了,所以生成的随机数不会与第一次调用rand 生成的数相同。

下面我们看看如何具体地使用rand 函数。在程序8-9 中,当我们调用五次rand,五次生成的随机数各不相同,整个程序以及结果如图8-2 所示。

程序8-9 随机数函数的运行结果

include <stdio.h>

include <stdlib.h>

void main(void){

int i;

for(i = 0;i<5;i++){

printf("%d\t",rand());

}

}

图

图8-2 随机函数的结果

不过现在我们有一个问题,当我们重新运行程序8-9 的时候,由于next 的初始值为1,所以……,所以生成的五个随机数与图8-2 结果中的五个随机数是一样的。如果你用随机数来模拟一个赌博游戏,并且你恰好遇到记忆力超好的、类似于电影《雨人》中的大哥,那么你会连裤子都输掉。

可以看出,rand 生成的是伪随机数。为了解决这个问题,我们必须在程序开始运行的时候,改变next 的值,这就是函数srand 的用处了。利用前面介绍的time 函数,我们可以在程序的开头调用srand(time(NULL));,这样,随机数种子就被赋值成当前time 函数运行的时候的系统时间。准确预测time 函数的运行时刻并不容易,因为程序被操作系统所控制,而目前所有的操作系统都是多线程的。所以你双击某个程序时,并不意味着这个程序马上就会运行,这样就可以保证rand 函数生成的随机数足够随机了。

关于随机数的一个经典的应用就是模拟扑克的洗牌,我们通常使用“交换”的技巧来模拟一副扑克洗牌的过程,程序8-10 演示了这一技巧。

程序8-10 扑克洗牌的程序

for(i = 0; i < 54; i++){

int c = rand()%54;

int t = a[i]; a[i] = a[i+c]; a[i+c] = t;

}

另外一个关于随机数的经典应用是产生一定数量的0 到1 之间的随机数,如程序8-11所示。这里需要注意RAND_MAX 常量,它定义了rand 函数返回的随机整数的最大值。

程序8-11 产生 100 个0 到1 之间的随机数的程序

int main ()

{

srand(time(NULL));

int i,ran_int ;

float ran_float;

for(i = 0;i<100;i++){

ran_int = rand();

ran_float = float(ran_int)/RAND_MAX;

printf("%f\n",ran_float);

}

}

随机数在统计理论中占有重要的位置,很多应用需要用到它。如何让随机函数真正的“随机”,是理论数学研究中一个重要的课题。

8.6 系统相关函数

下面简单介绍三个库函数,他们分别是exit 函数、system 函数及signal 函数。

exit 函数用于终止你当前程序的运行,有些同学认为用这个函数终止程序一定是程序有了问题,这也并不尽然。使用这个函数和在main 函数中使用return 语句终止程序是一样的,只不过这个函数有几个优点。第一,它可以在程序中的任何一个地方调用以终止当前的程序。第二,与return 不同,当exit 函数被调用的时候,它执行一些额外的操作,刷新所有用于流的缓冲区,关闭打开的文件。不仅如此,你还可以通过atexit 函数来注册一些退出函数,当调用exit 函数时,这些退出函数也会被执行。

system 函数把传入的字符串参数传送给宿主操作系统,由宿主操作系统的Shell 来执行。如果你在Linux 下编程序,你就可以通过调用system("rm file");来删掉file 文件。前面我们已经介绍过,宿主操作系统区别很大。如果你在Linux 下编程序,借助于Linux 中Shell 里的3000 多个命令,你可以做很多的事。如果在Windows 下,system 的作用就大打折扣了,这也是我一直推荐大家使用Linux 的原因之一。

signal 函数就是在程序收到指定信号的时候,指定你要调用的回调函数。这个解释明显有点像中国的法律,太学术,太抽象。而外国的法律,大都是一些事例,你可以简单地通过类比而获得直观的认识,所以下面我介绍一个简单的例子。

假设你现在就在电影《黑客帝国》描述的世界里,这个时候,你就是个程序。你正在家中吃着火锅,唱着歌……,突然传来了敲门声。这个敲门就是一个信号。然后你可以选择开门;或者如果你正欠人钱,那么你可以选择躲起来。无论是开门还是躲起来,都是你可以采用的候选功能函数。至于到底采用哪个动作,你可以通过传入signal 回调函数来指定。如果黑客帝国的中控系统在你的身上植入了signal(敲门,&躲起来())这个语句,那么程序世界里的你一旦听到敲门声,就会马上钻到桌子底下。那如何解释回调函数这个名称呢?因为你要指定的行为(函数)是通过函数指针传入这个函数的,详细的解释我们在10.12.2 节中给出。

信号这个称呼,基本上来源于UNIX 系统,具体有哪些信号,可以参考UNIX 或者是Linux 的参考文档。Dos 系统中通常将它叫作中断,而在Windows 系统上叫作消息。其实它们都是指“敲门”这件事。

8.7 库函数使用建议

库函数使用建议的第一条就是尽量熟悉标准库,标准库中有很多的函数,可以完成很多复杂的功能。我曾经花费很多时间编写过快速排序算法程序,也曾经编写过一段将英文文本分解成单个的单词的函数。当我发现了标准库中的qsort 函数和strtok 函数时,你们能猜出我当时的心情吗?所以这里我的第一条建议就是首先熟悉标准库中的函数。你可以参考本书网站中“扩展内容”网页中的“The C Library Reference Guide”。

不要把思路局限在标准库上,除了标准库,还有很多其他类型的库,也包含了很多有用的功能,如常用的数据计算,快速傅里叶转换,正则表达式等,当你要实现某个功能的时候,如果这个功能不是老师留给你的作业,那么要做的第一件事就是google 一下,看看这个功能是不是已经有人完成了。记住一句古训:“不要从头造轮子!”问题是现在很多人,在学校抄别人的程序,参加工作以后却自己写程序,正好给整反了。

对于实际的问题,除了现成的函数以外,还有很多其他的解决方法。例如目录结构是操作系统平台相关的,所以C 语言标准中并没有相关的函数去处理。当在Windows 平台下处理目录结构的时候,Windows API 中有专有的函数,你可以使用这些API,但是需要包含windows.h 这个东东,这可是一个庞然大物。其实,我们可以通过标准库中的system 函数来简单地完成这个任务,例如程序8-16 通过system 函数调用了系统的命令,来建立一个yan 的目录。知道各种解决方法,或通过各种组合得到解决方法,比较后得到最优的方案并最终解决问题,这才是一个优秀的工程师需要具备的品质。

程序8-12 利用 system 函数构建文件夹

char cmdbuf[100];

char namedir = "yan";

sprintf(cmdbuf, "mkdir %s ", namedir);

system(cmdbuf);

最后再给大家提个醒,使用任何库函数,一定要包含与之对应的正确的头文件,如果不包含,可能会发生非常难以发现的逻辑错误。

8.8 本章小结

与C++和C#等语言相比,C 语言中的库函数并不多,毕竟C 语言是一个相对低级的语言,主要用于完成一些核心的算法级的任务。我在本章有意回避了C 语言的图形相关函数。一般情况下,我们不会采用C 语言编写界面,界面一般都采用C#或Java 这样相对比较高级的语言来完成。

本章介绍了C 语言中时间函数和随机数函数的一些使用注意事项和使用技巧。你应该记住时间函数的“一个中心,两个基本点”,同时记住srand(time(NULL))这一语句,以便产生真正的随机数。

Linux 下,system 函数可以大展拳脚,极大地扩展了C 语言的功能,这个函数还是应该重点关注一下。对于singnal 函数,现实中用得并不多,但是值得注意的是它背后的回调函数的概念,以及什么是信号或消息,你都应该有所了解。关于回调函数的进一步说明,可以参考第10章中的介绍。