This commit is contained in:
louzefeng
2024-07-09 18:38:56 +00:00
parent 8bafaef34d
commit bf99793fd0
6071 changed files with 1017944 additions and 0 deletions

View File

@@ -0,0 +1,308 @@
<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="">

View File

@@ -0,0 +1,317 @@
<audio id="audio" title="12 | 三分天下的容器:恰当选择,事半功倍" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f1/3e/f1095473d788bb6d570af4ea65ba873e.mp3"></audio>
你好我是Chrono。
今天我要讲的是标准库里的一块“重地”容器它也是C++泛型编程范式的基础。
不过在正式开讲之前,我先问你个问题:什么是容器?
你也许会说:**容器,就是能够“容纳”“存放”元素的一些数据结构**。
这个回答非常正确,而且说到了“点”上。
还记得计算机先驱的那句经典名言吗?“**算法 + 数据结构 = 程序。**”在C++里,容器就是这个公式里面的“数据结构”。
所以,下面我就着重从数据结构的角度,来谈谈各种容器的区别、优缺点,还有如何选择最合适的容器。
## 认识容器
所谓的数据结构就是数据在计算机里的存储和组织形式比如堆、数组、链表、二叉树、B+树、哈希表,等等。
在计算机的发展历史上,众多“大牛”孜孜不倦地发明创造了这么多的数据结构,为什么呢?
因为没有一种数据结构是万能的、可以应用于任何场景。毕竟,不同的数据结构存储数据的形式不一样,效率也就不一样。有的是连续存放,有的是分散存放,有的存储效率高,有的查找效率高,我们必须要依据具体的应用场合来进行取舍。
我想,你肯定已经学过这些数据结构了,也知道它们的实现原理,自己写也不是什么太难的事情。
但是对于最基本、最经典的那些数据结构你完全没有必要去“自己造轮子”因为C++标准库里的容器就已经把它们给实现了。
容器其实就是C++对数据结构的抽象和封装。而且,因为标准库开发者的功力很深,对编译器的了解程度更是远超你我,所以,容器的性能和优化水平要比我们自己写的好上几十倍,这一点你绝对不用质疑。
我们要做的,就是仔细品鉴标准容器这盘大餐,从中找出最合适自己口味的那道菜。
由于容器相关的资料已经有很多了,无论是看图书还是网站,都可以找到非常详细的接口文档,所以今天,我就不去罗列每个容器的具体操作方法了,而是把重点放在特性介绍上。掌握了这些特性,今后你在面临选择的时候,不用太纠结,就可以选出最适合你的容器。
## 容器的通用特性
你必须要知道所有容器都具有的一个基本特性它保存元素采用的是“值”value语义也就是说**容器里存储的是元素的拷贝、副本,而不是引用**。
从这个基本特性可以得出一个推论,容器操作元素的很大一块成本就是值的拷贝。所以,如果元素比较大,或者非常多,那么操作时的拷贝开销就会很高,性能也就不会太好。
一个解决办法是,**尽量为元素实现转移构造和转移赋值函数**在加入容器的时候使用std::move()来“转移”,减少元素复制的成本:
```
Point p; // 一个拷贝成本很高的对象
v.push_back(p); // 存储对象,拷贝构造,成本很高
v.push_back(std::move(p)); // 定义转移构造后就可以转移存储,降低成本
```
你也可以使用C++11为容器新增加的emplace操作函数它可以“就地”构造元素免去了构造后再拷贝、转移的成本不但高效而且用起来也很方便
```
v.emplace_back(...); // 直接在容器里构造元素,不需要拷贝或者转移
```
当然,你可能还会想到在容器里存放元素的指针,来间接保存元素,但我不建议采用这种方案。
虽然指针的开销很低,但因为它是“间接”持有,就不能利用容器自动销毁元素的特性了,你必须要自己手动管理元素的生命周期,麻烦而且非常容易出错,有内存泄漏的隐患。
如果真的有这种需求可以考虑使用智能指针unique_ptr/shared_ptr让它们帮你自动管理元素。建议你再仔细复习一下[第8讲](https://time.geekbang.org/column/article/239580)的内容,弄清楚这两个智能指针之间的差异,区分“独占语义”和“共享语义”。
一般情况下shared_ptr是一个更好的选择它的共享语义与容器的值语义基本一致。使用unique_ptr就要当心它不能被拷贝只能被转移用起来就比较“微妙”。
## 容器的具体特性
上面讲的是所有容器的“共性”,接下来我们再来看看具体容器的“个性”。
C++里的容器很多,但可以按照不同的标准进行分类,常见的一种分类是依据元素的访问方式,分成**顺序容器、有序容器和无序容器**三大类别,先看一下最容易使用的顺序容器。
### 顺序容器
顺序容器就是数据结构里的线性表一共有5种array、vector、deque、list、forward_list。
按照存储结构这5种容器又可以再细分成两组。
- 连续存储的数组array、vector和deque。
- 指针结构的链表list和forward_list。
**array和vector直接对应C的内置数组内存布局与C完全兼容所以是开销最低、速度最快的容器**
**它们两个的区别在于容量能否动态增长**。array是静态数组大小在初始化的时候就固定了不能再容纳更多的元素。而vector是动态数组虽然初始化的时候设定了大小但可以在后面随需增长容纳任意数量的元素。
```
array&lt;int, 2&gt; arr; // 初始一个array长度是2
assert(arr.size() == 2); // 静态数组的长度总是2
vector&lt;int&gt; v(2); // 初始一个vector长度是2
for(int i = 0; i &lt; 10; i++) {
v.emplace_back(i); // 追加多个元素
}
assert(v.size() == 12); // 长度动态增长到12
```
deque也是一种可以动态增长的数组它和vector的区别是它可以在两端高效地插入删除元素这也是它的名字double-end queue的来历而vector则只能用push_back在末端追加元素。
```
deque&lt;int&gt; d; // 初始化一个deque长度是0
d.emplace_back(9); // 末端添加一个元素
d.emplace_front(1); // 前端添加一个元素
assert(d.size() == 2); // 长度动态增长到2
```
vector和deque里的元素因为是连续存储的所以在中间的插入删除效率就很低而list和forward_list是链表结构插入删除操作只需要调整指针所以在任意位置的操作都很高效。
链表的缺点是查找效率低只能沿着指针顺序访问这方面不如vector随机访问的效率高。list是双向链表可以向前或者向后遍历而forward_list顾名思义是单向链表只能向前遍历查找效率就更低了。
链表结构比起数组结构还有一个缺点,就是存储成本略高,因为必须要为每个元素附加一个或者两个的指针,指向链表的前后节点。
vector/deque和list/forward_list都可以动态增长来容纳更多的元素但它们的内部扩容机制却是不一样的。
当vector的容量到达上限的时候capacity它会再分配一块两倍大小的新内存然后把旧元素拷贝或者移动过去。这个操作的成本是非常大的所以你在使用vector的时候最好能够“预估”容量使用reserve提前分配足够的空间减少动态扩容的拷贝代价。
vector的做法太“激进”而deque、list的的扩容策略就“保守”多了只会按照固定的“步长”例如N个字节、一个节点去增加容量。但在短时间内插入大量数据的时候就会频繁分配内存效果反而不如vector一次分配来得好。
说完了这5个容器的优缺点你该怎么选择呢
我的看法是如果没有什么特殊需求首选的容器就是array和vector它们的速度最快、开销最低数组的形式也令它们最容易使用搭配算法也可以实现快速的排序和查找。
剩下的deque、list和forward_list则适合对插入删除性能比较敏感的场合如果还很在意空间开销那就只能选择非链表的deque了。
<img src="https://static001.geekbang.org/resource/image/6a/24/6ac671f2c8523c09343a34811ad7e324.jpg" alt="">
### 有序容器
顺序容器的特点是,元素的次序是由它插入的次序而决定的,访问元素也就按照最初插入的顺序。而有序容器则不同,它的元素在插入容器后就被按照某种规则自动排序,所以是“有序”的。
C++的有序容器使用的是树结构,通常是红黑树——有着最好查找性能的二叉树。
标准库里一共有四种有序容器set/multiset和map/multimap。set是集合map是关联数组在其他语言里也叫“字典”
有multi前缀的容器表示可以容纳重复的key内部结构与无前缀的相同所以也可以认为只有两种有序容器。
因为有序容器的数量很少,所以使用的关键就是要理解它的“有序”概念,也就是说,**容器是如何判断两个元素的“先后次序”,知道了这一点,才能正确地排序**。
这就导致了有序容器与顺序容器的另一个根本区别,**在定义容器的时候必须要指定key的比较函数**。只不过这个函数通常是默认的less表示小于关系不用特意写出来
```
template&lt;
class T // 模板参数只有一个元素类型
&gt; class vector; // vector
template&lt;
class Key, // 模板参数是key类型即元素类型
class Compare = std::less&lt;Key&gt; // 比较函数
&gt; class set; // 集合
template&lt;
class Key, // 第一个模板参数是key类型
class T, // 第二个模板参数是元素类型
class Compare = std::less&lt;Key&gt; // 比较函数
&gt; class map; // 关联数组
```
C++里的int、string等基本类型都支持比较排序放进有序容器里毫无问题。但很多自定义类型没有默认的比较函数要作为容器的key就有点麻烦。虽然这种情况不多见但有的时候还真是个“刚性需求”。
**解决这个问题有两种办法:一个是重载“&lt;”,另一个是自定义模板参数**
比如说我们有一个Point类它是没有大小概念的但只要给它重载“&lt;”操作符,就可以放进有序容器里了:
```
bool operator&lt;(const Point&amp; a, const Point&amp; b)
{
return a.x &lt; b.x; // 自定义比较运算
}
set&lt;Point&gt; s; // 现在就可以正确地放入有序容器
s.emplace(7);
s.emplace(3);
```
另一种方式是编写专门的函数对象或者lambda表达式然后在容器的模板参数里指定。这种方式更灵活而且可以实现任意的排序准则
```
set&lt;int&gt; s = {7, 3, 9}; // 定义集合并初始化3个元素
for(auto&amp; x : s) { // 范围循环输出元素
cout &lt;&lt; x &lt;&lt; &quot;,&quot;; // 从小到大排序3,7,9
}
auto comp = [](auto a, auto b) // 定义一个lambda用来比较大小
{
return a &gt; b; // 定义大于关系
};
set&lt;int, decltype(comp)&gt; gs(comp) // 使用decltype得到lambda的类型
std::copy(begin(s), end(s), // 拷贝算法,拷贝数据
inserter(gs, gs.end())); // 使用插入迭代器
for(auto&amp; x : gs) { // 范围循环输出元素
cout &lt;&lt; x &lt;&lt; &quot;,&quot;; // 从大到小排序9,7,3
}
```
除了**比较函数**这点,有序容器其实没有什么太多好说的,因为就这两个,选择起来很简单:**集合关系就用set关联数组就用map**。
不过还是要再提醒你一点,因为有序容器在插入的时候会自动排序,所以就有隐含的插入排序成本,当数据量很大的时候,内部的位置查找、树旋转成本可能会比较高。
还有如果你需要实时插入排序那么选择set/map是没问题的。如果是非实时那么最好还是用vector全部数据插入完成后再一次性排序效果肯定会更好。
### 无序容器
有“有序容器”,那自然会有对应的“无序容器”了。这两类容器不仅在字面上,在其他方面也真的是完全对应。
无序容器也有四种名字里也有set和map只是加上了unordered无序前缀分别是unordered_set/unordered_multiset、unordered_map/unordered_multimap。
无序容器同样也是集合和关联数组,用法上与有序容器几乎是一样的,区别在于内部数据结构:**它不是红黑树,而是散列表**也叫哈希表hash table
因为它采用散列表存储数据,元素的位置取决于计算的散列值,没有规律可言,所以就是“无序”的,你也可以把它理解为“乱序”容器。
下面的代码简单示范了无序容器的操作,虽然接口与有序容器一样,但输出元素的顺序是不确定的乱序:
```
using map_type = // 类型别名
unordered_map&lt;int, string&gt;; // 使用无序关联数组
map_type dict; // 定义一个无序关联数组
dict[1] = &quot;one&quot;; // 添加三个元素
dict.emplace(2, &quot;two&quot;);
dict[10] = &quot;ten&quot;;
for(auto&amp; x : dict) { // 遍历输出
cout &lt;&lt; x.first &lt;&lt; &quot;=&gt;&quot; // 顺序不确定
&lt;&lt; x.second &lt;&lt; &quot;,&quot;; // 既不是插入顺序,也不是大小序
}
```
无序容器虽然不要求顺序但是对key的要求反而比有序容器更“苛刻”一些拿unordered_map的声明来看一下
```
template&lt;
class Key, // 第一个模板参数是key类型
class T, // 第二个模板参数是元素类型
class Hash = std::hash&lt;Key&gt;, // 计算散列值的函数对象
class KeyEqual = std::equal_to&lt;Key&gt; // 相等比较函数
&gt; class unordered_map;
```
它要求key具备两个条件一是**可以计算hash值**,二是**能够执行相等比较操作**。第一个是因为散列表的要求只有计算hash值才能放入散列表第二个则是因为hash值可能会冲突所以当hash值相同时就要比较真正的key值。
与有序容器一样要把自定义类型作为key放入无序容器必须要实现这两个函数。
“==”函数比较简单,可以用与“&lt;”函数类似的方式,通过重载操作符来实现:
```
bool operator==(const Point&amp; a, const Point&amp; b)
{
return a.x == b.x; // 自定义相等比较运算
}
```
散列函数就略麻烦一点你可以用函数对象或者lambda表达式实现内部最好调用标准的std::hash函数对象而不要自己直接计算否则很容易造成hash冲突
```
auto hasher = [](const auto&amp; p) // 定义一个lambda表达式
{
return std::hash&lt;int&gt;()(p.x); // 调用标准hash函数对象计算
};
```
有了相等函数和散列函数,自定义类型也就可以放进无序容器了:
```
unordered_set&lt;Point, decltype(hasher)&gt; s(10, hasher);
s.emplace(7);
s.emplace(3);
```
有序容器和无序容器的接口基本一样,这两者该如何选择呢?
其实看数据结构就清楚了,**如果只想要单纯的集合、字典,没有排序需求,就应该用无序容器,没有比较排序的成本,它的速度就会非常快**。
## 小结
好了今天我从数据结构的角度全面介绍了C++标准库里的各种容器,只要你了解这些容器的基本特性,知道内部结构上的优缺点,今后在写程序的时候,也就不会再犯“选择困难症”了。
判断容器是否合适的基本依据是“**不要有多余的操作**”也就是说不要为不需要的功能付出代价。比如只在末尾添加元素就不要用deque/list只想快速查找元素不用排序就应该选unordered_set。
再简单小结一下今天的内容:
1. 标准容器可以分为三大类,即顺序容器、有序容器和无序容器;
1. 所有容器中最优先选择的应该是array和vector它们的速度最快开销最低
1. list是链表结构插入删除的效率高但查找效率低
1. 有序容器是红黑树结构对key自动排序查找效率高但有插入成本
1. 无序容器是散列表结构由hash值计算存储位置查找和插入的成本都很低
1. 有序容器和无序容器都属于关联容器元素有key的概念操作元素实际上是在操作key所以要定义对key的比较函数或者散列函数。
<img src="https://static001.geekbang.org/resource/image/8e/85/8e935b3e8573ab5a6eb417c314cea285.jpg" alt="">
我再教你一个使用这些容器的小技巧,就是**多利用类型别名,而不要“写死”容器定义**。因为容器的大部分接口是相同的,所以只要变动别名定义,就能够随意改换不同的容器,对于开发、测试都非常方便。
## 课下作业
最后是课下作业时间,给你留两个思考题:
1. 试着用自己的语言说一下这些容器的优点、缺点和区别。
1. 你最喜欢、最常用的是哪种容器?为什么?
欢迎在留言区写下你的思考和答案,如果觉得今天的内容对你有所帮助,也欢迎分享给你的朋友。我们下节课见。
<img src="https://static001.geekbang.org/resource/image/18/54/1802953e56e91e6a06e1d601e6f8c854.jpg" alt="">

View File

@@ -0,0 +1,384 @@
<audio id="audio" title="13 | 五花八门的算法不要再手写for循环了" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e7/3d/e7402ecdfb023dbb0738e8f52cf99a3d.mp3"></audio>
你好我是Chrono。
上节课我提到了计算机界的经典公式“算法 + 数据结构 = 程序”公式里的“数据结构”就是C++里的容器,容器我们已经学过了,今天就来学习下公式里的“算法”。
虽然算法是STL标准库前身的三大要件之一容器、算法、迭代器也是C++标准库里一个非常重要的部分,但它却没有像容器那样被大众广泛接受。
从我观察到的情况来看很多人都会在代码里普遍应用vector、set、map但几乎从来不用任何算法聊起算法这个话题也是“一问三不知”这的确是一个比较奇怪的现象。而且很多语言对算法也不太“上心”。
但是在C++里算法的地位非常高甚至有一个专门的“算法库”。早期它是泛型编程的示范和应用而在C++引入lambda表达式后它又成了函数式编程的具体实践所以**学习掌握算法能够很好地训练你的编程思维,帮你开辟出面向对象之外的新天地**。
## 认识算法
从纯理论上来说,算法就是一系列定义明确的操作步骤,并且会在有限次运算后得到结果。
计算机科学里有很多种算法像排序算法、查找算法、遍历算法、加密算法等等。但是在C++里,算法的含义就要狭窄很多了。
C++里的算法,指的是**工作在容器上的一些泛型函数**,会对容器内的元素实施的各种操作。
C++标准库目前提供了上百个算法,真的可以说是“五花八门”,涵盖了绝大部分的“日常工作”。比如:
- remove移除某个特定值
- sort快速排序
- binary_search执行二分查找
- make_heap构造一个堆结构
- ……
不过要是“说白了”算法其实并不神秘因为所有的算法本质上都是for或者while通过循环遍历来逐个处理容器里的元素。
比如说count算法它的功能非常简单就是统计某个元素的出现次数完全可以用range-for来实现同样的功能
```
vector&lt;int&gt; v = {1,3,1,7,5}; // vector容器
auto n1 = std::count( // count算法计算元素的数量
begin(v), end(v), 1 // begin()、end()获取容器的范围
);
int n2 = 0;
for(auto x : v) { // 手写for循环
if (x == 1) { // 判断条件,然后统计
n2++;
}
}
```
你可能会问既然是这样我们直接写for循环不就好了吗为什么还要调用算法来“多此一举”呢
在我看来,这应该是一种“境界”,**追求更高层次上的抽象和封装**,也是函数式编程的基本理念。
每个算法都有一个清晰、准确的命名,不需要额外的注释,让人一眼就可以知道操作的意图,而且,算法抽象和封装了反复出现的操作逻辑,也更有利于重用代码,减少手写的错误。
还有更重要的一点和容器一样算法是由那些“超级程序员”创造的它的内部实现肯定要比你随手写出来的循环更高效而且必然经过了良好的验证测试绝无Bug无论是功能还是性能都是上乘之作。
如果在以前,你不使用算法还有一个勉强可以说的理由,就是很多算法必须要传入一个函数对象,写起来很麻烦。但是现在,因为有可以“**就地定义函数**”的lambda表达式算法的形式就和普通循环非常接近了所以刚刚说的也就不再是什么问题了。
用算法加上lambda表达式你就可以初步体验函数式编程的感觉即函数套函数
```
auto n = std::count_if( // count_if算法计算元素的数量
begin(v), end(v), // begin()、end()获取容器的范围
[](auto x) { // 定义一个lambda表达式
return x &gt; 2; // 判断条件
}
); // 大函数里面套了三个小函数
```
## 认识迭代器
在详细介绍算法之前还有一个必须要了解的概念那就是迭代器iterator它相当于算法的“手脚”。
虽然刚才我说算法操作容器,但实际上它看到的并不是容器,而是指向起始位置和结束位置的迭代器,算法只能通过迭代器去“**间接**”访问容器以及元素,算法的能力是由迭代器决定的。
这种间接的方式有什么好处呢?
这就是泛型编程的理念,与面向对象正好相反,**分离了数据和操作**。算法可以不关心容器的内部结构,以一致的方式去操作元素,适用范围更广,用起来也更灵活。
当然万事无绝对这种方式也有弊端。因为算法是通用的免不了对有的数据结构虽然可行但效率比较低。所以对于merge、sort、unique等一些特别的算法容器就提供了专门的替代成员函数相当于特化这个稍后我会再提一下。
C++里的迭代器也有很多种,比如输入迭代器、输出迭代器、双向迭代器、随机访问迭代器,等等,概念解释起来不太容易。不过,你也没有必要把它们搞得太清楚,因为常用的迭代器用法都是差不多的。你可以把它简单地理解为另一种形式的“智能指针”,只是它**强调的是对数据的访问**,而不是生命周期管理。
容器一般都会提供begin()、end()成员函数调用它们就可以得到表示两个端点的迭代器具体类型最好用auto自动推导不要过分关心
```
vector&lt;int&gt; v = {1,2,3,4,5}; // vector容器
auto iter1 = v.begin(); // 成员函数获取迭代器,自动类型推导
auto iter2 = v.end();
```
不过我建议你使用更加通用的全局函数begin()、end()虽然效果是一样的但写起来比较方便看起来也更清楚另外还有cbegin()、cend()函数,返回的是常量迭代器):
```
auto iter3 = std::begin(v); // 全局函数获取迭代器,自动类型推导
auto iter4 = std::end(v);
```
迭代器和指针类似,也可以前进和后退,但你不能假设它一定支持“`++`”“`--`”操作符,最好也要用函数来操作,常用的有这么几个:
- distance(),计算两个迭代器之间的距离;
- advance()前进或者后退N步
- next()/prev(),计算迭代器前后的某个位置。
你可以参考下面的示例代码快速了解它们的作用:
```
array&lt;int, 5&gt; arr = {0,1,2,3,4}; // array静态数组容器
auto b = begin(arr); // 全局函数获取迭代器,首端
auto e = end(arr); // 全局函数获取迭代器,末端
assert(distance(b, e) == 5); // 迭代器的距离
auto p = next(b); // 获取“下一个”位置
assert(distance(b, p) == 1); // 迭代器的距离
assert(distance(p, b) == -1); // 反向计算迭代器的距离
advance(p, 2); // 迭代器前进两个位置,指向元素'3'
assert(*p == 3);
assert(p == prev(e, 2)); // 是末端迭代器的前两个位置
```
## 最有用的算法
接下来我们就要大量使用各种函数,进入算法的函数式编程领域了。
#### 手写循环的替代品
首先我带你来认识一个最基本的算法for_each它是手写for循环的真正替代品。
for_each在逻辑和形式上与for循环几乎完全相同
```
vector&lt;int&gt; v = {3,5,1,7,10}; // vector容器
for(const auto&amp; x : v) { // range for循环
cout &lt;&lt; x &lt;&lt; &quot;,&quot;;
}
auto print = [](const auto&amp; x) // 定义一个lambda表达式
{
cout &lt;&lt; x &lt;&lt; &quot;,&quot;;
};
for_each(cbegin(v), cend(v), print);// for_each算法
for_each( // for_each算法内部定义lambda表达式
cbegin(v), cend(v), // 获取常量迭代器
[](const auto&amp; x) // 匿名lambda表达式
{
cout &lt;&lt; x &lt;&lt; &quot;,&quot;;
}
);
```
初看上去for_each算法显得有些累赘既要指定容器的范围又要写lambda表达式没有range-for那么简单明了。
对于很简单的for循环来说确实是如此我也不建议你对这么简单的事情用for_each算法。
但更多的时候for循环体里会做很多事情会由if-else、break、continue等语句组成很复杂的逻辑。而单纯的for是“无意义”的你必须去查看注释或者代码才能知道它到底做了什么回想一下曾经被巨大的for循环支配的“恐惧”吧。
for_each算法的价值就体现在这里它把要做的事情分成了两部分也就是两个函数一个**遍历容器元素**,另一个**操纵容器元素**,而且名字的含义更明确,代码也有更好的封装。
我自己是很喜欢用for_each算法的我也建议你尽量多用for_each来替代for因为它能够促使我们更多地以“函数式编程”来思考使用lambda来封装逻辑得到更干净、更安全的代码。
#### 排序算法
for_each是for的等价替代还不能完全体现出算法的优越性。但对于“排序”这个计算机科学里的经典问题你是绝对没有必要自己写for循环的必须坚决地选择标准算法。
在求职面试的时候,你也许手写过不少排序算法吧,像选择排序、插入排序、冒泡排序,等等,但标准库里的算法绝对要比你所能写出的任何实现都要好。
说到排序你脑海里跳出的第一个词可能就是sort(),它是经典的快排算法,通常用它准没错。
```
auto print = [](const auto&amp; x) // lambda表达式输出元素
{
cout &lt;&lt; x &lt;&lt; &quot;,&quot;;
};
std::sort(begin(v), end(v)); // 快速排序
for_each(cbegin(v), cend(v), print); // for_each算法
```
不过排序也有多种不同的应用场景sort()虽然快,但它是不稳定的,而且是全排所有元素。
很多时候这样做的成本比较高比如TopN、中位数、最大最小值等我们只关心一部分数据如果你用sort(),就相当于“杀鸡用牛刀”,是一种浪费。
C++为此准备了多种不同的算法不过它们的名字不全叫sort所以你要认真理解它们的含义。
我来介绍一些常见问题对应的算法:
- 要求排序后仍然保持元素的相对顺序应该用stable_sort它是稳定的
- 选出前几名TopN应该用partial_sort
- 选出前几名但不要求再排出名次BestN应该用nth_element
- 中位数Median、百分位数Percentile还是用nth_element
- 按照某种规则把元素划分成两组用partition
- 第一名和最后一名用minmax_element。
下面的代码使用vector容器示范了这些算法注意它们“函数套函数”的形式
```
// top3
std::partial_sort(
begin(v), next(begin(v), 3), end(v)); // 取前3名
// best3
std::nth_element(
begin(v), next(begin(v), 3), end(v)); // 最好的3个
// Median
auto mid_iter = // 中位数的位置
next(begin(v), v.size()/2);
std::nth_element( begin(v), mid_iter, end(v));// 排序得到中位数
cout &lt;&lt; &quot;median is &quot; &lt;&lt; *mid_iter &lt;&lt; endl;
// partition
auto pos = std::partition( // 找出所有大于9的数
begin(v), end(v),
[](const auto&amp; x) // 定义一个lambda表达式
{
return x &gt; 9;
}
);
for_each(begin(v), pos, print); // 输出分组后的数据
// min/max
auto value = std::minmax_element( //找出第一名和倒数第一
cbegin(v), cend(v)
);
```
在使用这些排序算法时还要注意一点它们对迭代器要求比较高通常都是随机访问迭代器minmax_element除外所以**最好在顺序容器array/vector上调用**。
如果是list容器应该调用成员函数sort()它对链表结构做了特别的优化。有序容器set/map本身就已经排好序了直接对迭代器做运算就可以得到结果。而对无序容器则不要调用排序算法原因你应该不难想到散列表结构的特殊性质导致迭代器不满足要求、元素无法交换位置
#### 查找算法
排序算法的目标是让元素有序,这样就可以快速查找,节约时间。
算法binary_search顾名思义就是在已经排好序的区间里执行二分查找。但糟糕的是它只返回一个bool值告知元素是否存在而更多的时候我们是想定位到那个元素所以binary_search几乎没什么用。
```
vector&lt;int&gt; v = {3,5,1,7,10,99,42}; // vector容器
std::sort(begin(v), end(v)); // 快速排序
auto found = binary_search( // 二分查找,只能确定元素在不在
cbegin(v), cend(v), 7
);
```
想要在已序容器上执行二分查找要用到一个名字比较怪的算法lower_bound它返回第一个“**大于或等于**”值的位置:
```
decltype(cend(v)) pos; // 声明一个迭代器使用decltype
pos = std::lower_bound( // 找到第一个&gt;=7的位置
cbegin(v), cend(v), 7
);
found = (pos != cend(v)) &amp;&amp; (*pos == 7); // 可能找不到,所以必须要判断
assert(found); // 7在容器里
pos = std::lower_bound( // 找到第一个&gt;=9的位置
cbegin(v), cend(v), 9
);
found = (pos != cend(v)) &amp;&amp; (*pos == 9); // 可能找不到,所以必须要判断
assert(!found); // 9不在容器里
```
lower_bound的返回值是一个迭代器所以就要做一点判断工作才能知道是否真的找到了。判断的条件有两个一个是迭代器是否有效另一个是迭代器的值是不是要找的值。
注意lower_bound的查找条件是“**大于等于**”而不是“等于”所以它的真正含义是“大于等于值的第一个位置”。相应的也就有“大于等于值的最后一个位置”算法叫upper_bound返回的是第一个“**大于**”值的元素。
```
pos = std::upper_bound( // 找到第一个&gt;9的位置
cbegin(v), cend(v), 9
);
```
因为这两个算法不是简单的判断相等,作用有点“绕”,不太好掌握,我来给你解释一下。
它俩的返回值构成一个区间,这个区间往前就是所有比被查找值小的元素,往后就是所有比被查找值大的元素,可以写成一个简单的不等式:
```
begin &lt; x &lt;= lower_bound &lt; upper_bound &lt; end
```
比如在刚才的这个例子里对数字9执行lower_bound和upper_bound就会返回[10,10]这样的区间。
对于有序容器set/map就不需要调用这三个算法了它们有等价的成员函数find/lower_bound/upper_bound效果是一样的。
不过你要注意find与binary_search不同它的返回值不是bool而是迭代器可以参考下面的示例代码
```
multiset&lt;int&gt; s = {3,5,1,7,7,7,10,99,42}; // multiset允许重复
auto pos = s.find(7); // 二分查找,返回迭代器
assert(pos != s.end()); // 与end()比较才能知道是否找到
auto lower_pos = s.lower_bound(7); // 获取区间的左端点
auto upper_pos = s.upper_bound(7); // 获取区间的右端点
for_each( // for_each算法
lower_pos, upper_pos, print // 输出7,7,7
);
```
除了binary_search、lower_bound和upper_bound标准库里还有一些查找算法可以用于未排序的容器虽然肯定没有排序后的二分查找速度快但也正因为不需要排序所以适应范围更广。
这些算法以find和search命名不过可能是当时制定标准时的疏忽名称有点混乱其中用于查找区间的find_first_of/find_end或许更应该叫作search_first/search_last。
这几个算法调用形式都是差不多的,用起来也很简单:
```
vector&lt;int&gt; v = {1,9,11,3,5,7}; // vector容器
decltype(v.end()) pos; // 声明一个迭代器使用decltype
pos = std::find( // 查找算法,找到第一个出现的位置
begin(v), end(v), 3
);
assert(pos != end(v)); // 与end()比较才能知道是否找到
pos = std::find_if( // 查找算法用lambda判断条件
begin(v), end(v),
[](auto x) { // 定义一个lambda表达式
return x % 2 == 0; // 判断是否偶数
}
);
assert(pos == end(v)); // 与end()比较才能知道是否找到
array&lt;int, 2&gt; arr = {3,5}; // array容器
pos = std::find_first_of( // 查找一个子区间
begin(v), end(v),
begin(arr), end(arr)
);
assert(pos != end(v)); // 与end()比较才能知道是否找到
```
## 小结
C++里有上百个算法我们不可能也没办法在一节课的时间里全部搞懂所以我就精挑细选了一些我个人认为最有用的for_each、排序和查找算法把它们介绍给你。
在我看来C++里的算法像是一个大宝库非常值得你去发掘。比如类似memcpy的copy/move算法搭配插入迭代器、检查元素的all_of/any_of算法用好了都可以替代很多手写for循环。
你可以课后仔细阅读[标准文档](https://en.cppreference.com/w/cpp/algorithm),对照自己的现有代码,看看哪些能用得上,再试着用算法来改写实现,体会一下算法的简洁和高效。
简单小结一下这次的内容:
1. 算法是专门操作容器的函数是一种“智能for循环”它的最佳搭档是lambda表达式
1. 算法通过迭代器来间接操作容器,使用两个端点指定操作范围,迭代器决定了算法的能力;
1. for_each算法是for的替代品以函数式编程替代了面向过程编程
1. 有多种排序算法最基本的是sort但应该根据实际情况选择其他更合适的算法避免浪费
1. 在已序容器上可以执行二分查找应该使用的算法是lower_bound
1. list/set/map提供了等价的排序、查找函数更适应自己的数据结构
1. find/search是通用的查找算法效率不高但不必排序也能使用。
和上节课一样,我再附送一个小技巧。
因为标准算法的名字实在是太普通、太常见了所以建议你一定要显式写出“std::”名字空间限定,这样看起来更加醒目,也避免了无意的名字冲突。
## 课下作业
最后是课下作业时间,给你留两个思考题:
1. 你觉得for_each算法能完全替代for循环吗
1. 试着自己总结归纳一下,这些排序和查找算法在实际开发中应该如何使用。
欢迎你在留言区写下你的思考和答案,如果觉得今天的内容对你有所帮助,也欢迎分享给你的朋友。我们下节课见。
<img src="https://static001.geekbang.org/resource/image/77/d4/77cbcdf7cf05fe7c6fac877649d627d4.jpg" alt="">

View File

@@ -0,0 +1,301 @@
<audio id="audio" title="14 | 十面埋伏的并发:多线程真的很难吗?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/98/24/98b9d6d400c1486999fb8677b324a224.mp3"></audio>
你好我是Chrono。
今天我们来聊聊“并发”Concurrency、“多线程”multithreading
在20年前大多数人当然也包括我对这两个词还是十分陌生的。那个时候CPU的性能不高要做的事情也比较少没什么并发的需求简单的单进程、单线程就能够解决大多数问题。
但到了现在计算机硬件飞速发展不仅主频上G还有了多核心运算能力大幅度提升只使用单线程很难“喂饱”CPU。而且随着互联网、大数据、音频视频处理等新需求的不断涌现运算量也越来越大。这些软硬件上的变化迫使“并发”“多线程”成为了每个技术人都不得不面对的课题。
通俗地说,“并发”是指在一个时间段里有多个操作在同时进行,与“多线程”并不是一回事。
并发有很多种实现方式,而多线程只是其中最常用的一种手段。不过,因为多线程已经有了很多年的实际应用,也有很多研究成果、应用模式和成熟的软硬件支持,所以,对这两者的区分一般也不太严格,下面我主要来谈多线程。
## 认识线程和多线程
要掌握多线程就要先了解线程thread
线程的概念可以分成好几个层次从CPU、操作系统等不同的角度看它的定义也不同。今天我们单从语言的角度来看线程。
**在C++语言里,线程就是一个能够独立运行的函数**。比如你写一个lambda表达式就可以让它在线程里跑起来
```
auto f = []() // 定义一个lambda表达式
{
cout &lt;&lt; &quot;tid=&quot; &lt;&lt;
this_thread::get_id() &lt;&lt; endl;
};
thread t(f); // 启动一个线程运行函数f
```
任何程序一开始就有一个主线程它从main()开始运行。主线程可以调用接口函数,创建出子线程。子线程会立即脱离主线程的控制流程,单独运行,但共享主线程的数据。程序创建出多个子线程,执行多个不同的函数,也就成了多线程。
多线程的好处你肯定能列出好几条比如任务并行、避免I/O阻塞、充分利用CPU、提高用户界面响应速度等等。
不过,多线程也对程序员的思维、能力提出了极大的挑战。不夸张地说,它带来的麻烦可能要比好处更多。
这个问题相信你也很清楚,随手就能数出几个来,比如同步、死锁、数据竞争、系统调度开销等……每个写过实际多线程应用的人,可能都有“一肚子的苦水”。
其实,多线程编程这件事“说难也不难,说不难也难”。这句话听上去好像有点自相矛盾,但却有一定的道理。为什么这么说呢?
说它不难是因为线程本身的概念是很简单的只要规划好要做的工作不与外部有过多的竞争读写很容易就能避开“坑”充分利用多线程“跑满”CPU。
说它难,则是因为现实的业务往往非常复杂,很难做到完美的解耦。一旦线程之间有共享数据的需求,麻烦就接踵而至,因为要考虑各种情况、用各种手段去同步数据。随着线程数量的增加,复杂程度会以几何量级攀升,一不小心就可能会导致灾难性的后果。
多线程涵盖的知识点太多,许多大师、高手都不敢自称精通,想用一节课把多线程开发说清楚是完全不可能的。
所以今天我们只聚焦C++的标准库,了解下标准库为多线程编程提供了哪些工具,在语言层面怎么改善多线程应用。有了这个基础,你再去看那些专著时,就可以省很多力气,开发时也能少走些弯路。
首先,你要知道一个最基本但也最容易被忽视的常识:**“读而不写”就不会有数据竞争**。
所以在C++多线程编程里读取const变量总是安全的对类调用const成员函数、对容器调用只读算法也总是线程安全的。
知道了这一点,你就应该多实践[第7讲](https://time.geekbang.org/column/article/238486)里的做法多用const关键字尽可能让操作都是只读的为多线程打造一个坚实的基础。
然后,我要说一个多线程开发的原则,也是一句“自相矛盾”的话:
>
最好的并发就是没有并发,最好的多线程就是没有线程。
这又是什么意思呢?
简单来说,就是在大的、宏观的层面上“看得到”并发和线程,而在小的、微观的层面上“看不到”线程,减少死锁、同步等恶性问题的出现几率。
## 多线程开发实践
下面我就来讲讲具体该怎么实践这个原则。在C++里,有四个基本的工具:仅调用一次、线程局部存储、原子变量和线程对象。
### 仅调用一次
程序免不了要初始化数据,这在多线程里却是一个不大不小的麻烦。因为线程并发,如果没有某种同步手段来控制,会导致初始化函数多次运行。
为此C++提供了“仅调用一次”的功能,可以很轻松地解决这个问题。
这个功能用起来很简单,你要先声明一个**once_flag**类型的变量,最好是静态、全局的(线程可见),作为初始化的标志:
```
static std::once_flag flag; // 全局的初始化标志
```
然后调用专门的**call_once()**函数以函数式编程的方式传递这个标志和初始化函数。这样C++就会保证即使多个线程重入call_once(),也只能有一个线程会成功运行初始化。
下面是一个简单的示例使用了lambda表达式来模拟实际的线程函数。你可以把[GitHub仓库](https://github.com/chronolaw/cpp_study/blob/master/section3/thread.cpp)里的代码下到本地,实际编译运行看看效果:
```
auto f = []() // 在线程里运行的lambda表达式
{
std::call_once(flag, // 仅一次调用注意要传flag
[](){ // 匿名lambda初始化函数只会执行一次
cout &lt;&lt; &quot;only once&quot; &lt;&lt; endl;
} // 匿名lambda结束
); // 在线程里运行的lambda表达式结束
};
thread t1(f); // 启动两个线程运行函数f
thread t2(f);
```
call_once()完全消除了初始化时的并发冲突,在它的调用位置根本看不到并发和线程。所以,按照刚才说的基本原则,它是一个很好的多线程工具。
它也可以很轻松地解决多线程领域里令人头疼的“双重检查锁定”问题,你可以自己试一试,用它替代锁定来初始化。
### 线程局部存储
读写全局(或者局部静态)变量是另一个比较常见的数据竞争场景,因为共享数据,多线程操作时就有可能导致状态不一致。
但如果仔细分析的话你会发现有的时候全局变量并不一定是必须共享的可能仅仅是为了方便线程传入传出数据或者是本地cache而不是为了共享所有权。
换句话说,这应该是线程独占所有权,不应该在多线程之间共同拥有,术语叫“**线程局部存储**”thread local storage
这个功能在C++里由关键字**thread_local**实现它是一个和static、extern同级的变量存储说明有thread_local标记的变量在每个线程里都会有一个独立的副本是“线程独占”的所以就不会有竞争读写的问题。
下面是示范thread_local的代码先定义了一个线程独占变量然后用lambda表达式捕获引用再放进多个线程里运行
```
thread_local int n = 0; // 线程局部存储变量
auto f = [&amp;](int x) // 在线程里运行的lambda表达式捕获引用
{
n += x; // 使用线程局部变量,互不影响
cout &lt;&lt; n; // 输出,验证结果
};
thread t1(f, 10); // 启动两个线程运行函数f
thread t2(f, 20);
```
在程序执行后我们可以看到两个线程分别输出了10和20互不干扰。
你可以试着把变量的声明改成static再运行一下。这时因为两个线程共享变量所以n就被连加了两次最后的结果就是30。
```
static int n = 0; // 静态全局变量
... // 代码与刚才的相同
```
和call_once()一样thread_local也很容易使用。但它的应用场合不是那么显而易见的这要求你对线程的共享数据有清楚的认识区分出独占的那部分消除多线程对变量的并发访问。
### 原子变量
那么,对于那些非独占、必须共享的数据,该怎么办呢?
要想保证多线程读写共享数据的一致性,关键是**要解决同步问题**,不能让两个线程同时写,也就是“互斥”。
这在多线程编程里早就有解决方案了就是互斥量Mutex。但它的成本太高所以对于小数据应该采用“**原子化**”这个更好的方案。
所谓原子atomic在多线程领域里的意思就是不可分的。操作要么完成要么未完成不能被任何外部操作打断总是有一个确定的、完整的状态。所以也就不会存在竞争读写的问题不需要使用互斥量来同步成本也就更低。
但不是所有的操作都可以原子化的否则多线程编程就太轻松了。目前C++只能让一些最基本的类型原子化比如atomic_int、atomic_long等等
```
using atomic_bool = std::atomic&lt;bool&gt;; // 原子化的bool
using atomic_int = std::atomic&lt;int&gt;; // 原子化的int
using atomic_long = std::atomic&lt;long&gt;; // 原子化的long
```
这些原子变量都是模板类atomic的特化形式包装了原始的类型具有相同的接口用起来和bool、int几乎一模一样但却是原子化的多线程读写不会出错。
注意,我说了“几乎”这个词。它还是有些不同的,一个重要的区别是,**原子变量禁用了拷贝构造函数,所以在初始化的时候不能用“=”的赋值形式,只能用圆括号或者花括号**
```
atomic_int x {0}; // 初始化,不能用=
atomic_long y {1000L}; // 初始化,只能用圆括号或者花括号
assert(++x == 1); // 自增运算
y += 200; // 加法运算
assert(y &lt; 2000); // 比较运算
```
除了模拟整数运算原子变量还有一些特殊的原子操作比如store、load、fetch_add、fetch_sub、exchange、compare_exchange_weak/compare_exchange_strong最后一组就是著名的CASCompare And Swap操作。
而另一个同样著名的TASTest And Set操作则需要用到一个特殊的原子类型atomic_flag。
它不是简单的bool特化atomic<bool>没有store、load的操作只用来实现TAS保证绝对无锁。</bool>
你能用这些原子变量做些什么呢?
最基本的用法是把原子变量当作线程安全的全局计数器或者标志位这也算是“初心”吧。但它还有一个更重要的应用领域就是实现高效的无锁数据结构lock-free
但我**强烈不建议**你自己尝试去写无锁数据结构因为无锁编程的难度比使用互斥量更高可能会掉到各种难以察觉的“坑”例如ABA最好还是用现成的库。
遗憾的是,标准库在这方面帮不了你,虽然网上可以找到不少开源的无锁数据结构,但经过实际检验的不多,我个人觉得你可以考虑**boost.lock_free**。
### 线程
到现在我说了call_once、thread_local和atomic这三个C++里的工具,它们都不与线程直接相关,但却能够用于多线程编程,尽量消除显式地使用线程。
但是,必须要用线程的时候,我们也不能逃避。
C++标准库里有专门的线程类thread使用它就可以简单地创建线程在名字空间std::this_thread里还有yield()、get_id()、sleep_for()、sleep_until()等几个方便的管理函数。因为它们的用法比较简单,资料也随处可见,我就不再重复了。
下面的代码同时示范了thread和atomic的用法
```
static atomic_flag flag {false}; // 原子化的标志量
static atomic_int n; // 原子化的int
auto f = [&amp;]() // 在线程里运行的lambda表达式捕获引用
{
auto value = flag.test_and_set(); // TAS检查原子标志量
if (value) {
cout &lt;&lt; &quot;flag has been set.&quot; &lt;&lt; endl;
} else {
cout &lt;&lt; &quot;set flag by &quot; &lt;&lt;
this_thread::get_id() &lt;&lt; endl; // 输出线程id
}
n += 100; // 原子变量加法运算
this_thread::sleep_for( // 线程睡眠
n.load() * 10ms); // 使用时间字面量
cout &lt;&lt; n &lt;&lt; endl;
}; // 在线程里运行的lambda表达式结束
thread t1(f); // 启动两个线程运行函数f
thread t2(f);
t1.join(); // 等待线程结束
t2.join();
```
但还是基于那个原则我建议你不要直接使用thread这个“原始”的线程概念最好把它隐藏到底层因为“看不到的线程才是好线程”。
具体的做法是调用函数async(),它的含义是“**异步运行**”一个任务隐含的动作是启动一个线程去执行但不绝对保证立即启动也可以在第一个参数传递std::launch::async要求立即启动线程
大多数thread能做的事情也可以用async()来实现,但不会看到明显的线程:
```
auto task = [](auto x) // 在线程里运行的lambda表达式
{
this_thread::sleep_for( x * 1ms); // 线程睡眠
cout &lt;&lt; &quot;sleep for &quot; &lt;&lt; x &lt;&lt; endl;
return x;
};
auto f = std::async(task, 10); // 启动一个异步任务
f.wait(); // 等待任务完成
assert(f.valid()); // 确实已经完成了任务
cout &lt;&lt; f.get() &lt;&lt; endl; // 获取任务的执行结果
```
其实,这还是函数式编程的思路,在更高的抽象级别上去看待问题,异步并发多个任务,让底层去自动管理线程,要比我们自己手动控制更好(比如内部使用线程池或者其他机制)。
async()会返回一个future变量可以认为是代表了执行结果的“期货”如果任务有返回值就可以用成员函数get()获取。
不过要特别注意get()只能调一次再次获取结果会发生错误抛出异常std::future_error。至于为什么这么设计我也不太清楚没找到官方的解释
另外这里还有一个很隐蔽的“坑”如果你不显式获取async()的返回值即future对象它就会**同步阻塞**直至任务完成由于临时对象的析构函数于是“async”就变成了“sync”。
所以即使我们不关心返回值也总要用auto来配合async(),避免同步阻塞,就像下面的示例代码那样:
```
std::async(task, ...); // 没有显式获取future被同步阻塞
auto f = std::async(task, ...); // 只有上一个任务完成后才能被执行
```
标准库里还有mutex、lock_guard、condition_variable、promise等很多工具不过它们大多数都是广为人知的概念在C++里的具体实现,用法上没太多新意,所以我就不再多介绍了。
## 小结
说了这么长时间,你可能会有些奇怪,这节课的标题里有线程,但我并没有讲太多线程相关的东西,更多的是在讲“不用线程”的思维方式。
所谓“当局者迷”,如果你一头扎进多线程的世界,全力去研究线程、互斥量、锁等细节,就很容易“钻进死胡同”“一条道走到黑”。
很多时候,我们应该跳出具体的编码,换个角度来看问题,也许就能够“柳暗花明又一村”,得到新的、优雅的解决办法。
好了,今天就到这里,做个小结:
1. 多线程是并发最常用的实现方式,好处是任务并行、避免阻塞,坏处是开发难度高,有数据竞争、死锁等很多“坑”;
1. call_once()实现了仅调用一次的功能,避免多线程初始化时的冲突;
1. thread_local实现了线程局部存储让每个线程都独立访问数据互不干扰
1. atomic实现了原子化变量可以用作线程安全的计数器也可以实现无锁数据结构
1. async()启动一个异步任务,相当于开了一个线程,但内部通常会有优化,比直接使用线程更好。
我再告诉你一个好消息C++20正式加入了协程关键字co_wait/co_yield/co_return。它是用户态的线程没有系统级线程那么多的麻烦事使用它就可以写出开销更低、性能更高的并发程序。让我们一起期待吧
## 课下作业
最后是课下作业时间,给你留两个思考题:
1. 你在多线程编程的时候遇到过哪些“坑”,有什么经验教训?
1. 你觉得async()比直接用thread启动线程好在哪里
欢迎在留言区写下你的思考和答案,如果觉得今天的内容对你有所帮助,也欢迎分享给你的朋友。我们下节课见。
<img src="https://static001.geekbang.org/resource/image/3f/d2/3fee7b3958a1780a3441c49b89288dd2.jpg" alt="">