C语言中字符串详解
字符串时是C语言中非常重要的部分,我们从字符串的性质和字符串的创建、程序中字符串的输入输出和字符串的操作来对字符串进行详细的解析。
什么是字符串?
C语言本身没有内置的字符串类型,字符串本质上是一种
特殊类型
的数组,它的组成元素类型为char,除此之外不受制与数组长度的限制,以'\0'
作为结束标志,作为字符串结束的标志。(\0
作为一个特殊字符,它的ASCII值为0,但是它不是'0'
字符,'0'
字符的ASCII值为48。)
定义字符串
1. 字符串字面量(字符串常量)
字符串字面量形如
"string"
,也被称为字符串常量,编译器会将它末尾自动添加上字符串结尾标志\0
。它作为一种静态存储类型, 在程序开始运行时被分配地址,一直存在到程序结束,引号括起来的部分将表示它储存的首地址,很类似于数组,数组名作为数组首元素储存的地址。#include <stdio.h>
int main() {
printf("%s %p %c", "Hello", "Hello", *"Hello");
return 0;
}
/**
* Hello 00405044 H
* **/上面说明了字符串常量的储存形式,而且它本身只代表首元素的地址。
2. 字符串数组形式的初始化
字符串以一种特殊的字符串数组的形式存在,区别于一般数组,进行一般初始化时:
char a[] = {'h', 'e', 'l', 'l', 'o', '!', '\0'};
而不能是:
char a[] = {'h', 'e', 'l', 'l', 'o', '!'};
后者仍然是一个普通的字符串数组,不是字符串,这样的初始化显然是麻烦的,我们可以这样:
char a[] = "hello!";
或者
char *a = "hello!";
怎么理解这两种行为呢,他们都使用a储存了字符串
hello!
的地址,但是它们也是有所不同的,下面详细讨论下他们的区别所在。3. 字符串数组和指针
字符串数组形式:我们知道字符串常量以静态形式储存在程序中,使用字符串数组来对它进行存储时需要将其拷贝到新的储存空间,然后将新的储存空间地址赋值到a上。
指针形式:这时候就是一个常规意义上的赋值,我们把在静态储存区的常量地址直接赋值到a上。
这样本质的区别有什么在应用上的区别呢?其一,使用字符串数组a作为常量指针来储存地址,使用指针形式是一种变量来储存地址;其二,因为字符串数组将是一种对原字符常量的一种拷贝,所以我们支持和允许对这样字符串的修改,但是指针只是对原常量地址的一种储存,我们不允许对常量进行修改,所以通过这个指针对原字符进行修改是未定义的恶劣行为,我们看下面的程序:
#include <stdio.h>
int main() {
char *a = "hello!";
a[0] = 'w';
printf("%s", "hello!");
}这样的程序看起来没问题,我们希望将
hello!
修改为wello!
,然后我们希望打印hello!
,但是这样的程序可能输出wello!
,因为我们修改了源地址上的数据,当然编译器也有可能崩溃。所以一般情况下,我们只希望同过使用常量指针来储存字符串
const char *a = "hello!";
,这样可以避免在程序中出现异常的修改常量的错误。所以我们可以总结,我们希望修改字符串时使用字符串数组,只希望读取字符串时我们使用指针,而且应该是常量指针。
还有一些关于它们值得讨论的部分,假如我们想要使用我们有一个字符串数组(本质上作为一个字符数组的数组),有下面两种形式:
char a[3][20] = {"I love you.", "Do you love me?", "Please."};
char *a[3] = {"I love you.", "Do you love me?", "Please."};
这样又有什么区别呢?第一个字符串数组占用3*20*1 = 60byte,第二个占用3个指针为3*4=12byte。在程序非静态部分无疑是后者更为俭省。而且前者因为固定格式的原因,字符参差不齐但是它们创建的空间却都必须满足容纳最长的字符串,造成一定空间的浪费。
所以想要使用一系列待显示的字符串时可以使用指针数组,想要修改字符串在之后则使用一般形式的字符串的数组。
还有对字符串的拷贝,因为字符串变量所存在的形式都是字符串首元素的地址,所以我们下意识对于字符串的拷贝往往是不起作用的:
#include <stdio.h>
int main() {
char *a = "Hello!";
char *pa = a;
printf("a = %s %s = pa\n", a, pa);
printf("a -> %p\n", a);
printf("pa -> %p\n", pa);
printf("a = & %p\n", &a);
printf("pa = & %p\n", &pa);
}
/**
a = Hello! Hello! = pa
a -> 00405044
pa -> 00405044
a = & 0061FF1C
pa = & 0061FF18
* **/在这里a和pa作为字符串打印时,内容时完全相同的,但是仔细看我们发现他们起始指向了相同的地址,也就是所我们并没有完成对字符串内容的拷贝,而只是对地址值的拷贝,而且a和pa作为指针储存在相邻的两个单元,相隔4个字节。这样的拷贝在某些意义上不大,我们将在下面再讨论如何对于字符串进行拷贝。
字符串I/O
首先,在了解了字符串性质的情况下,我们来了解字符串I/O,因为字符串需要在创建时获得一段连续的数组空间,所以尝试将输入的字符串加载进入程序时,我们需要先 分配空间。
这样做是必要的,因为对于未分配内存的字符指针,我们并不知道它的初始状态,它可能指向任意位置,我们在进行输入的时候很有可能因此抹除了先前储存位置上的数据,通常这是不被编译器允许的,往往会造成程序崩溃。
#include <stdio.h>
int main() {
char *a;
scanf("%s", a);// 这个程序可能会崩溃
puts(a);
return 0;
}
所以在处理字符串I/O之前,首先要考虑的就是为输入的字符串分配空间,而且保证输入的字符串不超过我们申请的空间。
下面我们来看一些I/O函数来深入理解这样的理念。
1. gets()被废弃的选项
gets()
,gets需要一个参数,一个字符串指针,它从I/O设备上读取一行信息(等到遇到一个换行符停止),然后在末尾添加空字符,最后的换行符也会被读取并丢弃。看起来这是一个很不错的I\O函数,但是在C99标准中它被建议不要使用,在C11标准中被完全废弃,这是因为它存在着严重的隐患,看下面这段程序:
#include <stdio.h>
int main() {
char b[5] = "hhhh";
char a[5];
gets(a);
puts(a);
puts(b);
return 0;
}
/**
abcd
abcd
hhhh
* **/
/**
abcdefghijklmn
abcdefghijklmn
fghijklmn
**/这段程序,我们输入了两段内容进行测试,第一次
abcd
刚好长度为5,gets
函数正常接受将它放到a分配的地址中,没有出现问题;但是在第二次我们输入了超过了既定分配长度的字符,我们发现原字符出现了异常的变化,超过了既定长度5,容纳下了所有的输入字符,但是随之我们原有的字符串b也被完全修改,原数据被完全抹除。这是因为它们的地址刚好相邻,
gets
函数并不会对字符长度进行检查,它只会将一整行的数据放入指针指向区域上,即使超过申请空间的边界,他也会继续写入,抹去相邻区域的数据也在情理之中了。这给我们程序带来了巨大的危险,如果溢出的部分占用了未使用的空间问题并不大,但是它轻易抹除以使用空间中内容很可能导致程序崩溃,所以我们不要使用
gets
函数,应该尝试更多的根据建议使用fgets()
或则gets_s
来避免这样的问题。
2. fgets()和gets_s()
为了解决
gets
函数中存在的问题,有两个可以函数作为替代。首先是基于
gets
的升级版gets_s
他需要另外的一个参数指定最大读取长度,并根据这个长度来做出相对应的操作:
正常情况下,
gets_s
从标准输入流中读取信息,类似于gets
在未达到最大长度而且读取到换行符时,它将从缓冲区读取该换行符并将其丢弃并在末尾补充上空字符。在读取出现问题时,
gets_s
读取到最大读取长度数目的字符但是仍然未读取到换行符时,gets_s
将会将对应指针(数组首字符)指向数据设定为空字符,然后继续读取知道读取到文件末尾或者换行符,然后返回空指针,之后调用依赖实现的函数的处理函数,可能中止或者退出程序。在这里我们给出一段处理函数使用的实例:
#include <stdio.h>
#include <stdlib.h>
#include <crtdbg.h> // For _CrtSetReportMode
void myInvalidParameterHandler(const wchar_t* expression,
const wchar_t* function,
const wchar_t* file,
unsigned int line,
uintptr_t pReserved)
{
wprintf(L"Invalid parameter detected in function %s."
L" File: %s Line: %d\n", function, file, line);
wprintf(L"Expression: %s\n", expression);
printf("Error!");
}
int main() {
char a[5];
_invalid_parameter_handler oldHandler, newHandler;
newHandler = myInvalidParameterHandler;
oldHandler = _set_invalid_parameter_handler(newHandler);
_CrtSetReportMode(_CRT_ASSERT, 0);
gets_s(a, 5);
puts(a);
return 0;
}在这里及时我们输入超过5位字符,程序也不会呈现崩溃退出。
我们发现
gets_s
函数中使用并不特别方便,还有一个函数可以作为替代fgets
函数,它相较于前两者,还需要另外一个参数,读入文件名称,如果从键盘中读取,那么即为标准输入流stdin
。fgets
函数的一般行为:
正常读取到换行符或则文件末尾时,读取停止,将换行符读入字符串中然后在字符串末尾上填入空字符。这时候函数会返回指向读取函数储存位置的指针,如果到达文件末尾将返回空指针,当读取发生某些其他错误时也会返回空指针,在C语言中它被定义为宏
NULL
。在读取超过字符串最大长度的字符时,将要达到最大长度时停止读取然后在末尾补充上空字符。在读取到文件末尾时函数会返回空指针。
#include <stdio.h>
int main() {
char a[5];
char *status = fgets(a, 5, stdin);
puts(a);
printf("a = &%p\tstatus = &%p\n", a, status);
return 0;
}
/**
123456
123
a = &0135FA10 status = &0135FA10
* **/
/**
123
123
a = &0061FF17 status = &0061FF17
**/对于最大长度参数n,表明函数最多读取n-1个数据(包括换行符),所以输入123会将换行符正常读取然后
puts
函数又输出了一个换行符,所以输出了两个换行符;但是输入更多时,函数读取到四个数据后停止读取补充上空字符。不同于
gets_s
函数,读取不到换行符时,函数也不会对缓冲区中其他数据做出任何操作,对于前者会清空缓冲区中所有下一个换行符前的所有内容,但是fgets
并不会,我们可以*的选择对这些缓冲区的内容进行处理。由于fgets()函数的安全性和可扩展性更佳,所以我们推荐更多的去使用fgets()函数。它往往是最佳选择。
3. scanf()不甚理想的选择
scanf
作为泛用性很强的函数,也有它读取字符串的模式:
scanf("%s", a);
但是使用它来读取字符串并不是最理想的选择,因为
scanf
函数读取字符时开始与一个非空字符,终止于第一个空字符。这样下来他可能只可以读取到一个简单的单词,而不是我们期望的包含空格等完整内容的字符串,所以一般情况下我们不使用scanf
读取整句字符串,而将它用于单词和具有特定格式的字符的读取。我们可以通过转换说明修饰符来读取规则的字符串:
scanf("%5s", a);
这样就可以读取长度为5的单词(中间读取到换行符依旧会停止读取,其中不包括空字符),功能可以比拟fgets(a, 6, stdin);
,但是后者可能包括特殊的换行符之类,所以它们也算是各有用武之地。
4. 输出函数
系统的说明了几个C语言输入函数,我们现在来类似的梳理输出函数,它们与输入函数是相对应的,也是各有特色的。
puts
——gets
,输出字符串直到空字符,并且会在最后输出一个换行符,这样的存在也可能访问到未被分配的内存,这样的行为是未定义的,但是这样很不靠谱。
fputs
——fgets
,输出字符直到碰到空字符,但是与fgets
匹配,它不会在输出最后输出换行符,而且需要额外的参数指示输出位置,如果是屏幕则为stdout
。
printf
——scanf
,scanf
相较于前两者较为多才多艺,不会输出换行符,可以根据自己对格式的要求进行*控制,而且在同时输出多个字符时用起来十分方便。
字符串处理函数
讨论完字符串性质和I\O后我们来继续讨论和字符串息息相关的一些C语言自带的字符串处理函数(其中大部分都是我们可以实现的),熟悉他们方便我们更好的处理字符串。一般情况下他们定义在头文件
string.h
中。
strcat
和strncat
这两个函数被用来字符串合并。
对于
strcat
接受两个字符串指针作为参数,将第二个字符串接到第一个字符串上,然后返回第一个字符串的指针,但是它也存在类似gets
的缺陷,当第一个指针所指向被分配的空间并不足够大时,额外从第二个字符读取的字符将会可能覆写掉其他已经分配空间上的数据。但是基于C语言制定时相信程序员的准则它仍然可以继续使用,不同于gets
,gets
产生的错误可能由用户制造,但是strcat
制造的问题却可以由程序员来避免,所以它仍然可以使用。
strncat
需要额外的一个指定拷贝后的字符的最大长度(包含空字符),以此来保证拷贝后的数组不会超过以分配的储存空间,其他内容同strcat
一致。
strcmp
和strncmp
这两个函数用于字符串比较。
对于
strcmp
,接受两个字符串指针比较它们指向的字符串(而不是它们所指向的地址)如果相同则返回0,否则返回非零的数字,具体情况根据编译器的实现有所不同。
strcmp
也可以通过指定从指定的起始位置开始比较字符串,只需要在传递指针时进行加减运算:
strcmp(a+5, b+4);
这样使得字符串的比较更加灵活。
strncmp
使得字符串的比较更加灵活,通过第三个参数n来指定比较的长度,我们可以进行前缀匹配。
strcpy
和strncpy
这两个函数用于字符串的拷贝。
strcpy
拷贝第二个字符串指针的字符到第一个字符串指针所指向的空间中去,但是我们也需要注意第一个参数所指向的空间也必须足够大容纳第二个字符串。我们也大可不必从字符开始部分开始拷贝,我们可以吧参数指针移动到任何我们想要它拷贝到的位置:
strcpy(a+4, "hello!");
strncpy
弥补了strcpy
的缺点,可以在第三个参数中指定拷贝的最大长度(这个大小不包含空字符,因为函数设计就预想到可能碰不到空字符就要停止,所以拷贝完这个最大长度后,函数会向原字符后自动添加上空字符),但是n的大小最大为第一个字符数组空间大小减去1。
sprintf
sprintf
声明在stdio.h
中,类似于printf
它可以将字符串进行格式化并输出到一个字符串中,使用时同样需要考虑字符串分配空间的问题,这个问题在所有涉及字符串的使用时都要考虑!下面看一段用例:#include <stdio.h>
int main() {
char *s = "Today is ";
int year = 2021, month = 2, day = 2;
char data[30];
sprintf(data, "%s%d/%d/%d.", s, year, month, day);
puts(data);
return 0;
}
/**
Today is 2021/2/2.
* **/
总结
总的来说字符串使用时,无论在何时务必用注意分配空间的使用,不要访问到未分配的空间,这样会给程序带来无法预料的结果。