你好,我是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就增加了几个新的转换函数:
这几个小函数在处理用户数据、输入输出的时候,非常方便:
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++正则表达式主要有两个类。
C++正则匹配有三个算法,注意它们都是“只读”的,不会变动原字符串。
所以,你只要用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++标准库里处理文本的唯一工具,虽然离完美还有距离,但我们也别无选择。目前我们能做的,就是充分掌握一些核心技巧,规避一些使用误区。这节课是我的经验总结,建议你多读几遍,希望可以进一步提升你的编码能力。
简单小结一下今天的内容:
最后是课下作业时间,给你留两个思考题:
欢迎你在留言区写下你的思考和答案,如果觉得今天的内容对你有所帮助,也欢迎分享给你的朋友。我们下节课见。
评论