这一章在干嘛?
字符串(string)是 C 里最常用的数据类型之一——名字、地址、文章、日志都是字符串。这一章把它讲透:字符串在内存里长什么样、怎么读进来、怎么写出去,以及 C 库提供的成套字符串工具函数。
11.1 字符串的表示与字符串 I/O
核心事实只有一条:字符串就是以空字符(null character)\0 结尾的 char 数组,数组和指针的知识全部适用;也正因为字符串太常用,C 库专门为它准备了成套函数。先看本章开篇程序演示的四种定义方式(完整程序 strings.c 还演示了 gets() 读一行和 puts() 打一行):
// strings.c -- stringing the user along(节选声明部分)
#define MSG "You must have many talents. Tell me some."
#define LINELEN 81 // 最大字符串长度 + 1
int main(void)
{
char name[LINELEN]; // 待会儿读用户输入用
const char m1[40] = "Limit yourself to one line's worth."; // 定尺寸数组
const char m2[] = "If you can't think of anything, fake it."; // 编译器数尺寸
const char *m3 = "\nEnough about me -- what's your name?"; // 指针形式
const char *mytal[LIM] = { // 字符串指针数组
"Adding numbers swiftly", "Multiplying accurately", "Stashing data",
"Following instructions to the letter", "Understanding the C language"
};
/* ... */
}字符串常量(字符串字面量)
双引号括起来的内容就是字符串常量(string constant,也叫 string literal 字符串字面量),编译器自动在末尾补 \0。相邻字符串字面量会自动拼接:char greeting[50] = "Hello, and" " how are" " you";;想打双引号就前面加反斜杠。字符串常量属于静态存储期——不管在哪个函数里用,整个程序运行期间只存一份,且整个带引号的短语本身就是指向存储位置的指针:
程序清单 11.2 quotes.c
/* quotes.c -- strings as pointers */
#include <stdio.h>
int main(void)
{
printf("%s, %p, %c\n", "We", "are", *"space farers");
return 0;
}输出 We, 0x0040c010, s——"are" 直接当 %p 参数打出了地址,证明字符串常量本身就是地址;*"space farers" 解引用得到首字符 s。
字符串数组及其初始化
const char m1[40] = "Limit yourself to one line's worth.";
// 等价于逐字符初始化(注意最后的 '\0'):
const char m1[40] = { 'L', 'i', 'm', 'i', 't', /* ... */ 'h', '.', '\0' };没有 \0 就只是字符数组,不是字符串。指定尺寸时元素个数至少比字符串长度多 1(留给 \0),未用元素自动置 0(是 \0,不是字符 '0')。省略尺寸让编译器自己数最方便,因为字符串函数靠找 \0 就知道在哪结束;但内容是运行时才读进来的(如用户输入)就没法省,只能先给上限:char name[81];。数组名就是首元素地址:m1 == &m1[0]、*m1 == 'L'、*(m1+1) == m1[1]。
数组形式 vs 指针形式
const char m3[] = "\nEnough about me -- what's your name?";
const char *m3 = "\nEnough about me -- what's your name?";两者”几乎”相同,实则不同:数组形式分配 38 个元素(37 字符 + \0),把静态存储区的字符串复制进数组,数组名是地址常量——m3+1 合法,++m3 非法(自增只能用于变量);指针形式同样在静态区放一份字符串,另外分配一个指针变量存它的地址,指针是变量,可以 ++m3。一句话:**数组初始化复制字符串本身,指针初始化只复制地址。**对照示例:
char heart[] = "I love Tillie!";
char *head = "I love Millie!";两者都能用数组表示法和指针加法遍历,但只有指针能用自增:
while (*(head) != '\0') /* 到字符串结尾就停 */
putchar(*(head++)); /* 打印当前字符,指针前移 */指针还能改指向:head = heart; 合法;heart = head; 非法——数组名是常量,就像不能写 3 = x;。反过来可以改数组内容 heart[7] = 'M';,因为数组元素是变量(除非声明为 const)。
不要用指针去改字符串字面量
char *word = "frame"; word[1] = 'l';编译器多半不报错,但按现行 C 标准是未定义行为(undefined behavior)——编译器可以用同一份拷贝表示所有相同字面量,改"Klingon"的首字符可能让程序里所有"Klingon"变成"Flingon",甚至崩溃。推荐写法:const char *p = "Klingon";。用字面量初始化非 const 数组则没问题,因为数组拿到的是一份拷贝。
字符串数组:矩形数组与锯齿数组
管理一组字符串有两种方式:指针数组 const char *mytal[LIM](锯齿数组 ragged array,只存 5 个地址,字符串本体在常量区,各行长短随字符串,不浪费空间)和二维数组 char mytal_2[LIM][81](矩形数组 rectangular array,字符串本体存进数组,每行占满 81 元素,规矩但费空间)。

