这一章在干嘛?

字符串(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.2 矩形数组与锯齿数组——二维数组每行等长,指针数组各行随字符串长度而定

指针和字符串:复制地址 ≠ 复制字符串

程序清单 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; &copy = %p; value = %p\n", copy, &copy, copy);
    return 0;
}

输出显示:&mesg&copy 不同(两个指针变量各有各的家),但两者的完全相同——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 会读出 Portensia——第二次调用从第一次停下的地方继续。选择建议:整行文本用 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)tryANSWER 都是指针,!= 比的是地址——两者永远存在不同位置,地址永远不等,用户永远被告知答错。比较内容必须用 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_tsizeof 返回的类型,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() 交换两整段字符串轻快得多,还顺便保留了原始顺序。

/图:图11.6 排序字符串指针——只交换指针的指向,input 数组保持原序

选择排序(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 计数包含程序名

很多程序员把第二参数写成 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() 把字符串转成数字