<audio id="audio" title="11 | 一枝独秀的字符串：C++也能处理文本？" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/53/81/531c2929c0528909b2e01765cef5e881.mp3"></audio>

你好，我是Chrono。

在第一个单元里，我们学习了C++的生命周期和编程范式。在第二个单元里，我们学习了自动类型推导、智能指针、lambda表达式等特性。今天，我们又要开始进入一个新的单元了，这就是C++标准库。

以前，“C++”这个词还只是指编程语言，但是现在，“C++”早已变成了一个更大的概念——不单是词汇、语法，还必须要加上完备工整的标准库。只有语言、标准库“双剑合璧”，才能算是真正的C++。反过来说，如果只单纯用语言，拒绝标准库，那就成了“天残地缺”。

看一下官方发布的标准文档吧（C++14，可以参考[这份资料](http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2013/n3797.pdf)）， 全文有1300多页，而语言特性只有400出头，不足三分之一，其余的篇幅全是在讲标准库，可见它的份量有多重。

而且，按照标准委员会的意思，今后C++也会更侧重于扩充库而不是扩充语言，所以将来标准库的地位还会不断上升。

C++标准库非常庞大，里面有各式各样的精巧工具，可谓是“琳琅满目”。但是，正是因为它的庞大，很多人在学习标准库时会感觉无从下手，找不到学习的“突破口”。

今天我就先来讲和空气、水一样，最常用，也是最容易被忽视的字符串，看看在C++里该怎么处理文本数据。

## 认识字符串

对于C++里的字符串类string，你可能最熟悉不过了，几乎是天天用。但你知道吗？string其实并不是一个“真正的类型”，而是模板类basic_string的特化形式，是一个typedef：

```
using string = std::basic_string&lt;char&gt;;  // string其实是一个类型别名

```

这个特化是什么意思呢？

所谓的字符串，就是字符的序列。字符是人类语言、文字的计算机表示，而人类语言、文字又有很多种，相应的编码方式也有很多种。所以，C++就为字符串设计出了模板类basic_string，再用模板来搭配不同的字符类型，就能够更有“弹性”地处理各种文字了。

说到字符和编码，就不能不提到Unicode，它的目标是用一种编码方式统一处理人类语言文字，使用32位（4个字节）来保证能够容纳过去或者将来所有的文字。

但这就与C++产生了矛盾。因为C++的字符串源自C，而C里的字符都是单字节的char类型，无法支持Unicode。

为了解决这个问题，C++就又新增了几种字符类型。C++98定义了wchar_t，到了C++11，为了适配UTF-16、UTF-32，又多了char16_t、char32_t。于是，basic_string在模板参数里换上这些字符类型之后，就可以适应不同的编码方式了。

```
using wstring = std::basic_string&lt;wchar_t&gt;;
using u16string = std::basic_string&lt;char16_t&gt;;
using u32string = std::basic_string&lt;char32_t&gt;;

```

不过在我看来，虽然C++做了这些努力，但其实收效并不大。因为字符编码和国际化的问题实在是太复杂了，仅有这几个基本的字符串类型根本不够，而C++一直没有提供处理编码的配套工具，我们只能“自己造轮子”，用不好反而会把编码搞得一团糟。

这就导致wstring等新字符串基本上没人用，大多数程序员为了不“自找麻烦”，还是选择最基本的string。万幸的是Unicode还有一个UTF-8编码方式，与单字节的char完全兼容，用string也足以适应大多数的应用场合。

所以，我也建议你只用string，而且在涉及Unicode、编码转换的时候，尽量不要用C++，目前它还不太擅长做这种工作，可能还是改用其他语言来处理更好。接下来，我就讲一讲，该怎么用好String。

## 用好字符串

string在C++标准库里的身份也是比较特殊，虽然批评它的声音有不少，比如接口复杂、成本略高，但不像容器、算法，直到现在，仍然有且只有这么一个字符串类，“只此一家，别无分号”。

所以，在这种“别无选择”的情况下，我们就要多了解它的优缺点，尽量用好它。

首先你要看到，string是一个功能比较齐全的字符串类，可以提取子串、比较大小、检查长度、搜索字符……基本满足一般人对字符串的“想象”。