指针和字符串:复制地址 ≠ 复制字符串
程序清单 11.3 p_and_s.c
/* p_and_s.c -- pointers and strings */
#include <stdio.h>
int main(void)
{
const char * mesg = "Don't be a fool!";
const char * copy;
copy = mesg;
printf("mesg = %s; &mesg = %p; value = %p\n", mesg, &mesg, mesg);
printf("copy = %s; © = %p; value = %p\n", copy, ©, copy);
return 0;
}输出显示:&mesg 和 © 不同(两个指针变量各有各的家),但两者的值完全相同——copy = mesg; 没有复制字符串,只是让第二个指针指向同一个字符串。复制一个地址和复制几十个元素哪个划算一目了然;真要完整拷贝,用本章后面的 strcpy()。
11.2 字符串输入
想把字符串读进程序,前提是先腾好地方放它。char *name; scanf("%s", name); 能骗过编译器,但 scanf() 会把输入写到 name 指向的任意地方,可能覆盖程序数据或代码导致崩溃——要么显式声明数组,要么用第 12 章的动态内存分配。
gets() 函数(不安全,了解即可)
gets() 从标准输入读字符,遇到换行符 \n(按回车产生)就停,把换行符丢弃,补上 \0 交给程序。它返回读到的字符串地址(和参数是同一个数组),出错或遇文件结尾返回空指针(null pointer)NULL:
while (gets(name) != NULL) // 读取 + 检查 EOF 一气呵成gets() 是危险函数
gets()不检查输入是否装得下,超出的字符直接溢出到相邻内存。历史上曾有人利用 Unix 系统代码中gets()的这个弱点制造了”蠕虫”病毒。严肃开发请一律用fgets()。另注意:NULL是一个地址,\0是一个 char 数据,数值上都可用 0 表示,但概念完全不同。
fgets() 函数(安全之选)
fgets() 为文件 I/O 设计,比 gets() 多三个规矩:第二个参数 n 限定最多读 n-1 个字符;读到换行符会存进字符串(不像 gets() 丢弃它);第三个参数指定读哪个文件,键盘用 stdin。
程序清单 11.6 name3.c
/* name3.c -- reads a name using fgets() */
#include <stdio.h>
#define MAX 81
int main(void)
{
char name[MAX];
char *ptr;
ptr = fgets(name, MAX, stdin);
printf("%s? Ah! %s!\n", name, ptr);
return 0;
}输出会断成两行——换行符被存进了字符串,打印时它也跟着出来。这个”多出来的换行”可以用 strchr() 定位后替换成 \0(见 11.5 节末尾)。
scanf() 函数
scanf() 用 %s 读的是”单词”而不是整行:从第一个非空白字符开始,到下一个空白字符为止;可加字段宽度如 %10s。返回成功读取的项目数,文件结尾返回 EOF。输入 Portensia Callowit 配 %5s %10s 会读出 Porte 和 nsia——第二次调用从第一次停下的地方继续。选择建议:整行文本用 fgets();scanf() 强在混合读取不同类型的数据。
11.3 字符串输出
三个标准输出函数:puts()、fputs()、printf()。
程序清单 11.8 put_out.c
/* put_out.c -- using puts() */
#include <stdio.h>
int main(void)
{
char str1[80] = "An array was initialized to me.";
const char * str2 = "A pointer was initialized to me.";
puts(str1);
puts(str2);
puts(&str1[5]); // 从第 6 个元素开始打印
puts(str2+4); // 从 "pointer" 的 'i' 开始打印
return 0;
}puts() 只吃一个字符串地址,打印完自动加换行,从你给的地址一路打到 \0 为止——所以 &str1[5] 打出 ray was initialized to me.。它靠 \0 判断停在哪,反面教材:char dont[] = {'W','O','W','!'}; puts(dont); 没有 \0,会一路打印后面的内存直到碰巧撞上一个 \0,输出什么全看编译器心情。
fputs() 是 fgets() 的搭档:第二个参数指定输出到哪个文件(屏幕用 stdout),且不自动加换行。记住这组搭配:gets() 丢换行配 puts() 加换行;fgets() 存换行配 fputs() 不加换行——混搭 fgets + puts 会打出两个换行。printf() 不自动换行但能组合多种类型,printf("%s\n", string); 与 puts(string); 等效。
11.4 自制字符串输出函数
程序清单 11.10 / 11.11 put1.c / put2.c
/* put1.c -- 不加换行打印字符串 */
#include <stdio.h>
void put1(const char * string) /* string 不会被改动 */
{
while (*string != '\0')
putchar(*string++);
}
/* put2.c -- 打印字符串并统计字符数 */
int put2(const char * string)
{
int count = 0;
while (*string) /* 常见惯用法 */
{
putchar(*string++);
count++;
}
putchar('\n'); /* 换行不计入 */
return(count);
}指针初始指向首字符,打印一个前移一个,直到 \0。注意 *string++ 里 ++ 优先级更高:取 string 指向的值打印,然后 string 本身自增。put1("pizza"); 只打印;num = put2("pizza"); 还能把 5 赋给 num。老练的程序员会把循环条件简写成 while (*string)——指向 \0 时值就是 0,循环自然终止。
掌握这个模式,等于掌握了半个字符串库
“从首字符开始,靠
\0结束,指针一路推进”——strlen()、strcpy()、strcat()的实现全是这个套路。因为字符串自带\0哨兵,函数不需要额外传入长度。
11.5 字符串函数
ANSI C 在 string.h 里声明了成套字符串函数(sprintf() 在 stdio.h 里)。逐个看最常用的。
strlen():量长度
返回字符串长度(不含 \0)。写个”削短”函数:
void fit(char * string, unsigned int size)
{
if (strlen(string) > size)
*(string + size) = '\0'; // 下标 size 处放 '\0',字符串到此为止
}puts() 打到新 \0 就停——但数组后面的字符其实还在,puts(mesg + 39) 还能把后半截打出来。这个函数要改字符串,所以形参不带 const。
strcat() 与 strncat():拼接
strcat(s1, s2) 把 s2 的拷贝接到 s1 末尾(s2 不变),返回 s1,但不检查 s1 装不装得下。两个对策:先用 strlen() 算好总长(记得 +1 给 \0),或用带限额的 strncat()——最多追加 n 个字符且必补 \0。
程序清单 11.15 join_chk.c(节选)
/* join_chk.c -- joins two strings, check size first */
char flower[SIZE], bug[BUGSIZE];
char addon[] = "s smell like old shoes.";
int available;
if ((strlen(addon) + strlen(flower) + 1) <= SIZE)
strcat(flower, addon); // 先算账再拼接
available = BUGSIZE - strlen(bug) - 1;
strncat(bug, addon, available); // 限额拼接输入 Aphid(5 字符)后输出 Aphids smell:available = 13 − 5 − 1 = 7,strncat() 只追加了 addon 的前 7 个字符 s smell,结尾自动补 \0。
strcmp() 与 strncmp():比较
先看错误示范:while (try != ANSWER)。try 和 ANSWER 都是指针,!= 比的是地址——两者永远存在不同位置,地址永远不等,用户永远被告知答错。比较内容必须用 strcmp():
程序清单 11.17 compare.c
/* compare.c -- this will work */
#include <stdio.h>
#include <string.h> /* 声明了 strcmp() */
#define ANSWER "Grant"
#define MAX 40
int main(void)
{
char try[MAX];
puts("Who is buried in Grant's tomb?");
gets(try);
while (strcmp(try, ANSWER) != 0)
{
puts("No, that's wrong. Try again.");
gets(try);
}
puts("That's right!");
return 0;
}返回值规则:两串相同返回 0;按机器排序序列(machine collating sequence,通常即 ASCII 值)第一个串在前返回负数,在后返回正数。具体数值因实现而异(有的返回字符码差,有的返回 1/-1),只拿它和 0 比,别依赖具体值。ASCII 里大写排在小写前,strcmp("Z", "a") 是负数;比较在第一对不同字符处分胜负——"apples" 比 "apple" 多出 's',后者对应位置已是 \0(ASCII 0,排最前),所以返回正数。
strcmp 是给字符串用的,== 是给字符用的
if (strcmp(word, "quit") == 0)比较字符串;if (ch == 'q')比较字符。别把'q'塞给strcmp(),也别用==比较两个字符串。老手还会缩写成while (strcmp(try, ANSWER))——非零即为真。
strncmp(s1, s2, n) 只比前 n 个字符(或到 \0 为止)。想找所有以 astro 开头的词,strncmp(list[i], "astro", 5) == 0 就够了。
strcpy() 与 strncpy():复制
pts2 = pts1; 只复制地址。复制字符串本体用 strcpy(target, source)——目标是第一个参数(和赋值语句左边是目标同理)。target = "So long"; 是语法错误,数组名不是可修改的左值。目标数组必须你自己保证够大:
char *str;
strcpy(str, "The C of Tranquility"); /* 灾难!str 未初始化,拷到哪算哪 */两个附带特性:返回第一个参数的地址;第一个参数可指向数组中部实现部分复制——ps = strcpy(copy + 7, orig); 从 copy[7] 开始覆写,\0 也一起复制。
strncpy 的"手动补零"是必背惯用法
strncpy(target, source, n)最多复制 n 个字符;源串不足 n 则连\0一起复制,达到 n 仍没到结尾就不补\0——拷出来的可能不是合法字符串!标准对策:strncpy(qwords[i], temp, TARGSIZE - 1); qwords[i][TARGSIZE - 1] = '\0'; // 无论如何都保证字符串收尾
sprintf():把输出”打印”进字符串
用法和 printf() 一样,只是第一个参数换成目标字符串地址——把各路数据格式化合并成一个字符串存起来(清单 11.24 format.c):
char first[MAX], last[MAX], formal[2 * MAX + 10];
double prize;
/* ...读入 first、last、prize... */
sprintf(formal, "%s, %-19s: $%6.2f\n", last, first, prize);
puts(formal);输入 Teddy / Behr / 2000,formal 里存好的是 Behr, Teddy : $2000.00——两个字符串加一个 double 统一成了公文格式。
其他字符串函数速查表
| 原型 | 作用 |
|---|---|
char *strcpy(char * s1, const char * s2) | 把 s2(含 \0)复制到 s1,返回 s1 |
char *strncpy(char * s1, const char * s2, size_t n) | 最多复制 n 个字符;源不足 n 补 \0,达到 n 不补 |
char *strcat(char * s1, const char * s2) | 把 s2 接到 s1 末尾,返回 s1 |
char *strncat(char * s1, const char * s2, size_t n) | 最多追加前 n 个字符,结尾必补 \0 |
int strcmp(const char * s1, const char * s2) | 按机器排序序列:s1 在前负、相同 0、在后正 |
int strncmp(const char * s1, const char * s2, size_t n) | 同上,最多比 n 个字符 |
char *strchr(const char * s, int c) | s 中第一个字符 c 的位置,找不到返回空指针 |
char *strpbrk(const char * s1, const char * s2) | s1 中第一个属于 s2 任一字符的位置 |
char *strrchr(const char * s, int c) | s 中最后一个字符 c 的位置 |
char *strstr(const char * s1, const char * s2) | s1 中第一个完整出现 s2 的位置 |
size_t strlen(const char * s) | s 的长度,不含 \0 |
原型里的 const 标注了哪些字符串不会被改动:strcat(char *s1, const char *s2) 中 s1 会被改(目标),s2 只读(源)。size_t 是 sizeof 返回的类型,string.h 里定义。fgets() 存进来的换行符就用 strchr() 处理:
char line[80];
char * find;
fgets(line, 80, stdin);
find = strchr(line, '\n'); // 找换行符
if (find) // 返回地址非 NULL,说明找到了
*find = '\0'; // 原地替换成空字符若 find 是 NULL,说明整行超长,fgets 没读到换行就到了上限,可以加 else 分支处理。
11.6 字符串排序示例
综合演练:读入一串字符串,按字母顺序排序后输出。主角是 strcmp(),还藏着一个”小心机”。
程序清单 11.25 sort_str.c
/* sort_str.c -- reads in strings and sorts them */
#include <stdio.h>
#include <string.h>
#define SIZE 81 /* 字符串长度上限(含 \0) */
#define LIM 20 /* 最多读的行数 */
void stsrt(char *strings[], int num); /* 字符串排序函数 */
int main(void)
{
char input[LIM][SIZE]; /* 存放输入的数组 */
char *ptrstr[LIM]; /* 指针变量数组 */
int ct = 0, k;
while (ct < LIM && gets(input[ct]) != NULL
&& input[ct][0] != '\0') // 空行即停
{
ptrstr[ct] = input[ct]; /* 让指针指向字符串 */
ct++;
}
stsrt(ptrstr, ct); /* 排序 */
for (k = 0; k < ct; k++)
puts(ptrstr[k]); /* 打印排好序的指针 */
return 0;
}
/* 字符串指针排序函数 */
void stsrt(char *strings[], int num)
{
char *temp;
int top, seek;
for (top = 0; top < num-1; top++)
for (seek = top + 1; seek < num; seek++)
if (strcmp(strings[top], strings[seek]) > 0)
{
temp = strings[top];
strings[top] = strings[seek];
strings[seek] = temp;
}
}小心机在哪?排序的是指针,不是字符串本身。ptrstr[i] 指向 input[i],排序时交换的是指针变量,input 数组里的文字纹丝不动——交换两个指针比用 strcpy() 交换两整段字符串轻快得多,还顺便保留了原始顺序。

