| 目录 |

21 字符与字符串 II

我们已经说过,char 类型其实只是小整数类型……单引号里的字符也是一样。

但双引号里的字符串类型是 const char *。

原来字符串和字符还有好几种类型,而这会把你引向这门语言里最臭名昭著的兔子洞之一:多字节/宽字符/Unicode/本地化这一整套东西。

我们打算往那个兔子洞里张望一下,但不进去。……暂时不!

21.1 转义序列

我们习惯了由普通字母、标点和数字构成的字符串和字符:

char *s = "Hello!";
char t = 'c';

但如果我们想在里面放一些键盘上打不出来的特殊字符(比如“€”),因为它们压根不存在,或者我们就是想要一个单引号字符,那该怎么办?显然,下面这样是不行的:

char t = ''';

为了做到这些,我们使用所谓的转义序列。它由反斜杠字符(\)后跟另一个字符组成。这两个(或多个)字符合在一起便具有特殊含义。

回到单引号字符的例子,我们可以在中间那个单引号前面加一个转义符(也就是 \)来解决:

char t = '\'';

现在 C 知道 \' 只代表我们想打印的一个普通引号,而不是字符序列的结束。

在这种语境下,你说“反斜杠”或者“转义”(“把那个引号转义一下”)都行,C 开发者都能明白你在说什么。另外,这里的“转义”既不同于你键盘上的 Esc 键,也不同于 ASCII 里的 ESC 码。

21.1.1 常用的转义

依我浅见,这些转义字符占了所有转义的 99.2%139。

代码 说明
\n 换行符——打印时,后续输出转到下一行
\' 单引号——用于单引号字符常量
\" 双引号——用于字符串字面量中的双引号
\\ 反斜杠——用于在字符串或字符中表示字面的 \

下面是一些转义的示例,以及它们被打印时的输出。

printf("Use \\n for newline\n");  // 用 \n 表示换行
printf("Say \"hello\"!\n");       // 说 "hello"!
printf("%c\n", '\'');             // '

21.1.2 少用的转义

不过转义还有更多!只是这些你不常看到。

代码 说明
\a 警报。让终端发出声音或闪烁,或者两者兼有!
\b 退格。把光标往回移一个字符。并不删除该字符。
\f 换页。这会移到下一个“页”,但这个词在现代已没多大意义。在我的系统上,它的行为和 \v 一样。
\r 回车。移到同一行的行首。
\t 水平制表。移到下一个水平制表位。在我的机器上,它对齐到 8 的整数倍列,但你的情况可能不一样。
\v 垂直制表。移到下一个垂直制表位。在我的机器上,它会移到下一行的同一列。
\? 字面的问号。有时你需要用它来避开三字符组,如下所示。

21.1.2.1 单行状态更新

\b 或 \r 的一个用途是显示状态更新:让更新出现在屏幕的同一行上,不至于让显示内容滚动。下面是一个从 10 开始倒计时的例子。(如果你的编译器不支持多线程,可以使用来自 <unistd.h> 的非标准 POSIX 函数 sleep()——如果你用的不是类 Unix 系统,就搜一下你的平台上与 sleep 等价的函数。)

#include <stdio.h>
#include <threads.h>

int main(void)
{
    for (int i = 10; i >= 0; i--) {
        printf("\rT minus %d second%s... \b", i, i != 1? "s": "");

        fflush(stdout);  // 强制刷新输出

        // 休眠 1 秒
        thrd_sleep(&(struct timespec){.tv_sec=1}, NULL);
    }

    printf("\rLiftoff!             \n");
}

第 7 行上发生了不少事。首先,我们用 \r 开头,回到当前行的行首,然后用当前的倒计时数字覆盖掉那里原有的内容。(那儿还有个三目运算符,确保我们打印的是 1 second 而不是 1 seconds。)

另外,... 后面有一个空格。这是为了当 i 从 10 掉到 9、宽度少了一列时,我们能正确覆盖掉最后一个 .。去掉那个空格试试,你就明白我的意思了。

最后我们用一个 \b 收尾,退回到那个空格上,好让光标以一种赏心悦目的方式恰好停在这一行的末尾。

注意第 15 行末尾也有一大串空格,用来覆盖掉倒计时先前留下的字符。

最后,里面还有一句莫名其妙的 fflush(stdout),管它是什么意思呢。简短的回答是:大多数终端默认是行缓冲的,也就是说在遇到换行符之前它们其实什么都不会显示。由于我们这里没有换行(只有 \r),要是没有这一行,程序就会一直干等着,直到 Liftoff! 那一刻才把所有内容一次性打印出来。fflush() 会覆盖这一行为,强制输出立刻发生。

21.1.2.2 问号转义

何必费这个劲呢?毕竟下面这样完全没问题:

printf("Doesn't it?\n");

加上转义符,也照样没问题:

printf("Doesn't it\?\n");   // 注意 \?

那它到底有什么用??!

我们再加一个问号和一个感叹号,把语气加强些:

printf("Doesn't it??!\n");

我编译这段代码时,得到了这样的警告:

foo.c: In function ‘main’:
foo.c:5:23: warning: trigraph ??! converted to | [-Wtrigraphs]
    5 |     printf("Doesn't it??!\n");
      |    

而运行它,会得到这个意想不到的结果:

Doesn't it|

那么三字符组?这到底是什么鬼??!

我确信以后还会再回到这门语言这个积灰的角落,但简单说来就是:编译器会寻找以 ?? 开头的某些三字符组合,并把它们替换成别的字符。所以如果你用的是一台键盘上没有竖线符号(|)的老古董终端,就可以改打 ??!。

把第二个问号转义一下就能解决,像这样:

printf("Doesn't it?\?!\n");

这样它就能编译通过,并按预期工作。

当然,如今已经没人用三字符组了。不过如果你决定在字符串里用它来表示强调,那个 ??! 有时确实会冒出来。

21.1.3 数值转义

此外,还有办法在字符串或字符常量内部指定数值常量或其他字符值。

如果你知道某个字节的八进制或十六进制表示,就可以把它写进字符串或字符常量。

下表中的数字只是示例,实际可以使用任意十六进制或八进制数。必要时用前导零补齐,以达到要求的位数。

代码 说明
\123 嵌入八进制值为 123 的字节,正好 3 位数字。
\x4D 嵌入十六进制值为 4D 的字节,2 位数字。
\u2620 嵌入码点为十六进制值 2620 的 Unicode 字符,4 位数字。
\U0001243F 嵌入码点为十六进制值 1243F 的 Unicode 字符,8 位数字。

下面是一个示例,用不太常见的八进制记法在 A 和 C 之间表示字母 B。通常这种写法是留给某种不可打印的特殊字符的,不过我们在下面还有别的办法做到这点,这里只是演示八进制:

printf("A\102C\n");  // 在 ASCII/UTF-8 中,102 是 `B`

注意用这种方式写八进制数时,前面不加前导零。但它的长度必须是三个字符,所以需要时用前导零补齐。

不过如今更常见的是使用十六进制常量。下面这个示例你不该照着用,它演示的是把 UTF-8 字节 0xE2、0x80 和 0xA2 嵌入字符串,它们对应 Unicode 的“项目符号”字符(•)。

printf("\xE2\x80\xA2 Bullet 1\n");
printf("\xE2\x80\xA2 Bullet 2\n");
printf("\xE2\x80\xA2 Bullet 3\n");

如果你在 UTF-8 控制台上,会产生下面的输出(如果不是,大概就是一堆乱码):

• Bullet 1
• Bullet 2
• Bullet 3

但这样处理 Unicode 实在不怎么样。你可以用转义 \u(16 位)或 \U(32 位)直接按码点编号引用 Unicode 字符。项目符号在 Unicode 中是 2022(十六进制),所以你可以这样写,得到可移植性更好的结果:

printf("\u2022 Bullet 1\n");
printf("\u2022 Bullet 2\n");
printf("\u2022 Bullet 3\n");

记得给 \u 补上足够的前导零凑满四位十六进制数字,给 \U 补够八位。

例如,那个项目符号可以用 \U 加四个前导零来表示:

printf("\U00002022 Bullet 1\n");

可谁有工夫写得那么啰嗦呢?


| 目录 |