```
string str = &quot;abc&quot;;

assert(str.length() == 3); 
assert(str &lt; &quot;xyz&quot;);
assert(str.substr(0, 1) == &quot;a&quot;);
assert(str[1] == 'b');
assert(str.find(&quot;1&quot;) == string::npos);
assert(str + &quot;d&quot; == &quot;abcd&quot;);

```

刚才也说了，string的接口比较复杂，除了字符串操作，还有size()、begin()、end()、push_back()等类似容器的操作，这很容易让人产生“联想”，把它当成是一个“字符容器”。

但我不建议你这样做。**字符串和容器完全是两个不同的概念**。

字符串是“文本”，里面的字符之间是强关系，顺序不能随便调换，否则就失去了意义，通常应该视为一个整体来处理。而容器是“集合”，里面的元素之间没有任何关系，可以随意增删改，对容器更多地是操作里面的单个元素。

理解了这一点，**把每个字符串都看作是一个不可变的实体，你才能在C++里真正地用好字符串**。

但有的时候，我们也确实需要存储字符的容器，比如字节序列、数据缓冲区，这该怎么办呢？

这个时候，我建议你**最好改用`vector&lt;char&gt;`**，它的含义十分“纯粹”，只存储字符，没有string那些不必要的成本，用起来也就更灵活一些。

接下来我们再看看string的一些小技巧。

**1.字面量后缀**

C++14为方便使用字符串，新增了一个字面量的**后缀“s”**，明确地表示它是string字符串类型，而不是C字符串，这就可以利用auto来自动类型推导，而且在其他用到字符串的地方，也可以省去声明临时字符串变量的麻烦，效率也会更高：

```
using namespace std::literals::string_literals;  //必须打开名字空间

auto str = &quot;std string&quot;s;      // 后缀s，表示是标准字符串，直接类型推导

assert(&quot;time&quot;s.size() == 4);   // 标准字符串可以直接调用成员函数

```

不过要提醒你的是，**为了避免与用户自定义字面量的冲突，后缀“s”不能直接使用，必须用using打开名字空间才行**，这是它的一个小缺点。

**2.原始字符串**

C++11还为字面量增加了一个“**原始字符串**”（Raw string literal）的新表示形式，比原来的引号多了一个大写字母R和一对圆括号，就像下面这样：

```
auto str = R&quot;(nier:automata)&quot;;    // 原始字符串：nier:automata

```

这种形式初看上去显得有点多余，它有什么好处呢？

你一定知道，C++的字符有“转义”的用法，在字符前面加上一个“\”，就可以写出“\n”“\t”来表示回车、跳格等不可打印字符。

但这个特性也会带来麻烦，有时我们不想转义，只想要字符串的“原始”形式，在C++里写起来就很难受了。特别是在用正则表达式的时候，由于它也有转义，两个转义效果“相乘”，就很容易出错。

比如说，我要在正则里表示“`\$`”，需要写成"`\\\$`"，而在C++里需要对“\”再次转义，就是“`\\\\\\$`”，你能数出来里面到底有多少个“\”吗？

如果使用原始字符串的话，就没有这样的烦恼了，它不会对字符串里的内容做任何转义，完全保持了“原始风貌”，即使里面有再多的特殊字符都不怕：

```
auto str1 = R&quot;(char&quot;&quot;'')&quot;;    // 原样输出：char&quot;&quot;''
auto str2 = R&quot;(\r\n\t\&quot;)&quot;;    // 原样输出：\r\n\t\&quot;
auto str3 = R&quot;(\\\$)&quot;;        // 原样输出：\\\$
auto str4 = &quot;\\\\\\$&quot;;         // 转义后输出：\\\$

```

不过，想要在原始字符串里面写引号+圆括号的形式该怎么办呢？

对于这个问题，C++也准备了应对的办法，就是在圆括号的两边加上最多16个字符的特别“界定符”（delimiter），这样就能够保证不与字符串内容发生冲突：

```
auto str5 = R&quot;==(R&quot;(xxx)&quot;)==&quot;;// 原样输出：R&quot;(xxx)&quot;

```

**3.字符串转换函数**

在处理字符串的时候，我们还会经常遇到与数字互相转换的事情，以前只能用C函数atoi()、atol()，它们的参数是C字符串而不是string，用起来就比较麻烦，于是，C++11就增加了几个新的转换函数：

