文章目录
一、前言
最近在学习Redis和看点Redis源码,而Redis是用C语言写的,其中会用到很多字符串相关的知识。而且就C语言来说,关于字符串的内容其实一直都没咋了解(不太用得到),现在需要要用到了,因此写一篇关于字符串的博客来了解一下。
之前简单写过一些关于字符串的博客:
C语言和C++语言的字符串会有点区别,C语言支持的字符串类型C++全部都支持,C++在C的基础上新增了string对象。
因此在学习上,我们可以先学C语言支持的字符串,再学一下C++新增的string对象即可。
二、C/C++字符串
1.字符
字符串,它是多个字符的集合。在了解字符串之前,我们可以先了解一下字符。
字符的表示
我们可以用 char 类型来专门表示一个字符,例如:
//正确的写法
char a = '1';
char b = '$';
char c = 'X';
char d = ' '; // 空格也是一个字符
//错误的写法
char x = '中'; //char 类型不能包含 ASCII 编码之外的字符
char y = 'A'; //A 是一个全角字符
char z = "t"; //字符类型应该由单引号包围
char 称为字符类型,只能用单引号' '
来包围,不能用双引号" "
包围。而字符串只能用双引号" "
包围,不能用单引号' '
包围。
字符的输出
输出 char 类型的字符有两种方法,分别是:
- 使用专门的字符输出函数 putchar;
- 使用通用的格式化输出函数 printf,char 对应的格式控制符是
%c
。
请看下面的演示:
#include <stdio.h>
int main() {
char a = '1';
char b = '$';
char c = 'X';
char d = ' ';
//使用 putchar 输出
putchar(a); putchar(d);
putchar(b); putchar(d);
putchar(c); putchar('\n');
//使用 printf 输出
printf("%c %c %c\n", a, b, c);
return 0;
}
运行结果:
1 $ X
1 $ X
putchar 函数每次只能输出一个字符,输出多个字符需要调用多次。
字符与整数
我们知道,计算机在存储字符时并不是真的要存储字符实体,而是存储该字符在字符集中的编号(也可以叫编码值)。对于 char 类型来说,它实际上存储的就是字符的 ASCII 码。
无论在哪个字符集中,字符编号都是一个整数;从这个角度考虑,字符类型和整数类型本质上没有什么区别。
我们可以给字符类型赋值一个整数,或者以整数的形式输出字符类型。反过来,也可以给整数类型赋值一个字符,或者以字符的形式输出整数类型。
请看下面的例子:
#include <stdio.h>
int main()
{
char a = 'E';
char b = 70;
int c = 71;
int d = 'H';
printf("a: %c, %d\n", a, a);
printf("b: %c, %d\n", b, b);
printf("c: %c, %d\n", c, c);
printf("d: %c, %d\n", d, d);
return 0;
}
输出结果:
a: E, 69
b: F, 70
c: G, 71
d: H, 72
在 ASCII 码表中,字符 ‘E’、‘F’、‘G’、‘H’ 对应的编号分别是 69、70、71、72。
a、b、c、d 实际上存储的都是整数:
- 当给 a、d 赋值一个字符时,字符会先转换成 ASCII 码再存储;
- 当给 b、c 赋值一个整数时,不需要任何转换,直接存储就可以;
- 当以 %c 输出 a、b、c、d 时,会根据 ASCII 码表将整数转换成对应的字符;
- 当以 %d 输出 a、b、c、d 时,不需要任何转换,直接输出就可以。
可以说,是 ASCII 码表将英文字符和整数关联了起来。
2.字符和字符串编码
关于字符和字符串的编码,不可混为一谈,必须要区分开来谈论。
C语言是 70 年代的产物,那个时候只有 ASCII,各个国家的字符编码都还未成熟,所以C语言不可能从底层支持 GB2312、GBK、Big5、Shift-JIS 等国家编码,也不可能支持 Unicode 字符集。
稍微有点C语言基本功的读者可能认为C语言使用 ASCII 编码,字符在存储时会转换成对应的 ASCII 码值,这也是错误的,你被大学老师和教材误导了!在C语言中,只有 char 类型的窄字符才使用 ASCII 编码,char 类型的窄字符串、wchar_t 类型的宽字符和宽字符串都不使用 ASCII 编码!
wchar_t 类型的宽字符和宽字符串使用 UTF-16 或者 UTF-32 编码,这个在上节已经讲到了,现在只剩下 char 类型的窄字符串(下面称为窄字符串)没有讲了,这就是本节的重点。
对于窄字符串,C语言并没有规定使用哪一种特定的编码,只要选用的编码能够适应当前的环境即可,所以,窄字符串的编码与操作系统和编译器有关。
但是,可以肯定的说,在现代计算机中,窄字符串已经不再使用 ASCII 编码了,因为 ASCII 编码只能显示字母、数字等英文字符,对汉语、日语、韩语等其它地区的字符无能为力。
讨论窄字符串的编码要从以下两个方面下手。
源文件使用什么编码
源文件用来保存我们编写的代码,它最终会被存储到本地硬盘,或者远程服务器,这个时候就要尽量压缩文件体积,以节省硬盘空间或者网络流量,而代码中大部分的字符都是 ASCII 编码中的字符,用一个字节足以容纳,所以 UTF-8 编码是一个不错的选择。
UTF-8 兼容 ASCII,代码中的大部分字符可以用一个字节保存;另外 UTF-8 基于 Unicode,支持全世界的字符,我们编写的代码可以给全球的程序员使用,真正做到技术无国界。
常见的 IDE 或者编辑器,例如 Xcode、Sublime Text、Gedit、Vim 等,在创建源文件时一般也默认使用 UTF-8 编码。但是 Visual Studio 是个奇葩,它默认使用本地编码来创建源文件。
所谓本地编码,就是像 GBK、Big5、Shift-JIS 等这样的国家编码(地区编码);针对不同国家发行的操作系统,默认的本地编码一般不同。简体中文本的 Windows 默认的本地编码是 GBK。
对于编译器来说,它往往支持多种编码格式的源文件。微软编译器、GCC、LLVM/Clang(内嵌于 Xcode 中)都支持 UTF-8 和本地编码的源文件,不过微软编译器还支持 UTF-16 编码的源文件。如果考虑到源文件的通用性,就只能使用 UTF-8 和本地编码了。
窄字符串使用什么编码
前面讲到,用 puts 或者 printf 可以输出窄字符串,代码如下:
#include <stdio.h>
int main()
{
puts("C语言中文网");
printf("http://c.biancheng.net");
return 0;
}
"C语言中文网"
和"http://c.biancheng.net"
就是需要被处理的窄字符串,程序运行后,它们会被载入到内存中。你看,这里面还包含了中文,肯定不能使用 ASCII 编码了。
-
微软编译器使用本地编码来保存这些字符。不同地区的 Windows 版本默认的本地编码不一样,所以,同样的窄字符串在不同的 Windows 版本下使用的编码也不一样。对于简体中文版的 Windows,使用的是 GBK 编码。
-
GCC、LLVM/Clang 编译器使用和源文件相同的编码来保存这些字符:如果源文件使用的是 UTF-8 编码,那么这些字符也使用 UTF-8 编码;如果源文件使用的是 GBK 编码,那么这些字符也使用 GBK 编码。
你看,对于代码中需要被处理的窄字符串,不同的编译器差别还是挺大的。不过可以肯定的是,这些字符始终都使用窄字符(多字节字符)编码。
正是由于这些字符使用 UTF-8、GBK 等编码,而不是使用 ASCII 编码,所以它们才能包含中文。
那么,为什么很多初学者会误认为C语言使用 ASCII 编码呢?
不管是在课堂跟着老师学习,还是通过互联网自学,初学者都是从处理英文开始的,对于英文来说,使用 GBK、UTF-8、ASCII 都是一样的,GBK、UTF-8 都兼容 ASCII,初学者根本察觉不出用了哪种编码。
另外,很多大学老师和书籍作者也经常会念叨,字符在存储时会被转换成对应的 ASCII 码,在读取时又会从 ASCII 码转换成对应的字符实体,大家需要熟悉 ASCII 编码,它是C语言处理字符的基础,这从很大程度上给初学者造成一种错误印象:C语言和 ASCII 编码是绑定的,C语言使用 ASCII 编码。
总结
对于 char 类型的窄字符,始终使用 ASCII 编码。
对于 wchar_t 类型的宽字符和宽字符串,使用 UTF-16 或者 UTF-32 编码,它们都是基于 Unicode 字符集的。
对于 char 类型的窄字符串,微软编译器使用本地编码,GCC、LLVM/Clang 使用和源文件编码相同的编码。
另外,处理窄字符和处理宽字符使用的函数也不一样:
- <stdio.h> 头文件中的 putchar、puts、printf 函数只能用来处理窄字符;
- <wchar.h> 头文件中的 putwchar、wprintf 函数只能用来处理宽字符。
你看,仅仅是字符的处理,C语言就能玩出这么多花样,让人捉摸不透,不容易学习。这是因为,C语言是一种较为底层和古老的语言,既有历史遗留问题,又有贴近计算机底层的特性。不过,一旦搞明白这些繁杂的底层问题,你的编程内功将精进一个层次,这也许就是学习C语言的乐趣。
3.ASCII编码了解
ASCII(American Standard Code for Information Interchange,美国信息互换标准代码)是一套基于拉丁字母的字符编码,共收录了 128 个字符,用一个字节就可以存储,它等同于国际标准 ISO/IEC 646。
ASCII 规范于 1967 年第一次发布,最后一次更新是在 1986 年,它包含了 33 个控制字符(具有某些特殊功能但是无法显示的字符)和 95 个可显示字符。
二进制 | 十进制 | 十六进制 | 字符/缩写 | 解释 |
---|---|---|---|---|
00000000 | 0 | 00 | NUL (NULL) | 空字符 |
00000001 | 1 | 01 | SOH (Start Of Headling) | 标题开始 |
00000010 | 2 | 02 | STX (Start Of Text) | 正文开始 |
00000011 | 3 | 03 | ETX (End Of Text) | 正文结束 |
00000100 | 4 | 04 | EOT (End Of Transmission) | 传输结束 |
00000101 | 5 | 05 | ENQ (Enquiry) | 请求 |
00000110 | 6 | 06 | ACK (Acknowledge) | 回应/响应/收到通知 |
00000111 | 7 | 07 | BEL (Bell) | 响铃 |
00001000 | 8 | 08 | BS (Backspace) | 退格 |
00001001 | 9 | 09 | HT (Horizontal Tab) | 水平制表符 |
00001010 | 10 | 0A | LF/NL(Line Feed/New Line) | 换行键 |
00001011 | 11 | 0B | VT (Vertical Tab) | 垂直制表符 |
00001100 | 12 | 0C | FF/NP (Form Feed/New Page) | 换页键 |
00001101 | 13 | 0D | CR (Carriage Return) | 回车键 |
00001110 | 14 | 0E | SO (Shift Out) | 不用切换 |
00001111 | 15 | 0F | SI (Shift In) | 启用切换 |
00010000 | 16 | 10 | DLE (Data Link Escape) | 数据链路转义 |
00010001 | 17 | 11 | DC1/XON (Device Control 1/Transmission On) | 设备控制1/传输开始 |
00010010 | 18 | 12 | DC2 (Device Control 2) | 设备控制2 |
00010011 | 19 | 13 | DC3/XOFF (Device Control 3/Transmission Off) | 设备控制3/传输中断 |
00010100 | 20 | 14 | DC4 (Device Control 4) | 设备控制4 |
00010101 | 21 | 15 | NAK (Negative Acknowledge) | 无响应/非正常响应/拒绝接收 |
00010110 | 22 | 16 | SYN (Synchronous Idle) | 同步空闲 |
00010111 | 23 | 17 | ETB (End of Transmission Block) | 传输块结束/块传输终止 |
00011000 | 24 | 18 | CAN (Cancel) | 取消 |
00011001 | 25 | 19 | EM (End of Medium) | 已到介质末端/介质存储已满/介质中断 |
00011010 | 26 | 1A | SUB (Substitute) | 替补/替换 |
00011011 | 27 | 1B | ESC (Escape) | 逃离/取消 |
00011100 | 28 | 1C | FS (File Separator) | 文件分割符 |
00011101 | 29 | 1D | GS (Group Separator) | 组分隔符/分组符 |
00011110 | 30 | 1E | RS (Record Separator) | 记录分离符 |
00011111 | 31 | 1F | US (Unit Separator) | 单元分隔符 |
00100000 | 32 | 20 | (Space) | 空格 |
00100001 | 33 | 21 | ! | |
00100010 | 34 | 22 | " | |
00100011 | 35 | 23 | # | |
00100100 | 36 | 24 | $ | |
00100101 | 37 | 25 | % | |
00100110 | 38 | 26 | & | |
00100111 | 39 | 27 | ’ | |
00101000 | 40 | 28 | ( | |
00101001 | 41 | 29 | ) | |
00101010 | 42 | 2A | * | |
00101011 | 43 | 2B | + | |
00101100 | 44 | 2C | , | |
00101101 | 45 | 2D | - | |
00101110 | 46 | 2E | . | |
00101111 | 47 | 2F | / | |
00110000 | 48 | 30 | 0 | |
00110001 | 49 | 31 | 1 | |
00110010 | 50 | 32 | 2 | |
00110011 | 51 | 33 | 3 | |
00110100 | 52 | 34 | 4 | |
00110101 | 53 | 35 | 5 | |
00110110 | 54 | 36 | 6 | |
00110111 | 55 | 37 | 7 | |
00111000 | 56 | 38 | 8 | |
00111001 | 57 | 39 | 9 | |
00111010 | 58 | 3A | : | |
00111011 | 59 | 3B | ; | |
00111100 | 60 | 3C | < | |
00111101 | 61 | 3D | = | |
00111110 | 62 | 3E | > | |
00111111 | 63 | 3F | ? | |
01000000 | 64 | 40 | @ | |
01000001 | 65 | 41 | A | |
01000010 | 66 | 42 | B | |
01000011 | 67 | 43 | C | |
01000100 | 68 | 44 | D | |
01000101 | 69 | 45 | E | |
01000110 | 70 | 46 | F | |
01000111 | 71 | 47 | G | |
01001000 | 72 | 48 | H | |
01001001 | 73 | 49 | I | |
01001010 | 74 | 4A | J | |
01001011 | 75 | 4B | K | |
01001100 | 76 | 4C | L | |
01001101 | 77 | 4D | M | |
01001110 | 78 | 4E | N | |
01001111 | 79 | 4F | O | |
01010000 | 80 | 50 | P | |
01010001 | 81 | 51 | Q | |
01010010 | 82 | 52 | R | |
01010011 | 83 | 53 | S | |
01010100 | 84 | 54 | T | |
01010101 | 85 | 55 | U | |
01010110 | 86 | 56 | V | |
01010111 | 87 | 57 | W | |
01011000 | 88 | 58 | X | |
01011001 | 89 | 59 | Y | |
01011010 | 90 | 5A | Z | |
01011011 | 91 | 5B | [ | |
01011100 | 92 | 5C | \ | |
01011101 | 93 | 5D | ] | |
01011110 | 94 | 5E | ^ | |
01011111 | 95 | 5F | _ | |
01100000 | 96 | 60 | ` | |
01100001 | 97 | 61 | a | |
01100010 | 98 | 62 | b | |
01100011 | 99 | 63 | c | |
01100100 | 100 | 64 | d | |
01100101 | 101 | 65 | e | |
01100110 | 102 | 66 | f | |
01100111 | 103 | 67 | g | |
01101000 | 104 | 68 | h | |
01101001 | 105 | 69 | i | |
01101010 | 106 | 6A | j | |
01101011 | 107 | 6B | k | |
01101100 | 108 | 6C | l | |
01101101 | 109 | 6D | m | |
01101110 | 110 | 6E | n | |
01101111 | 111 | 6F | o | |
01110000 | 112 | 70 | p | |
01110001 | 113 | 71 | q | |
01110010 | 114 | 72 | r | |
01110011 | 115 | 73 | s | |
01110100 | 116 | 74 | t | |
01110101 | 117 | 75 | u | |
01110110 | 118 | 76 | v | |
01110111 | 119 | 77 | w | |
01111000 | 120 | 78 | x | |
01111001 | 121 | 79 | y | |
01111010 | 122 | 7A | z | |
01111011 | 123 | 7B | { | |
01111100 | 124 | 7C | | | |
01111101 | 125 | 7D | } | |
01111110 | 126 | 7E | ~ | |
01111111 | 127 | 7F | DEL (Delete) | 删除 |
对控制字符的解释
ASCII 编码中第 0~31 个字符(开头的 32 个字符)以及第 127 个字符(最后一个字符)都是不可见的(无法显示),但是它们都具有一些特殊功能,所以称为控制字符( Control Character)或者功能码(Function Code)。
这 33 个控制字符大都与通信、数据存储以及老式设备有关,有些在现代电脑中的含义已经改变了。
有些控制符需要一定的计算机功底才能理解,初学者可以跳过,选择容易的理解即可。
下面列出了部分控制字符的具体功能:
NUL (0)
NULL,空字符。空字符起初本意可以看作为 NOP(中文意为空操作,就是啥都不做的意思),此位置可以忽略一个字符。
之所以有这个空字符,主要是用于计算机早期的记录信息的纸带,此处留个 NUL 字符,意思是先占这个位置,以待后用,比如你哪天想起来了,在这个位置在放一个别的啥字符之类的。
后来呢,NUL 被用于C语言中,表示字符串的结束,当一个字符串中间出现 NUL 时,就意味着这个是一个字符串的结尾了。这样就方便按照自己需求去定义字符串,多长都行,当然只要你内存放得下,然后最后加一个\0,即空字符,意思是当前字符串到此结束。
SOH (1)
Start Of Heading,标题开始。如果信息沟通交流主要以命令和消息的形式的话,SOH 就可以用于标记每个消息的开始。
1963年,最开始 ASCII 标准中,把此字符定义为 Start of Message,后来又改为现在的 Start Of Heading。
现在,这个 SOH 常见于主从(master-slave)模式的 RS232 的通信中,一个主设备,以 SOH 开头,和从设备进行通信。这样方便从设备在数据传输出现错误的时候,在下一次通信之前,去实现重新同步(resynchronize)。如果没有一个清晰的类似于 SOH 这样的标记,去标记每个命令的起始或开头的话,那么重新同步,就很难实现了。
-
STX (2) 和 ETX (3)
STX 表示 Start Of Text,意思是“文本开始”;ETX 表示 End Of Text,意思是“文本结束”。
通过某种通讯协议去传输的一个数据(包),称为一帧的话,常会包含一个帧头,包含了寻址信息,即你是要发给谁,要发送到目的地是哪里,其后跟着真正要发送的数据内容。
而 STX,就用于标记这个数据内容的开始。接下来是要传输的数据,最后是 ETX,表明数据的结束。
而中间具体传输的数据内容,ASCII 并没有去定义,它和你所用的传输协议有关。
帧头 数据或文本内容 SOH(表明帧头开始) …(帧头信息,比如包含了目的地址,表明你发送给谁等等) STX(表明数据开始) …(真正要传输的数据) ETX(表明数据结束
BEL (7)
BELl,响铃。在 ASCII 编码中,BEL 是个比较有意思的东西。BEL 用一个可以听得见的声音来吸引人们的注意,既可以用于计算机,也可以用于周边设备(比如打印机)。
注意,BEL 不是声卡或者喇叭发出的声音,而是蜂鸣器发出的声音,主要用于报警,比如硬件出现故障时就会听到这个声音,有的计算机操作系统正常启动也会听到这个声音。蜂鸣器没有直接安装到主板上,而是需要连接到主板上的一种外设,现代很多计算机都不安装蜂鸣器了,即使输出 BEL 也听不到声音,这个时候 BEL 就没有任何作用了。
BS (8)
BackSpace,退格键。退格键的功能,随着时间变化,意义也变得不同了。
退格键起初的意思是,在打印机和电传打字机上,往回移动一格光标,以起到强调该字符的作用。比如你想要打印一个 a,然后加上退格键后,就成了 aBS^。在机械类打字机上,此方法能够起到实际的强调字符的作用,但是对于后来的 CTR 下时期来说,就无法起到对应效果了。
而现代所用的退格键,不仅仅表示光标往回移动了一格,同时也删除了移动后该位置的字符。
HT (9)
Horizontal Tab,水平制表符,相当于 Table/Tab 键。
水平制表符的作用是用于布局,它控制输出设备前进到下一个表格去处理。而制表符 Table/Tab 的宽度也是灵活不固定的,只不过在多数设备上制表符 Tab 都预定义为 4 个空格的宽度。
水平制表符 HT 不仅能减少数据输入者的工作量,对于格式化好的文字来说,还能够减少存储空间,因为一个Tab键,就代替了 4 个空格。
LF (10)
Line Feed,直译为“给打印机等喂一行”,也就是“换行”的意思。LF 是 ASCII 编码中常被误用的字符之一。
LF 的最原始的含义是,移动打印机的头到下一行。而另外一个 ASCII 字符,CR(Carriage Return)才是将打印机的头移到最左边,即一行的开始(行首)。很多串口协议和 MS-DOS 及 Windows 操作系统,也都是这么实现的。
而C语言和 Unix 操作系统将 LF 的含义重新定义为“新行”,即 LF 和 CR 的组合效果,也就是回车且换行的意思。
从程序的角度出发,C语言和 Unix 对 LF 的定义显得更加自然,而 MS-DOS 的实现更接近于 LF 的本意。
现在人们常将 LF 用做“新行(newline)”的功能,大多数文本编辑软件也都可以处理单个 LF 或者 CR/LF 的组合了。
VT (11)
Vertical Tab,垂直制表符。它类似于水平制表符 Tab,目的是为了减少布局中的工作,同时也减少了格式化字符时所需要存储字符的空间。VT 控制符用于跳到下一个标记行。
说实话,还真没看到有些地方需要用 VT,因为一般在换行的时候都是用 LF 代替 VT 了。
FF (12)
Form Feed,换页。设计换页键,是用来控制打印机行为的。当打印机收到此键码的时候,打印机移动到下一页。
不同的设备的终端对此控制符所表现的行为各不同,有些会清除屏幕,有些只是显示
^L
字符,有些只是新换一行而已。例如,Unix/Linux 下的 Bash Shell 和 Tcsh 就把 FF 看做是一个清空屏幕的命令。
CR (13)
Carriage return,回车,表示机器的滑动部分(或者底座)返回。
CR 回车的原意是让打印头回到左边界,并没有移动到下一行的意思。随着时间的流逝,后来人们把 CR 的意思弄成了 Enter 键,用于示意输入完毕。
在数据以屏幕显示的情况下,人们按下 Enter 的同时,也希望把光标移动到下一行,因此C语言和 Unix 重新定义了 CR 的含义,将其表示为移动到下一行。当输入 CR 时,系统也常常隐式地将其转换为LF。
SO (14) 和 SI (15)
SO,Shift Out,不用切换;SI,Shift In,启用切换。
早在 1960s 年代,设计 ASCII 编码的美国人就已经想到了,ASCII 编码不仅仅能用于英文,也要能用于外文字符集,这很重要,定义 Shift In 和 Shift Out 正是考虑到了这点。
最开始,其意为在西里尔语和拉丁语之间切换。西里尔语 ASCII(也即 KOI-7 编码)将 Shift 作为一个普通字符,而拉丁语 ASCII(也就是我们通常所说的 ASCII)用 Shift 去改变打印机的字体,它们完全是两种含义。
在拉丁语 ASCII 中,SO 用于产生双倍宽度的字符(类似于全角),而用 SI 打印压缩的字体(类似于半角)。
DLE (16)
Data Link Escape,数据链路转义。
有时候我们需要在通信过程中发送一些控制字符,但是总有一些情况下,这些控制字符被看成了普通的数据流,而没有起到对应的控制效果,ASCII 编码引入 DLE 来解决这类问题。
如果数据流中检测到了 DLE,数据接收端会对数据流中接下来的字符另作处理。但是具体如何处理,ASCII 规范中并没有定义,只是弄了个 DLE 去打断正常的数据流,告诉接下来的数据要特殊对待。
DC1 (17)
Device Control 1,或者 XON – Transmission on。
这个 ASCII 控制符尽管原先定义为 DC1, 但是现在常表示为 XON,用于串行通信中的软件流控制。其主要作用为,在通信被控制符 XOFF 中断之后,重新开始信息传输。
用过串行终端的人应该还记得,当有时候数据出错了,按 Ctrl+Q(等价于XON)有时候可以起到重新传输的效果。这是因为,此 Ctrl+Q 键盘序列实际上就是产生 XON 控制符,它可以将那些由于终端或者主机方面,由于偶尔出现的错误的 XOFF 控制符而中断的通信解锁,使其正常通信。
DC3 (19)
Device Control 3,或者 XOFF(Transmission off,传输中断)。
EM (25)
End of Medium,已到介质末端,介质存储已满。
EM 用于,当数据存储到达串行存储介质末尾的时候,就像磁带或磁头滚动到介质末尾一样。其用于表述数据的逻辑终点,即不必非要是物理上的达到数据载体的末尾。
FS(28)
File Separator,文件分隔符。FS 是个很有意思的控制字符,它可以让我们看到 1960s 年代的计算机是如何组织的。
我们现在习惯于随机访问一些存储介质,比如 RAM、磁盘等,但是在设计 ASCII 编码的那个年代,大部分数据还是顺序的、串行的,而不是随机访问的。此处所说的串行,不仅仅指的是串行通信,还指的是顺序存储介质,比如穿孔卡片、纸带、磁带等。
在串行通信的时代,设计这么一个用于表示文件分隔的控制字符,用于分割两个单独的文件,是一件很明智的事情。
GS(29)
Group Separator,分组符。
ASCII 定义控制字符的原因之一就是考虑到了数据存储。
大部分情况下,数据库的建立都和表有关,表包含了多条记录。同一个表中的所有记录属于同一类型,不同的表中的记录属于不同的类型。
而分组符 GS 就是用来分隔串行数据存储系统中的不同的组。值得注意的是,当时还没有使用 Excel 表格,ASCII 时代的人把它叫做组。
RS(30)
Record Separator,记录分隔符,用于分隔一个组或表中的多条记录。
US(31)
Unit Separator,单元分隔符。
在 ASCII 定义中,数据库中所存储的最小的数据项叫做单元(Unit)。而现在我们称其字段(Field)。单元分隔符 US 用于分割串行数据存储环境下的不同单元。
现在的数据库实现都要求大部分类型都拥有固定的长度,尽管有时候可能用不到,但是对于每一个字段,却都要分配足够大的空间,用于存放最大可能的数据。
这种做法的弊端就是占用了大量的存储空间,而 US 控制符允许字段具有可变的长度。在 1960s 年代,数据存储空间很有限,用 US 将不同单元分隔开,能节省很多空间。
DEL (127)
Delete,删除。
有人也许会问,为何 ASCII 编码中其它控制字符的值都很小(即 0~31),而 DEL 的值却很大呢(为 127)?
这是由于这个特殊的字符是为纸带而定义的。在那个年代,绝大多数的纸带都是用7个孔洞去编码数据的。而 127 这个值所对应的二进制值为111 1111(所有 7 个比特位都是1),将 DEL 用在现存的纸带上时,所有的洞就都被穿孔了,就把已经存在的数据都擦除掉了,就起到了删除的作用。
也有人将 ASCII 编码分成两部分:
- 前 128 个字符称为基本 ASCII,包含常见字符;
- 后 128 个字符称为扩展 ASCII,包含一些特殊字符。
4.C语言转义字符
字符集(Character Set)为每个字符分配了唯一的编号,我们不妨将它称为编码值。在C语言中,一个字符除了可以用它的实体(也就是真正的字符)表示,还可以用编码值表示。这种使用编码值来间接地表示字符的方式称为转义字符(Escape Character)。
转义字符以\
或者\x
开头,以\
开头表示后跟八进制形式的编码值,以\x
开头表示后跟十六进制形式的编码值。对于转义字符来说,只能使用八进制或者十六进制。
字符 1、2、3、a、b、c 对应的 ASCII 码的八进制形式分别是 61、62、63、141、142、143,十六进制形式分别是 31、32、33、61、62、63。下面的例子演示了转义字符的用法:
char a = '\61'; //字符1
char b = '\141'; //字符a
char c = '\x31'; //字符1
char d = '\x61'; //字符a
char *str1 = "\x31\x32\x33\x61\x62\x63"; //字符串"123abc"
char *str2 = "\61\62\63\141\142\143"; //字符串"123abc"
char *str3 = "The string is: \61\62\63\x61\x62\x63" //混用八进制和十六进制形式
转义字符既可以用于单个字符,也可以用于字符串,并且一个字符串中可以同时使用八进制形式和十六进制形式。
一个完整的例子:
#include <stdio.h>
int main(){
puts("\x68\164\164\x70://c.biancheng.\x6e\145\x74");
return 0;
}
运行结果:
http://c.biancheng.net
转义字符的初衷是用于 ASCII 编码,所以它的取值范围有限:
- 八进制形式的转义字符最多后跟三个数字,也即
\ddd
,最大取值是\177
; - 十六进制形式的转义字符最多后跟两个数字,也即
\xdd
,最大取值是\x7f
。
超出范围的转义字符的行为是未定义的,有的编译器会将编码值直接输出,有的编译器会报错。
对于 ASCII 编码,0~31(十进制)范围内的字符为控制字符,它们都是看不见的,不能在显示器上显示,甚至无法从键盘输入,只能用转义字符的形式来表示。不过,直接使用 ASCII 码记忆不方便,也不容易理解,所以,针对常用的控制字符,C语言又定义了简写方式,完整的列表如下:
转义字符 | 意义 | ASCII码值(十进制) |
---|---|---|
\a | 响铃(BEL) | 007 |
\b | 退格(BS) ,将当前位置移到前一列 | 008 |
\f | 换页(FF),将当前位置移到下页开头 | 012 |
\n | 换行(LF) ,将当前位置移到下一行开头 | 010 |
\r | 回车(CR) ,将当前位置移到本行开头 | 013 |
\t | 水平制表(HT) | 009 |
\v | 垂直制表(VT) | 011 |
’ | 单引号 | 039 |
" | 双引号 | 034 |
\ | 反斜杠 | 092 |
\n
和\t
是最常用的两个转义字符:
\n
用来换行,让文本从下一行的开头输出,前面的章节中已经多次使用;\t
用来占位,一般相当于四个空格,或者 tab 键的功能。
单引号、双引号、反斜杠是特殊的字符,不能直接表示:
- 单引号是字符类型的开头和结尾,要使用
\'
表示,也即'\''
; - 双引号是字符串的开头和结尾,要使用
\"
表示,也即"abc\"123"
; - 反斜杠是转义字符的开头,要使用
\\
表示,也即'\\'
,或者"abc\\123"
。
转义字符示例:
#include <stdio.h>
int main(){
puts("C\tC++\tJava\n\"C\" first appeared!");
return 0;
}
运行结果:
C C++ Java
“C” first appeared!
5.在C语言中使用中文字符
大部分C语言教材对中文字符的处理讳莫如深,甚至只字不提,导致很多初学者认为C语言只能处理英文,而不支持中文。其实C语言是一门全球化的编程语言,它支持世界上任何一个国家的语言文化,包括中文、日语、韩语等。
中文字符的存储
正确地存储中文字符需要解决两个问题。
1) 足够长的数据类型
char 只能处理 ASCII 编码中的英文字符,是因为 char 类型太短,只有一个字节,容纳不下我大中华几万个汉字,要想处理中文字符,必须得使用更长的数据类型。
一个字符在存储之前会转换成它在字符集中的编号,而这样的编号是一个整数,所以我们可以用整数类型来存储一个字符,比如 unsigned short、unsigned int、unsigned long 等。
2) 选择包含中文的字符集
C语言规定,对于汉语、日语、韩语等 ASCII 编码之外的单个字符,也就是专门的字符类型,要使用宽字符的编码方式。常见的宽字符编码有 UTF-16 和 UTF-32,它们都是基于 Unicode 字符集的,能够支持全球的语言文化。
在真正实现时,微软编译器(内嵌于 Visual Studio 或者 Visual C++ 中)采用 UTF-16 编码,使用 2 个字节存储一个字符,用 unsigned short 类型就可以容纳。GCC、LLVM/Clang(内嵌于 Xcode 中)采用 UTF-32 编码,使用 4 个字节存储字符,用 unsigned int 类型就可以容纳。
对于编号较小的字符,UTF-16 采用两个字节存储;对于编号较大的字符,UTF-16 使用四个字节存储。但是,全球常用的字符也就几万个,使用两个字节存储足以,只有极其罕见,或者非常古老的字符才会用到四个字节。
微软编译器使用两个字节来存储 UTF-16 编码的字符,虽然不能囊括所有的 Unicode 字符,但是也足以容纳全球的常见字符了,基本满足了软件开发的需求。使用两个字节存储的另外一个好处是可以节省内存,而使用四个字节会浪费 50% 以上的内存。
你看,不同的编译器可以使用不同的整数类型。如果我们的代码使用 unsigned int 来存储宽字符,那么在微软编译器下就是一种浪费;如果我们的代码使用 unsigned short 来存储宽字符,那么在 GCC、LLVM/Clang 下就不够。
为了解决这个问题,C语言推出了一种新的类型,叫做 wchar_t。w 是 wide 的首字母,t 是 type 的首字符,wchar_t 的意思就是宽字符类型。wchar_t 的长度由编译器决定:
- 在微软编译器下,它的长度是 2,等价于 unsigned short;
- 在GCC、LLVM/Clang 下,它的长度是 4,等价于 unsigned int。
wchar_t 其实是用 typedef 关键字定义的一个别名,我们会在《C语言typedef的用法详解》一节中深入讲解,大家暂时只需要记住,wchar_t 在不同的编译器下长度不一样。
wchar_t 类型位于 <wchar.h> 头文件中,它使得代码在具有良好移植性的同时,也节省了不少内存,以后我们就用它来存储宽字符。
上节我们讲到,单独的字符由单引号' '
包围,例如'B'
、'@'
、'9'
等;但是,这样的字符只能使用 ASCII 编码,要想使用宽字符的编码方式,就得加上L
前缀,例如L'A'
、L'9'
、L'中'
、L'国'
、L'。'。
注意,加上L
前缀后,所有的字符都将成为宽字符,占用 2 个字节或者 4 个字节的内存,包括 ASCII 中的英文字符。
下面的例子演示了如何存储宽字符(注意引入 <wchar.h> 头文件):
wchar_t a = L'A'; //英文字符(基本拉丁字符)
wchar_t b = L'9'; //英文数字(阿拉伯数字)
wchar_t c = L'中'; //中文汉字
wchar_t d = L'国'; //中文汉字
wchar_t e = L'。'; //中文标点
wchar_t f = L'ヅ'; //日文片假名
wchar_t g = L'♥'; //特殊符号
wchar_t h = L'༄'; //藏文
在以后的编程中,我们将不加L
前缀的字符称为窄字符,将加上L
前缀的字符称为宽字符。窄字符使用 ASCII 编码,宽字符使用 UTF-16 或者 UTF-32 编码。
宽字符的输出
putchar、printf 只能输出不加L
前缀的窄字符,对加了L
前缀的宽字符无能为力,我们必须使用 <wchar.h> 头文件中的宽字符输出函数,它们分别是 putwchar 和 wprintf:
- putwchar 函数专门用来输出一个宽字符,它和 putchar 的用法类似;
- wprintf 是通用的、格式化的宽字符输出函数,它除了可以输出单个宽字符,还可以输出宽字符串(稍后讲解)。宽字符对应的格式控制符为
%lc
。
另外,在输出宽字符之前还要使用 setlocale 函数进行本地化设置,告诉程序如何才能正确地处理各个国家的语言文化。由于大家基础还不够,关于本地化设置的内容我们不再展开讲解,请大家先记住这种写法。
如果希望设置为中文简体环境,在 Windows 下请写作:
setlocale(LC_ALL, “zh-CN”);
在 Linux 和 Mac OS 下请写作:
setlocale(LC_ALL, “zh_CN”);
setlocale 函数位于 <locale.h> 头文件中,我们必须引入它。
下面的代码完整地演示了宽字符的输出:
#include <wchar.h>
#include <locale.h>
int main(){
wchar_t a = L'A'; //英文字符(基本拉丁字符)
wchar_t b = L'9'; //英文数字(阿拉伯数字)
wchar_t c = L'中'; //中文汉字
wchar_t d = L'国'; //中文汉字
wchar_t e = L'。'; //中文标点
wchar_t f = L'ヅ'; //日文片假名
wchar_t g = L'♥'; //特殊符号
wchar_t h = L'༄'; //藏文
//将本地环境设置为简体中文
setlocale(LC_ALL, "zh_CN");
//使用专门的 putwchar 输出宽字符
putwchar(a); putwchar(b); putwchar(c); putwchar(d);
putwchar(e); putwchar(f); putwchar(g); putwchar(h);
putwchar(L'\n'); //只能使用宽字符
//使用通用的 wprintf 输出宽字符
wprintf(
L"Wide chars: %lc %lc %lc %lc %lc %lc %lc %lc\n", //必须使用宽字符串
a, b, c, d, e, f, g, h
);
return 0;
}
运行结果:
A9中国。ヅ♥༄
Wide chars: A 9 中 国 。 ヅ ♥ ༄
宽字符串
给字符串加上L
前缀就变成了宽字符串,它包含的每个字符都是宽字符,一律采用 UTF-16 或者 UTF-32 编码。输出宽字符串可以使用 <wchar.h> 头文件中的 wprintf 函数,对应的格式控制符是%ls
。
下面的代码演示了如何使用宽字符串:
#include <wchar.h>
#include <locale.h>
int main(){
wchar_t web_url[] = L"http://c.biancheng.net";
wchar_t *web_name = L"C语言中文网";
//将本地环境设置为简体中文
setlocale(LC_ALL, "zh_CN");
//使用通用的 wprintf 输出宽字符
wprintf(L"web_url: %ls \nweb_name: %ls\n", web_url, web_name);
return 0;
}
运行结果:
web_url: http://c.biancheng.net
web_name: C语言中文网
其实,不加L
前缀的窄字符串也可以处理中文,它和加上了L
前缀的宽字符串有什么区别呢?我们将在下节《C语言到底使用什么编码?谁说C语言使用ASCII码,真是荒谬!》中详细讲解。
6.C语言字符数组和字符串
用来存放字符的数组称为字符数组,例如:
char a[10]; //一维字符数组
char b[5][10]; //二维字符数组
char c[20]={'c', ' ', 'p', 'r', 'o', 'g', 'r', 'a','m'}; // 给部分数组元素赋值
char d[]={'c', ' ', 'p', 'r', 'o', 'g', 'r', 'a', 'm' }; //对全体元素赋值时可以省去长度
字符数组实际上是一系列字符的集合,也就是字符串(String)。在C语言中,没有专门的字符串变量,没有string类型,通常就用一个字符数组来存放一个字符串。
C语言规定,可以将字符串直接赋值给字符数组,例如:
char str[30] = {"c.biancheng.net"};
char str[30] = "c.biancheng.net"; //这种形式更加简洁,实际开发中常用
数组第 0 个元素为'c'
,第 1 个元素为'.'
,第 2 个元素为'b'
,后面的元素以此类推。
为了方便,你也可以不指定数组长度,从而写作:
char str[] = {"c.biancheng.net"};
char str[] = "c.biancheng.net"; //这种形式更加简洁,实际开发中常用
给字符数组赋值时,我们通常使用这种写法,将字符串一次性地赋值(可以指明数组长度,也可以不指明),而不是一个字符一个字符地赋值,那样做太麻烦了。
这里需要留意一个坑,字符数组只有在定义时才能将整个字符串一次性地赋值给它,一旦定义完了,就只能一个字符一个字符地赋值了。请看下面的例子:
char str[7];
str = "abc123"; //错误
//正确
str[0] = 'a'; str[1] = 'b'; str[2] = 'c';
str[3] = '1'; str[4] = '2'; str[5] = '3';
字符串结束标志(划重点)
字符串是一系列连续的字符的组合,要想在内存中定位一个字符串,除了要知道它的开头,还要知道它的结尾。找到字符串的开头很容易,知道它的名字(字符数组名或者字符串名)就可以;然而,如何找到字符串的结尾呢?C语言的解决方案有点奇妙,或者说有点奇葩。
在C语言中,字符串总是以'\0'
作为结尾,所以'\0'
也被称为字符串结束标志,或者字符串结束符。
'\0'
是 ASCII 码表中的第 0 个字符,英文称为 NUL,中文称为“空字符”。该字符既不能显示,也没有控制功能,输出该字符不会有任何效果,它在C语言中唯一的作用就是作为字符串结束标志。
C语言在处理字符串时,会从前往后逐个扫描字符,一旦遇到'\0'
就认为到达了字符串的末尾,就结束处理。'\0'
至关重要,没有'\0'
就意味着永远也到达不了字符串的结尾。
由" "
包围的字符串会自动在末尾添加'\0'
。例如,"abc123"
从表面看起来只包含了 6 个字符,其实不然,C语言会在最后隐式地添加一个'\0'
,这个过程是在后台默默地进行的,所以我们感受不到。
下图演示了"C program"
在内存中的存储情形:
需要注意的是,逐个字符地给数组赋值并不会自动添加'\0'
,例如:
char str[] = {'a', 'b', 'c'};
数组 str 的长度为 3,而不是 4,因为最后没有'\0'
。
当用字符数组存储字符串时,要特别注意'\0'
,要为'\0'
留个位置;这意味着,字符数组的长度至少要比字符串的长度大 1。请看下面的例子:
char str[7] = "abc123";
"abc123"
看起来只包含了 6 个字符,我们却将 str 的长度定义为 7,就是为了能够容纳最后的'\0'
。如果将 str 的长度定义为 6,它就无法容纳'\0'
了。
当字符串长度大于数组长度时,有些较老或者不严格的编译器并不会报错,甚至连警告都没有,这就为以后的错误埋下了伏笔,读者自己要多多注意。
有些时候,程序的逻辑要求我们必须逐个字符地为数组赋值,这个时候就很容易遗忘字符串结束标志'\0'
。下面的代码中,我们将 26 个大写英文字符存入字符数组,并以字符串的形式输出:
#include <stdio.h>
int main(){
char str[30];
char c;
int i;
for(c=65,i=0; c<=90; c++,i++){
str[i] = c;
}
printf("%s\n", str);
return 0;
}
在 VS2015 下的运行结果:
ABCDEFGHIJKLMNOPQRSTUVWXYZ口口口口i口口0 ?
口
表示无法显示的特殊字符。
大写字母在 ASCII 码表中是连续排布的,编码值从 65 开始,到 90 结束,使用循环非常方便。
在《C语言变量的定义位置以及初始值》一节中我们讲到,在很多编译器下,局部变量的初始值是随机的,是垃圾值,而不是我们通常认为的“零”值。局部数组(在函数内部定义的数组,本例中的 str 数组就是在 main() 函数内部定义的)也有这个问题,很多编译器并不会把局部数组的内存都初始化为“零”值,而是放任不管,爱是什么就是什么,所以它们的值也是没有意义的,也是垃圾值。
在函数内部定义的变量、数组、结构体、共用体等都称为局部数据。在很多编译器下,局部数据的初始值都是随机的、无意义的,而不是我们通常认为的“零”值。这一点非常重要,大家一定要谨记,否则后面会遇到很多奇葩的错误。
本例中的 str 数组在定义完成以后并没有立即初始化,所以它所包含的元素的值都是随机的,只有很小的概率会是“零”值。循环结束以后,str 的前 26 个元素被赋值了,剩下的 4 个元素的值依然是随机的,不知道是什么。
printf() 输出字符串时,会从第 0 个元素开始往后检索,直到遇见'\0'
才停止,然后把'\0'
前面的字符全部输出,这就是 printf() 输出字符串的原理。本例中我们使用 printf() 输出 str,按理说到了第 26 个元素就能检索到'\0'
,就到达了字符串的末尾,然而事实却不是这样,由于我们并未对最后 4 个元素赋值,所以第 26 个元素不是'\0'
,第 27 个也不是,第 28 个也不是……可能到了第 50 个元素才遇到'\0'
,printf() 把这 50 个字符全部输出出来,就是上面的样子,多出来的字符毫无意义,甚至不能显示。
数组总共才 30 个元素,到了第 50 个元素不早就超出数组范围了吗?是的,的确超出范围了!然而,数组后面依然有其它的数据,printf() 也会将这些数据作为字符串输出。
你看,不注意'\0'
的后果有多严重,不但不能正确处理字符串,甚至还会毁坏其它数据。
要想避免这些问题也很容易,在字符串的最后手动添加'\0'
即可。修改上面的代码,在循环结束后添加'\0'
:
#include <stdio.h>
int main(){
char str[30];
char c;
int i;
for(c=65,i=0; c<=90; c++,i++){
str[i] = c;
}
str[i] = 0; //此处为添加的代码,也可以写作 str[i] = '\0';
printf("%s\n", str);
return 0;
}
第 9 行为新添加的代码,它让字符串能够正常结束。根据 ASCII 码表,字符'\0'
的编码值就是 0。
但是,这样的写法貌似有点业余,或者说不够简洁,更加专业的做法是将数组的所有元素都初始化为“零”值,这样才能够从根本上避免问题。再次修改上面的代码:
#include <stdio.h>
int main(){
char str[30] = {0}; //将所有元素都初始化为 0,或者说 '\0'
char c;
int i;
for(c=65,i=0; c<=90; c++,i++){
str[i] = c;
}
printf("%s\n", str);
return 0;
}
还记得《什么是数组》一节中强调过的吗?如果只初始化部分数组元素,那么剩余的数组元素也会自动初始化为“零”值,所以我们只需要将 str 的第 0 个元素赋值为 0,剩下的元素就都是 0 了。
字符串长度
所谓字符串长度,就是字符串包含了多少个字符(不包括最后的结束符'\0'
)。例如"abc"
的长度是 3,而不是 4。
在C语言中,我们使用string.h
头文件中的 strlen() 函数来求字符串的长度,它的用法为:
length strlen(strname);
strname 是字符串的名字,或者字符数组的名字;length 是使用 strlen() 后得到的字符串长度,是一个整数。
下面是一个完整的例子,它输出《C语言入门教程》网址的长度:
#include <stdio.h>
#include <string.h> //记得引入该头文件
int main(){
char str[] = "http://c.biancheng.net/c/";
long len = strlen(str);
printf("The lenth of the string is %ld.\n", len);
return 0;
}
运行结果:
The lenth of the string is 25.
C语言字符串的输入和输出
字符串的输出
在C语言中,有两个函数可以在控制台(显示器)上输出字符串,它们分别是:
- puts():输出字符串并自动换行,该函数只能输出字符串。
- printf():通过格式控制符
%s
输出字符串,不能自动换行。除了字符串,printf() 还能输出其他类型的数据。
这两个函数相信大家已经非常熟悉了,这里不妨再演示一下,请看下面的代码:
#include <stdio.h>
int main(){
char str[] = "http://c.biancheng.net";
printf("%s\n", str); //通过字符串名字输出
printf("%s\n", "http://c.biancheng.net"); //直接输出
puts(str); //通过字符串名字输出
puts("http://c.biancheng.net"); //直接输出
return 0;
}
运行结果:
http://c.biancheng.net
http://c.biancheng.net
http://c.biancheng.net
http://c.biancheng.net
注意,输出字符串时只需要给出名字,不能带后边的[ ]
,例如,下面的两种写法都是错误的:
printf("%s\n", str[]);
puts(str[10]);
字符串的输入
在C语言中,有两个函数可以让用户从键盘上输入字符串,它们分别是:
- scanf():通过格式控制符
%s
输入字符串。除了字符串,scanf() 还能输入其他类型的数据。 - gets():直接输入字符串,并且只能输入字符串。
但是,scanf() 和 gets() 是有区别的:
- scanf() 读取字符串时以空格为分隔,遇到空格就认为当前字符串结束了,所以无法读取含有空格的字符串。
- gets() 认为空格也是字符串的一部分,只有遇到回车键时才认为字符串输入结束,所以,不管输入了多少个空格,只要不按下回车键,对 gets() 来说就是一个完整的字符串。换句话说,gets() 用来读取一整行字符串。
请看下面的例子:
#include <stdio.h>
int main(){
char str1[30] = {0};
char str2[30] = {0};
char str3[30] = {0};
//gets() 用法
printf("Input a string: ");
gets(str1);
//scanf() 用法
printf("Input a string: ");
scanf("%s", str2);
scanf("%s", str3);
printf("\nstr1: %s\n", str1);
printf("str2: %s\n", str2);
printf("str3: %s\n", str3);
return 0;
}
运行结果:
Input a string: C C++ Java Python↙
Input a string: PHP JavaScript↙
str1: C C++ Java Python
str2: PHP
str3: JavaScript
第一次输入的字符串被 gets() 全部读取,并存入 str1 中。第二次输入的字符串,前半部分被第一个 scanf() 读取并存入 str2 中,后半部分被第二个 scanf() 读取并存入 str3 中。
注意,scanf() 在读取数据时需要的是数据的地址,这一点是恒定不变的,所以对于 int、char、float 等类型的变量都要在前边添加&
以获取它们的地址。但是在本段代码中,我们只给出了字符串的名字,却没有在前边添加&
,这是为什么呢?因为字符串名字或者数组名字在使用的过程中一般都会转换为地址,所以再添加&
就是多此一举,甚至会导致错误了。
就目前学到的知识而言,int、char、float 等类型的变量用于 scanf() 时都要在前面添加&
,而数组或者字符串用于 scanf() 时不用添加&
,它们本身就会转换为地址。读者一定要谨记这一点。
至于数组名字(字符串名字)和地址的转换细节,以及数组名字什么时候会转换为地址,我们将在《数组到底在什么时候会转换为指针》一节中详细讲解,大家暂时“死记硬背”即可。
其实 scanf() 也可以读取带空格的字符串
以上是 scanf() 和 gets() 的一般用法,很多教材也是这样讲解的,所以大部分初学者都认为 scanf() 不能读取包含空格的字符串,不能替代 gets()。其实不然,scanf() 的用法还可以更加复杂和灵活,它不但可以完全替代 gets() 读取一整行字符串,而且比 gets() 的功能更加强大。比如,以下功能都是 gets() 不具备的:
- scanf() 可以控制读取字符的数目;
- scanf() 可以只读取指定的字符;
- scanf() 可以不读取某些字符;
- scanf() 可以把读取到的字符丢弃。
这些我们已经在《scanf的高级用法,原来scanf还有这么多新技能》讲解过了,本节就不再赘述了。
7.C语言数组是静态的,不能插入或删除元素
我们知道在C语言中,是不存在字符串这种数据类型的,字符串其实都是用字符数组来存储的。
因此理解数组的特性可以帮助我们更好的理解字符串。
在C语言中,数组一旦被定义后,占用的内存空间就是固定的,容量就是不可改变的,既不能在任何位置插入元素,也不能在任何位置删除元素,只能读取和修改元素,我们将这样的数组称为静态数组。
反过来说,如果数组在定义后可以改变容量,允许在任意位置插入或者删除元素,那么这样的数组称为动态数组。
PHP、JavaScript 等解释型的脚本语言一般都支持动态数组,而 C、C++ 等编译型的语言一般不支持动态数组。
总之,C语言中的数组是静态的,一旦定义后长度就不能改变了,大家要注意这一点,不要尝试去插入或删除元素。
如果由于项目要求,必须要在数组中插入或者删除元素,该怎么办呢?没办法,只能再造一个新数组!
下面的代码演示了数组元素的插入和删除操作:
#include <stdio.h>
//自定义函数,用来输出数组元素
void display_array(int arr[], int len){
int i;
for(i=0; i<len; i++){
printf("%d ", arr[i]);
}
printf("\n");
}
int main()
{
int nums[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
int nums_new1[9];
int nums_new2[11];
int i;
//删除nums第6个元素
for(i=0; i<10; i++){
//i等于6时直接跳过,不进行任何操作
if(i < 6){
nums_new1[i] = nums[i];
}else if(i > 6){
nums_new1[i-1] = nums[i];
}
}
display_array(nums_new1, 9);
//在nums第6个元素后面插入一个整数 55
for(i=0; i<10; i++){
if(i < 7){
nums_new2[i] = nums[i];
}else if(i > 7){
nums_new2[i+1] = nums[i];
}else { //i等于7
nums_new2[i] = 55;
nums_new2[i+1] = nums[i];
}
}
display_array(nums_new2, 11);
return 0;
}
运行结果:
1 2 3 4 5 6 8 9 10
1 2 3 4 5 6 7 55 8 9 10
display_array() 是一个自己定义的函数,用来输出数组的所有元素;使用 display_array() 时,需要将数组名和数组长度传递给它。读者暂时知道这些即可,之后我们将在《C语言函数》一章中详细讲解。
这段代码总体的思路是使用 for 循环遍历所有的数组元素,并逐个给新的数组元素赋值。这种方法比较简单粗暴,更加高效和简洁的做法是直接复制数组内存,不过由于大家的知识还不充足,所以暂时无法讲解,等大家学了指针以后可以尝试使用 memcpy() 函数来解决。
C语言数组为什么是静态的
不能插入和删除数组元素有时候会非常麻烦,比如一个数组保存了某个班级的学生学号,现在有一名学生退学了,就得把 TA 从数组中剔除,但是C语言并不支持这么做,这就给编程带来了不小的麻烦。
数组元素都是紧挨着排布的,中间没有空隙,不管是插入元素还是删除元素,都得移动该元素后面的内存:
- 在第 i 个元素后面插入一个新元素时,第 i 个元素后面的所有元素都要往后移动一个元素的位置,从而给新元素腾出位置来。如果该数组后面紧跟的是其它有用数据,那么为了防止覆盖有用数据,还不敢直接往后移动元素,必须得重新开辟一块内存,把所有的元素都复制过去。
- 删除第 i 个元素就比较简单了,不管三七二十一,把第 i 个元素后面的所有元素都向前移动即可。
插入和删除数组元素都要移动内存,甚至重新开辟一块内存,这是相当消耗资源的。如果一个程序中有大量的此类操作,那么程序的性能将堪忧,这有悖于「C语言非常高效」的初衷,所以C语言并不支持动态数组。
另外,很多时候我们需要把数组的地址保存到一个变量里面(等大家学到指针时就会见到这种情况),如果数组重新开辟了内存,而变量里面的地址不跟着改变的话,后续再使用该变量就会导致错误。让C语言本身去维护这些变量的值,以保持同步更新,这又是不可能做到的,所以这个矛盾无法从根本上解决。
总之,为了保证程序执行效率,为了防止操作错误,C语言只支持静态数组,不支持动态数组。
了解了数组这个特性,那我们可以联想到,在已经创建了字符串/字符数组之后,那么赋值就需要注意。
字符数组只有在定义时才能将整个字符串一次性地赋值给它,一旦定义完了,就只能一个字符一个字符地赋值了。请看下面的例子:
char str[30] = {"c.biancheng.net"};
char str[30] = "c.biancheng.net"; //这种形式更加简洁,实际开发中常用
char str[7];
str = "abc123"; //错误
//正确
str[0] = 'a'; str[1] = 'b'; str[2] = 'c';
str[3] = '1'; str[4] = '2'; str[5] = '3';
8.字符数组和字符串的初始化和赋值
我们知道在C语言中,字符串的存储是靠数组,我们又又知道,在C语言中,数组和指针之间有着非常紧密的练习。
因此,在C语言中,表示字符串有两种方式,数组和指针(实际还是数组,字符数组指针)。
字符数组我们上面已经介绍了,下面我们来了解一下字符串指针。
1.C语言字符串指针(指向字符串的指针)
C语言中没有特定的字符串类型,我们通常是将字符串放在一个字符数组中,这在《C语言字符数组和字符串》中已经进行了详细讲解,这里不妨再来演示一下:
#include <stdio.h>
#include <string.h>
int main(){
char str[] = "http://c.biancheng.net";
int len = strlen(str), i;
//直接输出字符串
printf("%s\n", str);
//每次输出一个字符
for(i=0; i<len; i++){
printf("%c", str[i]);
}
printf("\n");
return 0;
}
运行结果:
http://c.biancheng.net
http://c.biancheng.net
字符数组归根结底还是一个数组,上节讲到的关于指针和数组的规则同样也适用于字符数组。更改上面的代码,使用指针的方式来输出字符串:
#include <stdio.h>
#include <string.h>
int main(){
char str[] = "http://c.biancheng.net";
char *pstr = str;
int len = strlen(str), i;
//使用*(pstr+i)
for(i=0; i<len; i++){
printf("%c", *(pstr+i));
}
printf("\n");
//使用pstr[i]
for(i=0; i<len; i++){
printf("%c", pstr[i]);
}
printf("\n");
//使用*(str+i)
for(i=0; i<len; i++){
printf("%c", *(str+i));
}
printf("\n");
return 0;
}
运行结果:
http://c.biancheng.net
http://c.biancheng.net
http://c.biancheng.net
除了字符数组,C语言还支持另外一种表示字符串的方法,就是直接使用一个指针指向字符串,例如:
char *str = "http://c.biancheng.net";
或者:
char *str;
str = "http://c.biancheng.net";
字符串中的所有字符在内存中是连续排列的,str 指向的是字符串的第 0 个字符;我们通常将第 0 个字符的地址称为字符串的首地址。字符串中每个字符的类型都是char
,所以 str 的类型也必须是char *
。
下面的例子演示了如何输出这种字符串:
#include <stdio.h>
#include <string.h>
int main(){
char *str = "http://c.biancheng.net";
int len = strlen(str), i;
//直接输出字符串
printf("%s\n", str);
//使用*(str+i)
for(i=0; i<len; i++){
printf("%c", *(str+i));
}
printf("\n");
//使用str[i]
for(i=0; i<len; i++){
printf("%c", str[i]);
}
printf("\n");
return 0;
}
运行结果:
http://c.biancheng.net
http://c.biancheng.net
http://c.biancheng.net
这一切看起来和字符数组是多么地相似,它们都可以使用%s
输出整个字符串,都可以使用*
或[ ]
获取单个字符,这两种表示字符串的方式是不是就没有区别了呢?
有!它们最根本的区别是在内存中的存储区域不一样,字符数组存储在全局数据区或栈区,第二种形式的字符串存储在常量区。全局数据区和栈区的字符串(也包括其他数据)有读取和写入的权限,而常量区的字符串(也包括其他数据)只有读取权限,没有写入权限。
内存权限的不同导致的一个明显结果就是,字符数组在定义后可以读取和修改每个字符,而对于第二种形式的字符串,一旦被定义后就只能读取不能修改,任何对它的赋值都是错误的。
我们将第二种形式的字符串称为字符串常量,意思很明显,常量只能读取不能写入。请看下面的演示:
#include <stdio.h>
int main(){
char *str = "Hello World!";
str = "I love C!"; //正确
str[3] = 'P'; //错误
return 0;
}
这段代码能够正常编译和链接,但在运行时会出现段错误(Segment Fault)或者写入位置错误。
第4行代码是正确的,可以更改指针变量本身的指向;第5行代码是错误的,不能修改字符串中的字符。
到底使用字符数组还是字符串常量
在编程过程中如果只涉及到对字符串的读取,那么字符数组和字符串常量都能够满足要求;如果有写入(修改)操作,那么只能使用字符数组,不能使用字符串常量。
获取用户输入的字符串就是一个典型的写入操作,只能使用字符数组,不能使用字符串常量,请看下面的代码:
#include <stdio.h>
int main(){
char str[30];
gets(str);
printf("%s\n", str);
return 0;
}
运行结果:
C C++ Java Python JavaScript
C C++ Java Python JavaScript
最后我们来总结一下,C语言有两种表示字符串的方法,一种是字符数组,另一种是字符串常量,它们在内存中的存储位置不同,使得字符数组可以读取和修改,而字符串常量只能读取不能修改。
2.初始化
逐个字符初始化
当定义一个字符数组时,可以采用逐个字符初始化的方式:
char str[10]={ 'h','e','l','l','o'};
当显示指定的字符不足字符数组的长度时,编译器将剩余字符置为空字符’\0’。
字符串常量来初始化字符数组
在C语言中,将字符串作为字符数组来处理,因此可以使用字符串来初始化字符数组。
char str[]={"hello"};
也可以省略花括号。
char str[10]="hello";
不及字符数组长度时,剩余字符置为空字符’\0’。因此,我们不难得出,当为一个字符数组初始化为空字符数组的做法有如下几种:
char test1[256]="";
char test2[256]={""};
char test3[256]={0};
char test3[256]={'\0'};
使用字符指针
因为字符指针只是一个指针,指向一块连续的内存,自己本身没有申请内存空间,因此只能将其赋值指向一个字符串。
使用字符指针来访问一个字符串,通过字符指针指向存放字符串数组的首元素地址来进行访问.
char * a ="hello!" ;
但在这可以:
char * a ;
a="hello!" ; //正确
注意:这里只是用字符指针指向一个字符串,它依然是用字符数组存储的,这里只是把字符串首地址赋值给a!
这里是一个字符串常量,所以只能读,不能写
char c =a[0]; //正确
a[0]='a'; //错误
3.赋值
因为字符指针指向一个字符串,指向的字符串为字符常量,不能修改,因此这边的复制将的是字符数组的赋值。
当为已经完成定义的字符数组赋值时,不能采用类似于初始化的方式为字符数组赋值了。如下语句是错误的:
char str[10]; //已经完成定义(包括编译器默认的初始化)
str={'a','d','s'}; //错误
str="abc"; //错误
str={0}; //错误
错误的原因是字符数组名代表字符数组的收地址,不可修改,不能作为左值。左值的概念见博客:认识左值与常引用。
逐个字符赋值
(1)for循环的方式。
char str[10];
for(int i=0;i<sizeof(str):++i)
str[i]='\0';
(2)使用memset()赋值,较for循环高效率,建议使用。当然为字符数组置空应该在初始化时完成,不应该再多次一举。
char str[10];
memset(str,0,sizeof(str));
拷贝赋值
利用已有的字符串,通过memcpy,strcpy或者strncpy等函数实现拷贝赋值,参考代码如下:
char str[10];
char str2[]="hello";
memcpy(str,str2,sizeof(str2));
strcpy(str,str2);
strncpy(str,str2,strlen(str2)+1);
三、C++ string类(C++字符串)完全攻略
string 类是 STL 中 basic_string 模板实例化得到的模板类。其定义如下:
typedef basic_string string;
basic_string 此处可以不必深究。
string 类的成员函数有很多,同一个名字的函数也常会有五六个重载的版本。篇幅所限,不能将这些原型一一列出并加以解释。这里仅对常用成员函数按功能进行分类,并直接给出应用的例子,通过例子,读者可以基本掌握这些成员函数的用法。
要想更深入地了解 string 类,还要阅读 C++ 的参考手册或编译器自带的联机资料。对于前面介绍过的字符串处理的内容,这里不再重复说明。
1. 构造函数
string 类有多个构造函数,用法示例如下:
string s1(); // si = ""
string s2("Hello"); // s2 = "Hello"
string s3(4, 'K'); // s3 = "KKKK"
string s4("12345", 1, 3); //s4 = "234",即 "12345" 的从下标 1 开始,长度为 3 的子串
为称呼方便,本教程后文将从字符串下标 n 开始、长度为 m 的字符串称为“子串(n, m)”。
string 类没有接收一个整型参数或一个字符型参数的构造函数。下面的两种写法是错误的:
string s1('K');
string s2(123);
2. 对 string 对象赋值
可以用 char* 类型的变量、常量,以及 char 类型的变量、常量对 string 对象进行赋值。例如:
string s1;
s1 = "Hello"; // s1 = "Hello"
s2 = 'K'; // s2 = "K”
string 类还有 assign 成员函数,可以用来对 string 对象赋值。assign 成员函数返回对象自身的引用。例如:
string s1("12345"), s2;
s3.assign(s1); // s3 = s1
s2.assign(s1, 1, 2); // s2 = "23",即 s1 的子串(1, 2)
s2.assign(4, 'K'); // s2 = "KKKK"
s2.assign("abcde", 2, 3); // s2 = "cde",即 "abcde" 的子串(2, 3)
3. 求字符串的长度
length 成员函数返回字符串的长度。size 成员函数可以实现同样的功能。
4. string对象中字符串的连接
除了可以使用+
和+=
运算符对 string 对象执行字符串的连接操作外,string 类还有 append 成员函数,可以用来向字符串后面添加内容。append 成员函数返回对象自身的引用。例如:
string s1("123"), s2("abc");
s1.append(s2); // s1 = "123abc"
s1.append(s2, 1, 2); // s1 = "123abcbc"
s1.append(3, 'K'); // s1 = "123abcbcKKK"
s1.append("ABCDE", 2, 3); // s1 = "123abcbcKKKCDE",添加 "ABCDE" 的子串(2, 3)
5. string对象的比较
除了可以用 <、<=、==、!=、>=、> 运算符比较 string 对象外,string 类还有 compare 成员函数,可用于比较字符串。compare 成员函数有以下返回值:
- 小于 0 表示当前的字符串小;
- 等于 0 表示两个字符串相等;
- 大于 0 表示另一个字符串小。
例如:
string s1("hello"), s2("hello, world");
int n = s1.compare(s2);
n = s1.compare(1, 2, s2, 0, 3); //比较s1的子串 (1,2) 和s2的子串 (0,3)
n = s1.compare(0, 2, s2); // 比较s1的子串 (0,2) 和 s2
n = s1.compare("Hello");
n = s1.compare(1, 2, "Hello"); //比较 s1 的子串(1,2)和"Hello”
n = s1.compare(1, 2, "Hello", 1, 2); //比较 s1 的子串(1,2)和 "Hello" 的子串(1,2)
6. 求 string 对象的子串
substr 成员函数可以用于求子串 (n, m),原型如下:
string substr(int n = 0, int m = string::npos) const;
调用时,如果省略 m 或 m 超过了字符串的长度,则求出来的子串就是从下标 n 开始一直到字符串结束的部分。例如:
string s1 = "this is ok";
string s2 = s1.substr(2, 4); // s2 = "is i"
s2 = s1.substr(2); // s2 = "is is ok"
7. 交换两个string对象的内容
swap 成员函数可以交换两个 string 对象的内容。例如:
string s1("West”), s2("East");
s1.swap(s2); // s1 = "East",s2 = "West"
8. 查找子串和字符
string 类有一些查找子串和字符的成员函数,它们的返回值都是子串或字符在 string 对象字符串中的位置(即下标)。如果查不到,则返回 string::npos。string: :npos 是在 string 类中定义的一个静态常量。这些函数如下:
- find:从前往后查找子串或字符出现的位置。
- rfind:从后往前查找子串或字符出现的位置。
- find_first_of:从前往后查找何处出现另一个字符串中包含的字符。例如:
- s1.find_first_of(“abc”); //查找s1中第一次出现"abc"中任一字符的位置
- find_last_of:从后往前查找何处出现另一个字符串中包含的字符。
- find_first_not_of:从前往后查找何处出现另一个字符串中没有包含的字符。
- find_last_not_of:从后往前查找何处出现另一个字符串中没有包含的字符。
下面是 string 类的查找成员函数的示例程序。
#include <iostream>
#include <string>
using namespace std;
int main()
{
string s1("Source Code");
int n;
if ((n = s1.find('u')) != string::npos) //查找 u 出现的位置
cout << "1) " << n << "," << s1.substr(n) << endl;
//输出 l)2,urce Code
if ((n = s1.find("Source", 3)) == string::npos)
//从下标3开始查找"Source",找不到
cout << "2) " << "Not Found" << endl; //输出 2) Not Found
if ((n = s1.find("Co")) != string::npos)
//查找子串"Co"。能找到,返回"Co"的位置
cout << "3) " << n << ", " << s1.substr(n) << endl;
//输出 3) 7, Code
if ((n = s1.find_first_of("ceo")) != string::npos)
//查找第一次出现或 'c'、'e'或'o'的位置
cout << "4) " << n << ", " << s1.substr(n) << endl;
//输出 4) l, ource Code
if ((n = s1.find_last_of('e')) != string::npos)
//查找最后一个 'e' 的位置
cout << "5) " << n << ", " << s1.substr(n) << endl; //输出 5) 10, e
if ((n = s1.find_first_not_of("eou", 1)) != string::npos)
//从下标1开始查找第一次出现非 'e'、'o' 或 'u' 字符的位置
cout << "6) " << n << ", " << s1.substr(n) << endl;
//输出 6) 3, rce Code
return 0;
}
9. 替换子串
replace 成员函数可以对 string 对象中的子串进行替换,返回值为对象自身的引用。例如:
string s1("Real Steel");
s1.replace(1, 3, "123456", 2, 4); //用 "123456" 的子串(2,4) 替换 s1 的子串(1,3)
cout << s1 << endl; //输出 R3456 Steel
string s2("Harry Potter");
s2.replace(2, 3, 5, '0'); //用 5 个 '0' 替换子串(2,3)
cout << s2 << endl; //输出 HaOOOOO Potter
int n = s2.find("OOOOO"); //查找子串 "00000" 的位置,n=2
s2.replace(n, 5, "XXX"); //将子串(n,5)替换为"XXX"
cout << s2 < < endl; //输出 HaXXX Potter
10. 删除子串
erase 成员函数可以删除 string 对象中的子串,返回值为对象自身的引用。例如:
string s1("Real Steel");
s1.erase(1, 3); //删除子串(1, 3),此后 s1 = "R Steel"
s1.erase(5); //删除下标5及其后面的所有字符,此后 s1 = "R Ste"
11. 插入字符串
insert 成员函数可以在 string 对象中插入另一个字符串,返回值为对象自身的引用。例如:
string s1("Limitless"), s2("00");
s1.insert(2, "123"); //在下标 2 处插入字符串"123",s1 = "Li123mitless"
s1.insert(3, s2); //在下标 2 处插入 s2 , s1 = "Li10023mitless"
s1.insert(3, 5, 'X'); //在下标 3 处插入 5 个 'X',s1 = "Li1XXXXX0023mitless"
12. 将 string 对象作为流处理
使用流对象 istringstream 和 ostringstream,可以将 string 对象当作一个流进行输入输出。使用这两个类需要包含头文件 sstream。
示例程序如下:
#include <iostream>
#include <sstream>
#include <string>
using namespace std;
int main()
{
string src("Avatar 123 5.2 Titanic K");
istringstream istrStream(src); //建立src到istrStream的联系
string s1, s2;
int n; double d; char c;
istrStream >> s1 >> n >> d >> s2 >> c; //把src的内容当做输入流进行读取
ostringstream ostrStream;
ostrStream << s1 << endl << s2 << endl << n << endl << d << endl << c <<endl;
cout << ostrStream.str();
return 0;
}
程序的输出结果是:
Avatar
Titanic
123
5.2
K
第 11 行,从输入流 istrStream 进行读取,过程和从 cin 读取一样,只不过输入的来源由键盘变成了 string 对象 src。因此,“Avatar” 被读取到 s1,123 被读取到 n,5.2 被读取到 d,“Titanic” 被读取到s2,‘K’ 被读取到 c。
第 12 行,将变量的值输出到流 ostrStream。输出结果不会出现在屏幕上,而是被保存在 ostrStream 对象管理的某处。用 ostringstream 类的 str 成员函数能将输出到 ostringstream 对象中的内容提取出来。
13. 用 STL 算法操作 string 对象
string 对象也可以看作一个顺序容器,它支持随机访问迭代器,也有 begin 和 end 等成员函数。STL 中的许多算法也适用于 string 对象。下面是用 STL 算法操作 string 对象的程序示例。
#include <iostream>
#include <algorithm>
#include <string>
using namespace std;
int main()
{
string s("afgcbed");
string::iterator p = find(s.begin(), s.end(), 'c');
if (p!= s.end())
cout << p - s.begin() << endl; //输出 3
sort(s.begin(), s.end());
cout << s << endl; //输出 abcdefg
next_permutation(s.begin(), s.end());
cout << s << endl; //输出 abcdegf
return 0;
}
四、字符数组、字符指针和string对象之间的转换
在C/C++中,我们可以用字符数组和字符指针来表示字符串。
在C++中,我们可以同时使用字符数组、字符指针和string对象来表示字符串,因此有时我们需要在这几种字符串的表示方法中切换。
字符数组转化成string类型
char ch [] = "ABCDEFG";
string str(ch);//也可string str = ch;
或者
char ch [] = "ABCDEFG";
string str;
str = ch;//在原有基础上添加可以用str += ch;
可以直接转换
将string类型转换为字符数组
char buf[10];
string str("ABCDEFG");
length = str.copy(buf, 9);
buf[length] = '\0';
或者
char buf[10];
string str("ABCDEFG");
strcpy(buf, str.c_str());//strncpy(buf, str.c_str(), 10);
实验 : 字符串、数组、指针三者转换
#include <iostream>
#include <string>
using namespace std;
int main()
{
string str1= "String";
char ch[]={"charactar"};
char *p = (char *)"Point"; //强制转换赋值
str1 = ch; //字符串与数组
cout << str1 << endl ;
str1= "String";
//ch = str1; //错误,不能直接赋值
str1 = *p ; //字符串与指针
cout << str1 << endl; //
//p = str1;
p = ch; //数组与指针
cout << *p <<endl; //只输出ch首地址的内的c字符,需要调用循环
//ch = p;
return 0;
}
输出:
character
p
c
更加详细的可以参考这篇博客:c++ 中 char 与 string 之间的相互转换问题
五、字符操作函数,内存操作函数详解
这个可以参考我之前的博客:字符串,str函数,mem函数,文件操作大全(全网最全)