字符串

Everything should be built top-down, except the first time. ^1

目录


[TOC]

字符串


零 前言

前几章虽然我们用过 char 类型变量和 char 类型数组,但我们始终没有谈到处理字符序列(C 的术语是字符串)的便捷方法。

本章将介绍字符串常量(C 标准中称为字符串字面量)和字符串变量(可以在程序运行时修改)。

一 字符串字面量

字符串字面量(string literal)是一对用双引号括起来的字符序列。

C++ 中常称为字符串字面值,或称为常值,或称为字面量。有些 C 语言的书中称之为字串

1. 字符串字面量中的转义序列

字符串字面量可以包含转义序列。比如:

printf("Hello World\n");

虽然字符串字面量中的八进制数和十六进制数的转义序列也是合法的,但是字符转义序列更为常见。

注意:在字符串字面量中慎用八进制数和十六进制数的转义序列

  • 八进制数的转义序列在三个数字后 或者 在第一个非八进制数字符 处结束。如:\1234包含两个字符\1234

  • 十六进制数的转义序列则不限制 3 个数字,而是直到第一个非十六进制数字符处结束。如:Z\xfcrich 表示 6 个字符(Z\xfcrich

    十六进制转义序列的通常范围是\x0 ~ \xff,所以 \xfcber本应是两个字符(\xfcbe,r)但是这个十六进制数对字符来说太大了。

2. 延续字符串字面量

如果发现字符串字面量太长而无法放置在单独一行内,有下面几种办法解决这个问题。这个问题在前面详细讲过了,有兴趣可以看我之前的文章。下面我就不细说了。

  1. 使用 \

    printf("Hello \
    World\n");

    但是字符串字面量必须从下一行最左边继续,这样会破坏程序的缩进结。

  2. printf("Hello"
          " World\n");
    

    3. ```c
    printf("Hello ");
    printf(" World\n");

3. 字符串字面量的存储

本质上而言,C 语言把字符串字面量作为字符数组来处理。当 C 语言编译器在程序中遇到了长度为 n 的字符串字面量时,它会为字符串字面量分配长度为 n + 1 的内存空间。额外的 1 个空间用来存放一个空字符来标识字符串末尾。空字符是所有位都为 0 的字节,因此用转义序列\0来表示。

注意:不要混淆空字符'\0'和零字符'0'

'\0'的 ASCII 码值为 0;'0'的 ASCII 码值为 48

"abc"使用 4 个字符的数组来存储的:

字符串字面量可以为空:""表示单独存储一个空字符

既然字符串字面量是作为数组来存储的,那么编译器会把它看作是 char*类型的指针。

printfscanf函数都接收 char*类型的值作为它们的第一个参数。思考下面的例子:

printf("abc");

当调用 printf 函数时,会传递 “abc” 的地址。(即指向存储字母 a 的内存单元的指针)

4. 字符串字面量的操作

通常情况下,可以在任何C语言允许使用 char* 指针的地方使用字符串字面量。例如,字符串字面量可以出现在赋值运算符的右边。

char* p;
p = "abc";

这个操作不是复制 “abc” 中的字符,而是使 p 指向字符串的第一个字符。

C 语言允许对指针取下标,所以可以对字符串字面量取下标:

char ch;
ch = "abc"[1];

ch 的新值是 ‘b’ 。甚至可以:

ch = "abc"[3];// ch is now '\0'

字符串字面量的这种特性并不常用,但有时也很方便:这个函数将 0 ~ 15 的数转换成等价的十六进制的字符形式:

char digit_to_hex_char(int digit){
return "0123456789ABCDEF"[digit];
}

注意:试图改变字符串字面量会导致未定义行为

char* p = "abc";
*p = 'd'; // wrong

改变字符串字面量会导致程序崩溃或运行不稳定。

5. 字符串字面量与字符常量

只包含一个字符的字符串字面量不同于字符常量。字符串字面量"a"是用指针来表示的,这个指针指向存放字符"a"(后面紧跟空字符)的内存单元。字符常量'a'是用整数(字符集的数值码)来表示的。

注意:不要再需要字符串的时候使用字符(反之亦然)

函数调用:

printf("\n");

是合法的。然而使用字符则是非法的:

printf('\n');

二 字符串变量

一些编程语言专门为声明字符串变量提供了专门的 string 类型。C 语言采用了不同的方式:只要保证字符串是以空字符结尾的,任何一维的字符数组都可以用来存储字符串。

假设需要用一个变量来存储最多有 80 个字符的字符串。由于字符串末尾有空字符,我们需要声明含有 81 个字符的数组:

#define STR_LEN 80

char str[STR_LEN + 1];

这里把 STR_LEN定义为 80 而不是 81,强调的是 str 最多可以存储 80 个字符;然后才在 str 的声明中对 STR_LEN 加 1 。这是 C 程序员常用的方式。

注意:声明用于存放字符串的数组时,要始终保证数组长度比字符串长度多一个字符

这是因为 C 语言规定每个字符串都已 \0 结尾。如果没有空字符预留位置,可能导致运行时出现未定义行为。因为C函数库中的函数假设字符串都以空字符结尾。

声明长度为 STR_LEN + 1的字符数组并不意味着总是存放长度为 STR_LEN 的字符串。字符串长度取决于 \0出现的位置。

1. 初始化字符串变量

字符串变量可以在声明时进行初始化:

char date1[8] = "June 14";

编辑器将把字符串 “June 14” 中的字符复制到数组 data1 中,然后追加一个空字符:

“June 14” 看起来像是字符串字面量,但其实不然。C 编译器会把它看成是数组初始化式的缩写形式。实际上我们可以写成:

char date1[8] = {'J', 'u', 'n', 'e', ' ', '1', '4', '\0'};

不管是编写还是阅读,后者都不是好的选择。使用数组的初始化式时,切记要手动加上 ‘\0’

如果初始化式太短以致于不能填满字符串变量将会如何呢?在这种情况下,编译器会添加空的字符。因此,在声明:

char date2[9] = "June 14";

之后,data2 将如下图所示:

如果初始化式比字符串变量长会怎样?这对字符串而言是非法的,就如同对数组而言是非法的一样。然而,C 语言允许初始化式(不包括空字符)与变量有完全相同的长度。

char data3[7] = "June 14";

由于没有给空字符留出空间,所以编译器不会试图存储空字符。因此,data3 无法作为字符串使用。

字符串变量的声明中可以省略它的长度。这种情况下,编译器会自动计算长度:

char date4[] = "June 14";

编译器会为 date4 分配 8 个字符的空间。

如果初始化式很长,那么省略字符串变量的长度是特别有效的,因为手工计算长度很容易出错。

2. 字符数组与字符指针

char date[] = "June 14";
char* date = "June 14";

前者声明 date 是一个字符数组,或者声明 date 是一个指针。

它们的相同点类似数组和指针,现在我们看一下不同点:

  • 声明为数组,可以修改存储在 date 中的元素;声明为指针,date 指向字符串字面量,前面我们已经讲过字符串字面量是不能被修改的。
  • 声明为数组,date 是数组名。声明为指针,date 是变量,这个变量可以在程序执行期间指向其他字符串。

如果我们希望可以修改字符串,那么应该建立字符数组存储字符串。

如果我们声明了一个char*类型的指针,在使用它之前应让它指向字符串字面量或者字符串变量。

注意:使用未初始化的指针变量作为字符串是严重的错误

char *p;

p[0] = 'a'; // wrong
p[1] = 'b'; // wrong
p[2] = 'c'; // wrong
p[3] = '\0'; // wrong

这个程序试图创建一个字符串。因为 p 没有被初始化,所以我们不知道它指向哪里。直接解引用属于非法内存访问。

三 字符串的读和写

1. 用 printf 函数和 puts 函数写字符串

使用转换说明:%s

char str[] = "Are you happy?";
printf("%s\n", str);

输出会是:

Are you happy?

printf 函数会逐个写字符串中的字符,直到遇到空字符为止。如果空字符丢失,printf 函数会越过字符串的末尾继续写,直到最终在内存的某个地方找到空字符为止。

如果只想显示字符串的一部分,可以使用转换说明%.ps这里 p 是要显示的字符数量。

printf("%.3", str);
//输出:
Are

字符串跟数一样,可以指定字段内显示。转换说明%ms会在大小为 m 的字段内显示字符串。(对于超过 m 个字符的字符串,printf 函数会显示整个字符串,而不会截断。)如果字符串少于 m 个字符,则会在字段内右对齐输出。如果要前置左对齐,可以在 m 前加一个 - 号。m 和 p 可以组合使用:转换说明%m.ps会使字符串的前 p 个字符在大小为 m 的字段内输出。

比如:

printf("%.3s\n", str);
printf("%10s\n", str);
printf("%20s\n", str);
printf("%-20s\n", str);
printf("%5.3s\n", str);

输出:

Are
Are you happy?
Are you happy?
Are you happy?
Are

还可以使用 puts函数输出字符串。

puts(str);

puts 函数只有一个参数,即需要显示的字符串。写完字符串后,puts 函数总会添加一个额外的换行符:

puts(str);
puts(str);

输出:

Are you happy?
Are you happy?

puts 函数

int puts( const char *str )

头文件:<stdio.h>

参数:str - 要写入的参数

返回值:

成功时返回非负值

失败时,返回 EOF 并设置 stdout 的错误指示器

定义:

写入每个来自空终止字符串 str 的字符及附加换行符 ‘\n’ 到输出流 stdout ,如同以重复执行 putc 写入。

不写入来自 str 的空终止字符。

2. 用 scanf 函数和 gets 函数读字符串

转换说明 %s

scanf("%s", str);

在 scanf 函数调用中,不需要在 str 前加 & 运算符,因为 str 是数组名,编译器在把他传给函数时会把它当作指针来处理。

调用时,scanf 函数会跳过空白字符,然后读入字符并存储到 str 中,直到遇到空白字符为止。scanf 函数始终会在字符串末尾存储一个空字符

用 scanf 函数读入字符串永远不会包括空白字符。因此,scanf 函数通常不会读入一整行输入。换行符,空格符和制表符都会使 scanf 函数停止读入。为了一次读入一整行输入,可使用 gets函数。

gets 函数

char * gets(char * str)

head:<stdio.h>

Parameters:str - Pointer to a block of memory (array of char) where the string read is copied as a C string.

Return Value: On success, the function returns str.

Description:

Reads characters from the standard input (stdin) and stores them as a C string into str until a newline character or the end-of-file is reached.

The newline character, if found, is not copied into str.

A terminating null character is automatically appended after the characters copied to str.

总结一下重点就是:

  • gets 函数不会在开始读字符串之前跳过空白字符。
  • gets 函数会持续读入直到找到换行符才停止。换行符会被忽略,不会存储到数组中,在字符串末尾追加空字符

我们用程序来比较一下 scanf 和 gets :

先来测试 scanf:

char str[20];
scanf("%s", str);// 输入 Are you ok?
puts(str);

输出:

Are

只有 “Are” 被存储到了 str 中

测试 gets:

char str[20];
gets(str); // 输入 Are you ok?
puts(str);

输出:

Are you ok?

“Are you ok?” 一整行被存入 str 中

注意:

把字符读入数组时,scanf 函数和 gets 函数都无法检测数组何时被填满。因此,它们存储字符时可能会越过数组的边界,这会导致未定义行为。

通过转换说明 %ns代替%s可以使 scanf 更加安全。这里 n 指出可以存储的最多字符数。可惜的是,gets 天生就是不安全的,fgets函数则是好的多的选择(后面会讲)。

3. 逐个字符读取字符串

因为对许多程序而言,scanf 函数和 gets 函数都有风险而且不够灵活,C 程序员经常会自己编写输入函数。通过每次读一个字符的方式读取字符串。

如果决定自己设计输入函数,那么需要考虑以下问题:

  • 在开始存储字符串之前,函数应该跳过空白字符吗?
  • 什么字符导致函数停止读取:换行符,任意空白字符,还是其他某种字符?需要存储这些字符还是忽略掉?
  • 如果输入的字符串太长以至于无法存储,那么函数应该忽略额外的字符还是把它们留给下一次输入操作?

示例中,我们选择:不跳过空白字符,换行符结束,不存储换行符,忽略掉额外字符。

函数原型如下:

int read_line(char str[], int read_num);

参数:str 表示存储输入的数组,read_num 表示读入字符的最大数量。

返回值:返回读入字符的个数。

使用 getchar 实现按字符读入。(按理来说,getchar 函数读入字符失败,也应该结束循环,但这里暂时忽略这种情况)

int read_line(char str[], int read_num) {

int ch, i = 0;

while ((ch = getchar()) != '\n') {
// i 大于 read_num 不执行操作,跳过后面的字符
if (i < read_num)
str[i++] = ch;
}

str[i] = '\0';

return i;
}

注意:

ch 的类型是 int 而不是 char ,只是因为 getchar 把它读入的字符作为 int 类型的值返回。

四 访问字符串中的字符

编写一个函数统计字符串中空格的数量:

int count_spaces(const char s[]){
int i, count = 0;

for(i = 0; s[i] != '\0'; i++)
if(s[i] == ' ')
count++;

return count;
}

声明中 const 表明此函数不能改变数组元素。因为 s 是字符串,所以不需要传入数组的大小,遍历中如果出现 \0 就表示字符串结束。

C 程序员更喜欢用指针来跟踪字符串当前的位置:

int count_spaces(const char* s){
int count = 0;

while(*s != '\0'){
if(*s == ' ')
count++;
s++;
}

return count;
}

注意:

关于字符串数组用数组取下标还是用指针访问字符,形参声明为数组还是指针,这和上一讲中数组和指针的问题是一样的。

五 C 语言字符串库

一些编程语言提供的运算符可以对字符串进行复制,比较,拼接,选择字串等操作,但 C 语言的运算符根本无法操作字符串。所以我们需要常用到一些<string.h>中的库函数,当然你自己写也是可以的。

注意:

对于两个字符串数组:

char str1[] = "Hello";
char str2[] = "World";

如果你这样复制字符串:

str1 = str2;// wrong
str1 = "abc"; // wrong

如果想这样比较字符串的内容:

if(str1 == str2){ // wrong
...
}

上面这样的行为都是不能达到你的预期的。

如果你要使用 string.h 中的函数,需要包含它的头文件:

#include<stdio.h>

我们这里介绍几种最基本的函数。

  • strcpy
  • strlen
  • strcat
  • strcmp

程序圆寄语

这些函数在我的【C必知必会】的【慕课】篇和【C进阶】篇中各一篇文章详细的讲了这些函数的原理和实现。链接如下:

基础

进阶

如果你是初学者只需要看上面列举出来的那几个函数即可,甚至可以不用去实现它们。先把它们用起来。


程序:显示一个月的提醒列表

此程序会显示每一个月的每日提醒列表。用户需要输入一系列提醒,每条提醒都要有一个前缀来说明是那一个月中的那一天。当用户输入的是 0 而不是有效日期时,程序会显示出录入的全部提醒列表(按日期排序)。下面是会话示例:

Enter day and reminder: 24 Suan's birstday
Enter day and reminder: 5 6:00 - Dinner with Marge
Enter day and reminder: 7 10:30 - Movie - "Chinatown"
Enter day and reminder: 0
Day Reminder:
5 6:00 - Dinner with Marge
7 10:30 - Movie - "Chinatown"
24 Suan's birstday
  • 读入提醒使用我们写的 read_line 函数
  • 将提醒存放在二维数组中,数组的每一行看作一个字符串。日期和提示消息都要放进去 。
  • 日期我们用整型输入,然后转换为字符串放入二维数组的前面。
  • 每次读入新的日期和提示消息后,将转为字符串的当前日期和二维数组每行前面表示日期的部分比较。如果当前日期字符串小于二维数组当前行的字符串,说明当前日期较小,应当插入到当前数组的行前一行。我们可以将二维数组从当前行到存放提示的最后一行每行依次向后移动一行,从而使得当前日期和提示可以插入二维数组的当前行。
  • 打印二维数组

结合着程序一起看吧:

#include<stdio.h>
#include<string.h>

#define MAX_REMIND 50
#define MSG_LEN 100


int read_line(char str[], int read_num);

int main(void) {

char reminders[MAX_REMIND][MSG_LEN + 3]; // 存放提示的数组
// 如何使用这个二维数组呢?我们将它的每一行当作一个字符串,reminders[i] 就是每个字符串的指针,如果你不理解可以去前面看看我们的数组的二维数组中的行如何访问;本节第七部分也会讨论二维数组存放字符串的问题。
char day_str[3];//当前日期转换为字符串
char msg_str[MSG_LEN + 1]; //当前输入的提示消息
int day, num_remind = 0; // 日期和当前提示数
int i, j;


for (;;) {

if (num_remind == MAX_REMIND) {
printf("-- No space left --\n");
break;
}

printf("Enter day and reminder:");

scanf("%2d", &day); //每月的日期只用两个数表示即可,只读 2 个字段

if (day == 0)
break;

sprintf(day_str, "%2d", day); // 将 day 以 "%2d" 的格式写入 day_str 字符数组中。"%2d" 保证小于10的天占两位右对齐
read_line(msg_str, MSG_LEN);

// 寻找当前输入的提示应该放到提示数组的那个位置
for (i = 0; i < num_remind; i++) {
// 说明当前输入的日期应该排在此行前
if(strcmp(day_str, reminders[i]) < 0)
break;
}

// 将当前输入的提示插入到正确的位置
for (j = num_remind; j > i; j--) {
strcpy(reminders[j], reminders[j - 1]);
}

strcpy(reminders[i], day_str);
strcat(reminders[i], msg_str);// 刚好将 day_str 复制进去的空字符覆盖掉了

num_remind++;
}

printf("Day Reminder: \n");
for (i = 0; i < num_remind; i++)
printf("%s\n", reminders[i]);


return 0;
}


int read_line(char str[], int read_num) {

int ch, count = 0;

while ((ch = getchar()) != '\n') {
if (count < read_num) {
str[count++] = ch;
}
}

str[count] = '\0';

return count;
}

当然了,这个程序也是可以定义一个结构体来写的。我们就不展示这种写法了。

六 字符串惯用法

1. 搜索字符串结尾

我们来看一下 strlen 的一种模拟实现:

size_t my_strlen(const char* s){

const char* end = s;

while(*end++)
;
return end - s - 1;
}

惯用法:

while(*s)
s++;

循环结束后 s 指向空字符。

while(*s++)
;

循环结束 s 正好指向空字符后面的位置。所以上面返回值需要减去 1

2. 复制字符串

strcat 第一种模拟实现:

char* my_strcat(char* s1, const char* s2){

char* ret = s1;

while(*s1 != '\0')
s1++;

while(*s2 != '\0'){
*s1 = *s2;
s1++;
s2++;
}
*s1 = '\0';

return ret;
}

另一种更为简便的方法:

char* my_strcat(char* s1, const char* s2){

char* ret = s1;

while(*s1)
s1++;

while(*s1++ = *s2++)
;

return ret;
}

第二个循环结束时:s2 将其空字符赋值给 s1 ,所以不需要最后再用单独的一条语句在新字符串的末尾加上空字符。

惯用法:

while(*s1++ = *s2++)
;

七 字符串数组

存储字符串数组的最佳方式是什么?最明显的解决方案是创建一个二维字符数组,然后按照每行一个字符串来存储。

char planets[][8] = {
"Mercury", "Venus", "Earth",
"Mars", "Jupiter", "Saturn",
"Uranus", "Neptune", "Pluto"
};

因为只有 3 个行星的名字填满了一行,所以这样的数组有一点浪费空间。remind.c 程序就是这种浪费的的代表。

我们需要的是参差不齐的数组(ragged array),即每一行有不同长度的二维数组。C 语言本身不提供这样的数组类型。但是我们可以创建一个指针数组,数组的每个元素都是一个指向字符串的指针。声明方式:

char* planets[] = {
"Mercury", "Venus", "Earth",
"Mars", "Jupiter", "Saturn",
"Uranus", "Neptune", "Pluto"
};

现在 planets 的存储方式变为:

planets 中的每个元素都是指向以空字符结尾的字符串的指针。虽然必须为 planets 数组中的指针分配空间,但是字符串中不再有任何浪费的字符。

获取字符串和普通数组访问一样。由于数组和指针的特殊关系,我们可以这样访问字符串中的字符:

for(i = 0; i < 9; i++)
if(planets[i][0] == 'M')
printf("%s begins with M\n", planets[i]);

1. 命令行参数

命令行信息不仅对操作系统命令可用,它对所有程序都是可用的。为了可以访问这些命令行参数(C 标准中称为程序参数),必须把 main 函数定义为含有两个参数的函数。写法如下:

int main(int argc, char* argv[]){

}

argc - 参数计数,是命令行参数的数量(包括程序名本身)

argv - 参数向量,是指向命令行参数的指针数组,这些命令行参数以字符串形式存储。

argv[0]指向程序名,而从 argv[1] argv[argc - 1]则指向余下命令行参数。

argv[argc]是附加元素,这个元素始终是一个空指针 NULL 。空指针是一种不指向任何地方的特殊指针。后面我们会讨论空指针问题。

NULL 是一个宏,VS 中我们发现对这个宏的定义:#define NULL ((void*)0)

如果用户输入命令行:

ls -l remind.c

argc 将为 3:

程序名因操作系统而异。如果程序名不可用,那么 argv[0] 将为 NULL

访问命令行参数的方法:

int i;
for(i = 1; i < argc, i++)
printf("%s\n", argv[i]);

另一种方法是构造一个指向 argv[1] 的指针,然后对指针重复自增来逐个访问数组中的字符串指针。因为 argv[1] 是指针,所以我们要构造一个指向指针的指针:

char** p;
for(p = &argc[1]; p != NULL; p++){
printf("%s\n", *p);
}

因为 p 是指向指针的指针,所以 *p 就是指向参数字符串的指针

程序:核对行星的名字

设计一个程序检查一系列字符串,从而找出那些字符串是行星的名字。执行程序时,用户把待测试的字符串放置在命令行中:

planet Mercury Aotoman Pluto Thebug  Earth

程序会指出每个字符串是否为行星名。如果是,程序还将显示行星的编号:

Mercury is a planet 1
Aotoman is not a planet
Pluto is a planet 9
Thebug is not a planet
Earth is a planet 3

**注意:**命令行输入的第一个参数 planet 是 c 程序编译出的可执行程序名。一般一个叫 x.c 的程序编译后的可执行程序就叫做 x 。

我们命名这个 c 程序为 planet.c 所以编译后的可执行文件应该叫做 planet (在 Windows 上后缀应该为 .exe)

#include<stdio.h>
#include<string.h>

#define NUM_PLANETS 9

int main(int argc, char* argv[]) {

int i, j;
char* planets[NUM_PLANETS] = {
"Mercury", "Venus", "Earth",
"Mars", "Jupiter", "Saturn",
"Uranus", "Neptune", "Pluto"
};

for (i = 1; i < argc; i++) {
for (j = 0; j < NUM_PLANETS; j++)
if (strcmp(argv[i], planets[j]) == 0) {
printf("%s is a planet %d\n", argv[i], j + 1);
break;
}
if (j == NUM_PLANETS)
printf("%s is not a planet\n", argv[i]);
}

return 0;
}

程序会依次访问每个命令行参数,把它与 planets 中的字符串进行比较,直到找到匹配的名字或到了数组末尾才停止。

我们来看一下如何在 Windows 上测试我们写出的程序。打开 cmd 窗口,找到编译后的可执行程序的位置。

参考资料:cplusplus.com cppreference.com 《C语言程序设计:现代方法》