- stoi()、stol()、stoll()等把字符串转换成整数；
- stof()、stod()等把字符串转换成浮点数；
- to_string()把整数、浮点数转换成字符串。

这几个小函数在处理用户数据、输入输出的时候，非常方便：

```
assert(stoi(&quot;42&quot;) == 42);          // 字符串转整数
assert(stol(&quot;253&quot;) == 253L);       // 字符串转长整数
assert(stod(&quot;2.0&quot;) == 2.0);       // 字符串转浮点数

assert(to_string(1984) == &quot;1984&quot;);       // 整数转字符串

```

**4.字符串视图类**

再来说一下string的成本问题。它确实有点“重”，大字符串的拷贝、修改代价很高，所以我们通常都尽量用const string&amp;，但有的时候还是无法避免（比如使用C字符串、获取子串）。如果你对此很在意，就有必要找一个“轻量级”的替代品。

在C++17里，就有这么一个完美满足所有需求的东西，叫string_view。顾名思义，它是一个字符串的视图，成本很低，内部只保存一个指针和长度，无论是拷贝，还是修改，都非常廉价。

唯一的遗憾是，它只出现在C++17里，不过你也可以参考它的接口，自己在C++11里实现一个简化版本。下面我给你一个简单的示范，你可以课下去扩展：

```
class my_string_view final        // 简单的字符串视图类，示范实现
{
public:
    using this_type = my_string_view;     // 各种内部类型定义
    using string_type = std::string;
    using string_ref_type = const std::string&amp;;

    using char_ptr_type = const char*;
    using size_type = size_t;
private:
    char_ptr_type ptr = nullptr;     // 字符串指针
    size_type len = 0;               // 字符串长度
public:
    my_string_view() = default;
   ~my_string_view() = default;

    my_string_view(string_ref_type str) noexcept
        : ptr(str.data()), len(str.length())
    {}
public:
    char_ptr_type data() const     // 常函数，返回字符串指针
    {
        return ptr;
    }

    size_type size() const        // 常函数，返回字符串长度
    {
        return len;
    }
};

```

## 正则表达式

说了大半天，其实我们还是没有回答这节课开头提出的疑问，也就是“在C++里该怎么处理文本”。string只是解决了文本的表示和存储问题，要对它做大小写转换、判断前缀后缀、模式匹配查找等更复杂的处理，要如何做呢？

使用标准算法显然是不行的，因为算法的工作对象是容器，而刚才我就说了，字符串与容器是两个完全不同的东西，大部分算法都无法直接套用到字符串上，所以文本处理也一直是C++的“软肋”。

好在C++11终于在标准库里加入了正则表达式库regex（虽然有点晚），利用它的强大能力，你就能够任意操作文本、字符串。

