You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

17 KiB

11 | 一枝独秀的字符串C++也能处理文本?

你好我是Chrono。

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

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

看一下官方发布的标准文档吧C++14可以参考这份资料 全文有1300多页而语言特性只有400出头不足三分之一其余的篇幅全是在讲标准库可见它的份量有多重。

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

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

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

认识字符串

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

using string = std::basic_string<char>;  // 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<wchar_t>;
using u16string = std::basic_string<char16_t>;
using u32string = std::basic_string<char32_t>;

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

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

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

用好字符串

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

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

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

string str = "abc";

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

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

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

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

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

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

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

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

1.字面量后缀

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

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

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

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

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

2.原始字符串

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

auto str = R"(nier:automata)";    // 原始字符串nier:automata

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

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

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

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

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

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

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

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

auto str5 = R"==(R"(xxx)")==";// 原样输出R"(xxx)"

3.字符串转换函数

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

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

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

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

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

4.字符串视图类

再来说一下string的成本问题。它确实有点“重”大字符串的拷贝、修改代价很高所以我们通常都尽量用const string&但有的时候还是无法避免比如使用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&;

    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/我就重点介绍一下在C++里怎么用。

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

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

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

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

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

下面我举个例子:

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

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

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

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

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

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

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

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

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

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

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

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

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

cout << new_str << endl;          // 输出god of peace

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

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

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

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

小结

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

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

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

课下作业

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

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

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