选择排序(selection sort)思路
外层从第 0 个元素到倒数第二个:内层拿当前首位依次和后面的比,谁排前面就和谁交换——内圈跑完,最靠前的元素落位。外层再从第二个元素来一轮,直到全部落位。C 库还有更高级的
qsort()(第 16 章讲)。
11.7 ctype.h 字符函数与字符串
ctype.h 的字符函数不能作用于整个字符串,但可以逐字符套用(清单 11.26 mod_str.c 的两个工具函数)。核心还是 while (*str) 哨兵循环:
/* mod_str.c -- modifies a string(节选函数定义) */
#include <ctype.h>
void ToUpper(char * str)
{
while (*str)
{
*str = toupper(*str);
str++;
}
}
int PunctCount(const char * str)
{
int ct = 0;
while (*str)
{
if (ispunct(*str))
ct++;
str++;
}
return ct;
}ToUpper() 逐字符转大写,PunctCount() 数标点。ANSI C 规定 toupper() 只转小写字符,但很老的实现不检查,所以老代码常见先 if (islower(*str)) 再转。ctype.h 的函数通常以**宏(macro)**实现,第 16 章细讲。
11.8 命令行参数
命令行后面跟的额外项叫命令行参数(command-line arguments),C 程序通过给 main() 传参读取它们:
程序清单 11.27 repeat.c
/* repeat.c -- main() with arguments */
#include <stdio.h>
int main(int argc, char *argv[])
{
int count;
printf("The command line has %d arguments:\n", argc - 1);
for (count = 1; count < argc; count++)
printf("%d: %s\n", count, argv[count]);
return 0;
}运行 C>repeat Resistance is futile 输出 3 个参数。规则:
argc(argument count):命令行上的字符串个数,系统用空格分词。这条命令共 4 个串(含程序名),argc 是 4。argv(argument values):指向字符串的指针数组。约定argv[0]指向程序名,argv[1]起才是参数——所以打印从 1 开始,个数要减 1。
![/图:图11.7 命令行参数——argv[0] 是程序名,argc 计数包含程序名](../images/cpr/cd761ca078605ec2272b2e40ba2e12dca523f27292e5340cfcd56ce1b71c9889.jpg)
很多程序员把第二参数写成 char **argv(指向 char 指针的指针),与 char *argv[] 完全等价。Unix/DOS 还允许用引号把多个词合成一个参数:repeat "I am hungry" now 会让 argv[1] 是整个 "I am hungry"。
11.9 字符串→数字转换
数字有两种形式:字符串形式(一串数字字符)和数值形式(int 等)。屏幕显示要字符串,算术运算要数值;命令行参数一律是字符串,想参与计算得先转换。atoi()(alphanumeric to integer)应运而生:
程序清单 11.28 hello.c
/* hello.c -- converts command-line argument to number */
#include <stdio.h>
#include <stdlib.h>
int main(int argc, char *argv[])
{
int i, times;
if (argc < 2 || (times = atoi(argv[1])) < 1)
printf("Usage: %s positive-number\n", argv[0]);
else
for (i = 0; i < times; i++)
puts("Hello, good looking!");
return 0;
}运行 % hello 3 打印 3 行问候:参数其实是字符串 "3"(内存里是 ‘3’ 和 \0),atoi() 把它转成整数 3。注意 argc < 2 放在 || 前面——逻辑运算符求值顺序保证没有参数时 atoi(argv[1]) 不会被求值,避免野指针。atoi("42regular") 返回 42(读到非数字为止);但参数压根不像数字时行为未定义(很多实现返回 0)。
stdlib.h 三件套:atoi()(→int)、atol()(→long)、atof()(→double)。更讲究的升级版是 strtol()(→long)、strtoul()、strtod()——能报告第一个不属于数字的字符,还能指定进制:
程序清单 11.29 strcnvt.c(节选)
/* strcnvt.c -- try strtol() */
#include <stdio.h>
#include <stdlib.h>
int main()
{
char number[30];
char * end;
long value;
while (gets(number) && number[0] != '\0')
{
value = strtol(number, &end, 10); /* 十进制 */
printf("value: %ld, stopped at %s (%d)\n", value, end, *end);
value = strtol(number, &end, 16); /* 十六进制 */
printf("value: %ld, stopped at %s (%d)\n", value, end, *end);
}
return 0;
}输入 10atom:按十进制解析出 10,end 指向 'a'(打印 end 得 atom);按十六进制时 'a' 是合法数字字符,解析出 10a 的值 266,end 指向 't'。第二参数 &end 是”指针的地址”,函数用它告诉你数字停在哪——可靠的错误检查手段。strtol() 支持到 36 进制(字母当数字用);反过来数字转字符串,别用非标准的 itoa(),sprintf() 才是正路。
本章要点回顾
- 字符串 = 以
\0结尾的 char 数组;数组形式复制字符串,指针形式记录地址 - 比较用
strcmp(),赋值用strcpy()——==和=对字符串不管用 gets()不安全,用fgets();fgets()会把换行符存进来,用strchr()清理- 带 n 的版本(strncat/strncpy/strncmp)是”限额版”,
strncpy()后记得手动补\0 main(int argc, char *argv[])接收命令行参数;atoi()/strtol()把字符串转成数字
自测题(点击展开)
1.
char name[] = {'F', 'e', 's', 's'};作为字符串声明错在哪?答案:缺少结尾的空字符
\0。这样得到的是字符数组而不是字符串,字符串函数(如 strlen、puts)不知道该在哪停。2.
strcmp("A", "B")和strcmp("B", "A")的返回值符号分别是什么?答案:“A” 按排序序列排在 “B” 前面,所以
strcmp("A","B")返回负数;反过来返回正数。具体数值因实现而异,不要依赖。3.
char heart[] = "I love Tillie!";之后执行heart = head;为什么非法?怎样合法地改它内容?答案:数组名是地址常量,不能作为赋值目标。改内容要通过元素:
heart[7] = 'M';或*(heart + 7) = 'M';。4.
strncpy(qwords[i], temp, TARGSIZE - 1);之后为什么还要qwords[i][TARGSIZE - 1] = '\0';?答案:当源串长度达到 n 时 strncpy 不追加空字符,拷贝结果可能不是合法字符串。手动在最后一个元素放
\0才能保证收尾正确。5.
while (try != ANSWER)比较两个”字符串”为什么永远为真?答案:try 和 ANSWER 都是指针,
!=比较的是地址而非内容;两者存储位置不同,地址永远不等。应改为while (strcmp(try, ANSWER) != 0)。