很多语言都支持正则表达式，关于它的语法规则我也就不细说了（课下你可以参考下这个链接：[https://www.pcre.org/](https://www.pcre.org/)），我就重点介绍一下在C++里怎么用。

C++正则表达式主要有两个类。

- regex：表示一个正则表达式，是basic_regex的特化形式；
- smatch：表示正则表达式的匹配结果，是match_results的特化形式。

C++正则匹配有三个算法，注意它们都是“只读”的，不会变动原字符串。

- regex_match()：完全匹配一个字符串；
- regex_search()：在字符串里查找一个正则匹配；
- regex_replace()：正则查找再做替换。

所以，你只要用regex定义好一个表达式，然后再调用匹配算法，就可以立刻得到结果，用起来和其他语言差不多。不过，在写正则的时候，记得最好要用“原始字符串”，不然转义符绝对会把你折腾得够呛。

下面我举个例子：

```
auto make_regex = [](const auto&amp; txt)    // 生产正则表达式
{
    return std::regex(txt);
};

auto make_match = []()                  // 生产正则匹配结果
{
    return std::smatch();
};

auto str = &quot;neir:automata&quot;s;          // 待匹配的字符串
auto reg = 
    make_regex(R&quot;(^(\w+)\:(\w+)$)&quot;);  // 原始字符串定义正则表达式
auto what = make_match();             // 准备获取匹配的结果

```

这里我先定义了两个简单的lambda表达式，生产正则对象，主要是为了方便用auto自动类型推导。当然，同时也隐藏了具体的类型信息，将来可以随时变化（这也有点函数式编程的味道了）。

然后我们就可以调用regex_match()检查字符串，函数会返回bool值表示是否完全匹配正则。如果匹配成功，结果存储在what里，可以像容器那样去访问，第0号元素是整个匹配串，其他的是子表达式匹配串：

```
assert(regex_match(str, what, reg));  // 正则匹配

for(const auto&amp; x : what) {          // for遍历匹配的子表达式
    cout &lt;&lt; x &lt;&lt; ',';
}

```

regex_search()、regex_replace()的用法也都差不多，很好理解，直接看代码吧：

```
auto str = &quot;god of war&quot;s;             // 待匹配的字符串

auto reg  = 
  make_regex(R&quot;((\w+)\s(\w+))&quot;);    // 原始字符串定义正则表达式
auto what = make_match();          // 准备获取匹配的结果

auto found = regex_search(          // 正则查找，和匹配类似
                str, what, reg);

assert(found);                        // 断言找到匹配
assert(!what.empty());                // 断言有匹配结果
assert(what[1] == &quot;god&quot;);              // 看第一个子表达式
assert(what[2] == &quot;of&quot;);              // 看第二个子表达式

auto new_str = regex_replace(      // 正则替换，返回新字符串
    str,                           // 原字符串不改动
    make_regex(R&quot;(\w+$)&quot;),         // 就地生成正则表达式对象
    &quot;peace&quot;                        // 需要指定替换的文字
);

cout &lt;&lt; new_str &lt;&lt; endl;          // 输出god of peace

```

这段代码的regex_search()搜索了两个连续的单词，然后在匹配结果里以数组下标的形式输出。

regex_replace()不需要匹配结果，而是要提供一个替换字符串，因为算法是“只读”的，所以它会返回修改后的新字符串。利用这一点，就可以把它的输出作为另一个函数的输入，用“函数套函数”的形式实现“函数式编程”。

在使用regex的时候，还要注意正则表达式的成本。因为正则串只有在运行时才会处理，检查语法、编译成正则对象的代价很高，所以**尽量不要反复创建正则对象，能重用就重用**。在使用循环的时候更要特别注意，一定要把正则提到循环体外。

regex库的功能非常强大，我们没有办法把方方面面的内容都涉及到，刚刚我讲的都是最实用的方法。像大小写敏感、优化匹配引擎、扩展语法、正则迭代/切分等其他高级的功能，建议你课下多努力，参考一下[GitHub](https://github.com/chronolaw/cpp_study)仓库里的资料链接，深入研究它的接口和设置参数。

## 小结

好了，今天我讲了字符串类string和正则表达式库regex，它们是C++标准库里处理文本的唯一工具，虽然离完美还有距离，但我们也别无选择。目前我们能做的，就是充分掌握一些核心技巧，规避一些使用误区。这节课是我的经验总结，建议你多读几遍，希望可以进一步提升你的编码能力。

简单小结一下今天的内容：

1. C++支持多种字符类型，常用的string其实是模板类basic_string的特化形式；
1. 目前C++对Unicode的支持还不太完善，建议尽量避开国际化和编码转化，不要“自讨苦吃”；
1. 应当把string视为一个完整的字符串来操作，不要把它当成容器来使用；
1. 字面量后缀“s”表示字符串类，可以用来自动推导出string类型；
1. 原始字符串不会转义，是字符串的原始形态，适合在代码里写复杂的文本；
1. 处理文本应当使用正则表达式库regex，它的功能非常强大，但需要花一些时间和精力才能掌握。

## 课下作业

最后是课下作业时间，给你留两个思考题：

1. 你平时在使用字符串的时候有感觉到哪些不方便吗？如果有的话，是怎么解决的？
1. 你觉得正则表达式能够应用在什么地方，解决哪些实际的问题？

欢迎你在留言区写下你的思考和答案，如果觉得今天的内容对你有所帮助，也欢迎分享给你的朋友。我们下节课见。

<img src="https://static001.geekbang.org/resource/image/33/58/3301d0231ebb46c0e70d726af3cbc858.jpg" alt="">
