第11章 字符串和字符串函数
04-13Ctrl+D 收藏本站
本章介绍以下内容:
函数:gets、gets_s、fgets、puts、fputs、strcat、strncat、strcmp、strncmp、strcpy、strncpy、sprintf、strchr
创建并使用字符串
使用C库中的字符和字符串函数,并创建自定义的字符串函数
使用命令行参数
字符串是C语言中最有用、最重要的数据类型之一。虽然我们一直在使用字符串,但是要学的东西还很多。C 库提供大量的函数用于读写字符串、拷贝字符串、比较字符串、合并字符串、查找字符串等。通过本章的学习,读者将进一步提高自己的编程水平。
11.1 表示字符串和字符串I/O
第4章介绍过,字符串是以空字符(\0)结尾的char类型数组。因此,可以把上一章学到的数组和指针的知识应用于字符串。不过,由于字符串十分常用,所以 C提供了许多专门用于处理字符串的函数。本章将讨论字符串的性质、如何声明并初始化字符串、如何在程序中输入和输出字符串,以及如何操控字符串。
程序清单11.1演示了在程序中表示字符串的几种方式。
程序清单11.1 strings1.c程序
// strings1.c
#include <stdio.h>
#define MSG "I am a symbolic string constant."
#define MAXLENGTH 81
int main(void)
{
char words[MAXLENGTH] = "I am a string in an array.";
const char * pt1 = "Something is pointing at me.";
puts("Here are some strings:");
puts(MSG);
puts(words);
puts(pt1);
words[8] = 'p';
puts(words);
return 0;
}
和printf函数一样,puts函数也属于stdio.h系列的输入/输出函数。但是,与printf不同的是,puts函数只显示字符串,而且自动在显示的字符串末尾加上换行符。下面是该程序的输出:
Here are some strings:
I am an old-fashioned symbolic string constant.
I am a string in an array.
Something is pointing at me.
I am a spring in an array.
我们先分析一下该程序中定义字符串的几种方法,然后再讲解把字符串读入程序涉及的一些操作,最后学习如何输出字符串。
11.1.1 在程序中定义字符串
程序清单11.1中使用了多种方法(即字符串常量、char类型数组、指向char的指针)定义字符串。程序应该确保有足够的空间储存字符串,这一点我们稍后讨论。
1.字符串字面量(字符串常量)
用双引号括起来的内容称为字符串字面量(string literal),也叫作字符串常量(string constant)。双引号中的字符和编译器自动加入末尾的\0字符,都作为字符串储存在内存中,所以"I am a symbolic stringconstant."、"I am a string in an array."、"Something is pointed at me."、"Here are some strings:"都是字符串字面量。
从ANSI C标准起,如果字符串字面量之间没有间隔,或者用空白字符分隔,C会将其视为串联起来的字符串字面量。例如:
char greeting[50] = "Hello, and"" how are" " you"
" today!";
与下面的代码等价:
char greeting[50] = "Hello, and how are you today!";
如果要在字符串内部使用双引号,必须在双引号前面加上一个反斜杠(\):
printf("\"Run, Spot, run!\" exclaimed Dick.\n");
输出如下:
"Run, Spot, run!" exclaimed Dick.
字符串常量属于静态存储类别(static storage class),这说明如果在函数中使用字符串常量,该字符串只会被储存一次,在整个程序的生命期内存在,即使函数被调用多次。用双引号括起来的内容被视为指向该字符串储存位置的指针。这类似于把数组名作为指向该数组位置的指针。如果确实如此,程序清单11.2中的程序会输出什么?
程序清单11.2 strptr.c程序
/* strptr.c -- 把字符串看作指针 */
#include <stdio.h>
int main(void)
{
printf("%s, %p, %c\n", "We", "are", *"space farers");
return 0;
}
printf根据%s 转换说明打印 We,根据%p 转换说明打印一个地址。因此,如果"are"代表一个地址,printf将打印该字符串首字符的地址(如果使用ANSI之前的实现,可能要用%u或%lu代替%p)。最后,*"space farers"表示该字符串所指向地址上储存的值,应该是字符串*"space farers"的首字符。是否真的是这样?下面是该程序的输出:
We, 0x100000f61, s
2.字符串数组和初始化
定义字符串数组时,必须让编译器知道需要多少空间。一种方法是用足够空间的数组储存字符串。在下面的声明中,用指定的字符串初始化数组m1:
const char m1[40] = "Limit yourself to one line's worth.";
const表明不会更改这个字符串。
这种形式的初始化比标准的数组初始化形式简单得多:
const char m1[40] = { 'L','i', 'm', 'i', 't', ' ', 'y', 'o', 'u', 'r', 's', 'e', 'l',
'f', ' ', 't', 'o', ' ', 'o', 'n', 'e', ' ','l', 'i', 'n', 'e',
'\", 's', ' ', 'w', 'o', 'r','t', 'h', '.', '\0'
};
注意最后的空字符。没有这个空字符,这就不是一个字符串,而是一个字符数组。
在指定数组大小时,要确保数组的元素个数至少比字符串长度多1(为了容纳空字符)。所有未被使用的元素都被自动初始化为0(这里的0指的是char形式的空字符,不是数字字符0),如图11.1所示。
图11.1 初始化数组
通常,让编译器确定数组的大小很方便。回忆一下,省略数组初始化声明中的大小,编译器会自动计算数组的大小:
const char m2 = "If you can't think of anything, fake it.";
让编译器确定初始化字符数组的大小很合理。因为处理字符串的函数通常都不知道数组的大小,这些函数通过查找字符串末尾的空字符确定字符串在何处结束。
让编译器计算数组的大小只能用在初始化数组时。如果创建一个稍后再填充的数组,就必须在声明时指定大小。声明数组时,数组大小必须是可求值的整数。在C99新增变长数组之前,数组的大小必须是整型常量,包括由整型常量组成的表达式。
int n = 8;
char cookies[1];// 有效
char cakes[2 + 5];// 有效,数组大小是整型常量表达式
char pies[2*sizeof(long double) + 1]; // 有效
char crumbs[n]; // 在C99标准之前无效,C99标准之后这种数组是变长数组
字符数组名和其他数组名一样,是该数组首元素的地址。因此,假设有下面的初始化:
char car[10] = "Tata";
那么,以下表达式都为真:
car == &car[0]、*car == 'T'、*(car+1) == car[1] == 'a'。
还可以使用指针表示法创建字符串。例如,程序清单11.1中使用了下面的声明:
const char * pt1 = "Something is pointing at me.";
该声明和下面的声明几乎相同:
const char ar1 = "Something is pointing at me.";
以上两个声明表明,pt1和ar1都是该字符串的地址。在这两种情况下,带双引号的字符串本身决定了预留给字符串的存储空间。尽管如此,这两种形式并不完全相同。
3.数组和指针
数组形式和指针形式有何不同?以上面的声明为例,数组形式(ar1)在计算机的内存中分配为一个内含29个元素的数组(每个元素对应一个字符,还加上一个末尾的空字符'\0'),每个元素被初始化为字符串字面量对应的字符。通常,字符串都作为可执行文件的一部分储存在数据段中。当把程序载入内存时,也载入了程序中的字符串。字符串储存在静态存储区(static memory)中。但是,程序在开始运行时才会为该数组分配内存。此时,才将字符串拷贝到数组中(第 12 章将详细讲解)。注意,此时字符串有两个副本。一个是在静态内存中的字符串字面量,另一个是储存在ar1数组中的字符串。
此后,编译器便把数组名ar1识别为该数组首元素地址(&ar1[0])的别名。这里关键要理解,在数组形式中,ar1是地址常量。不能更改ar1,如果改变了ar1,则意味着改变了数组的存储位置(即地址)。可以进行类似ar1+1这样的操作,标识数组的下一个元素。但是不允许进行++ar1这样的操作。递增运算符只能用于变量名前(或概括地说,只能用于可修改的左值),不能用于常量。
指针形式(*pt1)也使得编译器为字符串在静态存储区预留29个元素的空间。另外,一旦开始执行程序,它会为指针变量pt1留出一个储存位置,并把字符串的地址储存在指针变量中。该变量最初指向该字符串的首字符,但是它的值可以改变。因此,可以使用递增运算符。例如,++pt1将指向第 2 个字符(o)。
字符串字面量被视为const数据。由于pt1指向这个const数据,所以应该把pt1声明为指向const数据的指针。这意味着不能用pt1改变它所指向的数据,但是仍然可以改变pt1的值(即,pt1指向的位置)。如果把一个字符串字面量拷贝给一个数组,就可以随意改变数据,除非把数组声明为const。
总之,初始化数组把静态存储区的字符串拷贝到数组中,而初始化指针只把字符串的地址拷贝给指针。程序清单11.3演示了这一点。
程序清单11.3 addresses.c程序
// addresses.c -- 字符串的地址
#define MSG "I'm special"
#include <stdio.h>
int main
{
char ar = MSG;
const char *pt = MSG;
printf("address of \"I'm special\": %p \n", "I'm special");
printf(" address ar: %p\n", ar);
printf(" address pt: %p\n", pt);
printf(" address of MSG: %p\n", MSG);
printf("address of \"I'm special\": %p \n", "I'm special");
return 0;
}
下面是在我们的系统中运行该程序后的输出:
address of "I'm special": 0x100000f10
address ar: 0x7fff5fbff858
address pt: 0x100000f10
address of MSG: 0x100000f10
address of "I'm special": 0x100000f10
该程序的输出说明了什么?第一,pt和MSG的地址相同,而ar的地址不同,这与我们前面讨论的内容一致。第二,虽然字符串字面量"I'm special"在程序的两个 printf函数中出现了两次,但是编译器只使用了一个存储位置,而且与MSG的地址相同。编译器可以把多次使用的相同字面量储存在一处或多处。另一个编译器可能在不同的位置储存3个"I'm special"。第三,静态数据使用的内存与ar使用的动态内存不同。不仅值不同,特定编译器甚至使用不同的位数表示两种内存。
数组和指针表示字符串的区别是否很重要?通常不太重要,但是这取决于想用程序做什么。我们来进一步讨论这个主题。
4.数组和指针的区别
初始化字符数组来储存字符串和初始化指针来指向字符串有何区别(“指向字符串”的意思是指向字符串的首字符)?例如,假设有下面两个声明:
char heart = "I love Tillie!";
const char *head = "I love Millie!";
两者主要的区别是:数组名heart是常量,而指针名head是变量。那么,实际使用有什么区别?
首先,两者都可以使用数组表示法:
for (i = 0; i < 6; i++)
putchar(heart[i]);
putchar('\n');
for (i = 0; i < 6; i++)
putchar(head[i]);
putchar('\n');
上面两段代码的输出是:
I love
I love
其次,两者都能进行指针加法操作:
for (i = 0; i < 6; i++)
putchar(*(heart + i));
putchar('\n');
for (i = 0; i < 6; i++)
putchar(*(head + i));
putchar('\n');
输出如下:
I love
I love
但是,只有指针表示法可以进行递增操作:
while (*(head) != '\0')/* 在字符串末尾处停止*/
putchar(*(head++));/* 打印字符,指针指向下一个位置 */
这段代码的输出如下:
I love Millie!
假设想让head和heart统一,可以这样做:
head = heart; /* head现在指向数组heart */
这使得head指针指向heart数组的首元素。
但是,不能这样做:
heart = head; /* 非法构造,不能这样写 */
这类似于x = 3;和3 = x;的情况。赋值运算符的左侧必须是变量(或概括地说是可修改的左值),如*pt_int。顺带一提,head = heart;不会导致head指向的字符串消失,这样做只是改变了储存在head中的地址。除非已经保存了"I love Millie!"的地址,否则当head指向别处时,就无法再访问该字符串。
另外,还可以改变heart数组中元素的信息:
heart[7]= 'M';或者*(heart + 7) = 'M';
数组的元素是变量(除非数组被声明为const),但是数组名不是变量。
我们来看一下未使用const限定符的指针初始化:
char * word = "frame";
是否能使用该指针修改这个字符串?
word[1] = 'l'; // 是否允许?
编译器可能允许这样做,但是对当前的C标准而言,这样的行为是未定义的。例如,这样的语句可能导致内存访问错误。原因前面提到过,编译器可以使用内存中的一个副本来表示所有完全相同的字符串字面量。例如,下面的语句都引用字符串"Klingon"的一个内存位置:
char * p1 = "Klingon";
p1[0] = 'F'; // ok?
printf("Klingon");
printf(": Beware the %ss!\n", "Klingon");
也就是说,编译器可以用相同的地址替换每个"Klingon"实例。如果编译器使用这种单次副本表示法,并允许p1[0]修改'F',那将影响所有使用该字符串的代码。所以以上语句打印字符串字面量"Klingon"时实际上显示的是"Flingon":
Flingon: Beware the Flingons!
实际上在过去,一些编译器由于这方面的原因,其行为难以捉摸,而另一些编译器则导致程序异常中断。因此,建议在把指针初始化为字符串字面量时使用const限定符:
const char * pl = "Klingon"; // 推荐用法
然而,把非const数组初始化为字符串字面量却不会导致类似的问题。因为数组获得的是原始字符串的副本。
总之,如果不修改字符串,不要用指针指向字符串字面量。
5.字符串数组
如果创建一个字符数组会很方便,可以通过数组下标访问多个不同的字符串。程序清单11.4演示了两种方法:指向字符串的指针数组和char类型数组的数组。
程序清单11.4 arrchar.c程序
// arrchar.c -- 指针数组,字符串数组
#include <stdio.h>
#define SLEN 40
#define LIM 5
int main(void)
{
const char *mytalents[LIM] = {
"Adding numbers swiftly",
"Multiplying accurately", "Stashing data",
"Following instructions to the letter",
"Understanding the C language"
};
char yourtalents[LIM][SLEN] = {
"Walking in a straight line",
"Sleeping", "Watching television",
"Mailing letters", "Reading email"
};
int i;
puts("Let's compare talents.");
printf("%-36s %-25s\n", "My Talents", "Your Talents");
for (i = 0; i < LIM; i++)
printf("%-36s %-25s\n", mytalents[i], yourtalents[i]);
printf("\nsizeof mytalents: %zd, sizeof yourtalents: %zd\n",
sizeof(mytalents), sizeof(yourtalents));
return 0;
}
下面是该程序的输出:
Let's compare talents.
My Talents Your Talents
Adding numbers swiftlyWalking in a straight line
Multiplying accuratelySleeping
Stashing data Watching television
Following instructions to the letter Mailing letters
Understanding the C language Reading email
sizeof mytalents: 40, sizeof yourtalents: 200
从某些方面来看,mytalents和yourtalents非常相似。两者都代表5个字符串。使用一个下标时都分别表示一个字符串,如mytalents[0]和yourtalents[0];使用两个下标时都分别表示一个字符,例如 mytalents[1][2]表示 mytalents 数组中第 2 个指针所指向的字符串的第 3 个字符'l', yourtalents[1][2]表示youttalentes数组的第2个字符串的第3个字符'e'。而且,两者的初始化方式也相同。
但是,它们也有区别。mytalents数组是一个内含5个指针的数组,在我们的系统中共占用40字节。而yourtalents是一个内含5个数组的数组,每个数组内含40个char类型的值,共占用200字节。所以,虽然mytalents[0]和yourtalents[0]都分别表示一个字符串,但mytalents和yourtalents的类型并不相同。mytalents中的指针指向初始化时所用的字符串字面量的位置,这些字符串字面量被储存在静态内存中;而 yourtalents 中的数组则储存着字符串字面量的副本,所以每个字符串都被储存了两次。此外,为字符串数组分配内存的使用率较低。yourtalents 中的每个元素的大小必须相同,而且必须是能储存最长字符串的大小。
我们可以把yourtalents想象成矩形二维数组,每行的长度都是40字节;把mytalents想象成不规则的数组,每行的长度不同。图 11.2 演示了这两种数组的情况(实际上,mytalents 数组的指针元素所指向的字符串不必储存在连续的内存中,图中所示只是为了强调两种数组的不同)。
图11.2 矩形数组和不规则数组
综上所述,如果要用数组表示一系列待显示的字符串,请使用指针数组,因为它比二维字符数组的效率高。但是,指针数组也有自身的缺点。mytalents 中的指针指向的字符串字面量不能更改;而yourtalentsde 中的内容可以更改。所以,如果要改变字符串或为字符串输入预留空间,不要使用指向字符串字面量的指针。
11.1.2 指针和字符串
读者可能已经注意到了,在讨论字符串时或多或少会涉及指针。实际上,字符串的绝大多数操作都是通过指针完成的。例如,考虑程序清单11.5中的程序。
程序清单11.5 p_and_s.c程序
/* p_and_s.c -- 指针和字符串 */
#include <stdio.h>
int main(void)
{
const char * mesg = "Don't be a fool!";
const char * copy;
copy = mesg;
printf("%s\n", copy);
printf("mesg = %s; &mesg = %p; value = %p\n", mesg, &mesg, mesg);
printf("copy = %s; © = %p; value = %p\n", copy, ©, copy);
return 0;
}
注意
如果编译器不识别%p,用%u或%lu代替%p。
你可能认为该程序拷贝了字符串"Don't be a fool!",程序的输出似乎也验证了你的猜测:
Don't be a fool!
mesg = Don't be a fool!; &mesg = 0x0012ff48; value = 0x0040a000
copy = Don't be a fool!; © = 0x0012ff44; value = 0x0040a000
我们来仔细分析最后两个printf的输出。首先第1项,mesg和copy都以字符串形式输出(%s转换说明)。这里没问题,两个字符串都是"Don't be a fool!"。
接着第2项,打印两个指针的地址。如上输出所示,指针mesg和copy分别储存在地址为0x0012ff48和0x0012ff44的内存中。
注意最后一项,显示两个指针的值。所谓指针的值就是它储存的地址。mesg 和 copy 的值都是0x0040a000,说明它们都指向的同一个位置。因此,程序并未拷贝字符串。语句copy = mesg;把mesg的值赋给copy,即让copy也指向mesg指向的字符串。
为什么要这样做?为何不拷贝整个字符串?假设数组有50个元素,考虑一下哪种方法更效率:拷贝一个地址还是拷贝整个数组?通常,程序要完成某项操作只需要知道地址就可以了。如果确实需要拷贝整个数组,可以使用strcpy或strncpy函数,本章稍后介绍这两个函数。
我们已经讨论了如何在程序中定义字符串,接下来看看如何从键盘输入字符串。
11.2 字符串输入
如果想把一个字符串读入程序,首先必须预留储存该字符串的空间,然后用输入函数获取该字符串。
11.2.1 分配空间
要做的第 1 件事是分配空间,以储存稍后读入的字符串。前面提到过,这意味着必须要为字符串分配足够的空间。不要指望计算机在读取字符串时顺便计算它的长度,然后再分配空间(计算机不会这样做,除非你编写一个处理这些任务的函数)。假设编写了如下代码:
char *name;
scanf("%s", name);
虽然可能会通过编译(编译器很可能给出警告),但是在读入name时,name可能会擦写掉程序中的数据或代码,从而导致程序异常中止。因为scanf要把信息拷贝至参数指定的地址上,而此时该参数是个未初始化的指针,name可能会指向任何地方。大多数程序员都认为出现这种情况很搞笑,但仅限于评价别人的程序时。
最简单的方法是,在声明时显式指明数组的大小:
char name[81];
现在name是一个已分配块(81字节)的地址。还有一种方法是使用C库函数来分配内存,第12章将详细介绍。
为字符串分配内存后,便可读入字符串。C 库提供了许多读取字符串的函数:scanf、gets和fgets。我们先讨论最常用gets函数。
11.2.2 不幸的gets函数
在读取字符串时,scanf和转换说明%s只能读取一个单词。可是在程序中经常要读取一整行输入,而不仅仅是一个单词。许多年前,gets函数就用于处理这种情况。gets函数简单易用,它读取整行输入,直至遇到换行符,然后丢弃换行符,储存其余字符,并在这些字符的末尾添加一个空字符使其成为一个 C 字符串。它经常和 puts函数配对使用,该函数用于显示字符串,并在末尾添加换行符。程序清单11.6中演示了这两个函数的用法。
程序清单11.6 getsputs.c程序
/* getsputs.c -- 使用 gets 和 puts */
#include <stdio.h>
#define STLEN 81
int main(void)
{
char words[STLEN];
puts("Enter a string, please.");
gets(words); // 典型用法
printf("Your string twice:\n");
printf("%s\n", words);
puts(words);
puts("Done.");
return 0;
}
下面是该程序在某些编译器(或者至少是旧式编译器)中的运行示例:
Enter a string, please.
I want to learn about string theory!
Your string twice:
I want to learn about string theory!
I want to learn about string theory!
Done.
整行输入(除了换行符)都被储存在 words 中,puts(words)和 printf("%s\n, words")的效果相同。
下面是该程序在另一个编译器中的输出示例:
Enter a string, please.
warning: this program uses gets, which is unsafe.
Oh, no!
Your string twice:
Oh, no!
Oh, no!
Done.
编译器在输出中插入了一行警告消息。每次运行这个程序,都会显示这行消息。但是,并非所有的编译器都会这样做。其他编译器可能在编译过程中给出警告,但不会引起你的注意。
这是怎么回事?问题出在 gets唯一的参数是 words,它无法检查数组是否装得下输入行。上一章介绍过,数组名会被转换成该数组首元素的地址,因此,gets函数只知道数组的开始处,并不知道数组中有多少个元素。
如果输入的字符串过长,会导致缓冲区溢出(buffer overflow),即多余的字符超出了指定的目标空间。如果这些多余的字符只是占用了尚未使用的内存,就不会立即出现问题;如果它们擦写掉程序中的其他数据,会导致程序异常中止;或者还有其他情况。为了让输入的字符串容易溢出,把程序中的STLEN设置为5,程序的输出如下:
Enter a string, please.
warning: this program uses gets, which is unsafe.
I think I'll be just fine.
Your string twice:
I think I'll be just fine.
I think I'll be just fine.
Done.
Segmentation fault: 11
“Segmentation fault”(分段错误)似乎不是个好提示,的确如此。在UNIX系统中,这条消息说明该程序试图访问未分配的内存。
C 提供解决某些编程问题的方法可能会导致陷入另一个尴尬棘手的困境。但是,为什么要特别提到gets函数?因为该函数的不安全行为造成了安全隐患。过去,有些人通过系统编程,利用gets插入和运行一些破坏系统安全的代码。
不久,C 编程社区的许多人都建议在编程时摒弃 gets。制定 C99 标准的委员会把这些建议放入了标准,承认了gets的问题并建议不要再使用它。尽管如此,在标准中保留gets也合情合理,因为现有程序中含有大量使用该函数的代码。而且,只要使用得当,它的确是一个很方便的函数。
好景不长,C11标准委员会采取了更强硬的态度,直接从标准中废除了gets函数。既然标准已经发布,那么编译器就必须根据标准来调整支持什么,不支持什么。然而在实际应用中,编译器为了能兼容以前的代码,大部分都继续支持gets函数。不过,我们使用的编译器,可没那么大方。
11.2.3 gets的替代品
过去通常用fgets来代替gets,fgets函数稍微复杂些,在处理输入方面与gets略有不同。C11标准新增的gets_s函数也可代替gets。该函数与gets函数更接近,而且可以替换现有代码中的gets。但是,它是stdio.h输入/输出函数系列中的可选扩展,所以支持C11的编译器也不一定支持它。
1.fgets函数(和fputs)
fgets函数通过第2个参数限制读入的字符数来解决溢出的问题。该函数专门设计用于处理文件输入,所以一般情况下可能不太好用。fgets和gets的区别如下。
fgets函数的第2个参数指明了读入字符的最大数量。如果该参数的值是n,那么fgets将读入n-1个字符,或者读到遇到的第一个换行符为止。
如果fgets读到一个换行符,会把它储存在字符串中。这点与gets不同,gets会丢弃换行符。
fgets函数的第3 个参数指明要读入的文件。如果读入从键盘输入的数据,则以stdin(标准输入)作为参数,该标识符定义在stdio.h中。
因为 fgets函数把换行符放在字符串的末尾(假设输入行不溢出),通常要与 fputs函数(和puts类似)配对使用,除非该函数不在字符串末尾添加换行符。fputs函数的第2个参数指明它要写入的文件。如果要显示在计算机显示器上,应使用stdout(标准输出)作为该参数。程序清单11.7演示了fgets和fputs函数的用法。
程序清单11.7 fgets1.c程序
/* fgets1.c -- 使用 fgets 和 fputs */
#include <stdio.h>
#define STLEN 14
int main(void)
{
char words[STLEN];
puts("Enter a string, please.");
fgets(words, STLEN, stdin);
printf("Your string twice (puts, then fputs):\n");
puts(words);
fputs(words, stdout);
puts("Enter another string, please.");
fgets(words, STLEN, stdin);
printf("Your string twice (puts, then fputs):\n");
puts(words);
fputs(words, stdout);
puts("Done.");
return 0;
}
下面是该程序的输出示例:
Enter a string, please.
apple pie
Your string twice (puts, then fputs):
apple pie
apple pie
Enter another string, please.
strawberry shortcake
Your string twice (puts, then fputs):
strawberry sh
strawberry shDone.
第1行输入,apple pie,比fgets读入的整行输入短,因此,apple pie\n\0被储存在数组中。所以当puts显示该字符串时又在末尾添加了换行符,因此apple pie后面有一行空行。因为fputs不在字符串末尾添加换行符,所以并未打印出空行。
第2行输入,strawberry shortcake,超过了大小的限制,所以fgets只读入了13个字符,并把strawberry sh\0 储存在数组中。再次提醒读者注意,puts函数会在待输出字符串末尾添加一个换行符,而fputs不会这样做。
fputs函数返回指向 char的指针。如果一切进行顺利,该函数返回的地址与传入的第 1 个参数相同。但是,如果函数读到文件结尾,它将返回一个特殊的指针:空指针(null pointer)。该指针保证不会指向有效的数据,所以可用于标识这种特殊情况。在代码中,可以用数字0来代替,不过在C语言中用宏NULL来代替更常见(如果在读入数据时出现某些错误,该函数也返回NULL)。程序清单11.8演示了一个简单的循环,读入并显示用户输入的内容,直到fgets读到文件结尾或空行(即,首字符是换行符)。
程序清单11.8 fgets2.c程序
/* fgets2.c -- 使用 fgets 和 fputs */
#include <stdio.h>
#define STLEN 10
int main(void)
{
char words[STLEN];
puts("Enter strings (empty line to quit):");
while (fgets(words, STLEN, stdin) != NULL && words[0] != '\n')
fputs(words, stdout);
puts("Done.");
return 0;
}
下面是该程序的输出示例:
Enter strings (empty line to quit):
By the way, the gets function
By the way, the gets function
also returns a null pointer if it
also returns a null pointer if it
encounters end-of-file.
encounters end-of-file.
Done.
有意思,虽然STLEN被设置为10,但是该程序似乎在处理过长的输入时完全没问题。程序中的fgets一次读入 STLEN - 1 个字符(该例中为 9 个字符)。所以,一开始它只读入了“By the wa”,并储存为By the wa\0;接着fputs打印该字符串,而且并未换行。然后while循环进入下一轮迭代,fgets继续从剩余的输入中读入数据,即读入“y, the ge”并储存为y, the ge\0;接着fputs在刚才打印字符串的这一行接着打印第 2 次读入的字符串。然后 while 进入下一轮迭代,fgets继续读取输入、fputs打印字符串,这一过程循环进行,直到读入最后的“tion\n”。fgets将其储存为tion\n\0, fputs打印该字符串,由于字符串中的\n,光标被移至下一行开始处。
系统使用缓冲的I/O。这意味着用户在按下Return键之前,输入都被储存在临时存储区(即,缓冲区)中。按下Return键就在输入中增加了一个换行符,并把整行输入发送给fgets。对于输出,fputs把字符发送给另一个缓冲区,当发送换行符时,缓冲区中的内容被发送至屏幕上。
fgets储存换行符有好处也有坏处。坏处是你可能并不想把换行符储存在字符串中,这样的换行符会带来一些麻烦。好处是对于储存的字符串而言,检查末尾是否有换行符可以判断是否读取了一整行。如果不是一整行,要妥善处理一行中剩下的字符。
首先,如何处理掉换行符?一个方法是在已储存的字符串中查找换行符,并将其替换成空字符:
while (words[i] != '\n') // 假设\n在words中
i++;
words[i] = '\0';
其次,如果仍有字符串留在输入行怎么办?一个可行的办法是,如果目标数组装不下一整行输入,就丢弃那些多出的字符:
while (getchar != '\n') // 读取但不储存输入,包括\n
continue;
程序清单11.9在程序清单11.8的基础上添加了一部分测试代码。该程序读取输入行,删除储存在字符串中的换行符,如果没有换行符,则丢弃数组装不下的字符。
程序清单11.9 fgets3.c程序
/* fgets3.c -- 使用 fgets */
#include <stdio.h>
#define STLEN 10
int main(void)
{
char words[STLEN];
int i;
puts("Enter strings (empty line to quit):");
while (fgets(words, STLEN, stdin) != NULL && words[0] != '\n')
{
i = 0;
while (words[i] != '\n' && words[i] != '\0')
i++;
if (words[i] == '\n')
words[i] = '\0';
else // 如果word[i] == '\0'则执行这部分代码
while (getchar != '\n')
continue;
puts(words);
}
puts("done");
return 0;
}
循环
while (words[i] != '\n' && words[i] != '\0')
i++;
遍历字符串,直至遇到换行符或空字符。如果先遇到换行符,下面的if语句就将其替换成空字符;如果先遇到空字符,else部分便丢弃输入行的剩余字符。下面是该程序的输出示例:
Enter strings (empty line to quit):
This
This
program seems
program s
unwilling to accept long lines.
unwilling
But it doesn't get stuck on long
But it do
lines either.
lines eit
done
空字符和空指针
程序清单 11.9 中出现了空字符和空指针。从概念上看,两者完全不同。空字符(或'\0')是用于标记C字符串末尾的字符,其对应字符编码是0。由于其他字符的编码不可能是 0,所以不可能是字符串的一部分。
空指针(或NULL)有一个值,该值不会与任何数据的有效地址对应。通常,函数使用它返回一个有效地址表示某些特殊情况发生,例如遇到文件结尾或未能按预期执行。
空字符是整数类型,而空指针是指针类型。两者有时容易混淆的原因是:它们都可以用数值0来表示。但是,从概念上看,两者是不同类型的0。另外,空字符是一个字符,占1字节;而空指针是一个地址,通常占4字节。
2.gets_s函数
C11新增的gets_s函数(可选)和fgets类似,用一个参数限制读入的字符数。假设把程序清单11.9中的fgets换成gets_s,其他内容不变,那么下面的代码将把一行输入中的前9个字符读入words数组中,假设末尾有换行符:
gets_s(words, STLEN);
gets_s与fgets的区别如下。
gets_s只从标准输入中读取数据,所以不需要第3个参数。
如果gets_s读到换行符,会丢弃它而不是储存它。
如果gets_s读到最大字符数都没有读到换行符,会执行以下几步。首先把目标数组中的首字符设置为空字符,读取并丢弃随后的输入直至读到换行符或文件结尾,然后返回空指针。接着,调用依赖实现的“处理函数”(或你选择的其他函数),可能会中止或退出程序。
第2个特性说明,只要输入行未超过最大字符数,gets_s和gets几乎一样,完全可以用gets_s替换gets。第3个特性说明,要使用这个函数还需要进一步学习。
我们来比较一下 gets、fgets和 gets_s的适用性。如果目标存储区装得下输入行,3 个函数都没问题。但是fgets会保留输入末尾的换行符作为字符串的一部分,要编写额外的代码将其替换成空字符。
如果输入行太长会怎样?使用gets不安全,它会擦写现有数据,存在安全隐患。gets_s函数很安全,但是,如果并不希望程序中止或退出,就要知道如何编写特殊的“处理函数”。另外,如果打算让程序继续运行,gets_s会丢弃该输入行的其余字符,无论你是否需要。由此可见,当输入太长,超过数组可容纳的字符数时,fgets函数最容易使用,而且可以选择不同的处理方式。如果要让程序继续使用输入行中超出的字符,可以参考程序清单11.8中的处理方法。如果想丢弃输入行的超出字符,可以参考程序清单11.9中的处理方法。
所以,当输入与预期不符时,gets_s完全没有fgets函数方便、灵活。也许这也是gets_s只作为C库的可选扩展的原因之一。鉴于此,fgets通常是处理类似情况的最佳选择。
3.s_gets函数
程序清单11.9演示了fgets函数的一种用法:读取整行输入并用空字符代替换行符,或者读取一部分输入,并丢弃其余部分。既然没有处理这种情况的标准函数,我们就创建一个,在后面的程序中会用得上。程序清单11.10提供了一个这样的函数。
程序清单11.10 s_gets函数
char * s_gets(char * st, int n)
{
char * ret_val;
int i = 0;
ret_val = fgets(st, n, stdin);
if (ret_val) // 即,ret_val != NULL
{
while (st[i] != '\n' && st[i] != '\0')
i++;
if (st[i] == '\n')
st[i] = '\0';
else
while (getchar != '\n')
continue;
}
return ret_val;
}
如果 fgets返回 NULL,说明读到文件结尾或出现读取错误,s_gets函数跳过了这个过程。它模仿程序清单11.9的处理方法,如果字符串中出现换行符,就用空字符替换它;如果字符串中出现空字符,就丢弃该输入行的其余字符,然后返回与fgets相同的值。我们在后面的示例中将讨论fgets函数。
也许读者想了解为什么要丢弃过长输入行中的余下字符。这是因为,输入行中多出来的字符会被留在缓冲区中,成为下一次读取语句的输入。例如,如果下一条读取语句要读取的是 double 类型的值,就可能导致程序崩溃。丢弃输入行余下的字符保证了读取语句与键盘输入同步。
我们设计的 s_gets函数并不完美,它最严重的缺陷是遇到不合适的输入时毫无反应。它丢弃多余的字符时,既不通知程序也不告知用户。但是,用来替换前面程序示例中的gets足够了。
11.2.4 scanf函数
我们再来研究一下scanf。前面的程序中用scanf和%s转换说明读取字符串。scanf和gets或fgets的区别在于它们如何确定字符串的末尾:scanf更像是“获取单词”函数,而不是“获取字符串”函数;如果预留的存储区装得下输入行,gets和fgets会读取第1个换行符之前所有的字符。scanf函数有两种方法确定输入结束。无论哪种方法,都从第1个非空白字符作为字符串的开始。如果使用%s转换说明,以下一个空白字符(空行、空格、制表符或换行符)作为字符串的结束(字符串不包括空白字符)。如果指定了字段宽度,如%10s,那么scanf将读取10 个字符或读到第1个空白字符停止(先满足的条件即是结束输入的条件),见图11.3。
图11.3 字段宽度和scanf
前面介绍过,scanf函数返回一个整数值,该值等于scanf成功读取的项数或EOF(读到文件结尾时返回EOF)。
程序清单11.11演示了在scanf函数中指定字段宽度的用法。
程序清单11.11 scan_str.c程序
/* scan_str.c -- 使用 scanf */
#include <stdio.h>
int main(void)
{
char name1[11], name2[11];
int count;
printf("Please enter 2 names.\n");
count = scanf("%5s %10s", name1, name2);
printf("I read the %d names %s and %s.\n", count, name1, name2);
return 0;
}
下面是该程序的3个输出示例:
Please enter 2 names.
Jesse Jukes
I read the 2 names Jesse and Jukes.
Please enter 2 names.
Liza Applebottham
I read the 2 names Liza and Applebotth.
Please enter 2 names.
Portensia Callowit
I read the 2 names Porte and nsia.
第1个输出示例,两个名字的字符个数都未超过字段宽度。第2个输出示例,只读入了Applebottham的前10个字符Applebotth(因为使用了%10s转换说明)。第3个输出示例,Portensia的后4个字符nsia被写入name2中,因为第2次调用scanf时,从上一次调用结束的地方继续读取数据。在该例中,读取的仍是Portensia中的字母。
根据输入数据的性质,用fgets读取从键盘输入的数据更合适。例如,scanf无法完整读取书名或歌曲名,除非这些名称是一个单词。scanf的典型用法是读取并转换混合数据类型为某种标准形式。例如,如果输入行包含一种工具名、库存量和单价,就可以使用scanf。否则可能要自己拼凑一个函数处理一些输入检查。如果一次只输入一个单词,用scanf也没问题。
scanf和gets类似,也存在一些潜在的缺点。如果输入行的内容过长,scanf也会导致数据溢出。不过,在%s转换说明中使用字段宽度可防止溢出。
11.3 字符串输出
讨论完字符串输入,接下来我们讨论字符串输出。C有3个标准库函数用于打印字符串:put、fputs和printf。
11.3.1 puts函数
puts函数很容易使用,只需把字符串的地址作为参数传递给它即可。程序清单11.12演示了puts的一些用法。
程序清单11.12 put_out.c程序
/* put_out.c -- 使用 puts */
#include <stdio.h>
#define DEF "I am a #defined string."
int main(void)
{
char str1[80] = "An array was initialized to me.";
const char * str2 = "A pointer was initialized to me.";
puts("I'm an argument to puts.");
puts(DEF);
puts(str1);
puts(str2);
puts(&str1[5]);
puts(str2 + 4);
return 0;
}
该程序的输出如下:
I'm an argument to puts.
I am a #defined string.
An array was initialized to me.
A pointer was initialized to me.
ray was initialized to me.
inter was initialized to me.
如上所示,每个字符串独占一行,因为puts在显示字符串时会自动在其末尾添加一个换行符。
该程序示例再次说明,用双引号括起来的内容是字符串常量,且被视为该字符串的地址。另外,储存字符串的数组名也被看作是地址。在第5个puts调用中,表达式&str1[5]是str1数组的第6个元素(r),puts从该元素开始输出。与此类似,第6个puts调用中,str2+4指向储存"pointer"中i的存储单元,puts从这里开始输出。
puts如何知道在何处停止?该函数在遇到空字符时就停止输出,所以必须确保有空字符。不要模仿程序清单11.13中的程序!
程序清单11.13 nono.c程序
/* nono.c -- 千万不要模仿! */
#include <stdio.h>
int main(void)
{
char side_a = "Side A";
char dont = { 'W', 'O', 'W', '!' };
char side_b = "Side B";
puts(dont); /* dont 不是一个字符串 */
return 0;
}
由于dont缺少一个表示结束的空字符,所以它不是一个字符串,因此puts不知道在何处停止。它会一直打印dont后面内存中的内容,直到发现一个空字符为止。为了让puts能尽快读到空字符,我们把dont放在side_a和side_b之间。下面是该程序的一个运行示例:
WOW!Side A
我们使用的编译器把side_a数组储存在dont数组之后,所以puts一直输出至遇到side_a中的空字符。你所使用的编译器输出的内容可能不同,这取决于编译器如何在内存中储存数据。如果删除程序中的side_a和side_b数组会怎样?通常内存中有许多空字符,如果幸运的话,puts很快就会发现一个。但是,这样做很不靠谱。
11.3.2 fputs函数
fputs函数是puts针对文件定制的版本。它们的区别如下。
fputs函数的第 2 个参数指明要写入数据的文件。如果要打印在显示器上,可以用定义在stdio.h中的stdout(标准输出)作为该参数。
与puts不同,fputs不会在输出的末尾添加换行符。
注意,gets丢弃输入中的换行符,但是puts在输出中添加换行符。另一方面,fgets保留输入中的换行符,fputs不在输出中添加换行符。假设要编写一个循环,读取一行输入,另起一行打印出该输入。可以这样写:
char line[81];
while (gets(line))// 与while (gets(line) != NULL)相同
puts(line);
如果gets读到文件结尾会返回空指针。对空指针求值为0(即为假),这样便可结束循环。或者,可以这样写:
char line[81];
while (fgets(line, 81, stdin))
fputs(line, stdout);
第1个循环(使用gets和puts的while循环),line数组中的字符串显示在下一行,因为puts在字符串末尾添加了一个换行符。第2个循环(使用fgets和fputs的while循环),line数组中的字符串也显示在下一行,因为 fgets把换行符储存在字符串末尾。注意,如果混合使用 fgets输入和puts输出,每个待显示的字符串末尾就会有两个换行符。这里关键要注意:puts应与gets配对使用,fputs应与fgets配对使用。
我们在这里提到已被废弃的 gets,并不是鼓励使用它,而是为了让读者了解它的用法。如果今后遇到包含该函数的代码,不至于看不懂。
11.3.3 printf函数
在第4章中,我们详细讨论过printf函数的用法。和puts一样,printf也把字符串的地址作为参数。printf函数用起来没有puts函数那么方便,但是它更加多才多艺,因为它可以格式化不同的数据类型。
与puts不同的是,printf不会自动在每个字符串末尾加上一个换行符。因此,必须在参数中指明应该在哪里使用换行符。例如:
printf("%s\n", string);
和下面的语句效果相同:
puts(string);
如上所示,printf的形式更复杂些,需要输入更多代码,而且计算机执行的时间也更长(但是你觉察不到)。然而,使用 printf打印多个字符串更加简单。例如,下面的语句把 Well、用户名和一个#define定义的字符串打印在一行:
printf("Well, %s, %s\n", name, MSG);
11.4 自定义输入/输出函数
不一定非要使用C库中的标准函数,如果无法使用这些函数或者不想用它们,完全可以在getchar和putchar的基础上自定义所需的函数。假设你需要一个类似puts但是不会自动添加换行符的函数。程序清单11.14给出了一个这样的函数。
程序清单11.14 put1函数
/* put1.c -- 打印字符串,不添加\n */
#include <stdio.h>
void put1(const char * string)/* 不会改变字符串 */
{
while (*string != '\0')
putchar(*string++);
}
指向char的指针string最初指向传入参数的首元素。因为该函数不会改变传入的字符串,所以形参使用了const限定符。打印了首元素的内容后,指针递增1,指向下一个元素。while循环重复这一过程,直到指针指向包含空字符的元素。记住,++的优先级高于*,因此putchar(*string++)打印string指向的值,递增的是string本身,而不是递增它所指向的字符。
可以把 put1.c 程序作为编写字符串处理函数的模型。因为每个字符串都以空字符结尾,所以不用给函数传递字符串的大小。函数依次处理每个字符,直至遇到空字符。
用数组表示法编写这个函数稍微复杂些:
int i = 0;
while (string[i]!= '\0')
putchar(string[i++]);
要为数组索引创建一个额外的变量。
许多C程序员会在while循环中使用下面的测试条件:
while (*string)
当string指向空字符时,*string的值是0,即测试条件为假,while循环结束。这种方法比上面两种方法简洁。但是,如果不熟悉C语言,可能觉察不出来。这种处理方法很普遍,作为C程序员应该熟悉这种写法。
注意
为什么程序清单11.14中的形式参数是const char * string,而不是const char sting?从技术方面看,两者等价且都有效。使用带方括号的写法是为了提醒用户:该函数处理的是数组。然而,如果要处理字符串,实际参数可以是数组名、用双引号括起来的字符串,或声明为 char *类型的变量。用const char * string可以提醒用户:实际参数不一定是数组。
假设要设计一个类似puts的函数,而且该函数还给出待打印字符的个数。如程序清单11.15所示,添加一个功能很简单。
程序清单11.15 put2.c程序
/* put2.c -- 打印一个字符串,并统计打印的字符数 */
#include <stdio.h>
int put2(const char * string)
{
int count = 0;
while (*string) /* 常规用法 */
{
putchar(*string++);
count++;
}
putchar('\n');/* 不统计换行符 */
return(count);
}
下面的函数调用将打印字符串pizza:
put1("pizza");
下面的调用将返回统计的字符数,并将其赋给num(该例中,num的值是5):
num = put2("pizza");
程序清单11.16使用一个简单的驱动程序测试put1和put2,并演示了嵌套函数的调用。
程序清单11.16 .c程序
//put_put.c -- 用户自定义输出函数
#include <stdio.h>
void put1(const char *);
int put2(const char *);
int main(void)
{
put1("If I'd as much money");
put1(" as I could spend,\n");
printf("I count %d characters.\n",
put2("I never would cry old chairs to mend."));
return 0;
}
void put1(const char * string)
{
while (*string) /* 与 *string != '\0' 相同 */
putchar(*string++);
}
int put2(const char * string)
{
int count = 0;
while (*string)
{
putchar(*string++);
count++;
}
putchar('\n');
return(count);
}
程序中使用 printf打印 put2的值,但是为了获得 put2的返回值,计算机必须先执行put2,因此在打印字符数之前先打印了传递给该函数的字符串。下面是该程序的输出:
If I'd as much money as I could spend,
I never would cry old chairs to mend.
I count 37 characters.
11.5 字符串函数
C库提供了多个处理字符串的函数,ANSI C把这些函数的原型放在string.h头文件中。其中最常用的函数有 strlen、strcat、strcmp、strncmp、strcpy和 strncpy。另外,还有sprintf函数,其原型在stdio.h头文件中。欲了解string.h系列函数的完整列表,请查阅附录B中的参考资料V“新增C99和C11的标准ANSI C库”。
11.5.1 strlen函数
strlen函数用于统计字符串的长度。下面的函数可以缩短字符串的长度,其中用到了strlen:
void fit(char *string, unsigned int size)
{
if (strlen(string) > size)
string[size] = '\0';
}
该函数要改变字符串,所以函数头在声明形式参数string时没有使用const限定符。
程序清单11.17中的程序测试了fit函数。注意代码中使用了C字符串常量的串联特性。
程序清单11.17 test_fit.c程序
/* test_fit.c -- 使用缩短字符串长度的函数 */
#include <stdio.h>
#include <string.h> /* 内含字符串函数原型 */
void fit(char *, unsigned int);
int main(void)
{
char mesg = "Things should be as simple as possible,"
" but not simpler.";
puts(mesg);
fit(mesg, 38);
puts(mesg);
puts("Let's look at some more of the string.");
puts(mesg + 39);
return 0;
}
void fit(char *string, unsigned int size)
{
if (strlen(string) > size)
string[size] = '\0';
}
下面是该程序的输出:
Things should be as simple as possible, but not simpler.
Things should be as simple as possible
Let's look at some more of the string.
but not simpler.
fit函数把第39个元素的逗号替换成'\0'字符。puts函数在空字符处停止输出,并忽略其余字符。然而,这些字符还在缓冲区中,下面的函数调用把这些字符打印了出来:
puts(mesg + 8);
表达式mesg + 39是mesg[39]的地址,该地址上储存的是空格字符。所以put显示该字符并继续输出直至遇到原来字符串中的空字符。图11.4演示了这一过程。
图11.4 puts函数和空字符
注意
一些ANSI之前的系统使用strings.h头文件,而有些系统可能根本没有字符串头文件。
string.h头文件中包含了C字符串函数系列的原型,因此程序清单11.17要包含该头文件。
11.5.2 strcat函数
strcat(用于拼接字符串)函数接受两个字符串作为参数。该函数把第2个字符串的备份附加在第1个字符串末尾,并把拼接后形成的新字符串作为第1个字符串,第2个字符串不变。strcat函数的类型是char *(即,指向char的指针)。strcat函数返回第1个参数,即拼接第2个字符串后的第1个字符串的地址。
程序清单11.18演示了strcat的用法。该程序还使用了程序清单11.10的s_gets函数。回忆一下,该函数使用fgets读取一整行,如果有换行符,将其替换成空字符。
程序清单11.18 str_cat.c程序
/* str_cat.c -- 拼接两个字符串 */
#include <stdio.h>
#include <string.h> /* strcat函数的原型在该头文件中 */
#define SIZE 80
char * s_gets(char * st, int n);
int main(void)
{
char flower[SIZE];
char addon = "s smell like old shoes.";
puts("What is your favorite flower?");
if (s_gets(flower, SIZE))
{
strcat(flower, addon);
puts(flower);
puts(addon);
}
else
puts("End of file encountered!");
puts("bye");
return 0;
}
char * s_gets(char * st, int n)
{
char * ret_val;
int i = 0;
ret_val = fgets(st, n, stdin);
if (ret_val)
{
while (st[i] != '\n' && st[i] != '\0')
i++;
if (st[i] == '\n')
st[i] = '\0';
else
while (getchar != '\n')
continue;
}
return ret_val;
}
该程序的输出示例如下:
What is your favorite flower?
wonderflower
wonderflowers smell like old shoes.
s smell like old shoes.
bye
从以上输出可以看出,flower改变了,而addon保持不变。
11.5.3 strncat函数
strcat函数无法检查第1个数组是否能容纳第2个字符串。如果分配给第1个数组的空间不够大,多出来的字符溢出到相邻存储单元时就会出问题。当然,可以像程序清单11.15那样,用strlen查看第1个数组的长度。注意,要给拼接后的字符串长度加1才够空间存放末尾的空字符。或者,用strncat,该函数的第3 个参数指定了最大添加字符数。例如,strncat(bugs, addon, 13)将把 addon字符串的内容附加给bugs,在加到第13个字符或遇到空字符时停止。因此,算上空字符(无论哪种情况都要添加空字符),bugs数组应该足够大,以容纳原始字符串(不包含空字符)、添加原始字符串在后面的13个字符和末尾的空字符。程序清单11.19使用这种方法,计算avaiable变量的值,用于表示允许添加的最大字符数。
程序清单11.19 join_chk.c程序
/* join_chk.c -- 拼接两个字符串,检查第1个数组的大小 */
#include <stdio.h>
#include <string.h>
#define SIZE 30
#define BUGSIZE 13
char * s_gets(char * st, int n);
int main(void)
{
char flower[SIZE];
char addon = "s smell like old shoes.";
char bug[BUGSIZE];
int available;
puts("What is your favorite flower?");
s_gets(flower, SIZE);
if ((strlen(addon) + strlen(flower) + 1) <= SIZE)
strcat(flower, addon);
puts(flower);
puts("What is your favorite bug?");
s_gets(bug, BUGSIZE);
available = BUGSIZE - strlen(bug) - 1;
strncat(bug, addon, available);
puts(bug);
return 0;
}
char * s_gets(char * st, int n)
{
char * ret_val;
int i = 0;
ret_val = fgets(st, n, stdin);
if (ret_val)
{
while (st[i] != '\n' && st[i] != '\0')
i++;
if (st[i] == '\n')
st[i] = '\0';
else
while (getchar != '\n')
continue;
}
return ret_val;
}
下面是该程序的运行示例:
What is your favorite flower?
Rose
Roses smell like old shoes.
What is your favorite bug?
Aphid
Aphids smell
读者可能已经注意到,strcat和 gets类似,也会导致缓冲区溢出。为什么 C11 标准不废弃strcat,只留下strncat?为何对gets那么残忍?这也许是因为gets造成的安全隐患来自于使用该程序的人,而strcat暴露的问题是那些粗心的程序员造成的。无法控制用户会进行什么操作,但是,可以控制你的程序做什么。C语言相信程序员,因此程序员有责任确保strcat的使用安全。
11.5.4 strcmp函数
假设要把用户的响应与已储存的字符串作比较,如程序清单11.20所示。
程序清单11.20 nogo.c程序
/* nogo.c -- 该程序是否能正常运行? */
#include <stdio.h>
#define ANSWER "Grant"
#define SIZE 40
char * s_gets(char * st, int n);
int main(void)
{
char try[SIZE];
puts("Who is buried in Grant's tomb?");
s_gets(try, SIZE);
while (try != ANSWER)
{
puts("No, that's wrong. Try again.");
s_gets(try, SIZE);
}
puts("That's right!");
return 0;
}
char * s_gets(char * st, int n)
{
char * ret_val;
int i = 0;
ret_val = fgets(st, n, stdin);
if (ret_val)
{
while (st[i] != '\n' && st[i] != '\0')
i++;
if (st[i] == '\n')
st[i] = '\0';
else
while (getchar != '\n')
continue;
}
return ret_val;
}
这个程序看上去没问题,但是运行后却不对劲。ANSWER和try都是指针,所以try != ANSWER检查的不是两个字符串是否相等,而是这两个字符串的地址是否相同。因为ANSWE和try储存在不同的位置,所以这两个地址不可能相同,因此,无论用户输入什么,程序都提示输入不正确。这真让人沮丧。
该函数要比较的是字符串的内容,不是字符串的地址。读者可以自己设计一个函数,也可以使用C标准库中的strcmp函数(用于字符串比较)。该函数通过比较运算符来比较字符串,就像比较数字一样。如果两个字符串参数相同,该函数就返回0,否则返回非零值。修改后的版本如程序清单11.21所示。
程序清单11.21 compare.c程序
/* compare.c -- 该程序可以正常运行 */
#include <stdio.h>
#include <string.h> // strcmp函数的原型在该头文件中
#define ANSWER "Grant"
#define SIZE 40
char * s_gets(char * st, int n);
int main(void)
{
char try[SIZE];
puts("Who is buried in Grant's tomb?");
s_gets(try, SIZE);
while (strcmp(try, ANSWER) != 0)
{
puts("No, that's wrong. Try again.");
s_gets(try, SIZE);
}
puts("That's right!");
return 0;
}
char * s_gets(char * st, int n)
{
char * ret_val;
int i = 0;
ret_val = fgets(st, n, stdin);
if (ret_val)
{
while (st[i] != '\n' && st[i] != '\0')
i++;
if (st[i] == '\n')
st[i] = '\0';
else
while (getchar != '\n')
continue;
}
return ret_val;
}
注意
由于非零值都为“真”,所以许多经验丰富的C程序员会把该例main中的while循环头写成:while (strcmp(try, ANSWER))
strcmp函数比较的是字符串,不是整个数组,这是非常好的功能。虽然数组try占用了40字节,而储存在其中的"Grant"只占用了6字节(还有一个用来放空字符),strcmp函数只会比较try中第1个空字符前面的部分。所以,可以用strcmp比较储存在不同大小数组中的字符串。
如果用户输入GRANT、grant或Ulysses S.Grant会怎样?程序会告知用户输入错误。希望程序更友好,必须把所有正确答案的可能性包含其中。这里可以使用一些小技巧。例如,可以使用#define定义类似GRANT这样的答案,并编写一个函数把输入的内容都转换成小写,就解决了大小写的问题。但是,还要考虑一些其他错误的形式,这些留给读者完成。
1.strcmp的返回值
如果strcmp比较的字符串不同,它会返回什么值?请看程序清单11.22的程序示例。
程序清单11.22 compback.c程序
/* compback.c -- strcmp的返回值 */
#include <stdio.h>
#include <string.h>
int main(void)
{
printf("strcmp(\"A\", \"A\") is ");
printf("%d\n", strcmp("A", "A"));
printf("strcmp(\"A\", \"B\") is ");
printf("%d\n", strcmp("A", "B"));
printf("strcmp(\"B\", \"A\") is ");
printf("%d\n", strcmp("B", "A"));
printf("strcmp(\"C\", \"A\") is ");
printf("%d\n", strcmp("C", "A"));
printf("strcmp(\"Z\", \"a\") is ");
printf("%d\n", strcmp("Z", "a"));
printf("strcmp(\"apples\", \"apple\") is ");
printf("%d\n", strcmp("apples", "apple"));
return 0;
}
在我们的系统中运行该程序,输出如下:
strcmp("A", "A") is 0
strcmp("A", "B") is -1
strcmp("B", "A") is 1
strcmp("C", "A") is 1
strcmp("Z", "a") is -1
strcmp("apples", "apple") is 1
strcmp比较"A"和本身,返回0;比较"A"和"B",返回-1;比较"B"和"A",返回1。这说明,如果在字母表中第1个字符串位于第2个字符串前面,strcmp中就返回负数;反之,strcmp则返回正数。所以,strcmp比较"C"和"A",返回1。其他系统可能返回2,即两者的ASCII码之差。ASCII标准规定,在字母表中,如果第1个字符串在第2个字符串前面,strcmp返回一个负数;如果两个字符串相同,strcmp返回0;如果第1个字符串在第2个字符串后面,strcmp返回正数。然而,返回的具体值取决于实现。例如,下面给出在不同实现中的输出,该实现返回两个字符的差值:
strcmp("A", "A") is 0
strcmp("A", "B") is -1
strcmp("B", "A") is 1
strcmp("C", "A") is 2
strcmp("Z", "a") is -7
strcmp("apples", "apple") is 115
如果两个字符串开始的几个字符都相同会怎样?一般而言,strcmp会依次比较每个字符,直到发现第 1 对不同的字符为止。然后,返回相应的值。例如,在上面的最后一个例子中,"apples"和"apple"只有最后一对字符不同("apples"的s和"apple"的空字符)。由于空字符在ASCII中排第1。字符s一定在它后面,所以strcmp返回一个正数。
最后一个例子表明,strcmp比较所有的字符,不只是字母。所以,与其说该函数按字母顺序进行比较,不如说是按机器排序序列(machine collating sequence)进行比较,即根据字符的数值进行比较(通常都使用ASCII值)。在ASCII中,大写字母在小写字母前面,所以strcmp("Z", "a")返回的是负值。
大多数情况下,strcmp返回的具体值并不重要,我们只在意该值是0还是非0(即,比较的两个字符串是否相等)。或者按字母排序字符串,在这种情况下,需要知道比较的结果是为正、为负还是为0。
注意
strcmp函数比较的是字符串,不是字符,所以其参数应该是字符串(如"apples"和"A"),而不是字符(如'A')。但是,char 类型实际上是整数类型,所以可以使用关系运算符来比较字符。假设word是储存在char类型数组中的字符串,ch是char类型的变量,下面的语句都有效:
if (strcmp(word, "quit") == 0) // 使用strcmp比较字符串
puts("Bye!");
if (ch == 'q') // 使用 == 比较字符
puts("Bye!");
尽管如此,不要使用ch或'q'作为strcmp的参数。
程序清单11.23用strcmp函数检查程序是否要停止读取输入。
程序清单11.23 quit_chk.c程序
/* quit_chk.c -- 某程序的开始部分 */
#include <stdio.h>
#include <string.h>
#define SIZE 80
#define LIM 10
#define STOP "quit"
char * s_gets(char * st, int n);
int main(void)
{
char input[LIM][SIZE];
int ct = 0;
printf("Enter up to %d lines (type quit to quit):\n", LIM);
while (ct < LIM && s_gets(input[ct], SIZE) != NULL &&
strcmp(input[ct], STOP) != 0)
{
ct++;
}
printf("%d strings entered\n", ct);
return 0;
}
char * s_gets(char * st, int n)
{
char * ret_val;
int i = 0;
ret_val = fgets(st, n, stdin);
if (ret_val)
{
while (st[i] != '\n' && st[i] != '\0')
i++;
if (st[i] == '\n')
st[i] = '\0';
else
while (getchar != '\n')
continue;
}
return ret_val;
}
该程序在读到EOF字符(这种情况下s_gets返回NULL)、用户输入quit或输入项达到LIM时退出。
顺带一提,有时输入空行(即,只按下Enter键或Return键)表示结束输入更方便。为实现这一功能,只需修改一下while循环的条件即可:
while (ct < LIM && s_gets(input[ct], SIZE) != NULL&& input[ct][0] != '\0')
这里,input[ct]是刚输入的字符串,input[ct][0]是该字符串的第1个字符。如果用户输入空行, s_gets便会把该行第1个字符(换行符)替换成空字符。所以,下面的表达式用于检测空行:
input[ct][0] != '\0'
2.strncmp函数
strcmp函数比较字符串中的字符,直到发现不同的字符为止,这一过程可能会持续到字符串的末尾。而strncmp函数在比较两个字符串时,可以比较到字符不同的地方,也可以只比较第3个参数指定的字符数。例如,要查找以"astro"开头的字符串,可以限定函数只查找这5 个字符。程序清单11.24 演示了该函数的用法。
程序清单11.24 starsrch.c程序
/* starsrch.c -- 使用 strncmp */
#include <stdio.h>
#include <string.h>
#define LISTSIZE 6
int main
{
const char * list[LISTSIZE] =
{
"astronomy", "astounding",
"astrophysics", "ostracize",
"asterism", "astrophobia"
};
int count = 0;
int i;
for (i = 0; i < LISTSIZE; i++)
if (strncmp(list[i], "astro", 5) == 0)
{
printf("Found: %s\n", list[i]);
count++;
}
printf("The list contained %d words beginning"
" with astro.\n", count);
return 0;
}
下面是该程序的输出:
Found: astronomy
Found: astrophysics
Found: astrophobia
The list contained 3 words beginning with astro.
11.5.5 strcpy和strncpy函数
前面提到过,如果pts1和pts2都是指向字符串的指针,那么下面语句拷贝的是字符串的地址而不是字符串本身:
pts2 = pts1;
如果希望拷贝整个字符串,要使用strcpy函数。程序清单11.25要求用户输入以q开头的单词。该程序把输入拷贝至一个临时数组中,如果第1 个字母是q,程序调用strcpy把整个字符串从临时数组拷贝至目标数组中。strcpy函数相当于字符串赋值运算符。
程序清单11.25 copy1.c程序
/* copy1.c -- 演示 strcpy */
#include <stdio.h>
#include <string.h> // strcpy的原型在该头文件中
#define SIZE 40
#define LIM 5
char * s_gets(char * st, int n);
int main(void)
{
char qwords[LIM][SIZE];
char temp[SIZE];
int i = 0;
printf("Enter %d words beginning with q:\n", LIM);
while (i < LIM && s_gets(temp, SIZE))
{
if (temp[0] != 'q')
printf("%s doesn't begin with q!\n", temp);
else
{
strcpy(qwords[i], temp);
i++;
}
}
puts("Here are the words accepted:");
for (i = 0; i < LIM; i++)
puts(qwords[i]);
return 0;
}
char * s_gets(char * st, int n)
{
char * ret_val;
int i = 0;
ret_val = fgets(st, n, stdin);
if (ret_val)
{
while (st[i] != '\n' && st[i] != '\0')
i++;
if (st[i] == '\n')
st[i] = '\0';
else
while (getchar != '\n')
continue;
}
return ret_val;
}
下面是该程序的运行示例:
Enter 5 words beginning with q:
quackery
quasar
quilt
quotient
no more
no more doesn't begin with q!
quiz
Here are the words accepted:
quackery
quasar
quilt
quotient
quiz
注意,只有在输入以q开头的单词后才会递增计数器i,而且该程序通过比较字符进行判断:
if (temp[0] != 'q')
这行代码的意思是:temp中的第1个字符是否是q?当然,也可以通过比较字符串进行判断:
if (strncmp(temp, "q", 1) != 0)
这行代码的意思是:temp字符串和"q"的第1个元素是否相等?
请注意,strcpy第2个参数(temp)指向的字符串被拷贝至第1个参数(qword[i])指向的数组中。拷贝出来的字符串被称为目标字符串,最初的字符串被称为源字符串。参考赋值表达式语句,很容易记住strcpy参数的顺序,即第1个是目标字符串,第2个是源字符串。
char target[20];
int x;
x = 50;/* 数字赋值*/
strcpy(target, "Hi ho!"); /* 字符串赋值*/
target = "So long";/* 语法错误 */程序员有责任确保目标数组有足够的空间容纳源字符串的副本。下面的代码有点问题:
char * str;
strcpy(str, "The C of Tranquility"); // 有问题
strcpy把"The C of Tranquility"拷贝至str指向的地址上,但是str未被初始化,所以该字符串可能被拷贝到任意的地方!
总之,strcpy接受两个字符串指针作为参数,可以把指向源字符串的第2个指针声明为指针、数组名或字符串常量;而指向源字符串副本的第1个指针应指向一个数据对象(如,数组),且该对象有足够的空间储存源字符串的副本。记住,声明数组将分配储存数据的空间,而声明指针只分配储存一个地址的空间。
1.strcpy的其他属性
strcpy函数还有两个有用的属性。第一,strcpy的返回类型是 char *,该函数返回的是第 1个参数的值,即一个字符的地址。第二,第 1 个参数不必指向数组的开始。这个属性可用于拷贝数组的一部分。程序清单11.26演示了该函数的这两个属性。
程序清单11.26 copy2.c程序
/* copy2.c -- 使用 strcpy */
#include <stdio.h>
#include <string.h>// 提供strcpy的函数原型
#define WORDS "beast"
#define SIZE 40
int main(void)
{
const char * orig = WORDS;
char copy[SIZE] = "Be the best that you can be.";
char * ps;
puts(orig);
puts(copy);
ps = strcpy(copy + 7, orig);
puts(copy);
puts(ps);
return 0;
}
下面是该程序的输出:
beast
Be the best that you can be.
Be the beast
beast
注意,strcpy把源字符串中的空字符也拷贝在内。在该例中,空字符覆盖了copy数组中that的第1个t(见图11.5)。注意,由于第1个参数是copy + 7,所以ps指向copy中的第8个元素(下标为7)。因此puts(ps)从该处开始打印字符串。
图11.5 使用指针strcpy函数
2.更谨慎的选择:strncpy
strcpy和 strcat都有同样的问题,它们都不能检查目标空间是否能容纳源字符串的副本。拷贝字符串用 strncpy更安全,该函数的第 3 个参数指明可拷贝的最大字符数。程序清单 11.27 用strncpy代替程序清单11.25中的strcpy。为了演示目标空间装不下源字符串的副本会发生什么情况,该程序使用了一个相当小的目标字符串(共7个元素,包含6个字符)。
程序清单11.27 copy3.c程序
/* copy3.c -- 使用strncpy */
#include <stdio.h>
#include <string.h> /* 提供strncpy的函数原型*/
#define SIZE 40
#define TARGSIZE 7
#define LIM 5
char * s_gets(char * st, int n);
int main(void)
{
char qwords[LIM][TARGSIZE];
char temp[SIZE];
int i = 0;
printf("Enter %d words beginning with q:\n", LIM);
while (i < LIM && s_gets(temp, SIZE))
{
if (temp[0] != 'q')
printf("%s doesn't begin with q!\n", temp);
else
{
strncpy(qwords[i], temp, TARGSIZE - 1);
qwords[i][TARGSIZE - 1] = '\0';
i++;
}
}
puts("Here are the words accepted:");
for (i = 0; i < LIM; i++)
puts(qwords[i]);
return 0;
}
char * s_gets(char * st, int n)
{
char * ret_val;
int i = 0;
ret_val = fgets(st, n, stdin);
if (ret_val)
{
while (st[i] != '\n' && st[i] != '\0')
i++;
if (st[i] == '\n')
st[i] = '\0';
else
while (getchar != '\n')
continue;
}
return ret_val;
}
下面是该程序的运行示例:
Enter 5 words beginning with q:
quack
quadratic
quisling
quota
quagga
Here are the words accepted:
quack
quadra
quisli
quota
quagga
strncpy(target, source, n)把source中的n个字符或空字符之前的字符(先满足哪个条件就拷贝到何处)拷贝至target中。因此,如果source中的字符数小于n,则拷贝整个字符串,包括空字符。但是,strncpy拷贝字符串的长度不会超过n,如果拷贝到第n个字符时还未拷贝完整个源字符串,就不会拷贝空字符。所以,拷贝的副本中不一定有空字符。鉴于此,该程序把 n 设置为比目标数组大小少1(TARGSIZE-1),然后把数组最后一个元素设置为空字符:
strncpy(qwords[i], temp, TARGSIZE - 1);
qwords[i][TARGSIZE - 1] = '\0';
这样做确保储存的是一个字符串。如果目标空间能容纳源字符串的副本,那么从源字符串拷贝的空字符便是该副本的结尾;如果目标空间装不下副本,则把副本最后一个元素设置为空字符。
11.5.6 sprintf函数
sprintf函数声明在stdio.h中,而不是在string.h中。该函数和printf类似,但是它是把数据写入字符串,而不是打印在显示器上。因此,该函数可以把多个元素组合成一个字符串。sprintf的第1个参数是目标字符串的地址。其余参数和printf相同,即格式字符串和待写入项的列表。
程序清单11.28中的程序用printf把3个项(两个字符串和一个数字)组合成一个字符串。注意, sprintf的用法和printf相同,只不过sprintf把组合后的字符串储存在数组formal中而不是显示在屏幕上。
程序清单11.28 format.c程序
/* format.c -- 格式化字符串 */
#include <stdio.h>
#define MAX 20
char * s_gets(char * st, int n);
int main(void)
{
char first[MAX];
char last[MAX];
char formal[2 * MAX + 10];
double prize;
puts("Enter your first name:");
s_gets(first, MAX);
puts("Enter your last name:");
s_gets(last, MAX);
puts("Enter your prize money:");
scanf("%lf", &prize);
sprintf(formal, "%s, %-19s: $%6.2f\n", last, first, prize);
puts(formal);
return 0;
}
char * s_gets(char * st, int n)
{
char * ret_val;
int i = 0;
ret_val = fgets(st, n, stdin);
if (ret_val)
{
while (st[i] != '\n' && st[i] != '\0')
i++;
if (st[i] == '\n')
st[i] = '\0';
else
while (getchar != '\n')
continue;
}
return ret_val;
}
下面是该程序的运行示例:
Enter your first name:
Annie
Enter your last name:
von Wurstkasse
Enter your prize money:
25000
von Wurstkasse, Annie: $25000.00
sprintf函数获取输入,并将其格式化为标准形式,然后把格式化后的字符串储存在formal中。
11.5.7 其他字符串函数
ANSI C库有20多个用于处理字符串的函数,下面总结了一些常用的函数。
char *strcpy(char * restrict s1, const char * restrict s2);
该函数把s2指向的字符串(包括空字符)拷贝至s1指向的位置,返回值是s1。
char *strncpy(char * restrict s1, const char * restrict s2, size_t n);