那会儿我们写 C++。
还是 “C with classes” 那味。
项目不大。
但线上一样会出事。
出事的时候。
你不会先怀疑字符串。
你会先怀疑自己的人生选择。
有一次半夜。
线上突然开始找不到配置文件。
日志里打印出来的路径看起来也没毛病。
我们一路查权限。
查部署。
查工作目录。
最后发现。
路径里少了一个反斜杠。
就少一个。
但它能让你熬一整夜。
线上啪一下:一个看不见的反斜杠
复现它用几行就够了。
#include <stdio.h>
int main() {
const char* path = "C:\temp\new.txt";
puts(path);
}
你以为这是 Windows 路径。 看起来也挺像。
但 \t 会被当成一个 tab,\n 会被当成一个换行。
你打印一下就知道。
它会自己断行,或者突然多出空白。
tab 就是你按一下 Tab 键那种“跳格”。 你本来想要一个反斜杠。 结果得到了一坨看不见的空白。
线上找文件的时候,也就跟着断了。 你还以为是部署问题。
当年写正则:像在给反斜杠交税
另一个经典现场,是正则。
const char* pattern = "\\d{4}-\\d{2}-\\d{2}";
你先要按 C++ 的规则读一遍。 然后这串字符再交给正则引擎,按正则的规则再读一遍。
少写一个 \\。
就等着线上匹配不到。
先把词说清楚:字面量、转义、解析
“字面量”听起来像论文。
其实就是你直接写在代码里的那坨。
比如双引号里面那串。
const char* s = "hello";
这里的 "hello" 就是字符串字面量。
顺手再补一个小细节。
字符串字面量末尾会自动带一个 \0。
int n = sizeof("hi");
这里的 n 是 3。
这事你迟早会踩到。
因为它解释了很多“为什么字符串长度看起来怪怪的”。
“转义”也不神秘。
就是用反斜杠告诉编译器:这几个字符别按原样理解。
比如 \n 表示换行,\t 表示 tab。
“解析”就是按规则把它读一遍。
麻烦在于。
有些场景要读两遍。
这就叫“两次解析”
第一遍。
编译器读字符串字面量。
它会把 \n、\t 这种东西提前变成真正的控制字符。
第二遍。
你把结果交给另一个系统。
比如正则引擎。
它再按正则的规则去理解 \d、{4}。
两套规则叠一起。
人就开始犯错。
反斜杠为什么这么凶
在 C/C++ 里,字符串字面量会先被编译器处理。
你写的 \n、\t 不是两个字符。
它们会在编译时变成一个“特殊字符”。
所以你想要一个“真正的反斜杠”,就得写 \\。
这也是为什么同一段字符串,你眼睛看着没问题。 程序跑起来却很离谱。
const char* path = "C:\\Program Files\\App\\data.txt";
这才是你以为的那个路径。 但代价也很明显:你写的是业务,读到的却是一坨转义符。
久了你会下意识开始数反斜杠。
当年没有原始字符串:我们只能硬转义
路径还能忍。
把 \\ 写齐就完事。
麻烦的是那些“本来就一堆符号”的东西。
比如 JSON。
const char* json = "{\\n \\\"name\\\": \\\"Tom\\\"\\n}\\n";
你不是在写 JSON。
你是在写“一个带很多转义的字符串”。
你既要照顾 JSON 的引号。
又要照顾 C++ 字符串的反斜杠。
更痛的其实是混着写。
比如 JSON 里塞一个 Windows 路径。
const char* json = "{\"path\":\"C:\\\\temp\\\\new.txt\"}";
你看一眼就累。
但它确实是当年的日常。
更更痛的是。
你要在 JSON 里再塞一个正则。
const char* json = "{\"re\":\"\\\\d{4}-\\\\d{2}-\\\\d{2}\"}";
这一行里。
有些反斜杠是给 C++ 的。
有些是给 JSON 的。
有些是给正则的。
你不崩溃谁崩溃。
你在逃的是哪一层
很多新手卡住,是因为他以为自己只在写“一种语言”。
但实际你经常在一行里写三种。
外面是 C++ 字符串。
里面是 JSON。
再里面是正则。
你真正想关掉的,是最外层那套 C++ 字符串转义规则。
只要少一层转义,你脑子就能少做一层心算。
里面那两层(JSON、正则)该怎么写还是怎么写。
再比如你只是想让字符串里出现一个双引号。
const char* s = "He said: \"hi\"";
当年大家的应对办法很朴素。
能不写就不写。
实在不行就拆开。
const char* p =
"C:\\Program Files\\App\\"
"data.txt";
相邻的字符串字面量在 C/C++ 里会自动拼起来。
这是老传统。
也算是我们那会儿少数能用的“排版技巧”。
然后 C++11 给了一个补丁。
把最外层这套转义先关掉。
C++11 的原始字符串:你写什么,它就是什么
C++11 给了一个很“工程”的补丁:原始字符串字面量。 它名字听起来挺学术。 但其实很接地气。
它不改变你的业务逻辑,也不需要你引入什么库。 它只是把“编译器自动转义”这一步先关掉。 让你别在字符串里做心算。
const char* path = R"(C:\Program Files\App\data.txt)";
R"(...)" 的意思很朴素:括号里看到什么,字符串里就是什么。
编译器不再把 \\ 当成转义的开头。
正则也立刻变得像人写的。
const char* pattern = R"(\d{4}-\d{2}-\d{2})";
你按正则的规则写。
不用再同时满足 C++ 的转义规则。 字符串终于变成“所见即所得”。
先别误会:原始字符串只关掉最外层
原始字符串解决的是 C++ 这一层。
你不需要再写 \\\\ 去表达一个 \\。
但 JSON 和正则的规则,它不会替你处理。
比如 JSON 里的反斜杠,本来就得写成 \\ 才能表达一个 \。
所以用原始字符串写 JSON,正确的姿势是这样。
const char* json = R"({"path":"C:\\temp\\new.txt"})";
这段字符串在运行时就是一份合法 JSON 文本。
它解析出来的 path,才会是 C:\\temp\\new.txt。
注意这里说的不是 C++ 字面量。
是“值”。
也就是两个字符:反斜杠和字母。
再把正则也塞进去。
const char* json = R"({"re":"\\d{4}-\\d{2}-\\d{2}"})";
你会发现它还是有反斜杠。
但至少它只剩 JSON + 正则这两层了。
不用再额外为 C++ 多写一层。
别的语言早就这么干了
这事 C++ 不是第一个想到的。
很多语言很早就给了“原始字符串”的写法。
Python 有 r"..."。
C# 有 @"..."。
Shell 里单引号也差不多是“所见即所得”。
C++11 这次算是补课。
只是语法长得很 C++。
你可以把它理解成:委员会想要一个不跟旧语法冲突的写法。
所以它看起来有点“拧巴”。
但换来的是兼容性。
为什么偏偏在 C++11 才补上
这不是因为大家突然爱写字符串。
而是 C++11 把 std::regex 也一起标准化了。
正则一进标准库。
大家马上发现:没有原始字符串,正则写起来太痛。
于是这俩东西就很自然地绑在一起。
你可以把它当成一次“工程党胜利”。
不是新理论。
就是把常年折磨人的坑填掉。
原始字符串最舒服的地方:可以直接写多行
以前你要塞一段多行文本。
就得 \n、拼接、缩进对齐。
看着就累。
原始字符串里你可以直接换行。
const char* sql = R"(
select *
from user
where id = 1
)";
你看到的。
就是你想交给下一层系统的。
还有一个经常被新人误解的点。
原始字符串里,\n 不会变成换行。
const char* s = R"(\n)";
它里面就是两个字符:反斜杠和 n。
对比一下普通字符串会发生什么。
const char* a = "\n";
const char* b = R"(\n)";
a 是一个真正的换行字符。
b 才是你眼睛看到的那两个字符。
你想要真正的换行,就直接在 raw string 里回车。
但内容里真有 )" 怎么办
原始字符串默认用 )" 当结尾。
所以你会担心:那我内容里刚好就有这俩字符呢。
C++11 也给了后手。
你可以加一个自定义分隔符。
const char* s = R"tag( here is )" inside )tag";
这个例子看着有点抽象。
我换一个更像现场的。
比如你真的要塞进去一段包含 )" 的文本。
const char* s = R"xx( ... )" ... )xx";
你只要保证结尾是 )xx"。
中间就随便写。
tag 是你自己起的。
结尾必须是 )tag"。
只要内容里不出现这段“完整结尾”,你想放多少引号括号都行。
很多人第一次见这个语法会觉得别扭。
但你一旦开始塞 JSON、SQL、正则。
它就会变得很顺眼。
Unicode 字面量:别让乱码靠运气
再说另一个老坑。
同一份代码,在你电脑上显示正常。
换个机器,日志就开始“火星文”。
当年最烦的其实不是“写中文”。
而是你根本说不清。
这一串字面量到底按什么编码落到可执行文件里。
先别急着说 UTF-8:当年大家默认是系统代码页
你在 Windows 上写过程序,就很容易遇到“默认 ANSI”。
所谓“默认”。
其实就是系统代码页。
你开发机是 GBK。
线上机器可能是别的。
于是同一份二进制。
输出就开始乱。
这里我再把坑掰得更碎一点。
乱码通常不是一个点造成的。
它至少有三层。
源码文件是什么编码。
编译器按什么编码去读它。
程序输出时,终端又按什么编码去显示。
你改对其中一层。
另外两层不配合。
照样乱。
早期的 C++:很多人会先想到 L"..." 和 wchar_t
很久以前 C++ 就有宽字符串。
const wchar_t* ws = L"你好";
这招能解决一部分问题。
但它也带来一个新问题。
wchar_t 在不同平台宽度不一样。
Windows 上通常 2 字节。
很多 Unix/Linux 上通常 4 字节。
你一跨平台。
就得重新谈。
C++11 的 u8 / u / U:把“编码意图”写在前面
所以 C++11 做了一件很实用的事。
跟 C11 基本同一时期。
把 Unicode 相关的字面量前缀补齐。
先看 UTF-8。
const char* s = u8"你好";
这句话的重点不是“你好”。
重点是 u8。
它在说:请按 UTF-8 把它编码成一串字节。
你不再靠编辑器默认值。
也不再靠操作系统默认值。
你是在代码里明说。
顺手再提一句历史。
到了 C++20,u8"..." 的类型改成了 char8_t。
所以你以后可能会在新项目里看到 const char8_t*。
别慌。
它本质还是那件事:把 UTF-8 的意图写在代码里。
还有一个小例子,能帮你把“字节”这个概念钉牢。
int n = sizeof(u8"你") - 1;
这里的 n 通常是 3。
别纠结“为什么不是 1”。
UTF-8 的设计就是这样。
它用可变长度去兼容 ASCII,又能覆盖全世界的字符。
你再对比一下 UTF-16 和 UTF-32。
int n16 = sizeof(u"你") / sizeof(char16_t) - 1;
int n32 = sizeof(U"你") / sizeof(char32_t) - 1;
这俩通常都是 1。
因为这里算的是“编码单元个数”。
不是“字节数”。
也不是“你眼里看到的字符个数”。
再看一个更直观的。
#include <stdio.h>
#include <string.h>
int main() {
printf("%zu\n", strlen(u8"你好"));
}
它会打印 6。
因为 UTF-8 里,一个汉字通常要 3 个字节。
你以为你写的是“两个字”。
机器拿到的是 6 个字节。
UTF-16 / UTF-32:为什么又冒出来两个新类型
如果你要的是 UTF-16 或 UTF-32,也有对应的前缀。
const char16_t* s16 = u"你好";
const char32_t* s32 = U"你好";
这里的 char16_t / char32_t 是 C++11 新加的整型。
你可以把它理解成“装 UTF-16 / UTF-32 编码单元的盒子”。
注意我说的是“编码单元”。
不是“一个元素等于一个你眼里的字符”。
所以别指望拿它下标 s16[i],就能按“汉字个数”去数。
横向看看:别的语言怎么处理 Unicode
很多语言把这事包得更严实。
Java 的 String 内部基本按 UTF-16 的思路来。
你平时不怎么关心编码单元。
但一旦碰到 emoji 或者一些超出 BMP 的字符。
它也一样会让你意识到“一个字符不一定等于一个 16 位单元”。
Python 3 则干脆把“源代码默认 UTF-8”写进了语言习惯里。
你写 "你好" 大多数时候就能跑通。
但 C/C++ 的世界没这么统一。
所以 C++11 选择给你工具。
让你把意图写清楚。
你不是在赌。
你是在声明。
小洞见
Knuth 有句老话。
“程序首先是写给人看的,然后才是给机器执行的。”
原始字符串和 Unicode 字面量,就是这种很朴素的改进。
它不炫技。
但它能让你少熬一次夜。
说白了。
原始字符串是在提醒你:字符串这东西,先过编译器这一关。
Unicode 前缀是在提醒你:字符这东西,最后会落到字节上。
原始字符串和 Unicode 字面量,让你写代码的时候,少一些心算和碰运气。