This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,146 @@
<audio id="audio" title="19 | 设计模式C++与设计模式有啥关系?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/61/be/612668ecffcd52db213b88b3ad536dbe.mp3"></audio>
你好我是Chrono。
今天,我们进入最后的“总结”单元,把前面学到的这些知识上升到“理论结合实践”的高度,做个归纳整理。我们先来了解一下设计模式和设计原则,然后再把理论“落地”,综合利用所有知识点,设计并开发出一个实际的服务器应用。
你可能会问了我们这是个C++的课程,为什么还要专门来讲设计模式呢?
我觉得设计模式是一门通用的技术是指导软件开发的“金科玉律”它不仅渗透进了C++语言和库的设计当然也包括其他编程语言而且也是成为高效C++程序员必不可缺的“心法”和“武器”。
掌握了它们理解了语言特性、库和工具后面的设计思想你就可以做到“知其然更知其所以然”然后以好的用法为榜样以坏的用法为警示扬长避短从而更好地运用C++。
所以我把我这些年的实践经验进行了提炼和总结糅合成了两节课帮你快速掌握并且用好设计模式写出高效、易维护的代码。这节课我会先讲一讲学好设计模式的核心方法下节课我们再讲在C++里具体应用了哪些设计模式。
## 为什么要有设计模式?
虽然C++支持多范式编程但面向对象毕竟还是它的根基而且面向对象编程也通用于当前各种主流的编程语言。所以学好、用好面向对象对于学好C++来说,非常有用。
但是,想要得到良好的面向对象设计,并不是一件容易的事情。
因为每个人自身的能力、所在的层次、看问题的角度都不同,仅凭直觉“对现实建模”,很有可能会生成一些大小不均、职责不清、关系混乱的对象,最后搭建出一个虽然可以运行,但却难以理解、难以维护的系统。
所以,设计模式就是为此而生的。
它系统地描述了一些软件开发中的常见问题、应用场景和对应的解决方案,给出了**专家级别的设计思路和指导原则**。
按照设计模式去创建面向对象的系统,就像是由专家来“手把手”教你,不能说绝对是“最优解”,但至少是“次优解”。
而且,在应用设计模式的过程中,你还可以从中亲身体会这些经过实际证明的成功经验,潜移默化地影响你自己思考问题的方式,从长远来看,学习和应用设计模式能够提高你的面向对象设计水平。
## 学习、理解设计模式才能用好面向对象的C++
经典的《设计模式》一书里面介绍了23个模式并依据设计目的把它们分成了三大类创建型模式、结构型模式和行为模式。
这三类模式分别对应了开发面向对象系统的三个关键问题:**如何创建对象、如何组合对象,以及如何处理对象之间的动态通信和职责分配**。解决了这三大问题,软件系统的“架子”也就基本上搭出来了。
<img src="https://static001.geekbang.org/resource/image/75/94/7568cdf68c4922e41188cd274a01c294.jpg" alt="">
23个模式看起来好像不是很多但它们的内涵和外延都是非常丰富的不然也不会有数不清的论文、书刊研究它们了所以我们要从多角度、多方面去评价、审视模式。
那该怎么做才好呢?
你可以看一下《设计模式》的原书,它用了一个很全面的体例来描述模式,包括名称、别名、动机、结构、示例、效果、相关模式,等等。
虽然显得有点琐碎、啰唆,但我们必须要承认,这种严谨、甚至是有些刻板的方式能够全方位、无死角地介绍模式,强迫你从横向、纵向、深层、浅层、抽象、具体等各个角度来研究、思考。只有在这个过程中,你才能真正掌握设计模式的内核。
模式里的结构和实现方式直接表现为代码,可能是最容易学习的部分,但我认为,其实这些反而是最不重要的。
**你更应该去关注它的参与者、设计意图、面对的问题、应用的场合、后续的效果等代码之外的部分,它们通常比实现代码更重要**
因为代码是“死”的只能限定由某几种语言实现而模式发现问题、分析问题、解决问题的思路是“活”的适用性更广泛这种思考“What、Where、When、Why、How”并逐步得出结论的过程才是设计模式专家经验的真正价值。
理解了这些内容我们就可以应用在C++面向对象编程里了。下节课我会具体给你讲一讲在C++里,这些该怎么用。
## 学习、理解设计原则才能用好多范式的C++
可能你在学习设计模式的时候还是有些困惑,设计模式是专家经验的总结不假,但专家们是如何察觉、发现、探索出这些模式的呢?
而且模式真的完全只是“模式”、固定的“套路”,有没有什么更一般的思想来指导我们呢?换句话说,有没有“设计‘设计模式’的模式”呢?
嗯,这个真的有(笑)。
其实,这些更高层次的指导思想你可能也听说过,它们被通称为“设计原则”。
最常用有5个原则也就是常说的“SOLID”。
1. SRP单一职责Single ResponsibilityPrinciple
1. OCP开闭Open Closed Principle
1. LSP里氏替换Liskov Substitution Principle
1. ISP接口隔离Interface-Segregation Principle
1. DIP依赖反转有的时候也叫依赖倒置Dependency Inversion Principle
不过可能是因为我最先接触、研究的是设计模式,所以后来再看到这些原则的时候,“认同感”就没有那么强烈了。
虽然它们都说得很对,但没有像设计模式那样给出完整、准确的论述。所以,我觉得它们有点“飘”,缺乏可操作性,在实践中不好把握使用的方式。
但另一方面,这些原则也确实提炼出了软件设计里最本质、最基本的东西,就好像是欧几里得五公设、牛顿三定律一样,初看上去似乎很浅显直白,但仔细品品,就会发现,可以应用到任何系统里,所以了解它们还是很有必要的。
下面我就来讲讲对设计原则的一些理解和看法再结合C++和设计模式帮你来加深认识进而在C++里实际用好它们。
第一个,**单一职责原则**,简单来说就是“**不要做多余的事**”,更常见的说法就是“**高内聚低耦合**”。在设计类的时候,要尽量缩小“粒度”,功能明确单一,不要设计出“大而全”的类。
使用单一职责原则,经常会得到很多“短小精悍”的对象,这时候,就需要应用设计模式来组合、复用它们了,比如,使用工厂来分类创建对象、使用适配器、装饰、代理来组合对象、使用外观来封装批量的对象。
单一职责原则的一个反例是C++标准库里的字符串类string参见[第11讲](https://time.geekbang.org/column/article/242603)),它集成了字符串和字符容器的双重身份,接口复杂,让人无所适从(所以,我们应该只把它当作字符串,而把字符容器的工作交给`vector&lt;char&gt;`)。
第二个是**开闭原则**,它也许是最“模糊”的设计原则了,通常的表述是“**对扩展开放,对修改关闭**”,但没有说具体该怎么做,跟没说一样。
我觉得,你可以反过来理解这个原则,在设计类的时候问一下自己,这个类封装得是否足够好,是否可以不改变源码就能够增加新功能。如果答案是否定的(要改源码),那就说明违反了开闭原则。
**应用开闭原则的关键是做好封装**,隐藏内部的具体实现细节,然后开放足够的接口,这样外部的客户代码就可以只通过接口去扩展功能,而不必侵入类的内部。
你可以在一些结构型模式和行为模式里找到开闭原则的“影子”:比如桥接模式让接口保持稳定,而另一边的实现任意变化;又比如迭代器模式让集合保持稳定,改变访问集合的方式只需要变动迭代器。
C++语言里的final关键字[第5讲](https://time.geekbang.org/column/article/235301))也是实践开闭原则的“利器”,把它用在类和成员函数上,就可以有效地防止子类的修改。
第三个原则是**里氏替换原则**,意思是**子类必须能够完全替代父类**。
这个原则还是比较好理解的就是说子类不能改变、违反父类定义的行为。像在第5讲里说的正方形、鸟类的例子它们就是违反了里氏替换原则。
不过因为C++支持泛型编程而且我也不建议多用继承所以在C++里你只要了解一下它就好。
第四个是**接口隔离原则**,它和单一职责原则有点像,但侧重点是对外的接口而不是内部的功能,目标是**尽量简化、归并给外界调用的接口**,避免写出大而不当的“面条类”。
大多数结构型模式都可以用来实现接口隔离,比如,使用适配器来转换接口,使用装饰模式来增加接口,使用外观来简化复杂系统的接口。
第五个原则是**依赖反转原则**,个人觉得是一个比较难懂的原则,我的理解是**上层要避免依赖下层的实现细节,下层要反过来依赖上层的抽象定义**,说白了,大概就是“解耦”吧。
模板方法模式可以算是比较明显的依赖反转的例子,父类定义主要的操作步骤,子类必须遵照这些步骤去实现具体的功能。
如果单从“解耦”的角度来理解的话,存在上下级调用关系的设计模式都可以算成是依赖反转,比如抽象工厂、桥接、适配器。
<img src="https://static001.geekbang.org/resource/image/c2/ca/c257c85fc3c5aefbfcdfb8d3ecb4b9ca.jpg" alt="">
除了SOLID这五个之外我觉得还有两个比较有用DRYDont Repeate Yourself和KISSKeep It Simple Stupid
它们的含义都是要让代码尽量保持简单、简洁避免重复的代码这在C++里可以有很多方式去实现比如用宏代替字面值用lambda表达式就地定义函数多使用容器、算法和第三方库。
## 小结
好了,今天就到这里吧,我从比较“宏观”的层面说了设计模式和设计原则。
其实这些就是对我们实际开发经验的高度浓缩和总结。理解掌握了这些经验你就会始终保持着清醒的头脑在写C++代码的过程中有意识地去发现、应用模式,设计出好的结构,对坏的代码进行重构。
小结一下这节课的要点:
1. 面向对象是主流编程范式,使用设计模式可以比较容易地得到良好的面向对象设计;
1. 经典的设计模式有23个分成三大类创建型模式、结构型模式和行为模式
1. 应该从多角度、多方面去研究设计模式,多关注代码之外的部分,学习解决问题的思路;
1. 设计原则是设计模式之上更高层面的指导思想,适用性强,但可操作性弱,需要多在实践中体会;
1. 最常用的五个设计原则是“SOLID”此外还有“DRY”和“KISS”。
不过,我还要特别提醒你,设计模式虽然很好,但它绝不是包治百病的“灵丹妙药”。如果不论什么项目都套上设计模式,就很容易导致过度设计,反而会增加复杂度,僵化系统。
对于我们C++程序员来说更是要清楚地认识到这一点因为在C++里,不仅有面向对象编程,还有泛型编程和函数式编程等其他范式,所以领会它的思想,在恰当的时候改用模板/泛型/lambda来替换“纯”面向对象才是使用设计模式的最佳做法。
## 课下作业
最后是课下作业时间,给你留两个思考题:
1. 你觉得使用设计模式有什么好处?
1. 你是怎么理解SOLID设计原则的哪个对你最有指导意义
欢迎你在留言区写下你的思考和答案,如果觉得今天的内容对你有所帮助,也欢迎分享给你的朋友。我们下节课见。
<img src="https://static001.geekbang.org/resource/image/36/c0/363f39702c4f6788b6b56d96881650c0.png" alt="">

View File

@@ -0,0 +1,167 @@
<audio id="audio" title="20 | 设计模式C++是怎么应用设计模式的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/21/ef/2142881779595245bb7403fc8b31a5ef.mp3"></audio>
你好我是Chrono。
上节课我谈了设计模式和设计原则。今天我就具体说说在C++里该怎么应用单件、工厂、适配器、代理、职责链等这些经典的设计模式用到的有call_once()、make_unique()、async()等C++工具,希望能够给你一些在实际编码时的启发。
(在接下来学的时候,你也可以同时思考一下它们都符合哪些设计原则,把设计模式和设计原则结合起来学习。)
## 创建型模式
首先来看看创建型模式,**它隐藏了类的实例化过程和细节,让对象的创建独立于系统的其他部分**。
创建型模式不多一共有5个我觉得最有用的是**单件和工厂**。
<img src="https://static001.geekbang.org/resource/image/9e/34/9e859a3bba338b1c4eff565d87a63834.jpg" alt="">
单件很简单要点在于控制对象的创建数量只能有一个实例就像是公司的CEO一样有且唯一。
关于它的使用方式、应用场景,存在着一些争议,但我个人觉得,它很好地体现了设计模式的基本思想,足够简单,可以作为范例,用来好好学习模式里的各个要素。
关于单件模式,一个“老生常谈”的话题是“双重检查锁定”,你可能也有所了解,它可以用来避免在多线程环境里多次初始化单件,写起来特别繁琐。
使用[第14讲](https://time.geekbang.org/column/article/245259)里提到的call_once可以很轻松地解决这个问题但如果你想要更省事的话其实在C++里还有一种方法C++ 11之后就是**直接使用函数内部的static静态变量**。C++语言会保证静态变量的初始化是线程安全的,绝对不会有线程冲突。比如:
```
auto&amp; instance() // 生产单件对象的函数
{
static T obj; // 静态变量
return obj; // 返回对象的引用
}
```
说完了单件,再来看工厂模式吧。
工厂模式是我个人的“笼统”说法,指的是抽象工厂、工厂方法这两个模式,因为它们就像是现实世界里的工厂一样,专门用来生产对象。
抽象工厂是一个类而工厂方法是一个函数在纯面向对象范式里两者的区别很大。而C++支持泛型编程不需要特意派生出子类只要接口相同就行所以这两个模式在C++里用起来也就更自由一些,界限比较模糊。
为什么非要用工厂来创建对象呢?这样做的好处在哪里呢?
我觉得你可以用DRYDont Repeate Yourself原则来理解也就是说尽量避免重复的代码简单地认为它就是“**对new的封装**”。
想象一下如果程序里到处都是“硬编码”的new一旦设计发生变动比如说把“new 苹果”改成“new 梨子”你就需要把代码里所有出现new的地方都改一遍不仅麻烦而且很容易遗漏甚至是出错。
如果把new用工厂封装起来就形成了一个“中间层”隔离了客户代码和创建对象两边只能通过工厂交互彼此不知情也就实现了解耦由之前的强联系转变成了弱联系。所以你就可以在工厂模式里拥有对象的“生杀大权”随意控制生产的方式、生产的时机、生产的内容。
在[第8讲](https://time.geekbang.org/column/article/239580)里说到的make_unique()、make_shared()这两个函数就是工厂模式的具体应用它们封装了创建的细节看不见new直接返回智能指针对象而且接口更简洁内部有更多的优化。
```
auto ptr1 = make_unique&lt;int&gt;(42);
auto ptr2 = make_shared&lt;string&gt;(&quot;metroid&quot;);
```
还有之前课程里的用函数抛出异常([第9讲](https://time.geekbang.org/column/article/240292))、创建正则对象([第11讲](https://time.geekbang.org/column/article/242603)、创建Lua虚拟机[第17讲](https://time.geekbang.org/column/article/245905)),其实也都是应用了工厂模式。这些你可以结合课程的具体内容,再回顾一下,我就不多说了。
使用工厂模式的关键,就是**要理解它面对的问题和解决问题的思路**比如说创建专属的对象、创建成套的对象重点是“如何创建对象、创建出什么样的对象”用函数或者类会比单纯用new更灵活。
## 结构型模式
接下来说说结构型模式,它关注的是对象的**静态联系,以灵活、可拆卸、可装配的方式组合出新的对象**。
这里你要注意结构型模式的重要特点:虽然它会有多个参与者,但最后必定得到且使用的是“**一个**”对象,而不是“多个”对象。
结构型模式一共有7个其中我觉得在C++里比较有用、常用的是**适配器、外观和代理**。
<img src="https://static001.geekbang.org/resource/image/0c/38/0c142aa2f536c22008b6e24aa609ce38.jpg" alt="">
**1.适配器模式**
适配器模式的目的是接口转换,不需要修改源码,就能够把一个对象转换成可以在本系统中使用的形式。
打个比方,就像是拿到了一个英式电源插头,无法插到国标插座上,但你不必拿工具去拆开插头改造,只要买个转换头就行。
适配器模式在C++里多出现在有第三方库或者外部接口的时候通常这些接口不会恰好符合我们自己的系统功能很好但不能直接用想改源码很难甚至是不可能的。所以就需要用适配器模式给“适配”一下让外部工具能够“match”我们的系统而两边都不需要变动“皆大欢喜”。
还记得[第12讲](https://time.geekbang.org/column/article/243319)里的容器array吗它就是一个适配器包装了C++的原生数组,转换成了容器的形式,让“裸内存数据”也可以接入标准库的泛型体系。
```
array&lt;int, 5&gt; arr = {0,1,2,3,4};
auto b = begin(arr);
auto e = end(arr);
for_each(b, e, [](int x){...});
```
**2.外观模式**
再来看外观模式,它封装了一组对象,目的是简化这组对象的通信关系,提供一个高层次的易用接口,让外部用户更容易使用,降低系统的复杂度。
外观模式的特点是内部会操作很多对象,然后对外表现成一个对象。使用它的话,你就可以不用“事必躬亲”了,只要发一个指令,后面的杂事就都由它代劳了,就像是一个“大管家”。
不过要注意,外观模式并不绝对控制、屏蔽内部包装的那些对象。如果你觉得外观不好用,完全可以越过它,自己“深入基层”,去实现外观没有提供的功能。
第14讲里提到的函数async()就是外观模式的一个例子它封装了线程的创建、调度等细节用起来很简单但也不排斥你直接使用thread、mutex等底层线程工具。
```
auto f = std::async([](){...});
f.wait();
```
**3.代理模式**
它和适配器有点像,都是包装一个对象,但关键在于它们的目的、意图有差异:不是为了适配插入系统,而是要“控制”对象,不允许外部直接与内部对象通信,所以叫作“代理”。
代理模式的应用非常广泛,如果你想限制、屏蔽、隐藏、增强或者优化一个类,就可以使用代理。这样,客户代码看到的只是代理对象,不知道原始对象(被代理的对象)是什么样,只能用代理对象给出的接口,这样就实现了控制的目的。
代理在C++里的一个典型应用就是智能指针([第8讲](https://time.geekbang.org/column/article/239580)),它接管了原始指针,限制了某些危险操作,并且添加了自动生命周期管理,虽然少了些自由,但获得了更多的安全。
## 行为模式
看完了适配器、外观和代理这三个结构型模式,再来看行为模式,**它描述了对象之间动态的消息传递,也就是对象的“行为”、工作的方式**。
行为模式比较多有11个这是因为面向对象的设计更注重运行时的组合比静态的组合更能增加系统的灵活性和可扩展性。
<img src="https://static001.geekbang.org/resource/image/ba/2e/ba02d02ec2cac0adf60342fa3054312e.jpg" alt="">
因为行为模式都是在运行时才建立联系,所以通常都很复杂,不太好理解对象之间的关系和通信机制。
我觉得比较难用,或者说是要尽量避免使用的模式有解释器和中介者,它们的结构比较难懂,会增加系统的复杂度。而比较容易理解、容易使用的有**职责链、命令和策略**,所以我重点说说它们。
职责链和命令这两个模式经常联合起来使用。职责链把多个对象串成一个“链条”,让链条里的每个对象都有机会去处理请求。而请求通常使用的是命令模式,把相关的数据打包成一个对象,解耦请求的发送方和接收方。
其实你仔细想一下就会发现C++的异常处理机制([第9讲](https://time.geekbang.org/column/article/240292))就是“职责链+命令”的一个实际应用。
在异常处理的过程中异常类exception就是一个命令对象throw抛出异常就是发起了一个请求处理流程。而一系列的try-catch块就构成了处理异常的职责链异常会自下而上地走过函数调用栈——也就是职责链直到在链条中找到一个能够处理的catch块。
策略模式的要点是“策略”这两个字,它封装了不同的算法,可以在运行的时候灵活地互相替换,从而在外部“非侵入”地改变系统的行为内核。
策略模式有点像装饰模式和状态模式,你可不要弄混了。跟它们相比,策略模式的的特点是不会改变类的外部表现和内部状态,只是动态替换一个很小的算法功能模块。
前面讲过的容器和算法用到的比较函数、散列函数还有for_each算法里的lambda表达式它们都可以算是策略模式的具体应用。
另外策略模式也非常适合应用在有if-else/switch-case这样“分支决策”的代码里你可以把每个分支逻辑都封装成类或者lambda表达式再把它们存进容器让容器来帮你查找最合适的处理策略。
## 小结
好了今天说了几个我个人认为比较重要的模式还列出了C++里的具体例子两者互相参照你就能更好地理解设计模式和C++语言。接下来你可以去借鉴这些用法,尝试看看自己以前写的程序,是不是能应用工厂、适配器、代理、策略等模式去重构,让代码更加优雅、灵活。
再小结一下今天的内容:
1. 创建型模式里常用的有单件和工厂,封装了对象的创建过程,隔离了对象的生产和使用;
1. 结构型模式里常用的有适配器、外观和代理,通过对象组合,得到一个新对象,目的是适配、简化或者控制,隔离了客户代码与原对象的接口;
1. 行为模式里常用的有职责链、命令和策略,只有在运行时才会建立联系,封装、隔离了程序里动态变化的那部分。
按照这些模式的使用的难易程度,我又画了一个表格,你可以对比着看一下。
<img src="https://static001.geekbang.org/resource/image/d5/b8/d54086bbbd999eeb3cddc0456b55c8b8.jpg" alt="">
今天虽然说了不少,但除了这些经典的设计模式,还有很多其他的设计模式,比如对象池、空对象、反应器、前摄器、包装外观,等等。
虽然它们也流传比较广,但还不那么“权威”,用得也不是很多,你不需要重点掌握,所以我就不多讲了,课下你可以再去找些资料学习。
## 课下作业
最后是课下作业时间,给你留两个思考题:
1. 你觉得创建型模式有什么好处?
1. 你能说一下适配器、外观和代理这三个模式的相同点和不同点吗?
欢迎在留言区写下你的思考和答案,如果觉得今天的内容对你有所帮助,也欢迎分享给你的朋友。我们下节课见。
<img src="https://static001.geekbang.org/resource/image/41/af/410920dee8bceaadd16e86de520e98af.jpg" alt="">

View File

@@ -0,0 +1,356 @@
<audio id="audio" title="21 | 知识串讲(上):带你开发一个书店应用" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a6/a2/a698f79cf02532c2a29a7276783061a2.mp3"></audio>
你好我是Chrono。
到今天为止课程里的C++知识就全部讲完了。前面我们总共学习了四大模块,我再带你做一个简略的回顾。
在“概论”单元我带你从宏观的层面上重新认识了C++讲了它的四个生命周期和五个编程范式分别介绍了在编码阶段、预处理阶段、编译阶段C++能够做哪些事情接着又重点说了在C++里,运用哪些特性才能更好地实践面向对象编程。
在“语言特性”单元我们一起研究了自动类型推导、常量、智能指针、异常、函数式编程这五个特性。这些特性是“现代”C++区别于“传统”C++的关键,掌握了它们,你就能够写出清晰、易读、安全的代码。
在“标准库”单元我介绍了字符串、容器、算法和并发。它们是C++标准库中最核心的部分也是现代C++和泛型编程的最佳应用和范例。学会了标准库你才能说是真正理解了C++。
在“技能进阶”单元我为你挑选出了一些第三方工具包括序列化、网络通信、脚本语言和性能分析它们很好地补充完善了C++语言和标准库,免去了我们“自己造轮子”的麻烦,让我们把精力集中在实现业务逻辑上。
除了上面的这“十八般武艺”我还谈了谈能够帮你更好地运用C++的设计模式和设计原则,介绍了几个比较重要、常用的模式,希望你在今后的实际开发工作中,能够有意识地写出灵活、可扩展的代码。
这么回顾下来,内容还真是不少啊。
为了让你更好地把这些知识融会贯通接下来我会再用两节课的时间从需求、设计到开发编码、编译运行再加上一些我自己的实用小技巧详细讲解一个C++程序的实际开发过程,把知识点都串联起来。
虽然说是“串讲”,但是你只要学过了前面的内容,就可以跟着我做出这个书店程序。不过,我担心有些知识点你可能忘记了,所以,涉及到具体的知识点时,我会给你标注出是在哪一节,你可以随时回去复习一下。
## 项目设计
那么该用个什么样的例子来串讲C++的这些知识点呢?
说实话找出一个合适的例子真的很难。因为大多数C++实际项目都很大、很底层,还有各种依赖或者内部库,不好直接学习研究。
所以我再三考虑,决定借鉴一下 **C++ Primer** 里的书店例子,修改一下它的需求,然后完全重新开发,作为我们这个课程的综合示例。
先介绍一下咱们这个书店程序。简单来说,就是销售记录管理,从多个渠道把书号、销售册数、销售额都汇总起来,做个统计分析,再把数据定期上报到后台。
**C++ Primer** 里的书店程序是本地运行的为了演示课程里讲到的的C++特性,我把它改成了网络版。不过,拓扑结构并不复杂,我画了张图,你可以看一下。
<img src="https://static001.geekbang.org/resource/image/62/97/62632ba7426af7731902c83724504097.png" alt="">
项目的前期需求就算是定下来了,接着就要开始做设计了,这就要用到设计模式和设计原则的知识了([第19讲](https://time.geekbang.org/column/article/248880)、[第20讲](https://time.geekbang.org/column/article/248883))。
不过这个系统还是比较简单的不需要用什么复杂的分析手段就能够得出设计主要应用的是单一职责原则、接口隔离原则和包装外观模式。这里我也画了一个UML图可以帮助你理解程序的架构。
<img src="https://static001.geekbang.org/resource/image/f0/ef/f08637cf6b49316c230d058cb2a9f5ef.jpg" alt="">
下面我就对照这个UML类图结合开发思路和源码仔细说一下具体的C++开发,完整的源码都放在了[GitHub](https://github.com/chronolaw/cpp_study/tree/master/section5)上,课下可以仔细地看一下。
## 核心头文件
首先要说的是我写C++项目的一个习惯,定义核心头文件:**cpplang.hpp**。它集中了C++标准头和语言相关的定义,被用于其他所有的源文件。
注意,在写它的时候,最好要有文件头注释([第2讲](https://time.geekbang.org/column/article/233689)而且要有“Include guard”[第3讲](https://time.geekbang.org/column/article/233711)),就像下面这样:
```
// Copyright (c) 2020 by Chrono
#ifndef _CPP_LANG_HPP // Include guard
#define _CPP_LANG_HPP // Include guard
#include &lt;cassert&gt; // C++标准头文件
...
#endif //_CPP_LANG_HPP
```
在核心头文件里,我们还可以利用预处理编程,使用宏定义、条件编译来屏蔽操作系统、语言版本的差异,增强程序的兼容性。
比如这里我就检查了C++的版本号然后定义了简化版的“deprecated”和“static_assert”
```
// must be C++11 or later
#if __cplusplus &lt; 201103
# error &quot;C++ is too old&quot;
#endif // __cplusplus &lt; 201103
// [[deprecated]]
#if __cplusplus &gt;= 201402
# define CPP_DEPRECATED [[deprecated]]
#else
# define CPP_DEPRECATED [[gnu::deprecated]]
#endif // __cplusplus &gt;= 201402
// static_assert
#if __cpp_static_assert &gt;= 201411
# define STATIC_ASSERT(x) static_assert(x)
#else
# define STATIC_ASSERT(x) static_assert(x, #x)
#endif
```
## 自旋锁
有了核心头文件之后我们的C++程序就有了一个很好的起点,就可以考虑引入多线程,提高吞吐量,减少阻塞。
在多线程里保护数据一般要用到互斥量Mutex但它的代价太高所以我设计了一个自旋锁它使用了原子变量所以成本低效率高[第14讲](https://time.geekbang.org/column/article/245259))。
自旋锁被封装为一个SpinLock类所以就要遵循一些C++里常用的面向对象的设计准则([第5讲](https://time.geekbang.org/column/article/235301)、[第19讲](https://time.geekbang.org/column/article/248880)比如用final禁止继承、用default/delete显式标记构造/析构函数、成员变量初始化、类型别名,等等,你可以看看代码:
```
class SpinLock final // 自旋锁类
{
public:
using this_type = SpinLock; // 类型别名
using atomic_type = std::atomic_flag;
public:
SpinLock() = default; // 默认构造函数
~SpinLock() = default;
SpinLock(const this_type&amp;) = delete; // 禁止拷贝
SpinLock&amp; operator=(const this_type&amp;) = delete;
private:
atomic_type m_lock {false}; // 成员变量初始化
};
```
在编写成员函数的时候为了尽量高效需要给函数都加上noexcept修饰表示绝不会抛出异常[第9讲](https://time.geekbang.org/column/article/240292)
```
public:
void lock() noexcept // 自旋锁定,绝不抛出异常
{
for(;;) { // 无限循环
if (!m_lock.test_and_set()) { // 原子变量的TAS操作
return; // TAS成功则锁定
}
std::this_thread::yield(); // TAS失败则让出线程
}
}
void unlock() noexcept // 解除自旋锁定,绝不抛出异常
{
m_lock.clear();
}
```
为了保证异常安全在任何时候都不会死锁还需要利用RAII技术再编写一个LockGuard类。它在构造时锁定在析构时解锁这两个函数也应该用noexcept来优化
```
class SpinLockGuard final // 自旋锁RAII类自动解锁
{
public:
using this_type = SpinLockGuard; // 类型别名
using spin_lock_type = SpinLock;
public:
SpinLockGuard(const this_type&amp;) = delete; // 禁止拷贝
SpinLockGuard&amp; operator=(const this_type&amp;) = delete;
public:
SpinLockGuard(spin_lock_type&amp; lock) noexcept
: m_lock(lock)
{
m_lock.lock();
}
~SpinLockGuard() noexcept
{
m_lock.unlock();
}
private:
spin_lock_type&amp; m_lock;
};
```
这样自旋锁就完成了,有了它就可以在多线程应用里保护共享的数据,避免数据竞争。
## 网络通信
自旋锁比较简单,但多线程只是书店程序的基本特性,它的核心关键词是“网络”,所以下面就来看看服务里的“重头”部分:网络通信。
正如我之前说的在现代C++里应当避免直接使用原生Socket来编写网络通信程序[第16讲](https://time.geekbang.org/column/article/245900)。这里我选择ZMQ作为底层通信库它不仅方便易用而且能够保证消息不丢失、完整可靠地送达目的地。
程序里使用ZmqContext类来封装底层接口包装外观它是一个模板类整数模板参数用来指定线程数在编译阶段就固定了ZMQ的多线程处理能力。
对于ZMQ必需的运行环境变量单件我使用了一个小技巧**以静态成员函数来代替静态成员变量**。这样就绕过了C++的语言限制,不必在实现文件“*.cpp”里再写一遍变量定义全部的代码都可以集中在hpp头文件里
```
template&lt;int thread_num = 1&gt; // 使用整数模板参数来指定线程数
class ZmqContext final
{
public:
static // 静态成员函数代替静态成员变量
zmq_context_type&amp; context()
{
static zmq_context_type ctx(thread_num);
return ctx;
}
};
```
然后我们要实现两个静态工厂函数创建收发数据的Socket对象。
这里要注意如果你看zmq.hpp的源码就会发现它的内部实际上是使用了异常来处理错误的。所以这里我们不能在函数后面加上noexcept修饰同时也就意味着在使用ZMQ的时候必须要考虑异常处理。
```
public:
static
zmq_socket_type recv_sock(int hwm = 1000) // 创建接收Socket
{
zmq_socket_type sock(context(), ZMQ_PULL); // 可能抛出异常
sock.setsockopt(ZMQ_RCVHWM, hwm);
return sock;
}
static
zmq_socket_type send_sock(int hwm = 1000) // 创建发送Socket
{
zmq_socket_type sock(context(), ZMQ_PUSH); // 可能抛出异常
sock.setsockopt(ZMQ_SNDHWM, hwm);
return sock;
}
```
现在有了ZmqContext类书店程序的网络基础也就搭建出来了后面就可以用它来收发数据了。
## 配置文件解析
接下来我要说的是解析配置文件的类Config。
大多数程序都会用到配置文件来保存运行时的各种参数常见的格式有INI、XML、JSON等等。但我通常会选择把Lua嵌入C++用Lua脚本写配置文件[第17讲](https://time.geekbang.org/column/article/242603))。
这么做的好处在哪里呢?
Lua是一个完备的编程语言所以写起来就非常自由灵活比如添加任意的注释数字可以写成“m × n”的运算形式。而INI、XML这些配置格式只是纯粹的数据很难做到这样很多时候需要在程序里做一些转换工作。
另外在Lua脚本里我们还能基于Lua环境写一些函数校验数据的有效性或者采集系统信息实现动态配置。
总而言之就是把Lua当作一个“可编程的配置语言”让配置“活起来”。
给你看一下配置文件的代码吧,里面包含了几个简单的值,配置了服务器的地址、时间间隔、缓冲区大小等信息:
```
config = {
-- should be same as client
-- you could change it to ipc
zmq_ipc_addr = &quot;tcp://127.0.0.1:5555&quot;,
-- see http_study's lua code
http_addr = &quot;http://localhost/cpp_study?token=cpp@2020&quot;,
time_interval = 5, -- seconds
max_buf_size = 4 * 1024,
}
```
Config类使用shared_ptr来管理Lua虚拟机[第17讲](https://time.geekbang.org/column/article/245905)),因为封装在类里,所以,你要注意类型别名和成员变量初始化的用法([第5讲](https://time.geekbang.org/column/article/235301)
```
class Config final // 封装读取Lua配置文件
{
public:
using vm_type = std::shared_ptr&lt;lua_State&gt;; // 类型别名
using value_type = luabridge::LuaRef;
public:
Config() noexcept // 构造函数
{
assert(m_vm);
luaL_openlibs(m_vm.get()); // 打开Lua基本库
}
~Config() = default; // 默认析构函数
private:
vm_type m_vm // 类型别名定义Lua虚拟机
{luaL_newstate(), lua_close}; // 成员变量初始化
};
```
加载Lua脚本的时候还要注意一点外部的脚本有可能会写错导致Lua解析失败。但因为这个问题极少出现而且一出现就很严重没有配置就无法走后续的流程所以非常适合用异常来处理[第9讲](https://time.geekbang.org/column/article/240292))。
load()函数不会改变虚拟机成员变量所以应该用const修饰是一个常函数
```
public:
void load(string_view_type filename) const // 解析配置文件
{
auto status = luaL_dofile(m_vm.get(), filename.c_str());
if (status != 0) { // 出错就抛出异常
throw std::runtime_error(&quot;failed to parse config&quot;);
}
}
```
为了访问Lua配置文件里的值我决定采用“key1.key2”这样简单的两级形式有点像INI的小节这也正好对应Lua里的表结构。
想要解析出字符串里的前后两个key可以使用正则表达式[第11讲](https://time.geekbang.org/column/article/242603)然后再去查询Lua表。
因为构造正则表达式的成本很高,所以我把正则对象都定义为成员变量,而不是函数里的局部变量。
正则的匹配结果m_what是“临时”的不会影响常量性所以要给它加上mutable修饰。
```
private:
const regex_type m_reg {R&quot;(^(\w+)\.(\w+)$)&quot;};
mutable match_type m_what; // 注意是mutable
```
在C++正则库的帮助下处理字符串就太轻松了拿到两个key再调用LuaBridge就可以获得Lua脚本里的配置项。
不过为了进一步简化客户代码我把get()函数改成了模板函数显式转换成int、string等C++标准类型,可读性、可维护性会更好。
```
public:
template&lt;typename T&gt; // 转换配置值的类型
T get(string_view_type key) const // const常函数
{
if (!std::regex_match(key, m_what, m_reg)) { // 正则匹配
throw std::runtime_error(&quot;config key error&quot;);// 格式错误抛异常
}
auto w1 = m_what[1].str(); // 取出两个key
auto w2 = m_what[2].str();
auto v = getGlobal( // 获取Lua表
m_vm.get(), w1.c_str());
return LuaRef_cast&lt;T&gt;(v[w2]); // 取表里的值,再做类型转换
}
```
到这里呢Config类也就完成了可以轻松解析Lua格式的配置文件。
## 小结
今天我用一个书店程序作为例子把前面的知识点都串联起来应用到了这个“半真实”的项目里完成了UML类图里的外围部分。你也可以把刚才说的核心头文件、自旋锁、Lua配置文件这些用法放到自己的实际项目里去试试。
简单小结一下今天的内容:
1. 在项目起始阶段,应该认真做需求分析,然后应用设计模式和设计原则,得出灵活、可扩展的面向对象系统;
1. C++项目里最好要有一个核心头文件cpplang.hpp集中定义所有标准头和语言特性规范源文件里的C++使用方式;
1. 使用原子变量atomic可以实现自旋锁比互斥量的成本要低更高效
1. 使用ZMQ可以简化网络通信但要注意它使用了异常来处理错误
1. 使用Lua脚本作为配置文件的好处很多是“可编程的配置文件”
1. 在编写代码时要理解、用好C++特性恰当地使用final、default、const等关键字让代码更安全、更可读有利于将来的维护。
今天我们分析了需求设计出了架构开发了一些工具类但还没有涉及业务逻辑代码下节课我会带你看看容器、算法、线程还有lambda表达式的实践应用看看它们是怎么服务于具体业务的。
## 课下作业
最后是课下作业时间,给你留一个思考题:你能说出,程序里是怎么应用设计模式和设计原则的吗?
欢迎你在留言区写下你的思考和答案,如果觉得今天的内容对你有所帮助,也欢迎分享给你的朋友。我们下节课见。
<img src="https://static001.geekbang.org/resource/image/9b/e9/9b2d2c8285643a9202d822639fffe8e9.png" alt="">

View File

@@ -0,0 +1,372 @@
<audio id="audio" title="22 | 知识串讲(下):带你开发一个书店应用" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/11/bf/117dbc537138f7d5125c1fb10622c2bf.mp3"></audio>
你好我是Chrono。
在上节课里我给出了一个书店程序的例子讲了项目设计、类图和自旋锁、Lua配置文件解析等工具类搭建出了应用的底层基础。
今天,我接着讲剩下的主要业务逻辑部分,也就是数据的表示与统计,还有数据的接收和发送主循环,最终开发出完整的应用程序。
这里我再贴一下项目的UML图希望给你提个醒。借助图形我们往往能够更好地把握程序的总体结构。
图中间标注为绿色的两个类SalesData、Summary和两个lambda表达式recv_cycle、log_cycle是今天这节课的主要内容实现了书店程序的核心业务逻辑所以需要你重点关注它。
<img src="https://static001.geekbang.org/resource/image/f0/ef/f08637cf6b49316c230d058cb2a9f5ef.jpg" alt="">
## 数据定义
首先我们来看一下怎么表示书本的销售记录。这里用的是SalesData类它是书店程序数据统计的基础。
如果是实际的项目SalesData会很复杂因为一本书的相关信息有很多。但是我们的这个例子只是演示所以就简化了一些基本的成员只有三个ID号、销售册数和销售金额。
上节课在讲自旋锁、配置文件等类时我只是重点说了说代码内部逻辑没有完整地细说到底该怎么应用前面讲过的那些C++编码准则。
那么这次在定义SalesData类的时候我就集中归纳一下。这些都是我写C++代码时的“惯用法”,你也可以在自己的代码里应用它们,让代码更可读可维护:
- 适当使用空行分隔代码里的逻辑段落;
- 类名使用CamelCase函数和变量用snake_case成员变量加“m_”前缀
- 在编译阶段使用静态断言,保证整数、浮点数的精度;
- 使用final终结类继承体系不允许别人产生子类
- 使用default显示定义拷贝构造、拷贝赋值、转移构造、转移赋值等重要函数
- 使用委托构造来编写多个不同形式的构造函数;
- 成员变量在声明时直接初始化;
- using定义类型别名
- 使用const来修饰常函数
- 使用noexcept标记不抛出异常优化函数。
列的点比较多,你可以对照着源码来进行理解:
```
class SalesData final // final禁止继承
{
public:
using this_type = SalesData; // 自己的类型别名
public:
using string_type = std::string; // 外部的类型别名
using string_view_type = const std::string&amp;;
using uint_type = unsigned int;
using currency_type = double;
STATIC_ASSERT(sizeof(uint_type) &gt;= 4); // 静态断言
STATIC_ASSERT(sizeof(currency_type) &gt;= 4);
public:
SalesData(string_view_type id, uint_type s, currency_type r) noexcept // 构造函数,保证不抛出异常
: m_id(id), m_sold(s), m_revenue(r)
{}
SalesData(string_view_type id) noexcept // 委托构造
: SalesData(id, 0, 0)
{}
public:
SalesData() = default; // 显式default
~SalesData() = default;
SalesData(const this_type&amp;) = default;
SalesData&amp; operator=(const this_type&amp;) = default;
SalesData(this_type&amp;&amp; s) = default; // 显式转移构造
SalesData&amp; operator=(this_type&amp;&amp; s) = default;
private:
string_type m_id = &quot;&quot;; // 成员变量初始化
uint_type m_sold = 0;
uint_type m_revenue = 0;
public:
void inc_sold(uint_type s) noexcept // 不抛出异常
{
m_sold += s;
}
public:
string_view_type id() const noexcept // 常函数,不抛出异常
{
return m_id;
}
uint_type sold() const noexcept // 常函数,不抛出异常
{
return m_sold;
}
};
```
需要注意的是,代码里显式声明了转移构造和转移赋值函数,这样,在放入容器的时候就避免了拷贝,能提高运行效率。
## 序列化
SalesData作为销售记录需要在网络上传输所以就需要序列化和反序列化。
这里我选择的是MessagePack[第15讲](https://time.geekbang.org/column/article/245880)),我看重的是它小巧轻便的特性,而且用起来也很容易,只要在类定义里添加一个宏,就可以实现序列化:
```
public:
MSGPACK_DEFINE(m_id, m_sold, m_revenue); // 实现MessagePack序列化功能
```
为了方便使用还可以为SalesData增加一个专门序列化的成员函数pack()
```
public:
msgpack::sbuffer pack() const // 成员函数序列化
{
msgpack::sbuffer sbuf;
msgpack::pack(sbuf, *this);
return sbuf;
}
```
不过你要注意写这个函数的同时也给SalesData类增加了点复杂度在一定程度上违反了单一职责原则和接口隔离原则。
如果你在今后的实际项目中遇到类似的问题,就要权衡后再做决策,确认引入新功能带来的好处大于它增加的复杂度,尽量抵制扩充接口的诱惑,否则很容易写出“巨无霸”类。
## 数据存储与统计
有了销售记录之后我们就可以定义用于数据存储和统计的Summary类了。
Summary类依然要遵循刚才的那些基本准则。从UML类图里可以看到它关联了好几个类所以类型别名对于它来说就特别重要不仅可以简化代码也方便后续的维护你可要仔细看一下源码
```
class Summary final // final禁止继承
{
public:
using this_type = Summary; // 自己的类型别名
public:
using sales_type = SalesData; // 外部的类型别名
using lock_type = SpinLock;
using lock_guard_type = SpinLockGuard;
using string_type = std::string;
using map_type = // 容器类型定义
std::map&lt;string_type, sales_type&gt;;
using minmax_sales_type =
std::pair&lt;string_type, string_type&gt;;
public:
Summary() = default; // 显式default
~Summary() = default;
Summary(const this_type&amp;) = delete; // 显式delete
Summary&amp; operator=(const this_type&amp;) = delete;
private:
mutable lock_type m_lock; // 自旋锁
map_type m_sales; // 存储销售记录
};
```
Summary类的职责是存储大量的销售记录所以需要选择恰当的容器。
考虑到销售记录不仅要存储还有对数据的排序要求所以我选择了可以在插入时自动排序的有序容器map。
不过要注意,这里我没有定制比较函数,所以默认是按照书号来排序的,不符合按销售量排序的要求。
如果要按销售量排序的话就比较麻烦因为不能用随时变化的销量作为Key而标准库里又没有多索引容器所以你可以试着把它改成unordered_map然后再用vector暂存来排序
为了能够在多线程里正确访问Summary使用自旋锁来保护核心数据在对容器进行任何操作前都要获取锁。锁不影响类的状态所以要用mutable修饰。
因为有了RAII的SpinLockGuard第21讲所以自旋锁用起来很优雅直接构造一个变量就行不用担心异常安全的问题。你可以看一下成员函数add_sales()的代码,里面还用到了容器的查找算法。
```
public:
void add_sales(const sales_type&amp; s) // 非const
{
lock_guard_type guard(m_lock); // 自动锁定,自动解锁
const auto&amp; id = s.id(); // const auto自动类型推导
if (m_sales.find(id) == m_sales.end()) {// 查找算法
m_sales[id] = s; // 没找到就添加元素
return;
}
m_sales[id].inc_sold(s.sold()); // 找到就修改销售量
m_sales[id].inc_revenue(s.revenue());
}
```
Summary类里还有一个特别的统计功能计算所有图书销量的第一名和最后一名。这用到了minmax_element算法[第13讲](https://time.geekbang.org/column/article/243357)。又因为比较规则是销量而不是ID号所以还要用lambda表达式自定义比较函数
```
public:
minmax_sales_type minmax_sales() const //常函数
{
lock_guard_type guard(m_lock); // 自动锁定,自动解锁
if (m_sales.empty()) { // 容器空则不处理
return minmax_sales_type();
}
auto ret = std::minmax_element( // 求最大最小值
std::begin(m_sales), std::end(m_sales),// 全局函数获取迭代器
[](const auto&amp; a, const auto&amp; b) // 匿名lambda表达式
{
return a.second.sold() &lt; b.second.sold();
});
auto min_pos = ret.first; // 返回的是两个迭代器位置
auto max_pos = ret.second;
return {min_pos-&gt;second.id(), max_pos-&gt;second.id()};
}
```
## 服务端主线程
好了,所有的功能类都开发完了,现在就可以把它们都组合起来了。
因为客户端程序比较简单只是序列化再用ZMQ发送所以我就不讲了你可以课下去看[GitHub](https://github.com/chronolaw/cpp_study/blob/master/section5/client.cpp)上的源码,今天我主要讲服务器端。
在main()函数开头首先要加载配置文件然后是数据存储类Summary再定义一个用来计数的原子变量count[第14讲](https://time.geekbang.org/column/article/245259)),这些就是程序运行的全部环境数据:
```
Config conf; // 封装读取Lua配置文件
conf.load(&quot;./conf.lua&quot;); // 解析配置文件
Summary sum; // 数据存储和统计
std::atomic_int count {0}; // 计数用的原子变量
```
接下来的服务器主循环我使用了lambda表达式引用捕获上面的那些变量
```
auto recv_cycle = [&amp;]() // 主循环lambda表达式
{
...
};
```
主要的业务逻辑其实很简单就是ZMQ接收数据然后MessagePack反序列化存储数据。
不过为了避免阻塞、充分利用多线程,我在收到数据后,就把它包装进智能指针,再扔到另外一个线程里去处理了。这样主循环就只接收数据,不会因为反序列化、插入、排序等大计算量的工作而阻塞。
我在代码里加上了详细的注释,你一定要仔细看、认真理解:
```
auto recv_cycle = [&amp;]() // 主循环lambda表达式
{
using zmq_ctx = ZmqContext&lt;1&gt;; // ZMQ的类型别名
auto sock = zmq_ctx::recv_sock(); // 自动类型推导获得接收Socket
sock.bind( // 绑定ZMQ接收端口
conf.get&lt;string&gt;(&quot;config.zmq_ipc_addr&quot;)); // 读取Lua配置文件
for(;;) { // 服务器无限循环
auto msg_ptr = // 自动类型推导获得智能指针
std::make_shared&lt;zmq_message_type&gt;();
sock.recv(msg_ptr.get()); // ZMQ阻塞接收数据
++count; // 增加原子计数
std::thread( // 再启动一个线程反序列化存储没有用async
[&amp;sum, msg_ptr]() // 显式捕获,注意!!
{
SalesData book;
auto obj = msgpack::unpack( // 反序列化
msg_ptr-&gt;data&lt;char&gt;(), msg_ptr-&gt;size()).get();
obj.convert(book);
sum.add_sales(book); // 存储数据
}).detach(); // 分离线程,异步运行
} // for(;;)结束
}; // recv_cycle lambda
```
你要特别注意lambda表达式与智能指针的配合方式要用值捕获而不能是引用捕获否则在线程运行的时候智能指针可能会因为离开作用域而被销毁引用失效导致无法预知的错误。
有了这个lambda现在就可以用async[第14讲](https://time.geekbang.org/column/article/245259))来启动服务循环:
```
auto fu1 = std::async(std::launch::async, recv_cycle);
fu1.wait();
```
现在我们就能够接收客户端发过来的数据,开始统计了。
## 数据外发线程
recv_cycle是接收前端发来的数据我们还需要一个线程把统计数据外发出去。同样我实现一个lambda表达式log_cycle。
它采用了HTTP协议把数据打包成JSON发送到后台的某个RESTful服务器。
搭建符合要求的Web服务不是件小事所以这里为了方便测试我联动了一下《透视HTTP协议》用那里的OpenResty写了个的HTTP接口接收POST数据然后打印到日志里你可以参考[第41讲](https://time.geekbang.org/column/article/146833)在Linux上搭建这个后台服务。
log_cycle其实就是一个简单的HTTP客户端所以代码的处理逻辑比较好理解要注意的知识点主要有三个都是前面讲过的
- 读取Lua配置中的HTTP服务器地址和周期运行时间[第17讲](https://time.geekbang.org/column/article/245905)
- JSON序列化数据[第15讲](https://time.geekbang.org/column/article/245880)
- HTTP客户端发送请求[第16讲](https://time.geekbang.org/column/article/245900))。
你如果有点忘了,可以回顾一下,再结合下面的代码来理解、学习:
```
auto log_cycle = [&amp;]() // 外发循环lambda表达式
{
// 获取Lua配置文件里的配置项
auto http_addr = conf.get&lt;string&gt;(&quot;config.http_addr&quot;);
auto time_interval = conf.get&lt;int&gt;(&quot;config.time_interval&quot;);
for(;;) { // 无限循环
std::this_thread::sleep_for(time_interval * 1s); // 线程睡眠等待
json_t j; // JSON序列化数据
j[&quot;count&quot;] = static_cast&lt;int&gt;(count);
j[&quot;minmax&quot;] = sum.minmax_sales();
auto res = cpr::Post( // 发送HTTP POST请求
cpr::Url{http_addr},
cpr::Body{j.dump()},
cpr::Timeout{200ms} // 设置超时时间
);
if (res.status_code != 200) { // 检查返回的状态码
cerr &lt;&lt; &quot;http post failed&quot; &lt;&lt; endl;
}
} // for(;;)
}; // log_cycle lambda
```
然后还是要在主线程里用async()函数来启动这个lambda表达式让它在后台定时上报数据。
```
auto fu2 = std::async(std::launch::async, log_cycle);
```
这样,整个书店程序就全部完成了,试着去编译运行一下看看吧。
## 小结
好了今天我就把书店示例程序从头到尾给讲完了。可以看到代码里面应用了很多我们之前讲的C++特性,这些特性互相重叠、嵌套,紧凑地集成在了这个不是很大的程序里,代码整齐,逻辑清楚,很容易就实现了多线程、高性能的服务端程序,开发效率和运行效率都非常高。
我再对今天代码里的要点做个简单的小结:
1. 编写类的时候要用好final、default、using、const等关键字从代码细节着手提高效率和安全性
1. 对于中小型项目序列化格式可以选择小巧高效的MessagePack
1. 在存储数据时应当选择恰当的容器有序容器在插入元素时会自动排序但注意排序的依据只能是Key
1. 在使用lambda表达式的时候要特别注意捕获变量的生命周期如果是在线程里异步执行应当尽量用智能指针的值捕获虽然有点麻烦但比较安全。
那么这些代码是否对你的工作有一些启迪呢你是否能够把这些知识点成功地应用到实际项目里呢希望你能多学习我在课程里给你分享的开发技巧和经验建议熟练地掌握它们写出媲美甚至超越示例代码的C++程序。
## 课下作业
最后是课下作业时间,这次就不是思考题,全是动手题,是时候检验你的编码实战能力了:
1. 添加try-catch处理可能发生的异常[第9讲](https://time.geekbang.org/column/article/240292)
1. 写一个动态库用Lua/Python调用C++发送请求,以脚本的方式简化客户端测试([第17讲](https://time.geekbang.org/column/article/245905)
1. 把前端与服务器的数据交换格式改成JSON或者ProtoBuf[第15讲](https://time.geekbang.org/column/article/245880)),然后用工厂类封装序列化和反序列化功能,隔离接口([第19讲](https://time.geekbang.org/column/article/248880)、[第20讲](https://time.geekbang.org/column/article/248883))。
再补充一点在动手实践的过程中你还可以顺便练习一下Git的版本管理不要直接在master分支上开发而是开几个不同的feature分支测试完确认没有问题后再合并到主干上。
欢迎你在留言区写下你的思考和答案,如果觉得今天的内容对你有所帮助,也欢迎分享给你的朋友。我们下节课见。
<img src="https://static001.geekbang.org/resource/image/d1/0a/d11b4c0f976109451d21bde86fdf6b0a.jpg" alt="">

View File

@@ -0,0 +1,10 @@
你好我是Chrono。
课程的核心内容,我们已经学完了,今天,我们来进行一场期末考试。
我出了一套测试题共有5道单选题和15道多选题满分100核心考点都出自前面讲到的所有重要知识。我建议你认真地做一下这套题目。如果有做错的地方可以及时复习一下。如果有不明白的欢迎随时在留言区提问我会尽快回复你的。
还等什么?我们一起来做下这套题吧!
[<img src="https://static001.geekbang.org/resource/image/28/a4/28d1be62669b4f3cc01c36466bf811a4.png" alt="">](http://time.geekbang.org/quiz/intro?act_id=188&amp;exam_id=445)

View File

@@ -0,0 +1,377 @@
<audio id="audio" title="15 | 序列化:简单通用的数据交换格式有哪些?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/32/8d/32f57c4e71ed343adef8d531f031f28d.mp3"></audio>
你好我是Chrono。
在前面的三个单元里我们学习了C++的语言特性和标准库算是把C++的编程范式、生命周期、核心特性、标准库的内容整体过了一遍。从今天起,我们的学习之旅又将开启一个新的篇章。
C++语言和标准库很强大,功能灵活,组件繁多,但也只能说是构建软件这座大厦的基石。想要仅凭它们去“包打天下”,不能说是绝对不可行,但至少是“吃力难讨好”。
还是那句老话“不要重复发明轮子。”Reinventing the wheel虽然很多C++程序员都热衷于此,但我觉得对于你我这样的“凡人”,还是要珍惜自己的时间和精力,把有限的资源投入到能有更多产出的事情上。
所以,接下来的这几节课,我会介绍一些第三方工具,精选出序列化/反序列化、网络通信、脚本语言混合编程和性能分析这四类工具,弥补标准库的不足,节约你的开发成本,让你的工作更有效率。
今天,我先来说一下序列化和反序列化。这两个功能在软件开发中经常遇到,你可能很熟悉了,所以我只简单解释一下。
序列化,就是把内存里“活的对象”转换成静止的字节序列,便于存储和网络传输;而反序列化则是反向操作,从静止的字节序列重新构建出内存里可用的对象。
我借用《三体》里的内容,打一个形象的比喻:序列化就是“三体人”的脱水,变成干纤维,在乱纪元方便存储运输;反序列化就是“三体人”的浸泡,在恒纪元由干纤维再恢复成活生生的人。(即使没读过《三体》,也是很好理解的吧?)
接下来我就和你介绍三种既简单又高效的数据交换格式JSON、MessagePack和ProtoBuffer看看在C++里怎么对数据做序列化和反序列化。
## JSON
JSON是一种轻量级的数据交换格式采用纯文本表示所以是“human readable”阅读和修改都很方便。
由于JSON起源于“最流行的脚本语言”JavaScript所以它也随之得到了广泛的应用在Web开发领域几乎已经成为了事实上的标准而且还渗透到了其他的领域。比如很多数据库就支持直接存储JSON数据还有很多应用服务使用JSON作为配置接口。
在[JSON的官方网站](https://www.json.org/json-zh.html)上你可以找到大量的C++实现不过用起来都差不多。因为JSON本身就是个KV结构很容易映射到类似map的关联数组操作方式。
如果不是特别在意性能的话,选个你自己喜欢的就好。否则,你就要做一下测试,看哪一个更适合你的应用场景。
不过我觉得JSON格式注重的是方便易用在性能上没有太大的优势所以**一般选择JSON来交换数据通常都不会太在意性能不然肯定会改换其他格式了还是自己用着顺手最重要**。
下面就来说说我的个人推荐:“[JSON for Modern C++](https://github.com/nlohmann/json)”这个库。
JSON for Modern C++可能不是最小最快的JSON解析工具但功能足够完善而且使用方便仅需要包含一个头文件“json.hpp”没有外部依赖也不需要额外的安装、编译、链接工作适合快速上手开发。
JSON for Modern C++可以用“git clone”下载源码或者更简单一点直接用wget获取头文件就行
```
git clone git@github.com:nlohmann/json.git # git clone
wget https://github.com/nlohmann/json/releases/download/v3.7.3/json.hpp # wget
```
JSON for Modern C++使用一个json类来表示JSON数据为了避免说的时候弄混我给这个类起了个别名json_t
```
using json_t = nlohmann::json;
```
json_t的序列化功能很简单和标准容器map一样用关联数组的“[]”来添加任意数据。
你不需要特别指定数据的类型,它会自动推导出恰当的类型。比如,连续多个“[]”就是嵌套对象array、vector或者花括号形式的初始化列表就是JSON数组map或者是花括号形式的pair就是JSON对象非常自然
```
json_t j; // JSON对象
j[&quot;age&quot;] = 23; // &quot;age&quot;:23
j[&quot;name&quot;] = &quot;spiderman&quot;; // &quot;name&quot;:&quot;spiderman&quot;
j[&quot;gear&quot;][&quot;suits&quot;] = &quot;2099&quot;; // &quot;gear&quot;:{&quot;suits&quot;:&quot;2099&quot;}
j[&quot;jobs&quot;] = {&quot;superhero&quot;}; // &quot;jobs&quot;:[&quot;superhero&quot;]
vector&lt;int&gt; v = {1,2,3}; // vector容器
j[&quot;numbers&quot;] = v; // &quot;numbers&quot;:[1,2,3]
map&lt;string, int&gt; m = // map容器
{{&quot;one&quot;,1}, {&quot;two&quot;, 2}}; // 初始化列表
j[&quot;kv&quot;] = m; // &quot;kv&quot;:{&quot;one&quot;:1,&quot;two&quot;:2}
```
添加完之后用成员函数dump()就可以序列化得到它的JSON文本形式。默认的格式是紧凑输出没有缩进如果想要更容易阅读的话可以加上指示缩进的参数
```
cout &lt;&lt; j.dump() &lt;&lt; endl; // 序列化,无缩进
cout &lt;&lt; j.dump(2) &lt;&lt; endl; // 序列化有缩进2个空格
```
json_t的反序列化功能同样也很简单只要调用静态成员函数parse()就行直接得到JSON对象而且可以用auto自动推导类型
```
string str = R&quot;({ // JSON文本原始字符串
&quot;name&quot;: &quot;peter&quot;,
&quot;age&quot; : 23,
&quot;married&quot; : true
})&quot;;
auto j = json_t::parse(str); // 从字符串反序列化
assert(j[&quot;age&quot;] == 23); // 验证序列化是否正确
assert(j[&quot;name&quot;] == &quot;peter&quot;);
```
json_t使用异常来处理解析时可能发生的错误如果你不能保证JSON数据的完整性就要使用try-catch来保护代码防止错误数据导致程序崩溃
```
auto txt = &quot;bad:data&quot;s; // 不是正确的JSON数据
try // try保护代码
{
auto j = json_t::parse(txt);// 从字符串反序列化
}
catch(std::exception&amp; e) // 捕获异常
{
cout &lt;&lt; e.what() &lt;&lt; endl;
}
```
对于通常的应用来说掌握了基本的序列化和反序列化就够用了不过JSON for Modern C++里还有很多高级用法比如SAX、BSON、自定义类型转换等。如果你需要这些功能可以去看[它的文档](https://github.com/nlohmann/json),里面写得都很详细。
## MessagePack
说完JSON再来说另外第二种格式MessagePack。
它也是一种轻量级的数据交换格式与JSON的不同之处在于它不是纯文本而是二进制。所以MessagePack就比JSON更小巧处理起来更快不过也就没有JSON那么直观、易读、好修改了。
由于二进制这个特点MessagePack也得到了广泛的应用著名的有Redis、Pinterest。
MessagePack支持几乎所有的编程语言你可以在[官网](https://msgpack.org/)上找到它的C++实现。
我常用的是官方库msgpack-c可以用apt-get直接安装。
```
apt-get install libmsgpack-dev
```
但这种安装方式有个问题可能发行方仓库里的是老版本像Ubuntu 16.04就是0.57),缺失很多功能,所以最好是从[GitHub](https://github.com/msgpack/msgpack-c)上下载最新版,编译时手动指定包含路径:
```
git clone git@github.com:msgpack/msgpack-c.git
g++ msgpack.cpp -std=c++14 -I../common/include -o a.out
```
和JSON for Modern C++一样msgpack-c也是仅头文件的库head only只要包含一个头文件“msgpack.hpp”就行了不需要额外的编译链接选项C版本需要用“-lmsgpackc”链接
但MessagePack的设计理念和JSON是完全不同的它没有定义JSON那样的数据结构而是比较底层只能对基本类型和标准容器序列化/反序列化,需要你自己去组织、整理要序列化的数据。
我拿vector容器来举个例子调用pack()函数序列化为MessagePack格式
```
vector&lt;int&gt; v = {1,2,3,4,5}; // vector容器
msgpack::sbuffer sbuf; // 输出缓冲区
msgpack::pack(sbuf, v); // 序列化
```
从代码里你可以看到它的用法不像JSON那么简单直观**必须同时传递序列化的输出目标和被序列化的对象**。
输出目标sbuffer是个简单的缓冲区你可以把它理解成是对字符串数组的封装`vector&lt;char&gt;`很像也可以用data()和size()方法获取内部的数据和长度。
```
cout &lt;&lt; sbuf.size() &lt;&lt; endl; // 查看序列化后数据的长度
```
除了sbuffer你还可以选择另外的zbuffer、fbuffer。它们是压缩输出和文件输出和sbuffer只是格式不同用法是相同的所以后面我就都用sbuffer来举例说明。
MessagePack反序列化的时候略微麻烦一些要用到函数unpack()和两个核心类object_handle和object。
函数unpack()反序列化数据得到的是一个object_handle再调用get()就是object
```
auto handle = msgpack::unpack( // 反序列化
sbuf.data(), sbuf.size()); // 输入二进制数据
auto obj = handle.get(); // 得到反序列化对象
```
这个object是MessagePack对数据的封装相当于JSON for Modern C++的JSON对象但你不能直接使用必须知道数据的原始类型才能转换还原
```
vector&lt;int&gt; v2; // vector容器
obj.convert(v2); // 转换反序列化的数据
assert(std::equal( // 算法比较两个容器
begin(v), end(v), begin(v2)));
```
因为MessagePack不能直接打包复杂数据所以用起来就比JSON麻烦一些你必须自己把数据逐个序列化连在一起才行。
好在MessagePack又提供了一个packer类可以实现串联的序列化操作简化代码
```
msgpack::sbuffer sbuf; // 输出缓冲区
msgpack::packer&lt;decltype(sbuf)&gt; packer(sbuf); // 专门的序列化对象
packer.pack(10).pack(&quot;monado&quot;s) // 连续序列化多个数据
.pack(vector&lt;int&gt;{1,2,3});
```
对于多个对象连续序列化后的数据反序列化的时候可以用一个偏移量offset参数来同样连续操作
```
for(decltype(sbuf.size()) offset = 0; // 初始偏移量是0
offset != sbuf.size();){ // 直至反序列化结束
auto handle = msgpack::unpack( // 反序列化
sbuf.data(), sbuf.size(), offset); // 输入二进制数据和偏移量
auto obj = handle.get(); // 得到反序列化对象
}
```
但这样还是比较麻烦能不能像JSON那样直接对类型序列化和反序列化呢
MessagePack为此提供了一个特别的宏MSGPACK_DEFINE把它放进你的类定义里就可以像标准类型一样被MessagePack处理。
下面定义了一个简单的Book类
```
class Book final // 自定义类
{
public:
int id;
string title;
set&lt;string&gt; tags;
public:
MSGPACK_DEFINE(id, title, tags); // 实现序列化功能的宏
};
```
它可以直接用于pack()和unpack()基本上和JSON差不多了
```
Book book1 = {1, &quot;1984&quot;, {&quot;a&quot;,&quot;b&quot;}}; // 自定义类
msgpack::sbuffer sbuf; // 输出缓冲区
msgpack::pack(sbuf, book1); // 序列化
auto obj = msgpack::unpack( // 反序列化
sbuf.data(), sbuf.size()).get(); // 得到反序列化对象
Book book2;
obj.convert(book2); // 转换反序列化的数据
assert(book2.id == book1.id);
assert(book2.tags.size() == 2);
cout &lt;&lt; book2.title &lt;&lt; endl;
```
使用MessagePack的时候你也要注意数据不完整的问题必须要用try-catch来保护代码捕获异常
```
auto txt = &quot;&quot;s; // 空数据
try // try保护代码
{
auto handle = msgpack::unpack( // 反序列化
txt.data(), txt.size());
}
catch(std::exception&amp; e) // 捕获异常
{
cout &lt;&lt; e.what() &lt;&lt; endl;
}
```
## ProtoBuffer
第三个要说的库就是著名的[ProtoBuffer](https://github.com/protocolbuffers/protobuf)通常简称为PB由Google出品。
PB也是一种二进制的数据格式但毕竟是工业级产品所以没有JSON和MessagePack那么“轻”相关的东西比较多要安装一个预处理器和开发库编译时还要链接动态库-lprotobuf
```
apt-get install protobuf-compiler
apt-get install libprotobuf-dev
g++ protobuf.cpp -std=c++14 -lprotobuf -o a.out
```
**PB的另一个特点是数据有“模式”schema**必须要先写一个IDLInterface Description Language文件在里面定义好数据结构只有预先定义了的数据结构才能被序列化和反序列化。
这个特点既有好处也有坏处:一方面,接口就是清晰明确的规范文档,沟通交流简单无歧义;而另一方面,就是缺乏灵活性,改接口会导致一连串的操作,有点繁琐。
下面是一个简单的PB定义
```
syntax = &quot;proto2&quot;; // 使用第2版
package sample; // 定义名字空间
message Vendor // 定义消息
{
required uint32 id = 1; // required表示必须字段
required string name = 2; // 有int32/string等基本类型
required bool valid = 3; // 需要指定字段的序号,序列化时用
optional string tel = 4; // optional字段可以没有
}
```
有了接口定义文件需要再用protoc工具生成对应的C++源码,然后把源码文件加入自己的项目中,就可以使用了:
```
protoc --cpp_out=. sample.proto // 生成C++代码
```
由于PB相关的资料实在太多了这里我就只简单说一下重要的接口
- 字段名会生成对应的has/set函数检查是否存在和设置值
- IsInitialized()检查数据是否完整required字段必须有值
- DebugString()输出数据的可读字符串描述;
- ByteSize()返回序列化数据的长度;
- SerializeToString()从对象序列化到字符串;
- ParseFromString()从字符串反序列化到对象;
- SerializeToArray()/ParseFromArray()序列化的目标是字节数组。
下面的代码示范了PB的用法
```
using vendor_t = sample::Vendor; // 类型别名
vendor_t v; // 声明一个PB对象
assert(!v.IsInitialized()); // required等字段未初始化
v.set_id(1); // 设置每个字段的值
v.set_name(&quot;sony&quot;);
v.set_valid(true);
assert(v.IsInitialized()); // required等字段都设置了数据完整
assert(v.has_id() &amp;&amp; v.id() == 1);
assert(v.has_name() &amp;&amp; v.name() == &quot;sony&quot;);
assert(v.has_valid() &amp;&amp; v.valid());
cout &lt;&lt; v.DebugString() &lt;&lt; endl; // 输出调试字符串
string enc;
v.SerializeToString(&amp;enc); // 序列化到字符串
vendor_t v2;
assert(!v2.IsInitialized());
v2.ParseFromString(enc); // 反序列化
```
虽然业界很多大厂都在使用PB但我觉得它真不能算是最好的IDL定义和接口都太死板生硬还只能用最基本的数据类型不支持标准容器在现代C++里显得“不太合群”,用起来有点别扭。
不过它后面有Google“撑腰”而且最近几年又有gRPC“助拳”所以很多时候也不得不用。
PB的另一个缺点是官方支持的编程语言太少通用性较差最常用的proto2只有C++、Java和Python。后来的proto3增加了对Go、Ruby等的支持但仍然不能和JSON、MessagePack相比。
## 小结
好了今天我讲了三种数据交换格式JSON、MessagePack和ProtoBuffer。
这三种数据格式各有特色,在很多领域都得到了广泛的应用,我来简单小结一下:
1. JSON是纯文本容易阅读方便编辑适用性最广
1. MessagePack是二进制小巧高效在开源界接受程度比较高
1. ProtoBuffer是工业级的数据格式注重安全和性能多用在大公司的商业产品里。
有很多开源库支持这些数据格式,官方的、民间的都有,你应该选择适合自己的高质量库,必要的时候可以做些测试。
再补充一点除了今天说的这三种你还可以尝试其他的数据格式比较知名的有Avro、Thrift虽然它们有点冷门但也有自己的独到之处比如天生支持RPC、可选择多种序列化格式和传输方式
## 课下作业
最后是课下作业时间,给你留两个思考题:
1. 为什么要有序列化和反序列化直接memcpy内存数据行不行呢
1. 你最常用的是哪种数据格式?它有什么优缺点?
欢迎你在留言区写下你的思考和答案,如果觉得今天的内容对你有所帮助,也欢迎分享给你的朋友。我们下节课见。
<img src="https://static001.geekbang.org/resource/image/a3/f1/a39719e615f1124d60b5b9ca51b88cf1.png" alt="">

View File

@@ -0,0 +1,325 @@
<audio id="audio" title="16 | 网络通信我不想写原生Socket" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/42/eb/42cbb0fe4bd124865b40e29a6fed73eb.mp3"></audio>
你好我是Chrono。
在上一节课我讲了JSON、MessagePack和ProtoBuffer这三种数据交换格式。现在我们手里有了这些跨语言、跨平台的通用数据该怎么与外部通信交换呢
你肯定首先想到的就是Socket网络编程使用TCP/IP协议栈收发数据这样不仅可以在本地的进程间通信也可以在主机、机房之间异地通信。
大方向上这是没错的但你也肯定知道原生的Socket API非常底层要考虑很多细节比如TIME_WAIT、CLOSE_WAIT、REUSEADDR等如果再加上异步就更复杂了。
虽然你可能看过、学过不少这方面的资料对如何处理这些问题“胸有成竹”但无论如何像Socket建连/断连、协议格式解析、网络参数调整等,都要自己动手做,想要“凭空”写出一个健壮可靠的网络应用程序还是相当麻烦的。
所以今天我就来谈谈C++里的几个好用的网络通信库libcurl、cpr和ZMQ让你摆脱使用原生Socket编程的烦恼。
## libcurl高可移植、功能丰富的通信库
第一个要说的库是libcurl它来源于著名的[curl项目](https://curl.haxx.se/)也是curl的底层核心。
libcurl经过了多年的开发和实际项目的验证非常稳定可靠拥有上百万的用户其中不乏Apple、Facebook、Google、Netflix等大公司。
它最早只支持HTTP协议但现在已经扩展到支持所有的应用层协议比如HTTPS、FTP、LDAP、SMTP等功能强大。
libcurl使用纯C语言开发兼容性、可移植性非常好基于C接口可以很容易写出各种语言的封装所以Python、PHP等语言都有libcurl相关的库。
因为C++兼容C所以我们也可以在C++程序里直接调用libcurl来收发数据。
在使用libcurl之前你需要用apt-get或者yum等工具安装开发库
```
apt-get install libcurl4-openssl-dev
```
虽然libcurl支持很多协议但最常用的还是HTTP。所以接下来我也主要介绍libcurl的HTTP使用方法这样对其他的协议你也可以做到“触类旁通”。
libcurl的接口可以粗略地分成两大类easy系列和multi系列。其中easy系列是同步调用比较简单multi系列是异步的多线程调用比较复杂。通常情况下我们用easy系列就足够了。
使用libcurl收发HTTP数据的基本步骤有4个
1. 使用curl_easy_init()创建一个句柄类型是CURL*。但我们完全没有必要关心句柄的类型直接用auto推导就行。
1. 使用curl_easy_setopt()设置请求的各种参数比如请求方法、URL、header/body数据、超时、回调函数等。这是最关键的操作。
1. 使用curl_easy_perform()发送数据,返回的数据会由回调函数处理。
1. 使用curl_easy_cleanup()清理句柄相关的资源,结束会话。
下面我用个简短的例子来示范一下这4步
```
#include &lt;curl/curl.h&gt; // 包含头文件
auto curl = curl_easy_init(); // 创建CURL句柄
assert(curl);
curl_easy_setopt(curl, CURLOPT_URL, &quot;http://nginx.org&quot;); // 设置请求URI
auto res = curl_easy_perform(curl); // 发送数据
if (res != CURLE_OK) { // 检查是否执行成功
cout &lt;&lt; curl_easy_strerror(res) &lt;&lt; endl;
}
curl_easy_cleanup(curl); // 清理句柄相关的资源
```
这段代码非常简单重点是调用curl_easy_setopt()设置了URL请求Nginx官网的首页其他的都使用默认值即可。
由于没有设置你自己的回调函数所以libcurl会使用内部的默认回调把得到的HTTP响应数据输出到标准流也就是直接打印到屏幕上。
这个处理结果显然不是我们所期待的所以如果想要自己处理返回的HTTP报文就得写一个回调函数在里面实现业务逻辑。
因为libcurl是C语言实现的所以回调函数必须是函数指针。不过C++11允许你写lambda表达式这利用了一个特别规定**无捕获的lambda表达式可以显式转换成一个函数指针**。注意一定要是“无捕获”也就是说lambda引出符“[]”必须是空的,不能捕获任何外部变量。
所以只要多做一个简单的转型动作你就可以用lambda表达式直接写libcurl的回调还是熟悉的函数式编程风格
```
// 回调函数的原型
size_t write_callback(char* , size_t , size_t , void* );
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, // 设置回调函数
(decltype(&amp;write_callback)) // decltype获取函数指针类型显式转换
[](char *ptr, size_t size, size_t nmemb, void *userdata)// lambda
{
cout &lt;&lt; &quot;size = &quot; &lt;&lt; size * nmemb &lt;&lt; endl; // 简单的处理
return size * nmemb; // 返回接收的字节数
}
);
```
libcurl的用法大概就是这个样子了开头的准备和结尾的清理工作都很简单关键的就是curl_easy_setopt()这一步的参数设置。我们必须通过查文档知道该用哪些标志宏,写一些单调重复的代码。
你可能想到了可以自己用C++包装出一个类就能够少敲点键盘。但不要着急因为我们有一个更好的选择就是cpr。
## cpr更现代、更易用的通信库
cpr是对libcurl的一个C++11封装使用了很多现代C++的高级特性对外的接口模仿了Python的requests库非常简单易用。
你可以从[GitHub](https://github.com/whoshuu/cpr)上获取cpr的源码再用cmake编译安装
```
git clone git@github.com:whoshuu/cpr.git
cmake . -DUSE_SYSTEM_CURL=ON -DBUILD_CPR_TESTS=OFF
make &amp;&amp; make install
```
和libcurl相比cpr用起来真的是太轻松了不需要考虑什么初始化、设置参数、清理等杂事一句话就能发送HTTP请求
```
#include &lt;cpr/cpr.h&gt; // 包含头文件
auto res = cpr::Get( // GET请求
cpr::Url{&quot;http://openresty.org&quot;} // 传递URL
);
```
你也不用写回调函数HTTP响应就是函数的返回值用成员变量url、header、status_code、text就能够得到报文的各个组成部分
```
cout &lt;&lt; res.elapsed &lt;&lt; endl; // 请求耗费的时间
cout &lt;&lt; res.url &lt;&lt; endl; // 请求的URL
cout &lt;&lt; res.status_code &lt;&lt; endl; // 响应的状态码
cout &lt;&lt; res.text.length() &lt;&lt; endl; // 响应的body数据
for(auto&amp; x : res.header) { // 响应的头字段
cout &lt;&lt; x.first &lt;&lt; &quot;=&gt;&quot; // 类似map的结构
&lt;&lt; x.second &lt;&lt; endl;
}
```
在cpr里HTTP协议的概念都被实现为相应的函数或者类内部再转化为libcurl操作主要的有
- GET/HEAD/POST等请求方法使用同名的Get/Head/Post函数
- URL使用Url类它其实是string的别名
- URL参数使用Parameters类KV结构近似map
- 请求头字段使用Header类它其实是map的别名使用定制的函数实现了大小写无关比较
- Cookie使用Cookies类也是KV结构近似map
- 请求体使用Body类
- 超时设置使用Timeout类。
这些函数和类的用法都非常自然、符合思维习惯而且因为可以使用C++11的花括号“{}”初始化语法如果你以前用过Python reqeusts库的话一定会感到很亲切
```
const auto url = &quot;http://openresty.org&quot;s; // 访问的URL
auto res1 = cpr::Head( // 发送HEAD请求
cpr::Url{url} // 传递URL
);
auto res2 = cpr::Get( // 发送GET请求
cpr::Url{url}, // 传递URL
cpr::Parameters{ // 传递URL参数
{&quot;a&quot;, &quot;1&quot;}, {&quot;b&quot;, &quot;2&quot;}}
);
auto res3 = cpr::Post( // 发送POST请求
cpr::Url{url}, // 传递URL
cpr::Header{ // 定制请求头字段
{&quot;x&quot;, &quot;xxx&quot;},{&quot;expect&quot;,&quot;&quot;}},
cpr::Body{&quot;post data&quot;}, // 传递body数据
cpr::Timeout{200ms} // 超时时间
);
```
cpr也支持异步处理但它内部没有使用libcurl的multi接口而是使用了标准库里的future和async参见[第14讲](https://time.geekbang.org/column/article/245259)和libcurl的实现相比既简单又好理解。
异步接口与同步接口的调用方式基本一样只是名字多了个“Async”的后缀返回的是一个future对象。你可以调用wait()或者get()来获取响应结果:
```
auto f = cpr::GetAsync( // 异步发送GET请求
cpr::Url{&quot;http://openresty.org&quot;}
);
auto res = f.get(); // 等待响应结果
cout &lt;&lt; res.elapsed &lt;&lt; endl; // 请求耗费的时间
```
看了上面这些介绍你是不是有些心动了。说实话我原来在C++里也是一直用libcurl也写过自己的包装类直到发现了cpr这个“大杀器”就立即“弃暗投明”了。
相信有了cpr你今后在C++里写HTTP应用就不再是痛苦而是一种享受了。
## ZMQ高效、快速、多功能的通信库
libcurl和cpr处理的都是HTTP协议虽然用起来很方便但协议自身也有一些限制比如必须要一来一回必须点对点直连在超大数据量通信的时候就不是太合适。
还有一点libcurl和cpr只能充当HTTP的客户端如果你想写服务器端程序这两个工具就完全派不上用场。
所以我们就需要一个更底层、更灵活的网络通信工具它应该能够弥补libcurl和cpr的不足不仅快速高效还能同时支持客户端和服务器端编程。
这就是我要说的第三个库:[ZMQ](https://zeromq.org/)。
其实ZMQ不仅是一个单纯的网络通信库更像是一个高级的异步并发框架。
从名字上就可以看出来Zero Message Queue——零延迟的消息队列意味着它除了可以收发数据外还可以用作消息中间件解耦多个应用服务之间的强依赖关系搭建高效、有弹性的分布式系统从而超越原生的Socket。
作为消息队列ZMQ的另一大特点是零配置零维护零成本不需要搭建额外的代理服务器只要安装了开发库就能够直接使用相当于把消息队列功能直接嵌入到你的应用程序里
```
apt-get install libzmq3-dev
```
ZMQ是用C++开发的但出于兼容的考虑对外提供的是纯C接口。不过它也有很多C++封装,这里我选择的是自带的[cppzmq](https://github.com/zeromq/cppzmq),虽然比较简单,但也基本够用了。
由于ZMQ把自身定位于更高层次的“异步消息队列”所以它的用法就不像Socket、HTTP那么简单直白而是定义了5种不同的工作模式来适应实际中常见的网络通信场景。
我来大概说一下这5种模式
- 原生模式RAW没有消息队列功能相当于底层Socket的简单封装
- 结对模式PAIR两个端点一对一通信
- 请求响应模式REQ-REP也是两个端点一对一通信但请求必须有响应
- 发布订阅模式PUB-SUB一对多通信一个端点发布消息多个端点接收处理
- 管道模式PUSH-PULL或者叫流水线可以一对多也可以多对一。
前四种模式类似HTTP协议、Client-Server架构很简单就不多说了。我拿我在工作中比较常用的管道模式来给你示范一下ZMQ的用法它非常适合进程间无阻塞传送海量数据也有点map-reduce的意思。
<img src="https://static001.geekbang.org/resource/image/8b/2d/8b868214b032aebfcee1e0e97a8a1e2d.png" alt="">
在ZMQ里有两个基本的类。
- 第一个是context_t它是ZMQ的运行环境。使用ZMQ的任何功能前必须要先创建它。
- 第二个是socket_t表示ZMQ的套接字需要指定刚才说的那5种工作模式。注意它与原生Socket没有任何关系只是借用了名字来方便理解。
下面的代码声明了一个全局的ZMQ环境变量并定义了一个lambda表达式生产ZMQ套接字
```
const auto thread_num = 1; // 并发线程数
zmq::context_t context(thread_num); // ZMQ环境变量
auto make_sock = [&amp;](auto mode) // 定义一个lambda表达式
{
return zmq::socket_t(context, mode); // 创建ZMQ套接字
};
```
和原生Socket一样ZMQ套接字也必须关联到一个确定的地址才能收发数据但它不仅支持TCP/IP还支持进程内和进程间通信这在本机交换数据时会更高效
- TCP通信地址的形式是“tcp://…”指定IP地址和端口号
- 进程内通信地址的形式是“inproc://…”,指定一个本地可访问的路径;
- 进程间通信地址的形式是“ipc://…”,也是一个本地可访问的路径。
用bind()/connect()这两个函数把ZMQ套接字连接起来之后就可以用send()/recv()来收发数据了,看一下示例代码吧:
```
const auto addr = &quot;ipc:///dev/shm/zmq.sock&quot;s; // 通信地址
auto receiver = [=]() // lambda表达式接收数据
{
auto sock = make_sock(ZMQ_PULL); // 创建ZMQ套接字拉数据
sock.bind(addr); // 绑定套接字
assert(sock.connected());
zmq::message_t msg;
sock.recv(&amp;msg); // 接收消息
string s = {msg.data&lt;char&gt;(), msg.size()};
cout &lt;&lt; s &lt;&lt; endl;
};
auto sender = [=]() // lambda表达式发送数据
{
auto sock = make_sock(ZMQ_PUSH); // 创建ZMQ套接字推数据
sock.connect(addr); // 连接到对端
assert(sock.connected());
string s = &quot;hello zmq&quot;;
sock.send(s.data(), s.size()); // 发送消息
};
```
这段代码实现了两个最基本的客户端和服务器看起来好像没什么特别的。但你应该注意到使用ZMQ完全不需要考虑底层的TCP/IP通信细节它会保证消息异步、安全、完整地到达服务器让你关注网络通信之上更有价值的业务逻辑。
ZMQ的用法就是这么简单但想要进一步发掘它的潜力处理大流量的数据还是要去看[它的文档](http://wiki.zeromq.org/),选择合适的工作模式,再仔细调节各种参数。
接下来,我再给你分享两个实际工作中会比较有用的细节吧。
一个是**ZMQ环境的线程数**。它的默认值是1太小了适当增大一些就可以提高ZMQ的并发处理能力。我一般用的是4~6具体设置为多少最好还是通过性能测试来验证下。
另一个是**收发消息时的本地缓存数量**ZMQ的术语叫High Water Mark。如果收发的数据过多数量超过HWMZMQ要么阻塞要么丢弃消息。
HWM需要调用套接字的成员函数setsockopt()来设置,注意收发使用的是两个不同的标志:
```
sock.setsockopt(ZMQ_RCVHWM, 1000); // 接收消息最多缓存1000条
sock.setsockopt(ZMQ_SNDHWM, 100); // 发送消息最多缓存100条
```
我们把HWM设置成多大都可以比如我就曾经在一个高并发系统里用过100万以上的值不用担心ZMQ会把一切都处理得很好。
关于ZMQ就暂时说到这里它还有很多强大的功能你可以阅读[官网](http://zguide.zeromq.org/page:all)上的教程和指南里面非常详细地讨论了ZMQ的各种模式和要点。
## 小结
好了,我来给今天的内容做一个小结:
1. libcurl是一个功能完善、稳定可靠的应用层通信库最常用的就是HTTP协议
1. cpr是对libcurl的C++封装,接口简单易用;
1. libcurl和cpr都只能作为客户端来使用不能编写服务器端应用
1. ZMQ是一个高级的网络通信库支持多种通信模式可以把消息队列功能直接嵌入应用程序搭建出高效、灵活、免管理的分布式系统。
最后再说说即将到来的C++20原本预计会加入期待已久的networking库但现在已经被推迟到了下一个版本C++23
networking库基于已有多年实践的boost.asio采用前摄器模式Proactor统一封装了操作系统的各种异步机制epoll、kqueue、IOCP而且支持协程。有了它我们的网络通信工作就会更加轻松。
## 课下作业
最后是课下作业时间,给你留两个思考题:
1. 你在网络编程的时候都遇到过哪些“坑”,今天说的这几个库能否解决你的问题?
1. 你觉得ZMQ能够在多大程度上代替原生Socket
欢迎你在留言区写下你的思考和答案,如果觉得今天的内容对你有所帮助,也欢迎分享给你的朋友。我们下节课见。
<img src="https://static001.geekbang.org/resource/image/3e/dd/3e07516e87c61172f9b2ddc317c74add.jpg" alt="">

View File

@@ -0,0 +1,367 @@
<audio id="audio" title="17 | 脚本语言:搭建高性能的混合系统" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a4/d6/a4cd64d4600479f1c11aaf9ae8e25ed6.mp3"></audio>
你好我是Chrono。
经过了前面这么多节课的学习相信你已经认识到了C++的高效、灵活和强大。使用现代特性再加上标准库和第三方库C++几乎“无所不能”。
但是C++也有自己的“阿喀琉斯之踵”,那就是语言复杂、学习曲线陡峭、开发周期长、排错/维护成本高。
所以C++不能完全适应现在的快速开发和迭代的节奏最终只能退到后端、底层等领域。要想充分发挥C++的功力,就要辅助其他的语言搭建混合系统,尽量扬长避短,做好那最关键、最核心的部分,这样才能展现出它应有的价值。
由于当前的操作系统、虚拟机、解释器、引擎很多都是用C或者C++编写的所以使用C++可以很容易地编写各种底层模块为上层的Java、Go等语言提供扩展功能。
不过今天我不去说这些大型语言而是讲两种轻便的脚本语言Python和Lua看看C++怎么和它们俩实现无缝对接以C++为底层基础Python和Lua作为上层建筑共同搭建起高性能、易维护、可扩展的混合系统。
## Python
Python应该是除了JavaScript以外最流行的一种脚本语言了一直在TIOBE榜单里占据前三名的位置。而且在新兴的大数据、人工智能、科学计算等领域也都有着广泛的应用。很多大公司都长期招聘Python程序员就是看中了它的高生产率。
Python本身就有C接口可以用C语言编写扩展模块把一些低效耗时的功能改用C实现有的时候会把整体性能提升几倍甚至几十倍。
但是使用纯C语言写扩展模块非常麻烦那么能不能利用C++的那些高级特性来简化这部分的工作呢?
很多人都想到了这个问题于是就出现了一些专门的C++/Python工具使用C++来开发Python扩展。其中我认为最好的一个就是[pybind11](https://github.com/pybind/pybind11)。
pybind11借鉴了“前辈”Boost.Python能够在C++和Python之间自由转换任意翻译两者的语言要素比如把C++的vector转换为Python的列表把Python的元组转换为C++的tuple既可以在C++里调用Python脚本也可以在Python里调用C++的函数、类。
pybind11名字里的“11”表示它完全基于现代C++开发C++11以上所以没有兼容旧系统的负担。它使用了大量的现代C++特性,不仅代码干净整齐,运行效率也更高。
下面我就带你看看怎么用pybind11让C++来辅助Python提升Python的性能。
pybind11是一个纯头文件的库但因为必须结合Python所以首先要有Python的开发库然后再用pip工具安装。
pybind11支持Python2.7、Python3和PyPy这里我用的是Python3
```
apt-get install python3-dev
apt-get install python3-pip
pip3 install pybind11
```
pybind11充分利用了C++预处理和模板元编程把原本无聊重复的代码都隐藏了起来展现了“神奇的魔法”——只需要短短几行代码就可以实现一个Python扩展模块。具体怎么实现呢
实际上,你只要用一个宏“**PYBIND11_MODULE**”再给它两个参数Python模块名和C++实例对象名,就可以了。
```
#include &lt;pybind11/pybind11.h&gt; // pybind11的头文件
PYBIND11_MODULE(pydemo, m) // 定义Python模块pydemo
{
m.doc() = &quot;pybind11 demo doc&quot;; // 模块的说明文档
} // Python模块定义结束
```
代码里的pydemo就是Python里的模块名之后在Python脚本里必须用这个名字才能import。
第二个参数“m”其实是pybind11::module的一个实例对象封装了所有的操作比如这里的doc()就是模块的说明文档。它只是个普通的变量起什么名字都可以但为了写起来方便一般都用“m”。
假设这个C++源文件名是“pybind.cpp”现在你就可以用g++把它编译成在Python里调用的模块了不过编译命令比较复杂
```
g++ pybind.cpp \ #编译的源文件
-std=c++11 -shared -fPIC \ #编译成动态库
`python3 -m pybind11 --includes` \ #获得包含路径
-o pydemo`python3-config --extension-suffix` #生成的动态库名字
```
我来稍微解释一下。第一行是指定编译的源文件第二行是指定编译成动态库这两个不用多说。第三行调用了Python获得pybind11所在的包含路径让g++能够找得到头文件。第四行最关键,是生成的动态库名字,**前面必须是源码里的模块名**而后面那部分则是Python要求的后缀名否则Python运行时会找不到模块。
编译完后会生成一个大概这样的文件pydemo.cpython-35m-x86_64-linux-gnu.so现在就可以在Python里验证了使用import导入然后用help就能查看模块说明
```
$ python3
&gt;&gt;&gt; import pydemo
&gt;&gt;&gt; help(pydemo)
```
刚才的代码非常简单只是个空模块里面什么都没有现在我们来看看怎么把C++的函数导入Python。
你需要用的是**def()函数**传递一个Python函数名和C++的函数、函数对象或者是lambda表达式形式上和Python的函数也差不多
```
namespace py = pybind11; // 名字空间别名,简化代码
PYBIND11_MODULE(pydemo, m) // 定义Python模块pydemo
{
m.def(&quot;info&quot;, // 定义Python函数
[]() // 定义一个lambda表达式
{
py::print(&quot;c++ version =&quot;, __cplusplus); // pybind11自己的打印函数
py::print(&quot;gcc version =&quot;, __VERSION__);
py::print(&quot;libstdc++ =&quot;, __GLIBCXX__);
}
);
m.def(&quot;add&quot;, // 定义Python函数
[](int a, int b) // 有参数的lambda表达式
{
return a + b;
}
);
} // Python模块定义结束
```
这样我们就非常轻松地实现了两个Python函数在Python里可以验证效果
```
import pydemo # 导入pybind11模块
pydemo.info() # 调用C++写的函数
x = pydemo.add(1,2) # 调用C++写的函数
```
pybind11也支持函数的参数、返回值使用标准容器会自动转换成Python里的list、dict不过你需要额外再包含一个“stl.h”的头文件。
下面的示例代码演示了C++的string、tuple和vector是如何用于Python的
```
#include &lt;pybind11/stl.h&gt; // 转换标准容器必须的头文件
PYBIND11_MODULE(pydemo, m) // 定义Python模块pydemo
{
m.def(&quot;use_str&quot;, // 定义Python函数
[](const string&amp; str) // 入参是string
{
py::print(str);
return str + &quot;!!&quot;; // 返回string
}
);
m.def(&quot;use_tuple&quot;, // 定义Python函数
[](tuple&lt;int, int, string&gt; x) // 入参是tuple
{
get&lt;0&gt;(x)++;
get&lt;1&gt;(x)++;
get&lt;2&gt;(x)+= &quot;??&quot;;
return x; // 返回元组
}
);
m.def(&quot;use_list&quot;, // 定义Python函数
[](const vector&lt;int&gt;&amp; v) // 入参是vector
{
auto vv = v;
py::print(&quot;input :&quot;, vv);
vv.push_back(100);
return vv; // 返回列表
}
);
}
```
因为都是面向对象的编程语言C++里的类也能够等价地转换到Python里面调用这要用到一个特别的模板类class_注意它有意模仿了关键字class后面多了一个下划线。
我拿一个简单的Point类来举个例子
```
class Point final
{
public:
Point() = default;
Point(int a)
public:
int get() const;
void set(int a);
};
```
使用pybind11你需要在模板参数里写上这个类名然后在构造函数里指定它在Python里的名字。
导出成员函数还是调用函数def(),但它会返回对象自身的引用,所以就可以连续调用,在一句话里导出所有接口:
```
py::class_&lt;Point&gt;(m, &quot;Point&quot;) // 定义Python类
.def(py::init()) // 导出构造函数
.def(py::init&lt;int&gt;()) // 导出构造函数
.def(&quot;get&quot;, &amp;Point::get) // 导出成员函数
.def(&quot;set&quot;, &amp;Point::set) // 导出成员函数
;
```
对于一般的成员函数来说,定义的方式和普通函数一样,只是你必须加上取地址操作符“&amp;把它写成函数指针的形式。而构造函数则比较特殊必须调用init()函数来表示如果有参数还需要在init()函数的模板参数列表里写清楚。
pybind11的功能非常丰富我们不可能一下子学完全部的功能刚才说的这些只是最基本也是非常实用的功能。除了这些它还支持异常、枚举、智能指针等很多C++特性,你可以再参考一下它的[文档](https://github.com/pybind/pybind11),学习一下具体的方法,挖掘出它的更多价值。
如果你在工作中重度使用Python那么pybind11绝对是你的得力助手它能够让C++紧密地整合进Python应用里让Python跑得更快、更顺畅建议你有机会就尽量多用。
## Lua
接下来我要说的第二个脚本语言是小巧高效的Lua号称是“最快的脚本语言”。
你可能对Lua不太了解但你一定听说过《魔兽世界》《愤怒的小鸟》吧它们就在内部大量使用了Lua来编写逻辑。在游戏开发领域Lua可以说是一种通用的工作语言。
Lua与其他语言最大的不同点在于它的设计目标不追求“大而全”而是“小而美”。Lua自身只有很小的语言核心能做的事情很少。但正是因为它小才能够很容易地嵌入到其他语言里为“宿主”添加脚本编程的能力让“宿主”更容易扩展和定制。
标准的LuaPUC-Rio Lua使用解释器运行速度虽然很快但和C/C++比起来还是有差距的。所以你还可以选择另一个兼容的项目LuaJIT[https://luajit.org/](https://luajit.org/)。它使用了JITJust in time技术能够把Lua代码即时编译成机器码速度几乎可以媲美原生C/C++代码。
不过LuaJIT也有一个问题它是一个个人项目更新比较慢最新的2.1.0-beta3已经是三年前的事情了。所以我推荐你改用它的一个非官方分支OpenResty-LuaJIT[https://github.com/openresty/luajit2](https://github.com/openresty/luajit2)。它由OpenResty负责维护非常活跃修复了很多小错误。
```
git clone git@github.com:openresty/luajit2.git
make &amp;&amp; make install
```
和Python一样Lua也有C接口用来编写扩展模块但因为它比较小众所以C++项目不是很多。现在我用的是LuaBridge虽然它没有用到太多的C++11新特性但也足够好。
LuaBridge是一个纯头文件的库只要下载下来把头文件拷贝到包含路径就能够直接用
```
git clone git@github.com:vinniefalco/LuaBridge.git
```
我们先来看看在Lua里怎么调C++的功能。
和前面说的pybind11类似LuaBridge也定义了很多的类和方法可以把C++函数、类注册到Lua里让Lua调用。
但我不建议你用这种方式因为我们现在有LuaJIT。它内置了一个ffi库Foreign Function Interface能够在Lua脚本里直接声明接口函数、直接调用不需要任何的注册动作更加简单方便。而且这种做法还越过了Lua传统的栈操作速度也更快。
使用ffi唯一要注意的是**它只能识别纯C接口不认识C++**所以写Lua扩展模块的时候内部可以用C++但对外的接口必须转换成纯C函数。
下面我写了一个简单的add()函数还有一个全局变量注意里面必须要用extern "C"声明:
```
extern &quot;C&quot; { // 使用纯C语言的对外接口
int num = 10;
int my_add(int a, int b);
}
int my_add(int a, int b) // 一个简单的函数供Lua调用
{
return a + b;
}
```
然后就可以用g++把它编译成动态库不像pybind11它没有什么特别的选项
```
g++ lua_shared.cpp -std=c++11 -shared -fPIC -o liblua_shared.so
```
在Lua脚本里你首先要用ffi.cdef声明要调用的接口再用ffi.load加载动态库这样就会把动态库所有的接口都引进Lua然后就能随便使用了
```
local ffi = require &quot;ffi&quot; -- 加载ffi库
local ffi_load = ffi.load -- 函数别名
local ffi_cdef = ffi.cdef
ffi_cdef[[ // 声明C接口
int num;
int my_add(int a, int b);
]]
local shared = ffi_load(&quot;./liblua_shared.so&quot;) -- 加载动态库
print(shared.num) -- 调用C接口
local x = shared.my_add(1, 2) -- 调用C接口
```
在ffi的帮助下让Lua调用C接口几乎是零工作量但这并不能完全发挥出Lua的优势。
因为和Python不一样Lua很少独立运行大多数情况下都要嵌入在宿主语言里被宿主调用然后再“回调”底层接口利用它的“胶水语言”特性去粘合业务逻辑。
要在C++里嵌入Lua首先要调用函数**luaL_newstate()**创建出一个Lua虚拟机所有的Lua功能都要在它上面执行。
因为Lua是用C语言写的Lua虚拟机用完之后必须要用函数**lua_close()**关闭所以最好用RAII技术写一个类来自动管理。可惜的是LuaBridge没有对此封装所以只能自己动手了。这里我用了智能指针shared_ptr在一个lambda表达式里创建虚拟机顺便再打开Lua基本库
```
auto make_luavm = []() // lambda表达式创建虚拟机
{
std::shared_ptr&lt;lua_State&gt; vm( // 智能指针
luaL_newstate(), lua_close // 创建虚拟机对象,设置删除函数
);
luaL_openlibs(vm.get()); // 打开Lua基本库
return vm;
};
#define L vm.get() // 获取原始指针,宏定义方便使用
```
在LuaBridge里一切Lua数据都被封装成了**LuaRef**类完全屏蔽了Lua底层那难以理解的栈操作。它可以隐式或者显式地转换成对应的数字、字符串等基本类型如果是表就可以用“[]”访问成员,如果是函数,也可以直接传参调用,非常直观易懂。
使用LuaBridge访问Lua数据时还要注意一点它只能用函数**getGlobal()**看到全局变量所以如果想在C++里调用Lua功能就一定不能加“local”修饰。
给你看一小段代码它先创建了一个Lua虚拟机然后获取了Lua内置的package模块输出里面的默认搜索路径path和cpath
```
auto vm = make_luavm(); // 创建Lua虚拟机
auto package = getGlobal(L, &quot;package&quot;); // 获取内置的package模块
string path = package[&quot;path&quot;]; // 默认的lua脚本搜索路径
string cpath = package[&quot;cpath&quot;]; // 默认的动态库搜索路径
```
你还可以调用**luaL_dostring()和luaL_dofile()**这两个函数直接执行Lua代码片段或者外部的脚本文件。注意luaL_dofile()每次调用都会从磁盘载入文件所以效率较低。如果是频繁调用最好把代码读进内存存成一个字符串再用luaL_dostring()运行:
```
luaL_dostring(L, &quot;print('hello lua')&quot;); // 执行Lua代码片段
luaL_dofile(L, &quot;./embedded.lua&quot;); // 执行外部的脚本文件
```
在C++里嵌入Lua还有另外一种方式**提前在脚本里写好一些函数加载后在C++里逐个调用**,这种方式比执行整个脚本更灵活。
具体的做法也很简单先用luaL_dostring()或者luaL_dofile()加载脚本然后调用getGlobal()从全局表里获得封装的LuaRef对象就可以像普通函数一样执行了。由于Lua是动态语言变量不需要显式声明类型所以写起来就像是C++的泛型函数,但却更简单:
```
string chunk = R&quot;( -- Lua代码片段
function say(s) -- Lua函数1
print(s)
end
function add(a, b) -- Lua函数2
return a + b
end
)&quot;;
luaL_dostring(L, chunk.c_str()); // 执行Lua代码片段
auto f1 = getGlobal(L, &quot;say&quot;); // 获得Lua函数
f1(&quot;say something&quot;); // 执行Lua函数
auto f2 = getGlobal(L, &quot;add&quot;); // 获得Lua函数
auto v = f2(10, 20); // 执行Lua函数
```
只要掌握了上面的这些基本用法并合理地划分出C++与Lua的职责边界就可以搭建出“LuaJIT + LuaBridge + C++”的高性能应用运行效率与开发效率兼得。比如说用C++写底层的框架、引擎暴露出各种调用接口作为“业务零件”再用灵活的Lua脚本去组合这些“零件”写上层的业务逻辑。
## 小结
好了今天我讲了怎么基于C++搭建混合系统介绍了Python和Lua这两种脚本语言。
Python很“大众”但比较复杂、性能不是特别高而Lua比较“小众”很小巧有LuaJIT让它运行速度极快。你可以结合自己的实际情况来选择比如语言的熟悉程度、项目的功能/性能需求、开发的难易度,等等。
今天的内容也比较多,我简单小结一下要点:
1. C++高效、灵活,但开发周期长、成本高,在混合系统里可以辅助其他语言,编写各种底层模块提供扩展功能,从而扬长避短;
1. pybind11是一个优秀的C++/Python绑定库只需要写很简单的代码就能够把函数、类等C++要素导入Python
1. Lua是另一种小巧快速的脚本语言它的兼容项目LuaJIT速度更快
1. 使用LuaBridge可以导出C++的函数、类但直接用LuaJIT的ffi库更好
1. 使用LuaBridge也可以很容易地执行Lua脚本、调用Lua函数让Lua跑在C++里。
## 课下作业
最后是课下作业时间,给你留两个思考题:
1. 你觉得使用脚本语言与C++搭建混合系统有什么优势?
1. 你觉得“把C++嵌入脚本语言”和“把脚本语言嵌入C++”有什么区别,哪种方式更好?
欢迎你在留言区写下你的思考和答案,如果觉得今天的内容对你有所帮助,也欢迎分享给你的朋友。我们下节课见。
<img src="https://static001.geekbang.org/resource/image/e4/2b/e47906e7f83ec210cc011e2652eee12b.jpg" alt="">

View File

@@ -0,0 +1,239 @@
<audio id="audio" title="18 | 性能分析:找出程序的瓶颈" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/aa/e5/aa37739f0b24085935bc2d189bc079e5.mp3"></audio>
你好我是Chrono。
今天是“技能进阶”单元的最后一节课,我也要兑现刚开始在“概论”里的承诺,讲一讲在运行阶段我们能做什么。
## 运行阶段能做什么
在编码阶段你会运用之前学习的各种范式和技巧写出优雅、高效的代码然后把它交给编译器。经过预处理和编译这两个阶段源码转换成了二进制的可执行程序就能够在CPU上“跑”起来。
在运行阶段C++静态程序变成了动态进程是一个实时、复杂的状态机由CPU全程掌控。但因为CPU的速度实在太快程序的状态又实在太多所以前几个阶段的思路、方法在这个时候都用不上。
所以,我认为,在运行阶段能做、应该做的事情主要有三件:**调试**Debug**、测试**Test**和性能分析**Performance Profiling
调试你一定很熟悉了常用的工具是GDB我在前面的“[轻松话题](https://time.geekbang.org/column/article/239599)”里也讲过一点它的使用技巧。它的关键是让高速的CPU慢下来把它降速到和人类大脑一样的程度于是我们就可以跟得上CPU的节奏理清楚程序的动态流程。
测试的目标是检验程序的功能和性能保证软件的质量它与调试是相辅相成的关系。测试发现Bug调试去解决Bug再返回给测试验证。好的测试对于软件的成功至关重要有很多现成的测试理论、应用、系统你可以参考下我就不多说了
一般来说,程序经过调试和测试这两个步骤,就可以上线运行了,进入第三个、也是最难的性能分析阶段。
什么是性能分析呢?
你可以把它跟Code Review对比一下。Code Review是一种静态的程序分析方法在编码阶段通过观察源码来优化程序、找出隐藏的Bug。而性能分析是一种动态的程序分析方法在运行阶段采集程序的各种信息再整合、研究找出软件运行的“瓶颈”为进一步优化性能提供依据指明方向。
从这个粗略的定义里,你可以看到,性能分析的关键就是“**测量**”,用数据说话。没有实际数据的支撑,优化根本无从谈起,即使做了,也只能是漫无目的的“不成熟优化”,即使成功了,也只是“瞎猫碰上死耗子”而已。
性能分析的范围非常广可以从CPU利用率、内存占用率、网络吞吐量、系统延迟等许多维度来评估。
今天我只讲多数时候最看重的CPU性能分析。因为CPU利用率通常是评价程序运行的好坏最直观、最容易获取的指标优化它是提升系统性能最快速的手段。而其他的几个维度也大多与CPU分析相关可以达到“以点带面”的效果。
## 系统级工具
刚才也说了,性能分析的关键是测量,而测量就需要使用工具,那么,你该选什么、又该怎么用工具呢?
其实Linux系统自己就内置了很多用于性能分析的工具比如top、sar、vmstat、netstat等等。但是Linux的性能分析工具太多、太杂有点“乱花渐欲迷人眼”的感觉想要学会并用在实际项目里不狠下一番功夫是不行的。
所以为了让你能够快速入门性能分析我根据我这些年的经验挑选了四个“高性价比”的工具top、pstack、strace和perf。它们用起来很简单而且实用性很强可以观测到程序的很多外部参数和内部函数调用由内而外、由表及里地分析程序性能。
第一个要说的是“**top**”它通常是性能分析的“起点”。无论你开发的是什么样的应用程序敲个top命令就能够简单直观地看到CPU、内存等几个最关键的性能指标。
top展示出来的各项指标的含义都非常丰富我来说几个操作要点吧帮助你快速地抓住它的关键信息。
一个是按“M”看内存占用RES/MEM另一个是按“P”看CPU占用这两个都会从大到小自动排序方便你找出最耗费资源的进程。
另外你也可以按组合键“xb”然后用“&lt;&gt;”手动选择排序的列,这样查看起来更自由。
我曾经做过一个“魔改”Nginx的实际项目下面的这个截图展示的就是一次top查看的性能
<img src="https://static001.geekbang.org/resource/image/6a/a8/6a44808ccc8b1df7bef0a51c888ce2a8.png" alt="">
从top的输出结果里你可以看到进程运行的概况知道CPU、内存的使用率。如果你发现某个指标超出了预期就说明可能存在问题接下来你就应该采取更具体的措施去进一步分析。
比如说这里面的一个进程CPU使用率太高我怀疑有问题那我就要深入进程内部看看到底是哪些操作消耗了CPU。
这时,我们可以选用两个工具:**pstack和strace**。
pstack可以打印出进程的调用栈信息有点像是给正在运行的进程拍了个快照你能看到某个时刻的进程里调用的函数和关系对进程的运行有个初步的印象。
下面这张截图显示了一个进程的部分调用栈可以看到跑了好几个ZMQ的线程在收发数据
<img src="https://static001.geekbang.org/resource/image/6c/9c/6c115ce03d6b4803960277468cf91b9c.png" alt="">
不过pstack显示的只是进程的一个“静态截面”信息量还是有点少而strace可以显示出进程的正在运行的系统调用实时查看进程与系统内核交换了哪些信息
<img src="https://static001.geekbang.org/resource/image/b7/f0/b747d0d977c7f420507ec9e9d84e6ff0.png" alt="">
把pstack和strace结合起来你大概就可以知道进程在用户空间和内核空间都干了些什么。当进程的CPU利用率过高或者过低的时候我们有很大概率能直接发现瓶颈所在。
不过,有的时候,你也可能会“一无所获”,毕竟这两个工具获得的信息只是“表象”,数据的“含金量”太低,做不出什么有效的决策,还是得靠“猜”。要拿到更有说服力的“数字”,就得**perf**出场了。
perf可以说是pstack和strace的“高级版”它按照固定的频率去“采样”相当于连续执行多次的pstack然后再统计函数的调用次数算出百分比。只要采样的频率足够大把这些“瞬时截面”组合在一起就可以得到进程运行时的可信数据比较全面地描述出CPU使用情况。
我常用的perf命令是“**perf top -K -p xxx**”按CPU使用率排序只看用户空间的调用这样很容易就能找出最耗费CPU的函数。
比如下面这张图显示的是大部分CPU时间都消耗在了ZMQ库上其中内存拷贝调用居然达到了近30%,是不折不扣的“大户”。所以,只要能把这些拷贝操作减少一点,就能提升不少性能。
<img src="https://static001.geekbang.org/resource/image/55/15/5543dec44c23d23b583bc937213e7c15.png" alt="">
总之,**使用perf通常可以快速定位系统的瓶颈帮助你找准性能优化的方向**。课下你也可以自己尝试多分析各种进程比如Redis、MySQL等等观察它们都在干什么。
## 源码级工具
top、pstack、strace和perf属于“非侵入”式的分析工具不需要修改源码就可以在软件的外部观察、收集数据。它们虽然方便易用但毕竟是“隔岸观火”还是不能非常细致地分析软件效果不是太理想。
所以,我们还需要有“侵入”式的分析工具,在源码里“埋点”,直接写特别的性能分析代码。这样针对性更强,能够有目的地对系统的某个模块做精细化分析,拿到更准确、更详细的数据。
其实,这种做法你并不陌生,比如计时器、计数器、关键节点打印日志,等等,只是通常并没有上升到性能分析的高度,手法比较“原始”。
在这里,我要推荐一个专业的源码级性能分析工具:**Google Performance Tools**一般简称为gperftools。它是一个C++工具集里面包含了几个专门的性能分析工具还有一个高效的内存分配器tcmalloc分析效果直观、友好、易理解被广泛地应用于很多系统经过了充分的实际验证。
```
apt-get install google-perftools
apt-get install libgoogle-perftools-dev
```
gperftools的性能分析工具有CPUProfiler和HeapProfiler两种用来分析CPU和内存。不过如果你听从我的建议总是使用智能指针、标准容器不使用new/delete就完全可以不用关心HeapProfiler。
CPUProfiler的原理和perf差不多也是按频率采样默认是每秒100次100Hz也就是每10毫秒采样一次程序的函数调用情况。
它的用法也比较简单,只需要在源码里添加三个函数:
- **ProfilerStart()**,开始性能分析,把数据存入指定的文件里;
- **ProfilerRegisterThread()**,允许对线程做性能分析;
- **ProfilerStop()**,停止性能分析。
所以你只要把想做性能分析的代码“夹”在这三个函数之间就行运行起来后gperftools就会自动产生分析数据。
为了写起来方便我用shared_ptr实现一个自动管理功能。这里利用了void*和空指针可以在智能指针析构的时候执行任意代码简单的RAII惯用法
```
auto make_cpu_profiler = // lambda表达式启动性能分析
[](const string&amp; filename) // 传入性能分析的数据文件名
{
ProfilerStart(filename.c_str()); // 启动性能分析
ProfilerRegisterThread(); // 对线程做性能分析
return std::shared_ptr&lt;void&gt;( // 返回智能指针
nullptr, // 空指针,只用来占位
[](void*){ // 删除函数执行停止动作
ProfilerStop(); // 停止性能分析
}
);
};
```
下面我写一小段代码,测试正则表达式处理文本的性能:
```
auto cp = make_cpu_profiler(&quot;case1.perf&quot;); // 启动性能分析
auto str = &quot;neir:automata&quot;s;
for(int i = 0; i &lt; 1000; i++) { // 循环一千次
auto reg = make_regex(R&quot;(^(\w+)\:(\w+)$)&quot;);// 正则表达式对象
auto what = make_match();
assert(regex_match(str, what, reg)); // 正则匹配
}
```
注意我特意在for循环里定义了正则对象现在就可以用gperftools来分析一下这样做是不是成本很高。
编译运行后会得到一个“case1.perf”的文件里面就是gperftools的分析数据但它是二进制的不能直接查看如果想要获得可读的信息还需要另外一个工具脚本pprof。
但是pprof脚本并不含在apt-get的安装包里所以你还要从[GitHub](https://github.com/gperftools/gperftools)上下载源码,然后用“`--text`”选项,就可以输出文本形式的分析报告:
```
git clone git@github.com:gperftools/gperftools.git
pprof --text ./a.out case1.perf &gt; case1.txt
Total: 72 samples
4 5.6% 5.6% 4 5.6% __gnu_cxx::__normal_iterator::base
4 5.6% 11.1% 4 5.6% _init
4 5.6% 16.7% 4 5.6% std::vector::begin
3 4.2% 20.8% 4 5.6% __gnu_cxx::operator-
3 4.2% 25.0% 5 6.9% std::__distance
2 2.8% 27.8% 2 2.8% __GI___strnlen
2 2.8% 30.6% 6 8.3% __GI___strxfrm_l
2 2.8% 33.3% 3 4.2% __dynamic_cast
2 2.8% 36.1% 2 2.8% __memset_sse2
2 2.8% 38.9% 2 2.8% operator new[]
```
pprof的文本分析报告和perf的很像也是列出了函数的采样次数和百分比但因为是源码级的采样会看到大量的内部函数细节虽然很详细但很难找出重点。
好在pprof也能输出图形化的分析报告支持有向图和火焰图需要你提前安装Graphviz和FlameGraph
```
apt-get install graphviz
git clone git@github.com:brendangregg/FlameGraph.git
```
然后,你就可以使用“`--svg`”“`--collapsed`”等选项,生成更直观易懂的图形报告了:
```
pprof --svg ./a.out case1.perf &gt; case1.svg
pprof --collapsed ./a.out case1.perf &gt; case1.cbt
flamegraph.pl case1.cbt &gt; flame.svg
flamegraph.pl --invert --color aqua case1.cbt &gt; icicle.svg
```
我就拿最方便的火焰图来“看图说话”吧。你也可以在[GitHub](https://github.com/chronolaw/cpp_study/blob/master/section4/icicle.svg)上找到原图。
<img src="https://static001.geekbang.org/resource/image/75/30/7587a411eb9c7a16f68bd3453a1eec30.png" alt="">
这张火焰图实际上是“倒置”的冰柱图,显示的是自顶向下查看函数的调用栈。
由于C++有名字空间、类、模板等特性函数的名字都很长看起来有点费劲不过这样也比纯文本要直观一些可以很容易地看出正则表达式占用了绝大部分的CPU时间。再仔细观察的话就会发现_Compiler()这个函数是真正的“罪魁祸首”。
找到了问题所在,现在我们就可以优化代码了,把创建正则对象的语句提到循环外面:
```
auto reg = make_regex(R&quot;(^(\w+)\:(\w+)$)&quot;); // 正则表达式对象
auto what = make_match();
for(int i = 0; i &lt; 1000; i++) { // 循环一千次
assert(regex_match(str, what, reg)); // 正则匹配
}
```
再运行程序你会发现程序瞬间执行完毕而且因为优化效果太好gperftools甚至都来不及采样不会产生分析数据。
基本的gperftools用法就这么多了你可以再去看它的[官方文档](https://github.com/gperftools/gperftools/tree/master/docs)了解更多的用法比如使用环境变量和信号来控制启停性能分析或者链接tcmalloc库优化C++的内存分配速度。
## 小结
好了今天主要讲了运行阶段里的性能分析它能够回答为什么系统“不够好”not good enough而调试和测试回答的是为什么系统“不好”not good
简单小结一下今天的内容:
1. 最简单的性能分析工具是top可以快速查看进程的CPU、内存使用情况
1. pstack和strace能够显示进程在用户空间和内核空间的函数调用情况
1. perf以一定的频率采样分析进程统计各个函数的CPU占用百分比
1. gperftools是“侵入”式的性能分析工具能够生成文本或者图形化的分析报告最直观的方式是火焰图。
性能分析与优化是一门艰深的课题也是一个广泛的议题CPU、内存、网络、文件系统、数据库等等每一个方向都可以再引出无数的话题。
今天介绍的这些是我挑选的对初学者最有用的内容学习难度不高容易上手见效快。希望你能以此为契机在今后的日子里多用、多实际操作并且不断去探索、应用其他的分析工具综合运用它们给程序“把脉”才能让C++在运行阶段跑得更好更快更稳,才能不辜负前面编码、预处理和编译阶段的苦心与努力。
## 课下作业
最后还是留两个思考题吧:
1. 你觉得在运行阶段还能够做哪些事情?
1. 你有性能分析的经验吗?听了今天的这节课之后,你觉得什么方式比较适合自己?
欢迎你在留言区写下你的思考和答案,如果觉得今天的内容对你有所帮助,也欢迎分享给你的朋友。我们下节课见。
<img src="https://static001.geekbang.org/resource/image/45/f1/45adfe31c60a89ff54b7dbce366e2bf1.png" alt="">

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="">

View File

@@ -0,0 +1,145 @@
<audio id="audio" title="01 | 重新认识C++:生命周期和编程范式" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d3/9c/d3dd3640be80d4be65703babec7b0e9c.mp3"></audio>
你好我是Chrono。
今天是专栏的第一节正式课。我想既然你选择了这个专栏你就应该已经对C++有所了解了,而且有过一些开发经验,甚至还掌握了一两种其他的语言。
苏轼有诗云“不识庐山真面目只缘身在此山中。”学习C++很容易被纷繁复杂的语法细节所吸引、迷惑所以我决定从“生命周期”和“编程范式”这两个不太常见的角度来“剖析”一下C++站在一个更高的层次上审视这门“历久弥新”的编程语言帮你认清楚C++最本质的东西。
这样,今后在写程序的时候,你也会有全局观或者说是大局观,更能从整体上把握程序架构,而不会迷失在那些琐碎的细枝末节里。
现在我们先来了解下C++的生命周期。
## C++程序的生命周期
如果你学过一点软件工程的知识,就一定知道“瀑布模型”,它定义了软件或者是项目的生命周期——从需求分析开始,经过设计、开发、测试等阶段,直到最终交付给用户。
“瀑布模型”把软件的生命周期分成了多个阶段,每个阶段之间分工明确,相互独立,而且有严格的先后次序,是一个经典的开发模型。虽然它已经不再适合瞬息万变的互联网产业了,但仍然有许多值得借鉴和参考的地方。
那么说了半天“瀑布模型”跟C++程序有什么关系呢?
其实从软件工程的视角来看一个C++程序的生命周期也是“瀑布”形态的也可以划分出几个明确的阶段阶段之间顺序衔接使用类似的方法就可以更好地理解C++程序的运行机制,帮助我们写出更好的代码。
不过因为C++程序本身就已经处在“开发”阶段了所以不会有“需求分析”“设计”这样的写文档过程。所以一个C++程序从“诞生”到“消亡”,要经历这么几个阶段:**编码Coding、预处理Pre-processing、编译Compiling和运行Running**。
<img src="https://static001.geekbang.org/resource/image/b6/4c/b6696db53248d122cd57ddd9a8e52a4c.jpg" alt="" title="C++程序的四个阶段">
## C++程序的四个阶段
**编码**应该是你很熟悉的一个阶段了,这也是我们“明面”上的开发任务最集中的地方。
在这个阶段,我们的主要工作就是在编辑器里“敲代码”:定义变量,写语句,实现各种数据结构、函数和类。
**编码阶段是C++程序生命周期的起点也是最重要的阶段是后续阶段的基础直接决定了C++程序的“生存质量”**
显然,在编码阶段,我们必须要依据一些规范,不能“胡写一气”,**最基本的要求是遵循语言规范和设计文档,再高级一点的话,还有代码规范、注释规范、设计模式、编程惯用法,等等**。现在市面上绝大部分的资料都是在教授这个阶段的知识,在专栏后面,我也会重点讲一讲我在这方面的一些经验积累。
那么,编码阶段之后是什么呢?
可能对你来说稍微有点陌生,这个阶段叫**预处理**。
所谓的预处理,其实是相对于下一个阶段“编译”而言的,在编译之前,预先处理一下源代码,既有点像是编码,又有点像是编译,是一个中间阶段。
预处理是C/C++程序独有的阶段其他编程语言都没有这也算是C/C++语言的一个特色了。
在这个阶段发挥作用的是预处理器Pre-processor。它的输入是编码阶段产生的源码文件输出是经过“预处理”的源码文件。“预处理”的目的是文字替换用到的就是我们熟悉的各种预处理指令比如#include#define#if等,实现“**预处理编程**”。这部分内容,我后面还会展开讲。
不过,你要注意的是,它们都以符号“#”开头虽然是C++程序的一部分但严格来说不属于C++语言的范畴,因为它走的是预处理器。
在预处理之后C++程序就进入了**编译**阶段更准确地说应该是“编译”和“链接Linking”。简单起见我统一称之为“编译”。
在编译阶段C++程序——也就是经过预处理的源码——要经过编译器和链接器的“锤炼”生成可以在计算机上运行的二进制机器码。这里面的讲究是最多的也是最复杂的C++编译器要分词、语法解析、生成目标码,并尽可能地去优化。
在编译的过程中编译器还会根据C++语言规则检查程序的语法、语义是否正确发现错误就会产生“编译失败”。这就是最基本的C++“静态检查”。
在处理源码时由于编译器是依据C++语法检查各种类型、函数的定义,所以,在这个阶段,我们就能够以编译器为目标进行编程,有意识地控制编译器的行为。这里有个新名词,叫“**模板元编程**”。不过,“模板元编程”比较复杂,不太好理解,属于比较高级的用法,稍后我会再简单讲一下。
编译阶段之后有了可执行文件C++程序就可以跑起来了,进入**运行**阶段。这个时候“静态的程序”被载入内存由CPU逐条语句执行就形成了“动态的进程”。
运行阶段也是我们最熟悉的了。在这个阶段我们常做的是GDB调试、日志追踪、性能分析等然后收集动态的数据、调整设计思路再返回编码阶段重走这个“瀑布模型”实现“螺旋上升式”的开发。
<img src="https://static001.geekbang.org/resource/image/9c/df/9cb2036ae3dbda30a00d58bdd4834ddf.jpg" alt="">
好了梳理清楚了C++程序生命周期的四个阶段,你可以看到,这和软件工程里的“瀑布模型”很相似,这些阶段也是职责明确的,前一个阶段的输出作为后一个阶段的输入,而且每个阶段都有自己的工作特点,我们可以有针对性地去做编程开发。
还有别忘了软件工程里的“蝴蝶效应”“混沌理论”大概意思是一个Bug在越早的阶段发现并解决它的价值就越高一个Bug在越晚的阶段发现并解决它的成本就越高。
所以,依据这个生命周期模型,**我们应该在“编码”“预处理”“编译”这前面三个阶段多下功夫消灭Bug优化代码尽量不要让Bug在“运行”阶段才暴露出来**,也就是所谓的“把问题扼杀在萌芽期”。
## C++语言的编程范式
说完了C++程序的生命周期再来看看C++的编程范式Paradigm
什么是编程范式呢?
关于这个概念,没有特别权威的定义,我给一个比较通俗的解释:**“编程范式”是一种“方法论”,就是指导你编写代码的一些思路、规则、习惯、定式和常用语**。
编程范式和编程语言不同,有的范式只能用于少数特定的语言,有的范式却适用于大多数语言;有的语言可能只支持一种范式,有的语言却可能支持多种范式。
那么你一定知道或者听说过C++是一种**多范式**的编程语言。具体来说现代C++11/14以后支持“面向过程”“面向对象”“泛型”“模板元”“函数式”这五种主要的编程范式。
其中,**“面向过程”“面向对象”是基础,支撑着后三种范式**。我画了一个“五环图”,圆环重叠表示有的语言特性会同时应用于多种范式,可以帮你理解它们的关系。
<img src="https://static001.geekbang.org/resource/image/6e/87/6ef13308109b2d1795e43c5206c32687.jpg" alt="" title="C++编程范式的“五环图”">
接下来,我就和你详细说说这五种编程范式。
## C++语言的五种范式
**面向过程**是C++里最基本的一种编程范式。它的核心思想是“命令”,通常就是顺序执行的语句、子程序(函数),把任务分解成若干个步骤去执行,最终达成目标。
面向过程体现在C++中就是源自它的前身——C语言的那部分比如变量声明、表达式、分支/循环/跳转语句,等等。
**面向对象**是C++里另一个基本的编程范式。**它的核心思想是“抽象”和“封装”**,倡导的是把任务分解成一些高内聚低耦合的对象,这些对象互相通信协作来完成任务。它强调对象之间的关系和接口,而不是完成任务的具体步骤。
在C++里面向对象范式包括class、public、private、virtual、this等类相关的关键字还有构造函数、析构函数、友元函数等概念。
**泛型编程**是自STL标准模板库纳入到C++标准以后才逐渐流行起来的新范式,核心思想是“一切皆为类型”,或者说是“参数化类型”“类型擦除”,使用模板而不是继承的方式来复用代码,所以运行效率更高,代码也更简洁。
在C++里泛型的基础就是template关键字然后是庞大而复杂的标准库里面有各种泛型容器和算法比如vector、map、sort等等。
与“泛型编程”很类似的是**模板元编程**这个词听起来好像很新其实也有十多年的历史了不过相对于前三个范式来说确实“资历浅”。它的核心思想是“类型运算”操作的数据是编译时可见的“类型”所以也比较特殊代码只能由编译器执行而不能被运行时的CPU执行。
在讲编译阶段的时候我也说了模板元编程是一种高级、复杂的技术C++语言对它的支持也比较少更多的是以库的方式来使用比如type_traits、enable_if等。
最后一个**函数式**它几乎和“面向过程”一样古老但却直到近些年才走入主流编程界的视野。所谓的“函数式”并不是C++里写成函数的子程序,而是数学意义上、无副作用的函数,**核心思想是“一切皆可调用”,通过一系列连续或者嵌套的函数调用实现对数据的处理**。
函数式早在C++98时就有少量的尝试bind1st/bind2nd等函数对象但直到C++11引入了Lambda表达式它才真正获得了可与其他范式并驾齐驱的地位。
好了介绍完了这五种编程范式你可以看到它们基本覆盖了C++语言和标准库的各个成分,彼此之间虽然有重叠,但在理念、关键字、实现机制、运行阶段等方面的差异还是非常大的。
这就好像是五种秉性不同的“真气”在C++语言里必须要有相当“浑厚”的内力才能把它们压制、收服、炼化,否则的话,一旦运用不当,就很容易“精神分裂”“走火入魔”。
**说得具体一点,就是要认识、理解这些范式的优势和劣势,在程序里适当混用,取长补短才是“王道”**
说到这儿,你肯定很关心,该选择哪种编程范式呢?
拿我自己来说,我的出发点是“**尽量让周围的人都能看懂代码**”,所以常用的范式是“过程+对象+泛型”,再加上少量的“函数式”,慎用“模板元”。
对于你来说,我建议根据自己的实际工作需求来决定。
我个人觉得,**面向过程和面向对象是最基本的范式是C++的基础,无论如何都是必须要掌握的**,而后三种范式的学习难度就大一些。
如果是开发直接面对用户的普通应用Application那么你可以再研究一下“泛型”和“函数式”就基本可以解决90%的开发问题了如果是开发面向程序员的库Library那么你就有必要深入了解“泛型”和“模板元”优化库的接口和运行效率。
当然,还有一种情况:如果你愿意挑战“最强大脑”,那么,“模板元编程”就绝对是你的不二选择(笑)。
## 小结
今天是开篇第一课我带你从“生命周期”和“编程范式”这两个特别的角度深度“透视”了一下C++,做个简单小结:
1. C++程序的生命周期包括编码、预处理、编译、运行四个阶段,它们都有各自的特点;
1. 虽然我们只写了一个C++程序但里面的代码可能会运行在不同的阶段分别由预处理器、编译器和CPU执行
1. C++支持面向过程、面向对象、泛型、模板元、函数式共五种主要的编程范式;
1. 在C++里可以“无缝”混用多范式编程,但因为范式的差异比较大,必须小心谨慎,避免导致混乱。
## 课下作业
最后是课下作业时间,给你留两个思考题:
1. 你是怎么理解C++程序的生命周期和编程范式的?
1. 试着从程序的生命周期和编程范式的角度把C++和其他语言例如Java、Python做个比较说说C++的优点和缺点分别是什么。
欢迎你在留言区写下你的思考和答案,如果觉得对你有所帮助,也欢迎把今天的内容分享给你的朋友,我们下节课见。
<img src="https://static001.geekbang.org/resource/image/4a/a4/4a40e5b8c618ab38945c1346ab3878a4.jpg" alt="">

View File

@@ -0,0 +1,232 @@
<audio id="audio" title="02 | 编码阶段能做什么秀出好的code style" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/7e/d7/7edff57af7a2f188139c7783ba030fd7.mp3"></audio>
你好我是Chrono。
上节课我介绍了C++程序的生命周期和编程范式,今天我就接着展开来讲,看看在编码阶段我们能做哪些事情。
在编码阶段,我们的核心任务就是写出在预处理、编译和运行等不同阶段里执行的代码,还记得那句编程格言吗:
“**任何人都能写出机器能看懂的代码,但只有优秀的程序员才能写出人能看懂的代码。**”
所以我们在编码阶段的首要目标不是实现功能而是要写出清晰易读的代码也就是要有好的code style。
怎么样才能写出human readable的好代码呢
这就需要有一些明确的、经过实践验证的规则来指导,只要自觉遵守、合理运用这些规则,想把代码写“烂”都很难。
在此,我强烈推荐一份非常棒的[指南](http://openresty.org/cn/c-coding-style-guide.html)它来自OpenResty的作者章亦春代码风格参照的是顶级开源产品Nginx内容非常详细完善。
不过有一点要注意这份指南描述的是C语言但对于C++仍然有很好的指导意义,所以接下来我就以它为基础,加上我的工作体会,从**代码格式**、**标识符命名**和**注释**三个方面讲一下怎么“秀出好的code style”。
## 空格与空行
当我们拿到一份编码风格指南的时候,不论它是公司内部的还是外部的,通常第一感觉就是“头大”,几十个、上百个的条款罗列在一起,规则甚至细致到了标点符号,再配上干巴巴的说明和示例,不花个半天功夫是绝对看不完的。而且,最大的可能是半途而废,成了“从入门到放弃”。
我写了很多年代码也看过不少这样的文档我从中总结出了一条最基本、最关键的规则只要掌握了这条规则就可以把你code style的“颜值”起码提高80%。
这条“神奇”的规则是什么呢?
认真听,只有五个字:**留白的艺术**。
再多说一点,就是像“写诗”一样去写代码,**恰当地运用空格和空行**。不要为了“节省篇幅”和“紧凑”而把很多语句挤在一起,而是要多用空格分隔开变量与操作符,用空行分隔开代码块,保持适当的阅读节奏。
你可以看看下面的这个示例,这是我从某个实际的项目中摘出来的真实代码(当然,我隐去了一些敏感信息):
```
if(!value.contains(&quot;xxx&quot;)){
LOGIT(WARNING,&quot;value is incomplete.\n&quot;)
return;
}
char suffix[16]=&quot;xxx&quot;;
int data_len = 100;
if(!value.empty()&amp;&amp;value.contains(&quot;tom&quot;)){
const char* name=value.c_str();
for(int i=0;i&lt;MAX_LEN;i++){
... // do something
}
int count=0;
for(int i=0;i&lt;strlen(name);i++){
... // do something
}
}
```
这段代码真可谓是“高密度”密密麻麻一大堆看着“有如滔滔江水连绵不绝”读起来让人“窒息”code style非常糟糕。
应用“留白的艺术”,代码就变成了下面的样子:
```
if (!value.contains(&quot;xxx&quot;)) { // if后{前有空格
LOGIT(WARNING, &quot;value is incomplete.\n&quot;) // 逗号后有空格
return; // 逻辑联系紧密就不用加空行
}
// 新增空行分隔段落
char suffix[16] = &quot;xxx&quot;; // 等号两边有空格
int data_len = 100; // 逻辑联系紧密就不用加空行
// 新增空行分隔段落
if (!value.empty() &amp;&amp; value.contains(&quot;tom&quot;)) { // &amp;&amp;两边有空格
const char* name = value.c_str(); // 等号两边有空格
// 新增空行分隔段落
for(int i = 0; i &lt; MAX_LEN; i++){ // =;&lt;处有空格
... // do something
}
// 新增空行分隔段落
int count = 0; // 等号两边有空格
// 新增空行分隔段落
for(int i = 0; i &lt; strlen(name); i++){ // =;&lt;处有空格
... // do something
}
}
```
加上了适当的空格和空行后,代码就显得错落有致,舒缓得当,看着就像是莎翁的十四行诗,读起来不那么累,也更容易理清楚代码的逻辑。
我还有个私人观点:**好程序里的空白行至少要占到总行数的20%以上**。虽然比较“极端”,但也不失为一个可量化的指标,你可以在今后的实际工作中尝试一下。
## 起个好名字
有了好的代码格式,接下来我们要操心的就是里面的内容了,而其中一个很重要的部分就是为变量、函数、类、项目等起一个好听易懂的名字。
这里有一个广泛流传的笑话:“**缓存失效与命名是计算机科学的两大难题**。”把起名字与缓存失效(也有说是并发)相提并论,足见它有多么重要了,值得引起你的重视。
但其实命名这件事并不难,主要就在于平时的词汇和经验积累,知道在什么情况下用哪个单词最合适,千万不要偷懒用“谜之缩写”和汉语拼音(更有甚者,是汉语拼音的缩写)。由于现在搜索引擎、电子词典都很发达,只要你有足够认真的态度,在网上一搜,就能够找到合适的名字。
另外你还可以用一些已经在程序员之间形成了普遍共识的变量名比如用于循环的i/j/k、用于计数的count、表示指针的p/ptr、表示缓冲区的buf/buffer、表示变化量的delta、表示总和的sum……
关于命名的风格,我所知道的应用比较广的有三种。
第一种风格叫“匈牙利命名法”在早期的Windows上很流行使用前缀i/n/sz等来表示变量的类型比如iNum/szName。它把类型信息做了“硬编码”不适合代码重构和泛型编程所以目前基本上被淘汰了。
不过它里面有一种做法我还是比较欣赏的就是给成员变量加“m_”前缀member给全局变量加“g_”前缀global比如m_count、g_total这样一看就知道了变量的作用域在大型程序里还是挺有用的。
第二种风格叫“CamelCase”也就是“驼峰式命名法”在Java语言里非常流行主张单词首字母大写比如MyJobClass、tryToLock但这种风格在C++世界里的接受程度不是太高。
第三种风格叫“snake_case”用的是全小写单词之间用下划线连接。这是C和C++主要采用的命名方式看一下标准库里面的vector、unordered_set、shrink_to_fit都是这样。
那么,你该选用哪种命名风格呢?
我的建议是“取百家之长”,混用这几种中能够让名字辨识度最高的那些优点,就是四条规则:
1. 变量、函数名和名字空间用snake_case全局变量加“g_”前缀
1. 自定义类名用CamelCase成员函数用snake_case成员变量加“m_”前缀
1. 宏和常量应当全大写,单词之间用下划线连接;
1. 尽量不要用下划线作为变量的前缀或者后缀比如_local、name_很难识别。
下面我举几个例子,你一看就能明白了:
```
#define MAX_PATH_LEN 256 //常量,全大写
int g_sys_flag; // 全局变量加g_前缀
namespace linux_sys { // 名字空间,全小写
void get_rlimit_core(); // 函数,全小写
}
class FilePath final // 类名,首字母大写
{
public:
void set_path(const string&amp; str); // 函数,全小写
private:
string m_path; // 成员变量m_前缀
int m_level; // 成员变量m_前缀
};
```
命名另一个相关的问题是“名字的长度”,有人喜欢写得长,有人喜欢写得短,我觉得都可以,只要易读易写就行。
不过一个被普遍认可的原则是:**变量/函数的名字长度与它的作用域成正比**,也就是说,局部变量/函数名可以短一点,而全局变量/函数名应该长一点。
想一下,如果你辛辛苦苦起了一个包含四五个单词的长名字,却只能在短短十几行的循环体里使用,岂不是太浪费了?
## 用好注释
写出了有好名字的变量、函数和类还不够,要让其他人能“一眼”看懂代码,还需要加上注释。
“注释”在任何编程语言里都是一项非常重要的功能甚至在编程语言之外比如配置文件ini、yml、标记语言html、xml都有注释。一个突出的反例就是JSON没有注释功能让许多人都很不适应。
注释表面上的功能很简单,就是给代码配上额外的文字,起到注解、补充说明的作用。但就像是写文章一样,写些什么、写多写少、写成什么样子,都是大有讲究的。
你可能也有不少写注释的经验了,一般来说,注释可以用来阐述目的、用途、工作原理、注意事项等代码本身无法“自说明”的那些东西。
但要小心,注释必须要正确、清晰、有效,尽量言简意赅、点到为止,不要画蛇添足,更不能写出含糊、错误的注释。
比如说有这么一个模板函数get_value
```
template&lt;typename T&gt;
int get_value(const T&amp; v);
```
代码很简单,但可用的信息太少了,你就可以给它加上作者、功能说明、调用注意事项、可能的返回值,等等,这样看起来就会舒服得多:
```
// author : chrono
// date : 2020-xx-xx
// purpose : get inner counter value of generic T
// notice : T must have xxx member
// notice : return value maybe -1, means xxx, you should xxx
template&lt;typename T&gt;
int get_value(const T&amp; v);
```
你可能注意到了在注释里我都用的是英文因为英文ASCII或者说是UTF-8的“兼容性”最好不会由于操作系统、编码的问题变成无法阅读的乱码而且还能够锻炼自己的英语表达能力。
不过,用英文写注释的同时也对你提出了更高的要求,最基本的是**不要出现低级的语法、拼写错误**。别笑我就经常见到有人英文水平不佳或者是“敷衍了事”写出的都是“Chinglish”看了让人哭笑不得。
写注释最好也要有一些标准的格式比如用统一的“标签”来标记作者、参数说明什么的。这方面我觉得你可以参考Javadoc它算是一个不错的工程化实践。
对于C++来说也有一个类似的工具叫Doxgen用好它甚至可以直接从源码生成完整的API文档。不过我个人觉得Doxgen的格式有些过于“死板”很难严格执行是否采用就在于你自己了。
除了给代码、函数、类写注释,我还建议**最好在文件的开头写上本文件的注释**,里面有文件的版权声明、更新历史、功能描述,等等。
下面这个就是我比较常用的一个文件头注释,简单明了,你可以参考一下。
```
// Copyright (c) 2020 by Chrono
//
// file : xxx.cpp
// since : 2020-xx-xx
// desc : ...
```
另外注释还有一个很有用的功能就是todo作为功能的占位符提醒将来的代码维护者也许就是你比如
```
// TODO: change it to unordered_map
// XXX: fixme later
```
总的来说,要写好注释,你要时刻“换位思考”,设身处地去想别人怎么看你的代码,这样的话,上面的那些细则也就不难实施了。
## 小结
在编码阶段,拥有一个良好的编程习惯和态度是非常重要的(我见过太多对此漫不经心的“老”程序员)。今天,我只介绍了三个最基本的部分,再来“敲黑板”画个重点:
1. 用好空格和空行,多留白,让写代码就像写诗一样;
1. 给变量、函数、类起个好名字,你的代码就成功了一半;
1. 给变量、函数、类再加上注释,让代码自带文档,就成了“人能够看懂的代码”。
有了这个基础你还可以更进一步使用其他高级规则写出更好的代码比如函数体不能太长、入口参数不宜过多避免使用else/switch导致层次太深圈复杂度等等这些虽然也很有用但比较琐碎暂时就不细说了。
对了还有一招“必杀技”善用code review和你周围的同事互相审查代码可以迅速改善自己的code style。
## 课下作业
最后是课下作业时间,给你留两个思考题:
1. 你用过哪些好的code style你最喜欢今天介绍的哪几条
1. 注释在代码里通常的作用是“说明文档”,但它还有另外一个重要的用法,你知道吗?
欢迎你在留言区写下你的思考和答案,如果觉得对你有所帮助,也欢迎分享给你的朋友,我们下节课见。
<img src="https://static001.geekbang.org/resource/image/3a/41/3a5325510a8c10a318f82f9ac2696941.jpg" alt="">

View File

@@ -0,0 +1,297 @@
<audio id="audio" title="03 | 预处理阶段能做什么:宏定义和条件编译" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d2/03/d25824d6423a51ee9db36a18dae10e03.mp3"></audio>
你好我是Chrono。
上一次我讲了在编码阶段要有好的code style尽量写出“人能够看懂的代码”。今天我就继续讲讲编码后的预处理阶段看看这个阶段我们能做哪些事情。
## 预处理编程
其实只要写C/C++程序,就会用到预处理,只是大多数时候,你只用到了它的一点点功能。比如,在文件开头写上“#include <vector>”这样的语句,或者用“#define”定义一些常数。只是这些功能都太简单了,没有真正发挥出预处理器的本领,所以你可能几乎感觉不到它的存在。</vector>
预处理只能用很少的几个指令,也没有特别严谨的“语法”,但它仍然是一套完整自洽的语言体系,使用预处理也能够实现复杂的编程,解决一些特别的问题——虽然代码可能会显得有些“丑陋”“怪异”。
那么,“预处理编程”到底能干什么呢?
你一定要记住:**预处理阶段编程的操作目标是“源码”,用各种指令控制预处理器,把源码改造成另一种形式,就像是捏橡皮泥一样。**
把上面的这句话多读几遍,仔细揣摩体会一下,理解了之后,你再去用那些预处理指令就会有不一样的感觉了。
C++语言有近百个关键字,预处理指令只有十来个,实在是少得可怜。而且,常用的也就是#include#define#if,所以很容易掌握。不过,有几个小点我还是要特别说一下。
首先,预处理指令都以符号“#”开头这个你应该很熟悉了。但同时你也应该意识到虽然都在一个源文件里但它不属于C++语言它走的是预处理器不受C++语法规则的约束。
所以预处理编程也就不用太遵守C++代码的风格。一般来说预处理指令不应该受C++代码缩进层次的影响不管是在函数、类里还是在if、for等语句里永远是**顶格写**。
另外,单独的一个“#”也是一个预处理指令,叫“空指令”,可以当作特别的预处理空行。而“#”与后面的指令之间也可以有空格,从而实现缩进,方便排版。
下面是一个示例,#号都在行首而且if里面的define有缩进看起来还是比较清楚的。以后你在写预处理代码的时候可以参考这个格式。
```
# // 预处理空行
#if __linux__ // 预处理检查宏是否存在
# define HAS_LINUX 1 // 宏定义,有缩进
#endif // 预处理条件语句结束
# // 预处理空行
```
预处理程序也有它的特殊性暂时没有办法调试不过可以让GCC使用“-E”选项略过后面的编译链接只输出预处理后的源码比如
```
g++ test03.cpp -E -o a.cxx #输出预处理后的源码
```
多使用这种方式,对比一下源码前后的变化,你就可以更好地理解预处理的工作过程了。
这几个小点有些杂,不过你只要记住“**#开头、顶格写**”就行了。
## 包含文件(#include
先来说说最常用的预处理指令“#include”,它的作用是“**包含文件**”。注意,不是“包含头文件”,而是**可以包含任意的文件**。
也就是说,只要你愿意,使用“#include”可以把源码、普通文本,甚至是图片、音频、视频都引进来。当然,出现无法处理的错误就是另外一回事了。
```
#include &quot;a.out&quot; // 完全合法的预处理包含指令,你可以试试
```
可以看到,“#include”其实是非常“弱”的,不做什么检查,就是“死脑筋”把数据合并进源文件。
所以,在写头文件的时候,为了防止代码被重复包含,通常要加上“**Include Guard**”,也就是用“#ifndef/#define/#endif”来保护整个头文件,像下面这样:
```
#ifndef _XXX_H_INCLUDED_
#define _XXX_H_INCLUDED_
... // 头文件内容
#endif // _XXX_H_INCLUDED_
```
这个手法虽然比较“原始”但在目前来说C++11/14是唯一有效的方法而且也向下兼容C语言。所以我建议你在所有头文件里强制使用。
除了最常用的包含头文件,你还可以利用“#include”的特点玩些“小花样”,编写一些代码片段,存进“*.inc”文件里然后有选择地加载用得好的话可以实现“源码级别的抽象”。
比如说,有一个用于数值计算的大数组,里面有成百上千个数,放在文件里占了很多地方,特别“碍眼”:
```
static uint32_t calc_table[] = { // 非常大的一个数组,有几十行
0x00000000, 0x77073096, 0xee0e612c, 0x990951ba,
0x076dc419, 0x706af48f, 0xe963a535, 0x9e6495a3,
0x0edb8832, 0x79dcb8a4, 0xe0d5e91e, 0x97d2d988,
0x09b64c2b, 0x7eb17cbd, 0xe7b82d07, 0x90bf1d91,
...
};
```
这个时候,你就可以把它单独摘出来,另存为一个“*.inc”文件然后再用“#include”替换原来的大批数字。这样就节省了大量的空间,让代码更加整洁。
```
static uint32_t calc_table[] = {
# include &quot;calc_values.inc&quot; // 非常大的一个数组,细节被隐藏
};
```
## 宏定义(#define/#undef
接下来要说的是预处理编程里最重要、最核心的指令“#define”,它用来定义一个源码级别的“**文本替换**”,也就是我们常说的“**宏定义**”。
#define”可谓“无所不能”在预处理阶段可以无视C++语法限制,替换任何文字,定义常量/变量实现函数功能为类型起别名typedef减少重复代码……
不过也正是因为它太灵活如果过于随意地去使用宏来写程序就有可能把正常的C++代码搞得“千疮百孔”,替换来替换去,都不知道真正有效的代码是什么样子了。
所以,**使用宏的时候一定要谨慎,时刻记着以简化代码、清晰易懂为目标,不要“滥用”,避免导致源码混乱不堪,降低可读性。**
下面,我就说几个注意事项,帮你用好宏定义。
首先因为宏的展开、替换发生在预处理阶段不涉及函数调用、参数传递、指针寻址没有任何运行期的效率损失所以对于一些调用频繁的小代码片段来说用宏来封装的效果比inline关键字要更好因为它真的是源码级别的无条件内联。
下面有几个示例摘自Nginx你可以作为参考
```
#define ngx_tolower(c) ((c &gt;= 'A' &amp;&amp; c &lt;= 'Z') ? (c | 0x20) : c)
#define ngx_toupper(c) ((c &gt;= 'a' &amp;&amp; c &lt;= 'z') ? (c &amp; ~0x20) : c)
#define ngx_memzero(buf, n) (void) memset(buf, 0, n)
```
其次,你要知道,**宏是没有作用域概念的,永远是全局生效**。所以,对于一些用来简化代码、起临时作用的宏,最好是用完后尽快用“#undef”取消定义,避免冲突的风险。像下面这样:
```
#define CUBE(a) (a) * (a) * (a) // 定义一个简单的求立方的宏
cout &lt;&lt; CUBE(10) &lt;&lt; endl; // 使用宏简化代码
cout &lt;&lt; CUBE(15) &lt;&lt; endl; // 使用宏简化代码
#undef CUBE // 使用完毕后立即取消定义
```
另一种做法是**宏定义前先检查**如果之前有定义就先undef然后再重新定义
```
#ifdef AUTH_PWD // 检查是否已经有宏定义
# undef AUTH_PWD // 取消宏定义
#endif // 宏定义检查结束
#define AUTH_PWD &quot;xxx&quot; // 重新宏定义
```
再次你可以适当使用宏来定义代码中的常量消除“魔术数字”“魔术字符串”magic number
虽然不少人认为定义常量更应该使用enum或者const但我觉得宏定义毕竟用法简单也是源码级的真正常量而且还是从C继承下来的传统用在头文件里还是有些优势的。
这种用法非常普遍,你可能也经常用,我就简单举两个例子吧:
```
#define MAX_BUF_LEN 65535
#define VERSION &quot;1.0.18&quot;
```
不过你要注意,关键是要“适当”,自己把握好分寸,不要把宏弄得“满天飞”。
除了上面说的三个如果你开动脑筋用好“文本替换”的功能也能发掘出许多新颖的用法。我有一个比较实际的例子用宏来代替直接定义名字空间namespace
```
#define BEGIN_NAMESPACE(x) namespace x {
#define END_NAMESPACE(x) }
BEGIN_NAMESPACE(my_own)
... // functions and classes
END_NAMESPACE(my_own)
```
这里我定义了两个宏BEGIN_NAMESPACE和END_NAMESPACE虽然只是简单的文本替换但它全大写的形式非常醒目可以很容易地识别出名字空间开始和结束的位置。
## 条件编译(#if/#else/#endif
利用“#define”定义出的各种宏,我们还可以在预处理阶段实现分支处理,通过判断宏的数值来产生不同的源码,改变源文件的形态,这就是“**条件编译**”。
条件编译有两个要点,一个是条件指令“#if”,另一个是后面的“判断依据”,也就是定义好的各种宏,而**这个“判断依据”是条件编译里最关键的部分**。
通常编译环境都会有一些预定义宏比如CPU支持的特殊指令集、操作系统/编译器/程序库的版本、语言特性等,使用它们就可以早于运行阶段,提前在预处理阶段做出各种优化,产生出最适合当前系统的源码。
你必须知道的一个宏是“**__cplusplus**”它标记了C++语言的版本号使用它能够判断当前是C还是C++是C++98还是C++11。你可以看下面这个例子。
```
#ifdef __cplusplus // 定义了这个宏就是在用C++编译
extern &quot;C&quot; { // 函数按照C的方式去处理
#endif
void a_c_function(int a);
#ifdef __cplusplus // 检查是否是C++编译
} // extern &quot;C&quot; 结束
#endif
#if __cplusplus &gt;= 201402 // 检查C++标准的版本号
cout &lt;&lt; &quot;c++14 or later&quot; &lt;&lt; endl; // 201402就是C++14
#elif __cplusplus &gt;= 201103 // 检查C++标准的版本号
cout &lt;&lt; &quot;c++11 or before&quot; &lt;&lt; endl; // 201103是C++11
#else // __cplusplus &lt; 201103 // 199711是C++98
# error &quot;c++ is too old&quot; // 太低则预处理报错
#endif // __cplusplus &gt;= 201402 // 预处理语句结束
```
除了“__cplusplus”C++里还有很多其他预定义的宏,像源文件信息的“**FILE**”“ **LINE**”“ **DATE**以及一些语言特性测试宏比如“__cpp_decltype” “__cpp_decltype_auto” “__cpp_lib_make_unique”等。
不过与优化更密切相关的底层系统信息在C++语言标准里没有定义但编译器通常都会提供比如GCC可以使用一条简单的命令查看
```
g++ -E -dM - &lt; /dev/null
#define __GNUC__ 5
#define __unix__ 1
#define __x86_64__ 1
#define __UINT64_MAX__ 0xffffffffffffffffUL
...
```
基于它们,你就可以更精细地根据具体的语言、编译器、系统特性来改变源码,有,就用新特性;没有,就采用变通实现:
```
#if defined(__cpp_decltype_auto) //检查是否支持decltype(auto)
cout &lt;&lt; &quot;decltype(auto) enable&quot; &lt;&lt; endl;
#else
cout &lt;&lt; &quot;decltype(auto) disable&quot; &lt;&lt; endl;
#endif //__cpp_decltype_auto
#if __GNUC__ &lt;= 4
cout &lt;&lt; &quot;gcc is too old&quot; &lt;&lt; endl;
#else // __GNUC__ &gt; 4
cout &lt;&lt; &quot;gcc is good enough&quot; &lt;&lt; endl;
#endif // __GNUC__ &lt;= 4
#if defined(__SSE4_2__) &amp;&amp; defined(__x86_64)
cout &lt;&lt; &quot;we can do more optimization&quot; &lt;&lt; endl;
#endif // defined(__SSE4_2__) &amp;&amp; defined(__x86_64)
```
除了这些内置宏你也可以用其他手段自己定义更多的宏来实现条件编译。比如Nginx就使用Shell脚本检测外部环境生成一个包含若干宏的源码配置文件再条件编译包含不同的头文件实现操作系统定制化
```
#if (NGX_FREEBSD)
# include &lt;ngx_freebsd.h&gt;
#elif (NGX_LINUX)
# include &lt;ngx_linux.h&gt;
#elif (NGX_SOLARIS)
# include &lt;ngx_solaris.h&gt;
#elif (NGX_DARWIN)
# include &lt;ngx_darwin.h&gt;
#endif
```
条件编译还有一个特殊的用法,那就是,使用“#if 1”“#if 0”来显式启用或者禁用大段代码要比“/* … */”的注释方式安全得多,也清楚得多,这也是我的一个“不传之秘”。
```
#if 0 // 0即禁用下面的代码1则是启用
... // 任意的代码
#endif // 预处理结束
#if 1 // 1启用代码用来强调下面代码的必要性
... // 任意的代码
#endif // 预处理结束
```
## 小结
今天我讲了预处理阶段现在你是否对我们通常写的程序有了新的认识呢它实际上是混合了预处理编程和C++编程的两种代码。
预处理编程由预处理器执行,使用#include#define#if等指令来实现文件包含、文本替换、条件编译,把编码阶段产生的源码改变为另外一种形式。适当使用的话,可以简化代码、优化性能,但如果是“炫技”式地过分使用,就会导致导致代码混乱,难以维护。
再简单小结一下今天的内容:
1. 预处理不属于C++语言,过多的预处理语句会扰乱正常的代码,除非必要,应当少用慎用;
1.#include”可以包含任意文件,所以可以写一些小的代码片段,再引进程序里;
1. 头文件应该加上“Include Guard”防止重复包含
1.#define”用于宏定义,非常灵活,但滥用文本替换可能会降低代码的可读性;
1. “条件编译”其实就是预处理编程里的分支语句,可以改变源码的形态,针对系统生成最合适的代码。
## 课下作业
最后是课下作业时间,给你留两个思考题:
1. 你认为宏的哪些用法可以用其他方式替代,哪些是不可替代的?
1. 你用过条件编译吗?分析一下它的优点和缺点。
欢迎你在留言区写下你的思考和答案,如果觉得对你有所帮助,也欢迎分享给你的朋友,我们下节课见。
<img src="https://static001.geekbang.org/resource/image/b8/11/b8b819b1331736ebc40664cd878f7511.jpg" alt="">

View File

@@ -0,0 +1,231 @@
<audio id="audio" title="04 | 编译阶段能做什么:属性和静态断言" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d4/db/d43e25a507bc2644f8101f105e8470db.mp3"></audio>
你好我是Chrono。
前面我讲了C++程序生命周期里的“编码阶段”和“预处理阶段”,它们的工作主要还是“文本编辑”,生成的是**人类可识别的源码**source code。而“编译阶段”就不一样了它的目标是**生成计算机可识别的机器码**machine instruction code
今天,我就带你来看看在这个阶段能做些什么事情。
## 编译阶段编程
编译是预处理之后的阶段它的输入是经过预处理的C++源码,输出是**二进制可执行文件**(也可能是汇编文件、动态库或者静态库)。这个处理动作就是由编译器来执行的。
和预处理阶段一样在这里你也可以“面向编译器编程”用一些指令或者关键字让编译器按照你的想法去做一些事情。只不过这时你要面对的是庞大的C++语法,而不是简单的文本替换,难度可以说是高了好几个数量级。
编译阶段的特殊性在于它看到的都是C++语法实体比如typedef、using、template、struct/class这些关键字定义的类型而不是运行阶段的变量。所以这时的编程思维方式与平常大不相同。我们熟悉的是CPU、内存、Socket但要去理解编译器的运行机制、知道怎么把源码翻译成机器码这可能就有点“强人所难”了。
比如说,让编译器递归计算斐波那契数列,这已经算是一个比较容易理解的编译阶段数值计算用法了:
```
template&lt;int N&gt;
struct fib // 递归计算斐波那契数列
{
static const int value =
fib&lt;N - 1&gt;::value + fib&lt;N - 2&gt;::value;
};
template&lt;&gt;
struct fib&lt;0&gt; // 模板特化计算fib&lt;0&gt;
{
static const int value = 1;
};
template&lt;&gt;
struct fib&lt;1&gt; // 模板特化计算fib&lt;1&gt;
{
static const int value = 1;
};
// 调用后输出2358
cout &lt;&lt; fib&lt;2&gt;::value &lt;&lt; endl;
cout &lt;&lt; fib&lt;3&gt;::value &lt;&lt; endl;
cout &lt;&lt; fib&lt;4&gt;::value &lt;&lt; endl;
cout &lt;&lt; fib&lt;5&gt;::value &lt;&lt; endl;
```
对于编译器来说可以在一瞬间得到结果但你要搞清楚它的执行过程就得在大脑里把C++模板特化的过程走一遍。整个过程无法调试,完全要靠自己去推导,特别“累人”。(你也可以把编译器想象成是一种特殊的“虚拟机”,在上面跑的是只有编译器才能识别、处理的代码。)
简单的尚且如此,那些复杂的就更不用说了。所以,今天我就不去讲那些太过于“烧脑”的知识了,而是介绍两个比较容易理解的编译阶段技巧:属性和静态断言,让你能够立即用得上,效果也是“立竿见影”。
## 属性attribute
“预处理编程”[这一讲](https://time.geekbang.org/column/article/233711)提到的#include#define都是预处理指令,是用来控制预处理器的。那么问题就来了,有没有用来控制编译器的“编译指令”呢?
虽然编译器非常聪明但因为C++语言实在是太复杂了,偶尔它也会“自作聪明”或者“冒傻气”。如果有这么一个东西,让程序员来手动指示编译器这里该如何做、那里该如何做,就有可能会生成更高效的代码。
在C++11之前标准里没有规定这样的东西但GCC、VC等编译器发现这样做确实很有用于是就实现出了自己“编译指令”在GCC里是“__ attribute __在VC里是“__declspec”。不过因为它们不是标准所以名字显得有点“怪异”。
到了C++11标准委员会终于认识到了“编译指令”的好处于是就把“民间”用法升级为“官方版本”起了个正式的名字叫“**属性**”。你可以把它理解为给变量、函数、类等“贴”上一个编译阶段的“标签”,方便编译器识别处理。
“属性”没有新增关键字,而是用两对方括号的形式“[[…]]”方括号的中间就是属性标签看着是不是很像一张方方正正的便签条。所以它的用法很简单比GCC、VC的都要简洁很多。
我举个简单的例子,你看一下就明白了:
```
[[noreturn]] // 属性标签
int func(bool flag) // 函数绝不会返回任何值
{
throw std::runtime_error(&quot;XXX&quot;);
}
```
不过在C++11里只定义了两个属性“noreturn”和“carries_dependency”它们基本上没什么大用处。
C++14的情况略微好了点增加了一个比较实用的属性“deprecated”用来标记不推荐使用的变量、函数或者类也就是被“废弃”。
比如说你原来写了一个函数old_func(),后来觉得不够好,就另外重写了一个完全不同的新函数。但是,那个老函数已经发布出去被不少人用了,立即删除不太可能,该怎么办呢?
这个时候你就可以让“属性”发挥威力了。你可以给函数加上一个“deprecated”的编译期标签再加上一些说明文字
```
[[deprecated(&quot;deadline:2020-12-31&quot;)]] // C++14 or later
int old_func();
```
于是,任何用到这个函数的程序都会在编译时看到这个标签,报出一条警告:
```
warning: int old_func() is deprecated: deadline:2020-12-31 [-Wdeprecated-declarations]
```
当然,程序还是能够正常编译的,但这种强制的警告形式会“提醒”用户旧接口已经被废弃了,应该尽快迁移到新接口。显然,这种形式要比毫无约束力的文档或者注释要好得多。
目前的C++17和C++20又增加了五六个新属性比如fallthrough、likely但我觉得标准委员会的态度还是太“保守”了在实际的开发中这些真的是不够用。
好在“属性”也支持非标准扩展允许以类似名字空间的方式使用编译器自己的一些“非官方”属性比如GCC的属性都在“gnu::”里。下面我就列出几个比较有用的(全部属性可参考[GCC文档](https://gcc.gnu.org/onlinedocs/gcc/Attribute-Syntax.html))。
- deprecated与C++14相同但可以用在C++11里。
- unused用于变量、类型、函数等表示虽然暂时不用但最好保留着因为将来可能会用。
- constructor函数会在main()函数之前执行,效果有点像是全局对象的**构造**函数。
- destructor函数会在main()函数结束之后执行,有点像是全局对象的**析构**函数。
- always_inline要求编译器强制内联函数作用比inline关键字更强。
- hot标记“热点”函数要求编译器更积极地优化。
这几个属性的含义还是挺好理解的吧我拿“unused”来举个例子。
在没有这个属性的时候,如果有暂时用不到的变量,我们只能用“(void) **var**;”的方式假装用一下,来“骗”过编译器,属于“不得已而为之”的做法。
那么现在我们就可以用“unused”属性来清楚地告诉编译器这个变量我暂时不用请不要过度紧张不要发出警告来烦我
```
[[gnu::unused]] // 声明下面的变量暂不使用,不是错误
int nouse;
```
[GitHub仓库](https://github.com/chronolaw/cpp_study/blob/master/section1/compile.cpp)里的示例代码里还展示了其他属性的用法,你可以在课下参考。
## 静态断言static_assert
“属性”像是给编译器的一个“提示”“告知”,无法进行计算,还算不上是编程,而接下来要讲的“**静态断言**”,就有点编译阶段写程序的味道了。
你也许用过assert吧它用来断言一个表达式必定为真。比如说数字必须是正数指针必须非空、函数必须返回true
```
assert(i &gt; 0 &amp;&amp; &quot;i must be greater than zero&quot;);
assert(p != nullptr);
assert(!str.empty());
```
当程序也就是CPU运行到assert语句时就会计算表达式的值如果是false就会输出错误消息然后调用abort()终止程序的执行。
注意assert虽然是一个宏但在预处理阶段不生效而是在运行阶段才起作用所以又叫“**动态断言**”。
有了“动态断言”,那么相应的也就有“静态断言”,名字也很像,叫“**static_assert**”,不过它是一个专门的关键字,而不是宏。因为它只在编译时生效,运行阶段看不见,所以是“静态”的。
“静态断言”有什么用呢?
类比一下assert你就可以理解了。它是编译阶段里检测各种条件的“断言”编译器看到static_assert也会计算表达式的值如果值是false就会报错导致编译失败。
比如说,这节课刚开始时的斐波拉契数列计算函数,可以用静态断言来保证模板参数必须大于等于零:
```
template&lt;int N&gt;
struct fib
{
static_assert(N &gt;= 0, &quot;N &gt;= 0&quot;);
static const int value =
fib&lt;N - 1&gt;::value + fib&lt;N - 2&gt;::value;
};
```
再比如说要想保证我们的程序只在64位系统上运行可以用静态断言在编译阶段检查long的大小必须是8个字节当然你也可以换个思路用预处理编程来实现
```
static_assert(
sizeof(long) &gt;= 8, &quot;must run on x64&quot;);
static_assert(
sizeof(int) == 4, &quot;int must be 32bit&quot;);
```
这里你一定要注意static_assert运行在编译阶段只能看到编译时的常数和类型看不到运行时的变量、指针、内存数据等是“静态”的所以不要简单地把assert的习惯搬过来用。
比如,下面的代码想检查空指针,由于变量只能在运行阶段出现,而在编译阶段不存在,所以静态断言无法处理。
```
char* p = nullptr;
static_assert(p == nullptr, &quot;some error.&quot;); // 错误用法
```
说到这儿你大概对static_assert的“编译计算”有点感性认识了吧。在用“静态断言”的时候你就要在脑子里时刻“绷紧一根弦”把自己代入编译器的角色**像编译器那样去思考**,看看断言的表达式是不是能够在编译阶段算出结果。
不过这句话说起来容易做起来难,计算数字还好说,在泛型编程的时候,怎么检查模板类型呢?比如说,断言是整数而不是浮点数、断言是指针而不是引用、断言类型可拷贝可移动……
这些检查条件表面上看好像是“不言自明”的但要把它们用C++语言给精确地表述出来可就没那么简单了。所以想要更好地发挥静态断言的威力还要配合标准库里的“type_traits”它提供了对应这些概念的各种编译期“函数”。
```
// 假设T是一个模板参数即template&lt;typename T&gt;
static_assert(
is_integral&lt;T&gt;::value, &quot;int&quot;);
static_assert(
is_pointer&lt;T&gt;::value, &quot;ptr&quot;);
static_assert(
is_default_constructible&lt;T&gt;::value, &quot;constructible&quot;);
```
你可能看到了“static_assert”里的表达式样子很奇怪既有模板符号“&lt;&gt;”,又有作用域符号“::”,与运行阶段的普通表达式大相径庭,初次见到这样的代码一定会吓一跳。
这也是没有办法的事情。因为C++本来不是为编译阶段编程所设计的。受语言的限制,编译阶段编程就只能“魔改”那些传统的语法要素了:把类当成函数,把模板参数当成函数参数,把“::”当成return返回值。说起来倒是和“函数式编程”很神似只是它运行在编译阶段。
由于“type_traits”已经初步涉及模板元编程的领域不太好一下子解释清楚所以在这里我就不再深入介绍了你可以课后再看看这方面的其他资料或者是留言提问。
## 小结
好了今天我和你聊了C++程序在编译阶段能够做哪些事情。
编译阶段的“主角”是编译器它依据C++语法规则处理源码。在这个过程中,我们可以用一些手段来帮助编译器,让它听从我们的指挥,优化代码或者做静态检查,更好地为运行阶段服务。
但要当心毕竟只有编译器才能真正了解C++程序,所以我们还是要充分信任它,不要过分干预它的工作,更不要有意与它作对。
我们来小结一下今天的要点。
1. “属性”相当于编译阶段的“标签”,用来标记变量、函数或者类,让编译器发出或者不发出警告,还能够手工指定代码的优化方式。
1. 官方属性很少常用的只有“deprecated”。我们也可以使用非官方的属性需要加上名字空间限定。
1. static_assert是“静态断言”在编译阶段计算常数和类型如果断言失败就会导致编译错误。它也是迈向模板元编程的第一步。
1. 和运行阶段的“动态断言”一样static_assert可以在编译阶段定义各种前置条件充分利用C++静态类型语言的优势,让编译器执行各种检查,避免把隐患带到运行阶段。
## 课下作业
最后是课下作业时间,给你留两个思考题:
1. 预处理阶段可以自定义宏,但编译阶段不能自定义属性标签,这是为什么呢?
1. 你觉得,怎么用“静态断言”,才能更好地改善代码质量?
欢迎你在留言区写下你的思考和答案,如果觉得今天的内容对你有所帮助,也欢迎把它分享给你的朋友。我们下节课见。
<img src="https://static001.geekbang.org/resource/image/25/39/25232468a72b55a41bf7af90583ae239.jpg" alt="">

View File

@@ -0,0 +1,253 @@
<audio id="audio" title="05 | 面向对象编程:怎样才能写出一个“好”的类?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c5/8f/c5070fcfb479a08e9578eb1668b1d88f.mp3"></audio>
你好我是Chrono。
如果按照前几节课的惯例今天应该是讲运行阶段的。但是运行阶段跟前面的编码、预处理和编译阶段不同它是动态的、实时的内外部环境非常复杂CPU、内存、磁盘、信号、网络套接字……各种资源交织在一起可谓千变万化正如我在[第1节课](https://time.geekbang.org/column/article/231454)里所说,每一个阶段的差异都非常大)。
解决这个阶段面临的问题已经不是编程技术了更多的是要依靠各种调试、分析、日志工具比如GDB、Valgrind、Systemtap等。
所以我觉得把这些运行阶段的工具、技巧放在课程前面不是太合适咱们还是往后延一延等把C++的核心知识点都学完了,再来看它比较好。
那么,今天要和你聊哪些内容呢?
我想了想还是讲讲“面向对象编程”Object Oriented Programming吧。毕竟它是C++诞生之初“安身立命”的看家本领也是C++的核心编程范式。
不管我们是否喜欢“面向对象”早就已经成为了编程界的共识和主流。C++、Java、Python等流行的语言无一不支持面向对象编程而像Pascal、BASIC、PHP那样早期面向过程的语言在发展过程中也都增加了对它的支持新出的Go、Swift、Rust就更不用说了。
毫无疑问,**掌握“面向对象编程”是现在程序员的基本素养**。但落到实际开发时,每个人对它的理解程度却有深有浅,应用的水平也有高有低,有的人设计出的类精致灵活,而有的人设计出来的却是粗糙笨重。
细想起来,“面向对象”里面可以研究的地方实在是太多了。那么,到底“面向对象”的精髓是什么?怎样才能用好它?怎样才能写出一个称得上是“好”的类呢?
所以今天我就从设计思想、实现原则和编码准则这几个角度谈谈我对它的体会心得以及在C++里应用的一些经验技巧帮你写出更高效、更安全、更灵活的类。在第19、20课我还会具体讲解到时候你可以参考下。
## 设计思想
首先要说的是,虽然很多语言都内建语法支持面向对象编程,但它本质上是一种设计思想、方法,与语言细节无关,要点是**抽象**Abstraction和**封装**Encapsulation
掌握了这种代码之外的思考方式,就可以“高屋建瓴”,站在更高的维度上去设计程序,不会被语言、语法所限制。
所以即使是像C这样“纯”面向过程的编程语言也能够应用面向对象的思想以struct实现抽象和封装得到良好的程序结构。
面向对象编程的基本出发点是“对现实世界的模拟”,把问题中的实体抽象出来,封装为程序里的类和对象,这样就在计算机里为现实问题建立了一个“虚拟模型”。
然后以这个模型为基础不断演化,继续抽象对象之间的关系和通信,再用更多的对象去描述、模拟……直到最后,就形成了一个由许多互相联系的对象构成的系统。
把这个系统设计出来、用代码实现出来,就是“面向对象编程”了。
不过,因为现实世界非常复杂,“面向对象编程”作为一种工程方法,是不可能完美模拟的,纯粹的面向对象也有一些缺陷,其中最明显的就是“继承”。
“继承”的本意是重用代码表述类型的从属关系Is-A但它却不能与现实完全对应所以用起来就会出现很多意外情况。
比如那个著名的长方形的例子。Rectangle表示长方形Square继承Rectangle表示正方形。现在问题就来了这个关系在数学中是正确的但表示为代码却不太正确。长方形可以用成员函数单独变更长宽但正方形却不行长和宽必须同时变更。
还有那个同样著名的鸟类的例子。基类Bird有个Fly方法所有的鸟类都应该继承它。但企鹅、鸵鸟这样的鸟类却不会飞实现它们就必须改写Fly方法。
各种编程语言为此都加上了一些“补丁”像C++就有“多态”“虚函数”“重载”,虽然解决了“继承”的问题,但也使代码复杂化了,一定程度上扭曲了“面向对象”的本意。
## 实现原则
说了些“高大上”的理论是不是有点犯迷糊没关系下面我就在C++里细化一下。
就像我刚才说的,“面向对象编程”的关键点是“抽象”和“封装”,而“继承”“多态”并不是核心,只能算是附加品。
所以,我建议你在设计类的时候**尽量少用继承和虚函数**。
特别的,如果完全没有继承关系,就可以让对象不必承受“父辈的重担”(父类成员、虚表等额外开销),轻装前行,更小更快。没有隐含的重用代码也会降低耦合度,让类更独立,更容易理解。
还有把“继承”切割出去之后可以避免去记忆、实施那一大堆难懂的相关规则比如public/protected/private继承方式的区别、多重继承、纯虚接口类、虚析构函数还可以绕过动态转型、对象切片、函数重载等很多危险的陷阱减少冗余代码提高代码的健壮性。
如果非要用继承不可,那么我觉得一定要**控制继承的层次**用UML画个类体系的示意图来辅助检查。如果继承深度超过三层就说明有点“过度设计”了需要考虑用组合关系替代继承关系或者改用模板和泛型。
<img src="https://static001.geekbang.org/resource/image/14/d5/145780476bd0beb148e5e130c2336ed5.jpg" alt="">
在设计类接口的时候,我们也要让类尽量简单、“短小精悍”,**只负责单一的功能**。
如果很多功能混在了一起出现了“万能类”“意大利面条类”有时候也叫God Class就要应用设计模式、重构等知识把大类拆分成多个各负其责的小类。
我还看到过很多人有一种不好的习惯,就是喜欢在类内部定义一些嵌套类,美其名曰“高内聚”。但恰恰相反,这些内部类反而与上级类形成了强耦合关系,也是另一种形式的“万能类”。
其实,这本来是名字空间该做的事情,用类来实现就有点“越权”了。正确的做法应该是,**定义一个新的名字空间,把内部类都“提”到外面,降低原来类的耦合度和复杂度**。
## 编码准则
有了这些实现原则,下面我再来讲几个编码时的细节,从安全和性能方面帮你改善类的代码。
C++11新增了一个特殊的标识符“**final**”(注意,它不是关键字),把它用于类定义,就可以显式地禁用继承,防止其他人有意或者无意地产生派生类。无论是对人还是对编译器,效果都非常好,我建议你一定要积极使用。
```
class DemoClass final // 禁止任何人继承我
{ ... };
```
在必须使用继承的场合,建议你**只使用public继承避免使用virtual、protected**因为它们会让父类与子类的关系变得难以捉摸带来很多麻烦。当到达继承体系底层时也要及时使用“final”终止继承关系。
```
class Interface // 接口类定义没有final可以被继承
{ ... };
class Implement final : // 实现类final禁止再被继承
public Interface // 只用public继承
{ ... };
```
C++里类的四大函数你一定知道吧它们是构造函数、析构函数、拷贝构造函数、拷贝赋值函数。C++11因为引入了右值Rvalue和转移Move又多出了两大函数**转移构造函数**和**转移赋值函数**。所以在现代C++里,一个类总是会有六大基本函数:**三个构造**、**两个赋值**、**一个析构**。
好在C++编译器会自动为我们生成这些函数的默认实现,省去我们重复编写的时间和精力。但我建议,对于比较重要的构造函数和析构函数,应该用“**= default**”的形式,明确地告诉编译器(和代码阅读者):“应该实现这个函数,但我不想自己写。”这样编译器就得到了明确的指示,可以做更好的优化。
```
class DemoClass final
{
public:
DemoClass() = default; // 明确告诉编译器,使用默认实现
~DemoClass() = default; // 明确告诉编译器,使用默认实现
};
```
这种“= default”是C++11新增的专门用于六大基本函数的用法相似的还有一种“**= delete**”的形式。它表示**明确地禁用某个函数形式**,而且不限于构造/析构,可以用于任何函数(成员函数、自由函数)。
比如说如果你想要禁止对象拷贝就可以用这种语法显式地把拷贝构造和拷贝赋值“delete”掉让外界无法调用。
```
class DemoClass final
{
public:
DemoClass(const DemoClass&amp;) = delete; // 禁止拷贝构造
DemoClass&amp; operator=(const DemoClass&amp;) = delete; // 禁止拷贝赋值
};
```
因为C++有隐式构造和隐式转型的规则,如果你的类里有单参数的构造函数,或者是转型操作符函数,为了防止意外的类型转换,保证安全,就要使用“**explicit**”将这些函数标记为“显式”。
```
class DemoClass final
{
public:
explicit DemoClass(const string_type&amp; str) // 显式单参构造函数
{ ... }
explicit operator bool() // 显式转型为bool
{ ... }
};
```
## 常用技巧
C++11里还有很多能够让类更优雅的新特性这里我从“投入产出比”的角度出发挑出了三个我最喜欢的特性给你介绍一下让你不用花太多力气就能很好地改善代码质量。
第一个是“**委托构造**”delegating constructor
如果你的类有多个不同形式的构造函数为了初始化成员肯定会有大量的重复代码。为了避免重复常见的做法是把公共的部分提取出来放到一个init()函数里,然后构造函数再去调用。这种方法虽然可行,但**效率和可读性较差**毕竟init()不是真正的构造函数。
在C++11里你就可以使用“委托构造”的新特性一个构造函数直接调用另一个构造函数把构造工作“委托”出去既简单又高效。
```
class DemoDelegating final
{
private:
int a; // 成员变量
public:
DemoDelegating(int x) : a(x) // 基本的构造函数
{}
DemoDelegating() : // 无参数的构造函数
DemoDelegating(0) // 给出默认值,委托给第一个构造函数
{}
DemoDelegating(const string&amp; s) : // 字符串参数构造函数
DemoDelegating(stoi(s)) // 转换成整数,再委托给第一个构造函数
{}
};
```
第二个是“**成员变量初始化**”In-class member initializer
如果你的类有很多成员变量,那么在写构造函数的时候就比较麻烦,必须写出一长串的名字来逐个初始化,不仅不美观,更危险的是,容易“手抖”,遗漏成员,造成未初始化的隐患。
而在C++11里你可以在类里声明变量的同时给它赋值实现初始化这样**不但简单清晰,也消除了隐患。**
```
class DemoInit final // 有很多成员变量的类
{
private:
int a = 0; // 整数成员,赋值初始化
string s = &quot;hello&quot;; // 字符串成员,赋值初始化
vector&lt;int&gt; v{1, 2, 3}; // 容器成员,使用花括号的初始化列表
public:
DemoInit() = default; // 默认构造函数
~DemoInit() = default; // 默认析构函数
public:
DemoInit(int x) : a(x) {} // 可以单独初始化成员,其他用默认值
};
```
第三个是“**类型别名**”Type Alias
C++11扩展了关键字using的用法增加了typedef的能力可以定义类型别名。它的格式与typedef正好相反别名在左边原名在右边是标准的赋值形式所以易写易读。
```
using uint_t = unsigned int; // using别名
typedef unsigned int uint_t // 等价的typedef
```
在写类的时候我们经常会用到很多外部类型比如标准库里的string、vector还有其他的第三方库和自定义类型。这些名字通常都很长特别是带上名字空间、模板参数书写起来很不方便这个时候我们就可以在类里面用using给它们起别名不仅简化了名字同时还能增强可读性。
```
class DemoClass final
{
public:
using this_type = DemoClass; // 给自己也起个别名
using kafka_conf_type = KafkaConfig; // 外部类起别名
public:
using string_type = std::string; // 字符串类型别名
using uint32_type = uint32_t; // 整数类型别名
using set_type = std::set&lt;int&gt;; // 集合类型别名
using vector_type = std::vector&lt;std::string&gt;;// 容器类型别名
private:
string_type m_name = &quot;tom&quot;; // 使用类型别名声明变量
uint32_type m_age = 23; // 使用类型别名声明变量
set_type m_books; // 使用类型别名声明变量
private:
kafka_conf_type m_conf; // 使用类型别名声明变量
};
```
类型别名不仅能够让代码规范整齐而且因为引入了这个“语法层面的宏定义”将来在维护时还可以随意改换成其他的类型。比如把字符串改成string_viewC++17里的字符串只读视图把集合类型改成unordered_set只要变动别名定义就行了原代码不需要做任何改动。
## 小结
今天我们谈了“面向对象编程”,这节课的内容也比较多,我划一下重点。
1. “面向对象编程”是一种设计思想,要点是“抽象”和“封装”,“继承”“多态”是衍生出的特性,不完全符合现实世界。
1. 在C++里应当少用继承和虚函数,降低对象的成本,绕过那些难懂易错的陷阱。
1. 使用特殊标识符“final”可以禁止类被继承简化类的层次关系。
1. 类有六大基本函数,对于重要的构造/析构函数,可以使用“= default”来显式要求编译器使用默认实现。
1. “委托构造”和“成员变量初始化”特性可以让创建对象的工作更加轻松。
1. 使用using或typedef可以为类型起别名既能够简化代码还能够适应将来的变化。
所谓“仁者见仁智者见智”,今天我讲的也只能算是我自己的经验、体会。到底要怎么用,你还是要看自己的实际情况,千万不要完全照搬。
## 课下作业
这次的课下作业时间,我给你留两个思考题:
1. 你对“面向对象编程”有哪些认识,是否赞同这节课的观点?(希望你大胆地说出来,如果意见有分歧,那也很正常,我们一起讨论。)
1. 你觉得应用这节课讲到的准则和技巧能否写出一个“好”的类,还缺什么吗?
欢迎你在留言区写下你的思考和答案,如果觉得今天的内容对你有所帮助,也欢迎分享给你的朋友。我们下节课见。
<img src="https://static001.geekbang.org/resource/image/67/b3/6739782414607164bdbe20fca7fd5fb3.jpg" alt="">

View File

@@ -0,0 +1,85 @@
<audio id="audio" title="结束语 | 路远,未有穷期" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/27/b2/27ef54452cd5a9354a070f93178a5bb2.mp3"></audio>
你好我是Chrono。
两个月的时间过得好快啊,一眨眼的功夫,就到了和你说“再见”的时候了。
和之前的《透视HTTP协议》一样我首先要感谢的还是你——感谢对我和这门课的支持正是你持续的支持和鼓励才让我有了创作的激情和动力Thanks for your support
同时我也要祝贺你祝贺你凭着力量、智慧和勇气《The Legend of Zelda》的黄金三角突破了C++的“重重迷雾”成功地掌握了C++的核心知识和应用技能,相信这将会是你今后工作履历中的一笔“浓墨重彩”。
现在再回头看各节课程,我觉得基本上算是达成了当初预计的目标:贴近实际的开发经验分享,没有剑走偏锋的“葵花宝典”,而是踏踏实实的“太祖长拳”。
但是我觉得这门课程的交付还是有一点点遗憾的。因为C++的内容实在是太丰富了,在课程里,我们只能挑出“必修”的重点和要点来讲,还有很多外围的点没办法展开细讲,还有很多小点来不及说出来,真是舍不得和你说再见。
所以我想在这最后一讲里我还能和你交代点啥我觉得最实际的就是和你聊聊结课后你该怎么继续学习C++。
## 课程结束后你该怎么学C++
要回答这个问题我们再来看看这门课的主角C++吧。
从1979年贝尔实验室发明“C with Classes”到1983年正式命名为“C++”再到现在C++已经走过了四十多年可以说是处于“不惑之年”了。在如今的编程语言界和Java1995、Go2009、Rust2010等比起来C++真算得上是“老前辈”。
这么多年来C++一直没有停止发展的脚步不过确实有点缓慢不仅修补了曾经的缺陷还增加了越来越多的新特性。不可否认C++虽然变得越来越复杂和庞大,但也正在一步步趋近“完美”,你可以批评它,但绝不能无视它。
个人认为,**C++最大的优点是与C兼容最大的缺点也是与C兼容**。
一方面它是C之外唯一成熟可靠的系统级编程语言目前Rust还没有达到可以和C++“叫板”的程度大部分用C的地方都能用C++替代这就让它拥有了广阔的应用天地。而面向对象、泛型等编程范式又比C能够更好地组织代码提高抽象层次管理复杂的软件项目。比如前段时间刚成功发射的“龙”飞船它的应用软件就是用C++开发的。
但另一方面为了保持与C兼容C++的改革和发展也被“束缚了手脚”做出任何新设计时都要考虑是否会对C代码造成潜在的破坏。这就使得很多C++新特性要么是“一拖再拖”要么是“半成品”要么是“古里古怪”最后导致C++变得有些不伦不类,丢掉了编程语言本应该的简洁、纯粹。
如果你看看新出的C++17、C++20这两方面就表现得特别明显比如结构化绑定、模块、协程、模板lambda。也许这就是C++追求自由和性能的代价吧。
这次的课程里我们主要用的是C++11/14**那么面对后续的新标准、新特性,在没有“入职导师”的情况下,你要再怎么学呢?**
我觉得对于C++这样复杂的编程语言,你要把握一个基本原则:**不要当“语言律师”**language lawyer。也就是说不要像孔乙己那样沉迷于“茴”字有多少种写法又或者是“抖机灵”式的代码而是要注重实践、实用。
因为C++的编程范式太多,“摊子”实在是铺得太大,它为了避免各种特性可能导致的歧义和冲突,就会有许许多多细致到“令人发指”的规定,我们在学习的时候,一不小心就会钻进细节里出不来了。
这样的例子有很多比如说ADL、引用折叠、可变参数模板、“++”的前置和后置用法、模板声明里的typename和class、初始化列表与构造函数、重载函数里的默认参数……
弄懂这些位于“犄角旮旯”里的特性(无贬义),需要花费我们很多的脑力,但在我们一般的开发过程中,通常很少会触及这些点,或者说是会尽力避免,它们通常只是对编译器有意义,所以在这些“细枝末节”上下功夫就不是很值了,说白了,就是性价比太低。
我个人认为在掌握了专栏里C++11/14知识的基础上如果再面对一个C++新的语言特性,你不能够在五分钟(或者再略长一点)的时间里理解它的含义和作用,就说明它里面的“坑”很深。
你应当采用“迂回战术”,暂时放弃,不要细究,把精力集中在现有知识的消化和理解上,练好“基本功”,等你以后真正不得不用它的时候,通过实践再来学习会更好。
这也是我自己多年实践的经验,希望对你有用。
## 结课赠言
结束语的最后,我还是要来一个“首尾呼应”,回顾一下开篇词里的“初心”。
还记得开课时的那三句编程格言吗?
>
任何人都能写出机器能看懂的代码,但只有优秀的程序员才能写出人能看懂的代码。
>
有两种写程序的方式:一种是把代码写得非常复杂,以至于“看不出明显的错误”;另一种是把代码写得非常简单,以至于“明显看不出错误”。
>
“把正确的代码改快速”,要比“把快速的代码改正确”,容易得太多。
这两个月的学习下来结合C++的各种编程范式、特性和应用经验,你是否对它们又有了一些新的认识呢?
我说说我对这三句格言的理解吧,也许能和你产生共鸣:
- 写代码是为了给人看而不是给机器编译器、CPU也就是human readable
- 代码简单、易理解最重要长而复杂的函数、类是不受欢迎的要经常做Code Clean
- 功能实现优先性能优化次之在没有学会走之前不要想着跑也就是Do the right thing。
课程即将结束,在漫长的学习道路上,它的作用就好像是一个小小的驿站,让你适时停下来休息,补充点食物和清水,为下一次冒险做好准备。希望在你将来的回忆里,还会记得有这么一个给你安心和舒适的地方。
这里我还给你准备了一份[毕业问卷](https://jinshuju.net/f/tGsQrr),题目不多,希望你能抽出几分钟时间填写一下。我非常愿意听听你对这个课程的反馈和建议,你可以在问卷中畅所欲言。
好了,临别之际,在你上马、踏上新的征途之前,我再送你一句“老话”吧:
>
一个人写出一个好程序不难,难的是一辈子只写好程序,不写坏程序。
路远,未有穷期,期待我们的下次再会!

View File

@@ -0,0 +1,267 @@
<audio id="audio" title="06 | auto/decltype为什么要有自动类型推导" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d0/33/d0dec00dd6b1003f2238d36f60b20e33.mp3"></audio>
你好我是Chrono。
前两周我们从宏观的层面上重新认识了C++从今天开始我们将进入一个新的“语言特性”单元“下沉”到微观的层面去观察C++一起去见一些老朋友、新面孔比如const、exception、lambda。
这次要说的就是C++11里引入的一个很重要的语言特性自动类型推导。
## 自动类型推导
如果你有过一些C++的编程经验了解过C++11那就一定听说过“**自动类型推导**”auto type deduction
它其实是一个非常“老”的特性C++之父Bjarne StroustrupB·S )早在C++诞生之初就设计并实现了它但因为与早期C语言的语义有冲突所以被“雪藏”了近三十年。直到C99消除了兼容性问题C++11才让它再度登场亮相。
那为什么要重新引入这个“老特性”呢?为什么非要有“自动类型推导”呢?
我觉得,你可以先从字面上去理解,把这个词分解成三个部分:“自动”“类型”和“推导”。
- “自动”就是让计算机去做,而不是人去做,相对的是“手动”。
- “类型”指的是操作目标,出来的是编译阶段的类型,而不是数值。
- “推导”就是演算、运算,把隐含的值给算出来。
好,我们来看一看“自动类型推导”之外的其他几种排列组合,通过对比的方式来帮你理解它。
像计算“a = 1 + 1”你可以在写代码的时候直接填上2这就是“手动数值推导”。你也可以“偷懒”只写上表达式让电脑在运行时自己算这就是“自动数值推导”。
“数值推导”对于人和计算机来说都不算什么难事,所以手动和自动的区别不大,只有快慢的差异。但“类型推导”就不同了。
因为C++是一种静态强类型的语言,任何变量都要有一个确定的类型,否则就不能用。在“自动类型推导”出现之前,我们写代码时只能“手动推导”,也就是说,在声明变量的时候,必须要明确地给出类型。
这在变量类型简单的时候还好说比如int、double但在泛型编程的时候麻烦就来了。因为泛型编程里会有很多模板参数有的类型还有内部子类型一下子就把C++原本简洁的类型体系给搞复杂了,这就迫使我们去和编译器“斗智斗勇”,只有写对了类型,编译器才会“放行”(编译通过)。
```
int i = 0; // 整数变量,类型很容易知道
double x = 1.0; // 浮点数变量,类型很容易知道
std::string str = &quot;hello&quot;; // 字符串变量,有了名字空间,麻烦了一点
std::map&lt;int, std::string&gt; m = // 关联数组,名字空间加模板参数,很麻烦
{{1,&quot;a&quot;}, {2,&quot;b&quot;}}; // 使用初始化列表的形式
std::map&lt;int, std::string&gt;::const_iterator // 内部子类型,超级麻烦
iter = m.begin();
= bind1st(std::less&lt;int&gt;(), 2); // 根本写不出来
```
虽然你可以用typedef或者using来简化类型名部分减轻打字的负担但关键的“手动推导”问题还是没有得到解决还是要去翻看类型定义找到正确的声明。这时C++的静态强类型的优势反而成为了劣势,阻碍了程序员的工作,降低了开发效率。
其实编译器是知道(而且也必须知道)这些类型的,但它却没有办法直接告诉你,这就很尴尬了。一边是急切地想知道答案,而另一边却只给判个对错,至于怎么错了、什么是正确答案,“打死了也不说”。
但有了“自动类型推导”,问题就迎刃而解了。这就像是在编译器紧闭的大门上开了道小口子,你跟它说一声,它就递过来张小纸条,具体是什么不重要,重要的是里面存了我们想要的类型。
这个“小口子”就是关键字**auto**在代码里的作用像是个“占位符”placeholder。写上它你就可以让编译器去自动“填上”正确的类型既省力又省心。
```
auto i = 0; // 自动推导为int类型
auto x = 1.0; // 自动推导为double类型
auto str = &quot;hello&quot;; // 自动推导为const char [6]类型
std::map&lt;int, std::string&gt; m = {{1,&quot;a&quot;}, {2,&quot;b&quot;}}; // 自动推导不出来
auto iter = m.begin(); // 自动推导为map内部的迭代器类型
auto f = bind1st(std::less&lt;int&gt;(), 2); // 自动推导出类型,具体是啥不知道
```
不过需要注意的是因为C++太复杂“自动类型推导”有时候可能失效给不出你想要的结果。比如在上面的这段代码里就把字符串的类型推导成了“const char [6]”而不是“std::string”。而有的时候编译器也理解不了代码的意思推导不出恰当的类型还得你自己“亲力亲为”。
在这个示例里你还可以直观感觉到auto让代码干净整齐了很多不用去写那些复杂的模板参数了。但如果你把“自动类型推导”理解为仅仅是简化代码、少打几个字那就实在是浪费了C++标准委员会的一番苦心。
**除了简化代码auto还避免了对类型的“硬编码”**也就是说变量类型不是“写死”的而是能够“自动”适应表达式的类型。比如你把map改为unordered_map那么后面的代码都不用动。这个效果和类型别名[第5讲](https://time.geekbang.org/column/article/235301)有点像但你不需要写出typedef或者using全由auto“代劳”。
另外你还应该认识到“自动类型推导”实际上和“attribute”一样[第4讲](https://time.geekbang.org/column/article/235295)),是编译阶段的特殊指令,指示编译器去计算类型。所以,它在泛型编程和模板元编程里还有更多的用处,后面我会陆续讲到。
## 认识auto
刚才说了auto有时候会不如你设想的那样工作因此在使用的时候有一些需要特别注意的地方下面我就给你捋一捋。
首先你要知道auto的“自动推导”能力只能用在“**初始化**”的场合。
具体来说,就是**赋值初始化**或者**花括号初始化**初始化列表、Initializer list变量右边必须要有一个表达式简单、复杂都可以。这样你才能在左边放上auto编译器才能找到表达式帮你自动计算类型。
如果不是初始化的形式只是“纯”变量声明那就无法使用auto。因为这个时候没有表达式可以让auto去推导。
```
auto x = 0L; // 自动推导为long
auto y = &amp;x; // 自动推导为long*
auto z {&amp;x}; // 自动推导为long*
auto err; // 错误,没有赋值表达式,不知道是什么类型
```
这里还有一个特殊情况,在类成员变量初始化的时候([第5讲](https://time.geekbang.org/column/article/235301)目前的C++标准不允许使用auto推导类型但我个人觉得其实没有必要也许以后会放开吧。所以在类里你还是要老老实实地去“手动推导类型”。
```
class X final
{
auto a = 10; // 错误类里不能使用auto推导类型
};
```
知道了应用场合你还需要了解auto的推导规则保证它能够按照你的意思去工作。虽然标准里规定得很复杂、很细致但我总结出了两条简单的规则基本上够用了
- **auto总是推导出“值类型”绝不会是“引用”**
- **auto可以附加上const、volatile、*、&amp;这样的类型修饰符,得到新的类型。**
下面我举几个例子,你一看就能明白:
```
auto x = 10L; // auto推导为longx是long
auto&amp; x1 = x; // auto推导为longx1是long&amp;
auto* x2 = &amp;x; // auto推导为longx2是long*
const auto&amp; x3 = x; // auto推导为longx3是const long&amp;
auto x4 = &amp;x3; // auto推导为const long*x4是const long*
```
## 认识decltype
前面我都在说auto其实C++的“自动类型推导”还有另外一个关键字:**decltype**。
刚才你也看到了auto只能用于“初始化”而这种“**向编译器索取类型**”的能力非常有价值,把它限制在这么小的场合,实在是有点“屈才”了。
“自动类型推导”要求必须从表达式推导,那在没有表达式的时候,该怎么办呢?
其实解决思路也很简单,就是“自己动手,丰衣足食”,自己带上表达式,这样就走到哪里都不怕了。
decltype的形式很像函数后面的圆括号里就是可用于计算类型的表达式和sizeof有点类似其他方面就和auto一样了也能加上const、*、&amp;来修饰。
但因为它已经自带表达式,所以不需要变量后面再有表达式,也就是说可以直接声明变量。
```
int x = 0; // 整型变量
decltype(x) x1; // 推导为intx1是int
decltype(x)&amp; x2 = x; // 推导为intx2是int&amp;,引用必须赋值
decltype(x)* x3; // 推导为intx3是int*
decltype(&amp;x) x4; // 推导为int*x4是int*
decltype(&amp;x)* x5; // 推导为int*x5是int**
decltype(x2) x6 = x2; // 推导为int&amp;x6是int&amp;,引用必须赋值
```
把decltype和auto比较一下简单来看好像就是把表达式改到了左边而已但实际上在推导规则上它们有一点细微且重要的区别
**decltype不仅能够推导出值类型还能够推导出引用类型也就是表达式的“原始类型”**
在示例代码中,我们可以看到,除了加上*和&amp;修饰decltype还可以直接从一个引用类型的变量推导出引用类型而auto就会把引用去掉推导出值类型。
所以你完全可以把decltype看成是一个真正的类型名用在变量声明、函数参数/返回值、模板参数等任何类型能出现的地方,只不过这个类型是在编译阶段通过表达式“计算”得到的。
如果不信的话你可以用using类型别名来试一试。
```
using int_ptr = decltype(&amp;x); // int *
using int_ref = decltype(x)&amp;; // int &amp;
```
既然decltype类型推导更精确那是不是可以替代auto了呢
实际上,它也有个缺点,就是写起来略麻烦,特别在用于初始化的时候,表达式要重复两次(左边的类型计算,右边的初始化),把简化代码的优势完全给抵消了。
所以C++14就又增加了一个“**decltype(auto)**”的形式既可以精确推导类型又能像auto一样方便使用。
```
int x = 0; // 整型变量
decltype(auto) x1 = (x); // 推导为int&amp;,因为(expr)是引用类型
decltype(auto) x2 = &amp;x; // 推导为int*
decltype(auto) x3 = x1; // 推导为int&amp;
```
## 使用auto/decltype
现在我已经讲完了“自动类型推导”的两个关键字auto和decltype那么该怎么用好它们呢
我觉得因为auto写法简单推导规则也比较好理解所以**在变量声明时应该尽量多用auto**。前面已经举了不少例子,这里就不再重复了。
auto还有一个“最佳实践”就是“**range-based for**”不需要关心容器元素类型、迭代器返回值和首末位置就能非常轻松地完成遍历操作。不过为了保证效率最好使用“const auto&amp;”或者“auto&amp;”。
```
vector&lt;int&gt; v = {2,3,5,7,11}; // vector顺序容器
for(const auto&amp; i : v) { // 常引用方式访问元素,避免拷贝代价
cout &lt;&lt; i &lt;&lt; &quot;,&quot;; // 常引用不会改变元素的值
}
for(auto&amp; i : v) { // 引用方式访问元素
i++; // 可以改变元素的值
cout &lt;&lt; i &lt;&lt; &quot;,&quot;;
}
```
在C++14里auto还新增了一个应用场合就是能够推导函数返回值这样在写复杂函数的时候比如返回一个pair、容器或者迭代器就会很省事。
```
auto get_a_set() // auto作为函数返回值的占位符
{
std::set&lt;int&gt; s = {1,2,3};
return s;
}
```
再来看decltype怎么用最合适。
它是auto的高级形式更侧重于编译阶段的类型计算所以常用在泛型编程里获取各种类型配合typedef或者using会更加方便。当你感觉“这里我需要一个特殊类型”的时候选它就对了。
比如说定义函数指针在C++里一直是个比较头疼的问题因为传统的写法实在是太怪异了。但现在就简单了你只要手里有一个函数就可以用decltype很容易得到指针类型。
```
// UNIX信号函数的原型看着就让人晕你能手写出函数指针吗
void (*signal(int signo, void (*func)(int)))(int)
// 使用decltype可以轻松得到函数指针类型
using sig_func_ptr_t = decltype(&amp;signal) ;
```
在定义类的时候因为auto被禁用了所以这也是decltype可以“显身手”的地方。它可以搭配别名任意定义类型再应用到成员变量、成员函数上变通地实现auto的功能。
```
class DemoClass final
{
public:
using set_type = std::set&lt;int&gt;; // 集合类型别名
private:
set_type m_set; // 使用别名定义成员变量
// 使用decltype计算表达式的类型定义别名
using iter_type = decltype(m_set.begin());
iter_type m_pos; // 类型别名定义成员变量
};
```
## 小结
好了今天我介绍了C++里的“自动类型推导”,简单小结一下今天的内容。
1. “自动类型推导”是给编译器下的指令,让编译器去计算表达式的类型,然后返回给程序员。
1. auto用于初始化时的类型推导总是“值类型”也可以加上修饰符产生新类型。它的规则比较好理解用法也简单应该积极使用。
1. decltype使用类似函数调用的形式计算表达式的类型能够用在任意场合因为它就是一个编译阶段的类型。
1. decltype能够推导出表达式的精确类型但写起来比较麻烦在初始化时可以采用decltype(auto)的简化形式。
1. 因为auto和decltype不是“硬编码”的类型所以用好它们可以让代码更清晰减少后期维护的成本。
## 课下作业
最后是课下作业时间,给你留两个思考题:
1. auto和decltype虽然很方便但用多了也确实会“隐藏”真正的类型增加阅读时的理解难度你觉得这算是缺点吗是否有办法克服或者缓解
1. 说一下你对auto和decltype的认识。你认为两者有哪些区别呢推导规则、应用场合等
欢迎你在留言区写下你的思考和答案,如果觉得今天的内容对你有所帮助,也欢迎分享给你的朋友,我们下节课见。
<img src="https://static001.geekbang.org/resource/image/6e/14/6ec0c53ee9917795c0e2a494cfe70014.png" alt="">

View File

@@ -0,0 +1,213 @@
<audio id="audio" title="07 | const/volatile/mutable常量/变量究竟是怎么回事?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/13/2a/130847d342028d4dcd2277aede0cf42a.mp3"></audio>
你好我是Chrono。
上节课我讲了自动类型推导提到auto推导出的类型可以附加const、volatile修饰通常合称为“cv修饰符”。别看就这么两个关键字里面的“门道”其实挺多的用好了可以让你的代码更安全、运行得更快。今天我就来说说它们俩以及比较少见的另一个关键字mutable。
## const与volatile
先来看**const**吧,你一定对它很熟悉了。正如它的字面含义,表示“常量”。最简单的用法就是,**定义程序用到的数字、字符串常量,代替宏定义**。
```
const int MAX_LEN = 1024;
const std::string NAME = &quot;metroid&quot;;
```
但如果我们从C++程序的生命周期角度来看的话,就会发现,它和宏定义还是有本质区别的:**const定义的常量在预处理阶段并不存在而是直到运行阶段才会出现**。
所以准确地说它实际上是运行时的“变量”只不过不允许修改是“只读”的read only叫“只读变量”更合适。
既然它是“变量”那么使用指针获取地址再“强制”写入也是可以的。但这种做法破坏了“常量性”绝对不提倡。这里我只是给你做一个示范性质的实验还要用到另外一个关键字volatile。
```
// 需要加上volatile修饰运行时才能看到效果
const volatile int MAX_LEN = 1024;
auto ptr = (int*)(&amp;MAX_LEN);
*ptr = 2048;
cout &lt;&lt; MAX_LEN &lt;&lt; endl; // 输出2048
```
可以看到这段代码最开始定义的常数是1024但是输出的却是2048。
你可能注意到了const后面多出了一个volatile的修饰它是这段代码的关键。如果没有这个volatile那么即使用指针得到了常量的地址并且尝试进行了各种修改但输出的仍然会是常数1024。
这是为什么呢?
因为“真正的常数”对于计算机来说有特殊意义,它是绝对不变的,所以编译器就要想各种办法去优化。
const常量虽然不是“真正的常数”但在大多数情况下它都可以被认为是常数在运行期间不会改变。编译器看到const定义就会采取一些优化手段比如把所有const常量出现的地方都替换成原始值。
所以对于没有volatile修饰的const常量来说虽然你用指针改了常量的值但这个值在运行阶段根本没有用到因为它在编译阶段就被优化掉了。
现在就来看看**volatile**的作用。
它的含义是“不稳定的”“易变的”在C++里,表示变量的值可能会以“难以察觉”的方式被修改(比如操作系统信号、外界其他的代码),所以要禁止编译器做任何形式的优化,每次使用的时候都必须“老老实实”地去取值。
现在再去看刚才的那段示例代码你就应该明白了。MAX_LEN虽然是个“只读变量”但加上了volatile修饰就表示它不稳定可能会悄悄地改变。编译器在生成二进制机器码的时候不会再去做那些可能有副作用的优化而是用最“保守”的方式去使用MAX_LEN。
也就是说编译器不会再把MAX_LEN替换为1024而是去内存里取值而它已经通过指针被强制修改了。所以这段代码最后输出的是2048而不是最初的1024。
看到这里你是不是也被const和volatile这两个关键字的表面意思迷惑了呢我的建议是你最好把const理解成read only虽然是“只读”但在运行阶段没有什么是不可以改变的也可以强制写入把变量标记成const可以让编译器做更好的优化。
而volatile会禁止编译器做优化所以除非必要应当少用volatile这也是你几乎很少在代码里见到它的原因我也建议你最好不要用除非你真的知道变量会如何被“悄悄地”改变
## 基本的const用法
作为一个类型修饰符const的用途非常多除了我刚才提到的修饰变量外下面我再带你看看它的常量引用、常量指针等其他用法。而volatile因为比较“危险”我就不再多说了。
在C++里除了最基本的值类型还有引用类型和指针类型它们加上const就成了**常量引用**和**常量指针**
```
int x = 100;
const int&amp; rx = x;
const int* px = &amp;x;
```
const &amp;被称为**万能引用**,也就是说,它可以引用任何类型,即不管是值、指针、左引用还是右引用,它都能“照单全收”。
而且它还会给变量附加上const特性这样“变量”就成了“常量”只能读、禁止写。编译器会帮你检查出所有对它的写操作发出警告在编译阶段防止有意或者无意的修改。这样一来const常量用起来就非常安全了。
因此,**在设计函数的时候,我建议你尽可能地使用它作为入口参数,一来保证效率,二来保证安全**。
const用于指针的情况会略微复杂一点。常见的用法是const放在声明的最左边表示指向常量的指针。这个其实很好理解指针指向的是一个“只读变量”不允许修改
```
string name = &quot;uncharted&quot;;
const string* ps1 = &amp;name; // 指向常量
*ps1 = &quot;spiderman&quot;; // 错误,不允许修改
```
另外一种比较“恶心”的用法是const在“*”的右边,表示指针不能被修改,而指向的变量可以被修改:
```
string* const ps2 = &amp;name; // 指向变量,但指针本身不能被修改
*ps2 = &quot;spiderman&quot;; // 正确,允许修改
```
再进一步,那就是“*”两边都有const你看看是什么意思呢
```
const string* const ps3 = &amp;name; // 很难看懂
```
实话实说我对const在“*”后面的用法“深恶痛绝”,每次看到这种形式,脑子里都会“绕一下”,实在是太难理解了,似乎感觉到了代码作者“深深的恶意”。
还是那句名言:“代码是给人看的,而不是给机器看的。”
所以,我从来不用“* const”的形式也建议你最好不要用而且这种形式在实际开发时也确实没有多大作用除非你想“炫技”。如果真有必要也最好换成其他实现方式让代码好懂一点将来的代码维护者会感谢你的。
## 与类相关的const用法
刚才说的const用法都是面向过程的在面向对象里const也很有用。
定义const成员变量很简单但你用过const成员函数吗像这样
```
class DemoClass final
{
private:
const long MAX_SIZE = 256; // const成员变量
int m_value; // 成员变量
public:
int get_value() const // const成员函数
{
return m_value;
}
};
```
注意这里const的用法有点特别。它被放在了函数的后面表示这个函数是一个“常量”。如果在前面就代表返回值是const int
“const成员函数”的意思并不是说函数不可修改。实际上在C++里函数并不是变量lambda表达式除外所以“只读”对于函数来说没有任何意义。它的真正含义是函数的执行过程是const的不会修改对象的状态即成员变量也就是说**成员函数是一个“只读操作”**。
听起来有点平淡无奇吧,但如果你把它和刚才讲的“常量引用”“常量指针”结合起来,就不一样了。
因为“常量引用”“常量指针”关联的对象是只读、不可修改的那么也就意味着对它的任何操作也应该是只读、不可修改的否则就无法保证它的安全性。所以编译器会检查const对象相关的代码如果成员函数不是const就不允许调用。
这其实也是对“常量”语义的一个自然延伸既然对象是const那么它所有的相关操作也必然是const。同样保证了安全之后编译器确认对象不会变也可以去做更好的优化。
看到这里,你会不会觉得常量引用、常量指针、常量函数这些概念有些“绕”呢?别担心,我给你总结了一个表格,看了它,以后你写代码的时候就不会晕了。
<img src="https://static001.geekbang.org/resource/image/ed/99/ed894e66f2ee7a651aca07dffbff2799.jpg" alt="">
这方面你还可以借鉴一下标准库比如vector它的empty()、size()、capacity()等查看基本属性的操作都是const的而reserve()、clear()、erase()则是非const的。
## 关键字mutable
说到这里,就要牵扯出另一个关键字“**mutable**”了。
mutable与volatile的字面含义有点像但用法、效果却大相径庭。volatile可以用来修饰任何变量而mutable却只能修饰类里面的成员变量表示变量即使是在const对象里也是可以修改的。
换句话说就是标记为mutable的成员不会改变对象的状态也就是不影响对象的常量性所以允许const成员函数改写mutable成员变量。
你是不是有些奇怪“这个mutable好像有点多此一举它有什么用呢
在我看来mutable像是C++给const对象打的一个“补丁”让它部分可变。因为对象与普通的int、double不同内部会有很多成员变量来表示状态但因为“封装”特性外界只能看到一部分状态判断对象是否const应该由这些外部可观测的状态特征来决定。
比如说对象内部用到了一个mutex来保证线程安全或者有一个缓冲区来暂存数据再或者有一个原子变量做引用计数……这些属于内部的私有实现细节外面看不到变与不变不会改变外界看到的常量性。这时如果const成员函数不允许修改它们就有点说不过去了。
所以,**对于这些有特殊作用的成员变量你可以给它加上mutable修饰解除const的限制让任何成员函数都可以操作它**。
```
class DemoClass final
{
private:
mutable mutex_type m_mutex; // mutable成员变量
public:
void save_data() const // const成员函数
{
// do someting with m_mutex
}
};
```
不过要当心mutable也不要乱用太多的mutable就丧失了const的好处。在设计类的时候我们一定要仔细考虑和volatile一样要少用、慎用。
## 小结
好了今天我和你聊了const、volatile、mutable这三个关键字在这里简单小结一下。
1.const
- 它是一个类型修饰符,可以给任何对象附加上“只读”属性,保证安全;
- 它可以修饰引用和指针“const &amp;”可以引用任何类型,是函数入口参数的最佳类型;
- 它还可以修饰成员函数表示函数是“只读”的const对象只能调用const成员函数。
2.volatile
它表示变量可能会被“不被察觉”地修改,禁止编译器优化,影响性能,应当少用。
3.mutable
它用来修饰成员变量允许const成员函数修改mutable变量的变化不影响对象的常量性但要小心不要误用损坏对象。
你今后再写类的时候就要认真想一想哪些操作改变了内部状态哪些操作没改变内部状态对于只读的函数就要加上const修饰。写错了也不用怕编译器会帮你检查出来。
总之就是一句话:**尽可能多用const让代码更安全。**
<img src="https://static001.geekbang.org/resource/image/f0/b8/f011dc96ff693faa4d763ea36bdc67b8.jpg" alt="">
这在多线程编程时尤其有用,让编译器帮你检查对象的所有操作,把“只读”属性持续传递出去,避免有害的副作用。
## 课下作业
最后是课下作业时间,给你留两个思考题:
1. 学完了这节课你觉得今后应该怎么用const呢
1. 给函数的返回值加上const也就是说返回一个常量对象有什么好处
欢迎你在留言区写下你的思考和答案,如果觉得文章对你有所帮助,也欢迎把文章分享给你的朋友,我们下节课见。
<img src="https://static001.geekbang.org/resource/image/bd/dd/bdd9bb369fcbe65a8c879f37995a77dd.jpg" alt="">

View File

@@ -0,0 +1,284 @@
<audio id="audio" title="08 | smart_ptr智能指针到底“智能”在哪里" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/06/3c/0619ce3b770efca7f227e80d9eea4d3c.mp3"></audio>
你好我是Chrono。
上节课在讲const的时候说到const可以修饰指针不过今天我要告诉你请忘记这种用法在现代C++中绝对不要再使用“裸指针naked pointer”了而是应该使用“智能指针smart pointer”。
你肯定或多或少听说过、用过智能指针,也可能看过实现源码,那么,你心里有没有一种疑惑,智能指针到底“智能”在哪里?难道它就是解决一切问题的“灵丹妙药”吗?
学完了今天的这节课,我想你就会有个明确的答案了。
## 什么是智能指针?
所谓的“智能指针”,当然是相对于“不智能指针”,也就是“裸指针”而言的。
所以,我们就先来看看裸指针,它有时候也被称为原始指针,或者直接简称为指针。
指针是源自C语言的概念本质上是一个内存地址索引代表了一小片内存区域也可能会很大能够直接读写内存。
因为它完全映射了计算机硬件所以操作效率高是C/C++高效的根源。当然,这也是引起无数麻烦的根源。访问无效数据、指针越界,或者内存分配后没有及时释放,就会导致运行错误、内存泄漏、资源丢失等一系列严重的问题。
其他的编程语言比如Java、Go就没有这方面的顾虑因为它们内置了一个“垃圾回收”机制会检测不再使用的内存自动释放资源让程序员不必为此费心。
其实C++里也是有垃圾回收的不过不是Java、Go那种严格意义上的垃圾回收而是广义上的垃圾回收这就是**构造/析构函数**和**RAII惯用法**Resource Acquisition Is Initialization
我们可以应用代理模式把裸指针包装起来在构造函数里初始化在析构函数里释放。这样当对象失效销毁时C++就会**自动**调用析构函数,完成内存释放、资源回收等清理工作。
和Java、Go相比这算是一种“微型”的垃圾回收机制而且回收的时机完全“自主可控”非常灵活。当然也有一点代价——你必须要针对每一个资源手写包装代码又累又麻烦。
智能指针就是代替你来干这些“脏活累活”的。它完全实践了RAII包装了裸指针而且因为重载了*和-&gt;操作符,用起来和原始指针一模一样。
不仅如此,它还综合考虑了很多现实的应用场景,能够自动适应各种复杂的情况,防止误用指针导致的隐患,非常“聪明”,所以被称为“智能指针”。
常用的有两种智能指针,分别是**unique_ptr**和**shared_ptr**,下面我就来分别介绍一下。
## 认识unique_ptr
unique_ptr是最简单、最容易使用的一个智能指针在声明的时候必须用模板参数指定类型
```
unique_ptr&lt;int&gt; ptr1(new int(10)); // int智能指针
assert(*ptr1 == 10); // 可以使用*取内容
assert(ptr1 != nullptr); // 可以判断是否为空指针
unique_ptr&lt;string&gt; ptr2(new string(&quot;hello&quot;)); // string智能指针
assert(*ptr2 == &quot;hello&quot;); // 可以使用*取内容
assert(ptr2-&gt;size() == 5); // 可以使用-&gt;调用成员函数
```
你需要注意的是unique_ptr虽然名字叫指针用起来也很像但**它实际上并不是指针而是一个对象。所以不要企图对它调用delete它会自动管理初始化时的指针在离开作用域时析构释放内存。**
另外,它也没有定义加减运算,不能随意移动指针地址,这就完全避免了指针越界等危险操作,可以让代码更安全:
```
ptr1++; // 导致编译错误
ptr2 += 2; // 导致编译错误
```
除了调用delete、加减运算初学智能指针还有一个容易犯的错误是把它当成普通对象来用不初始化而是声明后直接使用
```
unique_ptr&lt;int&gt; ptr3; // 未初始化智能指针
*ptr3 = 42 ; // 错误!操作了空指针
```
未初始化的unique_ptr表示空指针这样就相当于直接操作了空指针运行时就会产生致命的错误比如core dump
为了避免这种低级错误,你可以调用工厂函数**make_unique()**,强制创建智能指针的时候必须初始化。同时还可以利用自动类型推导([第6讲](https://time.geekbang.org/column/article/237964)的auto少写一些代码
```
auto ptr3 = make_unique&lt;int&gt;(42); // 工厂函数创建智能指针
assert(ptr3 &amp;&amp; *ptr3 == 42);
auto ptr4 = make_unique&lt;string&gt;(&quot;god of war&quot;); // 工厂函数创建智能指针
assert(!ptr4-&gt;empty());
```
不过make_unique()要求C++14好在它的原理比较简单。如果你使用的是C++11也可以自己实现一个简化版的make_unique(),可以参考下面的代码:
```
template&lt;class T, class... Args&gt; // 可变参数模板
std::unique_ptr&lt;T&gt; // 返回智能指针
my_make_unique(Args&amp;&amp;... args) // 可变参数模板的入口参数
{
return std::unique_ptr&lt;T&gt;( // 构造智能指针
new T(std::forward&lt;Args&gt;(args)...)); // 完美转发
}
```
## unique_ptr的所有权
使用unique_ptr的时候还要特别注意指针的“**所有权**”问题。
正如它的名字,表示指针的所有权是“唯一”的,不允许共享,任何时候只能有一个“人”持有它。
为了实现这个目的unique_ptr应用了C++的“转移”move语义同时禁止了拷贝赋值所以在向另一个unique_ptr赋值的时候要特别留意必须用**std::move()**函数显式地声明所有权转移。
赋值操作之后指针的所有权就被转走了原来的unique_ptr变成了空指针新的unique_ptr接替了管理权保证所有权的唯一性
```
auto ptr1 = make_unique&lt;int&gt;(42); // 工厂函数创建智能指针
assert(ptr1 &amp;&amp; *ptr1 == 42); // 此时智能指针有效
auto ptr2 = std::move(ptr1); // 使用move()转移所有权
assert(!ptr1 &amp;&amp; ptr2); // ptr1变成了空指针
```
如果你对右值、转移这些概念不是太理解,也没关系,它们用起来也的确比较“微妙”,这里你只要记住,**尽量不要对unique_ptr执行赋值操作**就好了,让它“自生自灭”,完全自动化管理。
## 认识shared_ptr
接下来要说的是shared_ptr它是一个比unique_ptr更“智能”的智能指针。
初看上去shared_ptr和unique_ptr差不多也可以使用工厂函数来创建也重载了*和-&gt;操作符,用法几乎一样——只是名字不同,看看下面的代码吧:
```
shared_ptr&lt;int&gt; ptr1(new int(10)); // int智能指针
assert(*ptr1 = 10); // 可以使用*取内容
shared_ptr&lt;string&gt; ptr2(new string(&quot;hello&quot;)); // string智能指针
assert(*ptr2 == &quot;hello&quot;); // 可以使用*取内容
auto ptr3 = make_shared&lt;int&gt;(42); // 工厂函数创建智能指针
assert(ptr3 &amp;&amp; *ptr3 == 42); // 可以判断是否为空指针
auto ptr4 = make_shared&lt;string&gt;(&quot;zelda&quot;); // 工厂函数创建智能指针
assert(!ptr4-&gt;empty()); // 可以使用-&gt;调用成员函数
```
但shared_ptr的名字明显表示了它与unique_ptr的最大不同点**它的所有权是可以被安全共享的**,也就是说支持拷贝赋值,允许被多个“人”同时持有,就像原始指针一样。
```
auto ptr1 = make_shared&lt;int&gt;(42); // 工厂函数创建智能指针
assert(ptr1 &amp;&amp; ptr1.unique() ); // 此时智能指针有效且唯一
auto ptr2 = ptr1; // 直接拷贝赋值不需要使用move()
assert(ptr1 &amp;&amp; ptr2); // 此时两个智能指针均有效
assert(ptr1 == ptr2); // shared_ptr可以直接比较
// 两个智能指针均不唯一且引用计数为2
assert(!ptr1.unique() &amp;&amp; ptr1.use_count() == 2);
assert(!ptr2.unique() &amp;&amp; ptr2.use_count() == 2);
```
shared_ptr支持安全共享的秘密在于**内部使用了“引用计数”**。
引用计数最开始的时候是1表示只有一个持有者。如果发生拷贝赋值——也就是共享的时候引用计数就增加而发生析构销毁的时候引用计数就减少。只有当引用计数减少到0也就是说没有任何人使用这个指针的时候它才会真正调用delete释放内存。
因为shared_ptr具有完整的“值语义”即可以拷贝赋值所以**它可以在任何场合替代原始指针,而不用再担心资源回收的问题**,比如用于容器存储指针、用于函数安全返回动态创建的对象,等等。
## shared_ptr的注意事项
那么既然shared_ptr这么好是不是就可以只用它而不再考虑unique_ptr了呢
答案当然是否定的,不然也就没有必要设计出来多种不同的智能指针了。
虽然shared_ptr非常“智能”但天下没有免费的午餐它也是有代价的**引用计数的存储和管理都是成本**这方面是shared_ptr不如unique_ptr的地方。
如果不考虑应用场合过度使用shared_ptr就会降低运行效率。不过你也不需要太担心shared_ptr内部有很好的优化在非极端情况下它的开销都很小。
另外一个要注意的地方是**shared_ptr的销毁动作**。
因为我们把指针交给了shared_ptr去自动管理但在运行阶段引用计数的变动是很复杂的很难知道它真正释放资源的时机无法像Java、Go那样明确掌控、调整垃圾回收机制。
你要特别小心对象的析构函数不要有非常复杂、严重阻塞的操作。一旦shared_ptr在某个不确定时间点析构释放资源就会阻塞整个进程或者线程“整个世界都会静止不动”也许用过Go的同学会深有体会。这也是我以前遇到的实际案例排查起来费了很多功夫真的是“血泪教训”。
```
class DemoShared final // 危险的类,不定时的地雷
{
public:
DemoShared() = default;
~DemoShared() // 复杂的操作会导致shared_ptr析构时世界静止
{
// Stop The World ...
}
};
```
shared_ptr的引用计数也导致了一个新的问题就是“**循环引用**”这在把shared_ptr作为类成员的时候最容易出现典型的例子就是**链表节点**。
下面的代码演示了一个简化的场景:
```
class Node final
{
public:
using this_type = Node;
using shared_type = std::shared_ptr&lt;this_type&gt;;
public:
shared_type next; // 使用智能指针来指向下一个节点
};
auto n1 = make_shared&lt;Node&gt;(); // 工厂函数创建智能指针
auto n2 = make_shared&lt;Node&gt;(); // 工厂函数创建智能指针
assert(n1.use_count() == 1); // 引用计数为1
assert(n2.use_count() == 1);
n1-&gt;next = n2; // 两个节点互指,形成了循环引用
n2-&gt;next = n1;
assert(n1.use_count() == 2); // 引用计数为2
assert(n2.use_count() == 2); // 无法减到0无法销毁导致内存泄漏
```
在这里两个节点指针刚创建时引用计数是1但指针互指即拷贝赋值之后引用计数都变成了2。
这个时候shared_ptr就“犯傻”了意识不到这是一个循环引用多算了一次计数后果就是引用计数无法减到0无法调用析构函数执行delete最终导致内存泄漏。
这个例子很简单,你一下子就能看出存在循环引用。但在实际开发中,指针的关系可不像例子那么清晰,很有可能会不知不觉形成一个链条很长的循环引用,复杂到你根本无法识别,想要找出来基本上是不可能的。
想要从根本上杜绝循环引用光靠shared_ptr是不行了必须要用到它的“小帮手”**weak_ptr**。
weak_ptr顾名思义功能很“弱”。它专门为打破循环引用而设计只观察指针不会增加引用计数弱引用但在需要的时候可以调用成员函数lock()获取shared_ptr强引用
刚才的例子里只要你改用weak_ptr循环引用的烦恼就会烟消云散
```
class Node final
{
public:
using this_type = Node;
// 注意这里别名改用weak_ptr
using shared_type = std::weak_ptr&lt;this_type&gt;;
public:
shared_type next; // 因为用了别名,所以代码不需要改动
};
auto n1 = make_shared&lt;Node&gt;(); // 工厂函数创建智能指针
auto n2 = make_shared&lt;Node&gt;(); // 工厂函数创建智能指针
n1-&gt;next = n2; // 两个节点互指,形成了循环引用
n2-&gt;next = n1;
assert(n1.use_count() == 1); // 因为使用了weak_ptr引用计数为1
assert(n2.use_count() == 1); // 打破循环引用,不会导致内存泄漏
if (!n1-&gt;next.expired()) { // 检查指针是否有效
auto ptr = n1-&gt;next.lock(); // lock()获取shared_ptr
assert(ptr == n2);
}
```
## 小结
好了,今天就先到这里。智能指针的话题很大,但是学习的时候我们不可能一下子把所有知识点都穷尽,而是要有优先级。所以我会捡最要紧的先介绍给你,剩下的接口函数等细节,还是需要你根据自己的情况,再去参考一些其他资料深入学习的。
我们来回顾一下这节课的重点。
1. 智能指针是代理模式的具体应用它使用RAII技术代理了裸指针能够自动释放内存无需程序员干预所以被称为“智能指针”。
1. 如果指针是“独占”使用就应该选择unique_ptr它为裸指针添加了很多限制更加安全。
1. 如果指针是“共享”使用就应该选择shared_ptr它的功能非常完善用法几乎与原始指针一样。
1. 应当使用工厂函数make_unique()、make_shared()来创建智能指针强制初始化而且还能使用auto来简化声明。
1. shared_ptr有少量的管理成本也会引发一些难以排查的错误所以不要过度使用。
我还有一个很重要的建议:
**既然你已经理解了智能指针就尽量不要再使用裸指针、new和delete来操作内存了**
如果严格遵守这条建议用好unique_ptr、shared_ptr那么你的程序就不可能出现内存泄漏你也就不需要去费心研究、使用valgrind等内存调试工具了生活也会更“美好”一点。
## 课下作业
最后是课下作业时间,给你留两个思考题:
1. 你觉得unique_ptr和shared_ptr的区别有哪些列举一下。
1. 你觉得应该如何在程序里“消灭”new和delete
欢迎你在留言区写下你的思考和答案,如果觉得今天的内容对你有所帮助,也欢迎分享给你的朋友,我们下节课见。
<img src="https://static001.geekbang.org/resource/image/e5/51/e5298af2501d0156fcc50d50cdb82351.jpg" alt="">

View File

@@ -0,0 +1,265 @@
<audio id="audio" title="09 | exception怎样才能用好异常" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/39/21/398ce491728143f0c00a210fe2b03e21.mp3"></audio>
你好我是Chrono。
上节课我建议尽量不用裸指针、new和delete因为它们很危险容易导致严重错误。这就引出了一个问题如何正确且优雅地处理运行时的错误。
实际上,想要达成这个目标,还真不是件简单的事情。
程序在运行的时候不可能“一帆风顺”,总会遇到这样那样的内外部故障,而我们写程序的人就要尽量考虑周全,准备各种“预案”,让程序即使遇到问题也能够妥善处理,保证“健壮性”。
C++处理错误的标准方案是“异常”exception。虽然它已经在Java、C#、Python等语言中得到了广泛的认可和应用但在C++里却存在诸多争议。
你也可能在其他地方听到过一种说法:“**现代C++里应该使用异常**。”但这之后呢?应该怎么去用异常呢?
所以,今天我就和你好好聊聊“异常那些事”,说一说为什么要有异常,该怎么用好异常,有哪些要注意的地方。
## 为什么要有异常?
很多人认为C++里的“异常”非常可怕,一旦发生异常就是“了不得的大事”,这其实是因为没有理解异常的真正含义。
实际上,你可以按照它的字面意思,把它理解成“**异于正常**”,就是正常流程之外发生的一些特殊情况、严重错误。一旦遇到这样的错误,程序就会跳出正常流程,甚至很难继续执行下去。
归根到底,**异常只是C++为了处理错误而提出的一种解决方案,当然也不会是唯一的一种**。
在C++之前处理异常的基本手段是“错误码”。函数执行后需要检查返回值或者全局的errno看是否正常如果出错了就执行另外一段代码处理错误
```
int n = read_data(fd, ...); // 读取数据
if (n == 0) {
... // 返回值不太对,适当处理
}
if (errno == EAGAIN) {
... // 适当处理错误
}
```
这种做法很直观,但也有一个问题,那就是**正常的业务逻辑代码与错误处理代码混在了一起**看起来很乱你的思维要在两个本来不相关的流程里来回跳转。而且有的时候错误处理的逻辑要比正常业务逻辑复杂、麻烦得多看了半天你可能都会忘了它当初到底要干什么了容易引起新的错误。你可以对比一下预处理代码与C++代码混在一起的情景。)
错误码还有另一个更大的问题:**它是可以被忽略的**。也就是说,你完全可以不处理错误,“假装”程序运行正常,继续跑后面的代码,这就可能导致严重的安全隐患。(可能是无意的,因为你确实不知道发生了什么错误。)
“没有对比就没有伤害”,现在你就应该明白了,作为一种新的错误处理方式,异常就是针对错误码的缺陷而设计的,它有三个特点。
<li>
**异常的处理流程是完全独立的**throw抛出异常后就可以不用管了错误处理代码都集中在专门的catch块里。这样就彻底分离了业务逻辑与错误逻辑看起来更清楚。
</li>
<li>
**异常是绝对不能被忽略的,必须被处理**。如果你有意或者无意不写catch捕获异常那么它会一直向上传播出去直至找到一个能够处理的catch块。如果实在没有那就会导致程序立即停止运行明白地提示你发生了错误而不会“坚持带病工作”。
</li>
<li>
**异常可以用在错误码无法使用的场合**这也算是C++的“私人原因”。因为它比C语言多了构造/析构函数、操作符重载等新特性有的函数根本就没有返回值或者返回值无法表示错误而全局的errno实在是“太不优雅”了与C++的理念不符,所以也必须使用异常来报告错误。
</li>
记住这三个关键点是在C++里用好异常的基础,它们能够帮助你在本质上理解异常的各种用法。
## 异常的用法和使用方式
C++里异常的用法想必你已经知道了:**用try把可能发生异常的代码“包”起来然后编写catch块捕获异常并处理**。
刚才的错误码例子改用异常,就会变得非常干净清晰:
```
try
{
int n = read_data(fd, ...); // 读取数据,可能抛出异常
... // do some right thing
}
catch(...)
{
... // 集中处理各种错误情况
}
```
基本的try-catch谁都会写那么怎样才能用好异常呢
首先你要知道C++里对异常的定义非常宽松任何类型都可以用throw抛出也就是说你可以直接把错误码int、或者错误消息char*、string抛出catch也能接住然后处理。
但我建议你最好不要“图省事”因为C++已经为处理异常设计了一个配套的异常类型体系,定义在标准库的&lt;stdexcept&gt;头文件里。
下面我画了个简单的示意图,你可以看一下。
<img src="https://static001.geekbang.org/resource/image/8b/67/8ba78367ce53d54a393a8963bb62e867.jpg" alt="">
标准异常的继承体系有点复杂最上面是基类exception下面是几个基本的异常类型比如bad_alloc、bad_cast、runtime_error、logic_error再往下还有更细致的错误类型像runtime_error就有range_error、overflow_error等等。
我在[第5节课](https://time.geekbang.org/column/article/235301)讲过,如果继承深度超过三层,就说明有点“过度设计”,很明显现在就有这种趋势了。所以,我建议你最好选择上面的第一层或者第二层的某个类型作为基类,不要再加深层次。
比如说你可以从runtime_error派生出自己的异常类
```
class my_exception : public std::runtime_error
{
public:
using this_type = my_exception; // 给自己起个别名
using super_type = std::runtime_error; // 给父类也起个别名
public:
my_exception(const char* msg): // 构造函数
super_type(msg) // 别名也可以用于构造
{}
my_exception() = default; // 默认构造函数
~my_exception() = default; // 默认析构函数
private:
int code = 0; // 其他的内部私有数据
};
```
在抛出异常的时候我建议你最好不要直接用throw关键字而是要封装成一个函数这和不要直接用new、delete关键字是类似的道理——**通过引入一个“中间层”来获得更多的可读性、安全性和灵活性**。
抛异常的函数不会有返回值,所以应该用[第4节课](https://time.geekbang.org/column/article/235295)里的“属性”做编译阶段优化:
```
[[noreturn]] // 属性标签
void raise(const char* msg) // 函数封装throw没有返回值
{
throw my_exception(msg); // 抛出异常,也可以有更多的逻辑
}
```
使用catch捕获异常的时候也要注意C++允许编写多个catch块捕获不同的异常再分别处理。但是**异常只能按照catch块在代码里的顺序依次匹配而不会去找最佳匹配**。
这个特性导致实际开发的时候有点麻烦特别是当异常类型体系比较复杂的时候有可能会因为写错了顺序进入你本不想进的catch块。所以**我建议你最好只用一个catch块绕过这个“坑”**。
写catch块就像是写一个标准函数所以入口参数也应当使用“const &amp;”的形式,避免对象拷贝的代价:
```
try
{
raise(&quot;error occured&quot;); // 函数封装throw抛出异常
}
catch(const exception&amp; e) // const &amp;捕获异常,可以用基类
{
cout &lt;&lt; e.what() &lt;&lt; endl; // what()是exception的虚函数
}
```
关于try-catch还有一个很有用的形式**function-try。**我一直都觉得非常奇怪的是这个形式如此得简单清晰早在C++98的时候就已经出现了但知道的人却非常少。
所谓function-try就是把整个函数体视为一个大try块而catch块放在后面与函数体同级并列给你看个示例
```
void some_function()
try // 函数名之后直接写try块
{
...
}
catch(...) // catch块与函数体同级并列
{
...
}
```
这样做的好处很明显,不仅能够捕获函数执行过程中所有可能产生的异常,而且少了一级缩进层次,处理逻辑更清晰,我也建议你多用。
## 谨慎使用异常
掌握了异常和它的处理方式,下面我结合我自己的经验,和你讨论一下应该在什么时候使用异常来处理错误。
目前的C++世界里有三种使用异常的方式(或者说是观点)。
第一种是绝不使用异常就像是C语言那样只用传统的错误码来检查错误。
选择禁止异常的原因当然有很多,有的也很合理,但我觉得这就等于浪费了异常机制,对于改善代码质量没有帮助,属于“**因噎废食**”。
第二种则与第一种相反,主张全面采用异常,所有的错误都用异常的形式来处理。
但你要知道,异常也是有成本的。
异常的抛出和处理需要特别的栈展开stack unwind操作如果异常出现的位置很深但又没有被及时处理或者频繁地抛出异常就会对运行性能产生很大的影响。这个时候程序全忙着去处理异常了正常逻辑反而被搁置。
这种观点我认为是“**暴饮暴食**”,也不可取。
所以,第三种方式就是两者的折中:区分“非”错误、“轻微”错误和“严重”错误,谨慎使用异常。我认为这应该算是“**均衡饮食**”。
具体来说,就是要仔细分析程序中可能发生的各种错误情况,按严重程度划分出等级,把握好“度”。
对于正常的返回值,或者不太严重、可以重试/恢复的错误,我建议你不使用异常,把它们归到正常的流程里。
比如说字符串未找到(不是错误)、数据格式不对(轻微错误)、数据库正忙(可重试错误),这样的错误比较轻微,而且在业务逻辑里会经常出现,如果你用异常处理,就会“小题大做”,影响性能。
剩下的那些中级、高级错误也不是都必须用异常,你还要再做分析,尽量降低引入异常的成本。
我自己总结了几个应当使用异常的判断准则:
1. 不允许被忽略的错误;
1. 极少数情况下才会发生的错误;
1. 严重影响正常流程,很难恢复到正常状态的错误;
1. 无法本地处理,必须“穿透”调用栈,传递到上层才能被处理的错误。
规则听起来可能有点不好理解,我给你举几个例子。
比如说构造函数,如果内部初始化失败,无法创建,那后面的逻辑也就进行不下去了,所以这里就可以用异常来处理。
再比如,读写文件,通常文件系统很少会出错,总会成功,如果用错误码来处理不存在、权限错误等,就显得太啰嗦,这时也应该使用异常。
相反的例子就是socket通信。因为网络链路的不稳定因素太多收发数据失败简直是“家常便饭”。虽然出错的后果很严重但它出现的频率太高了使用异常会增加很多的处理成本为了性能考虑还是检查错误码重试比较好。
## 保证不抛出异常
看到这里,你是不是觉得异常是把“双刃剑”呢?优点缺点都有,难以取舍。
有没有什么办法既能享受异常的好处,又不用承担异常的成本呢?
还真有这样的“好事”毕竟写C++程序追求的就是性能所以C++标准就又提出了一个新的编译阶段指令:**noexcept**,但它也有一点局限,不是“万能药”。
noexcept专门用来修饰函数告诉编译器这个函数不会抛出异常。编译器看到noexcept就得到了一个“保证”就可以对函数做优化不去加那些栈展开的额外代码消除异常处理的成本。
和const一样noexcept要放在函数后面
```
void func_noexcept() noexcept // 声明绝不会抛出异常
{
cout &lt;&lt; &quot;noexcept&quot; &lt;&lt; endl;
}
```
不过你要注意noexcept只是做出了一个“不可靠的承诺”不是“强保证”编译器无法彻底检查它的行为标记为noexcept的函数也有可能抛出异常
```
void func_maybe_noexcept() noexcept // 声明绝不会抛出异常
{
throw &quot;Oh My God&quot;; // 但也可以抛出异常
}
```
noexcept的真正意思是“我对外承诺不抛出异常我也不想处理异常如果真的有异常发生请让我死得干脆点直接崩溃crash、core dump。”
所以你也不要一股脑地给所有函数都加上noexcept修饰毕竟你无法预测内部调用的那些函数是否会抛出异常。
## 小结
今天的话题是错误处理和异常,因为它实在太大了,想要快速说清、说透实在是“不可能的任务”,我们可以在课后继续讨论。
异常也与上一讲的智能指针密切相关如果你决定使用异常为了确保出现异常的时候资源会正确释放就必须禁用裸指针改成智能指针用RAII来管理内存。
由于异常出现和处理的时机都不好确定当前的C++也没有在语言层面提出更好的机制,所以,你还要在编码阶段写好文档和注释,说清楚哪些函数、什么情况下会抛出什么样的异常,应如何处理,加上一些“软约束”。
再简单小结一下今天的内容:
1. 异常是针对错误码的缺陷而设计的,它不能被忽略,而且可以“穿透”调用栈,逐层传播到其他地方去处理;
1. 使用try-catch机制处理异常能够分离正常流程与错误处理流程让代码更清晰
1. throw可以抛出任何类型作为异常但最好使用标准库里定义的exception类
1. 完全用或不用异常处理错误都不可取,而是应该合理分析,适度使用,降低异常的成本;
1. 关键字noexcept标记函数不抛出异常可以让编译器做更好的优化。
## 课下作业
最后是课下作业时间,给你留两个思考题:
1. 结合自己的实际情况,谈一下使用异常有什么好处和坏处。
1. 你觉得用好异常还有哪些要注意的地方?
欢迎你在留言区写下你的思考和答案,如果觉得今天的内容对你有所帮助,也欢迎分享给你的朋友,我们下节课见。
<img src="https://static001.geekbang.org/resource/image/96/6e/96a9e2f3c794a3b24df1a49e1ce8c16e.jpg" alt="">

View File

@@ -0,0 +1,271 @@
<audio id="audio" title="10 | lambda函数式编程带来了什么" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e2/57/e2839e9fd15c657822c170e1d3542957.mp3"></audio>
你好我是Chrono。
在[第1节课](https://time.geekbang.org/column/article/231454)的时候,我就说到过“函数式编程”,但只是简单提了提,没有展开讲。
作为现代C++里的五种基本编程范式之一,“函数式编程”的作用和地位正在不断上升,而且在其他语言里也非常流行,很有必要再深入研究一下。
掌握了函数式编程,你就又多了一件“趁手的兵器”,可以更好地运用标准库里的容器和算法,写出更灵活、紧凑、优雅的代码。
所以今天我就和你聊聊函数式编程看看它给C++带来了什么。
## C++函数的特殊性
说到“函数式编程”那肯定就要先从函数function说起。
C++里的函数概念来源于C是面向过程编程范式的基本部件。但严格来说它其实应该叫“子过程”sub-procedure、“子例程”sub-routine是命令的集合、操作步骤的抽象。
函数的目的是封装执行的细节,简化程序的复杂度,但因为它有入口参数,有返回值,形式上和数学里的函数很像,所以就被称为“函数”。
在语法层面上C/C++里的函数是比较特别的。虽然有函数类型,但不存在对应类型的变量,不能直接操作,只能用指针去间接操作(即函数指针),这让函数在类型体系里显得有点“格格不入”。
函数在用法上也有一些特殊之处。在C/C++里所有的函数都是全局的没有生存周期的概念static、名字空间的作用很弱只是简单限制了应用范围避免名字冲突。而且函数也都是平级的不能在函数里再定义函数也就是**不允许定义嵌套函数、函数套函数**。
```
void my_square(int x) // 定义一个函数
{
cout &lt;&lt; x*x &lt;&lt; endl; // 函数的具体内容
}
auto pfunc = &amp;my_square; // 只能用指针去操作函数,指针不是函数
(*pfunc)(3); // 可以用*访问函数
pfunc(3); // 也可以直接调用函数指针
```
所以,在面向过程编程范式里,函数和变量虽然是程序里最关键的两个组成部分,但却因为没有值、没有作用域而不能一致地处理。函数只能是函数,变量只能是变量,彼此之间虽不能说是“势同水火”,但至少是“泾渭分明”。
## 认识lambda
好了搞清楚了函数现在再来看看C++11引入的lambda表达式下面是一个简单的例子
```
auto func = [](int x) // 定义一个lambda表达式
{
cout &lt;&lt; x*x &lt;&lt; endl; // lambda表达式的具体内容
};
func(3); // 调用lambda表达式
```
暂时不考虑代码里面的语法细节,单从第一印象上,我们可以看到有一个函数,但更重要的,是这个函数采用了赋值的方式,存入了一个变量。
这就是lambda表达式与普通函数最大、也是最根本的区别。
因为lambda表达式是一个变量所以我们就可以“按需分配”随时随地在调用点“**就地**”定义函数,限制它的作用域和生命周期,实现函数的局部化。
而且因为lambda表达式和变量一样是“一等公民”用起来也就更灵活自由能对它做各种运算生成新的函数。这就像是数学里的复合函数那样把多个简单功能的小lambda表达式组合变成一个复杂的大lambda表达式。
如果你比较熟悉C++98或者看过一些相关的资料可能会觉得lambda表达式只不过是函数对象function object的一种简化形式只是一个好用的“语法糖”syntactic sugar
大道理上是没错的,但如果把它简单地等同于函数对象,认为它只是免去了手写函数对象的麻烦,那就实在是有点太“肤浅”了。
lambda表达式为C++带来的变化可以说是革命性的。虽然它表面上只是一个很小的改进,简化了函数的声明/定义,但深层次带来的编程理念的变化,却是非常巨大的。
这和C++当初引入bool、class、template这些特性时有点类似乍看上去好像只是一点点的语法改变但后果却如同雪崩促使人们更多地去思考、探索新的编程方向而lambda引出的全新思维方式就是“函数式编程”——把写计算机程序看作是数学意义上的求解函数。
C++里的lambda表达式除了可以像普通函数那样被调用还有一个普通函数所不具备的特殊本领就是可以**“捕获”外部变量**,在内部的代码里直接操作。
```
int n = 10; // 一个外部变量
auto func = [=](int x) // lambda表达式用“=”值捕获
{
cout &lt;&lt; x*n &lt;&lt; endl; // 直接操作外部变量
};
func(3); // 调用lambda表达式
```
看到这里如果你用过JavaScript那么一定会有种眼熟的感觉。没错lambda表达式就是在其他语言中大名鼎鼎的“**闭包**”closure这让它真正超越了函数和函数对象。
“闭包”是什么,很难一下子说清楚,我就不详细解释了。说得形象一点,你可以把闭包理解为一个“活的代码块”“活的函数”。它虽然在出现时被定义,但因为保存了定义时捕获的外部变量,就可以跳离定义点,把这段代码“打包”传递到其他地方去执行,而仅凭函数的入口参数是无法做到这一点的。
这就导致函数式编程与命令式编程(即面向过程)在结构上有很大不同,程序流程不再是按步骤执行的“死程序”,而是一个个的“活函数”,像做数学题那样逐步计算、推导出结果,有点像下面的这样:
```
auto a = [](int x) // a函数执行一个功能
{...}
auto b = [](double x) // b函数执行一个功能
{...}
auto c = [](string str) // c函数执行一个功能
{...}
auto f = [](...) // f函数执行一个功能
{...}
return f(a, b, c) // f调用a/b/c运算得到结果
```
你也可以再对比面向对象来理解。在面向对象编程里,程序是由一个个实体对象组成的,对象通信完成任务。而在函数式编程里,程序是由一个个函数组成的,函数互相嵌套、组合、调用完成任务。
不过毕竟函数式编程在C++里是一种较新的编程范式,而且面向过程里的函数概念“根深蒂固”,我说了这么多,你可能还是不太能领会它的奥妙,这也很正常。
下面我就来讲讲lambda表达式的使用细节掌握了以后多用就能够更好地理解了。
## 使用lambda的注意事项
要学好用好lambda我觉得就是三个重点语法形式变量捕获规则还有泛型的用法。
**1.lambda的形式**
首先你要知道C++没有为lambda表达式引入新的关键字并没有“lambda”这样的词汇而是用了一个特殊的形式“**[]**”,术语叫“**lambda引出符**”lambda introducer
在lambda引出符后面就可以像普通函数那样用圆括号声明入口参数用花括号定义函数体。
下面的代码展示了我最喜欢的一个lambda表达式也是最简单的
```
auto f1 = [](){}; // 相当于空函数,什么也不做
```
这行语句定义了一个相当于空函数的lambda表达式三个括号“排排坐”看起来有种奇特的美感让人不由得想起那句经典台词“一家人最要紧的就是整整齐齐。”不过还是差了个尖括号&lt;&gt;)。
当然了实际开发中不会有这么简单的lambda表达式它的函数体里可能会有很多语句所以一定**要有良好的缩进格式**——特别是有嵌套定义的时候尽量让人能够一眼就看出lambda表达式的开始和结束必要的时候可以用注释来强调。
```
auto f2 = []() // 定义一个lambda表达式
{
cout &lt;&lt; &quot;lambda f2&quot; &lt;&lt; endl;
auto f3 = [](int x) // 嵌套定义lambda表达式
{
return x*x;
};// lambda f3 // 使用注释显式说明表达式结束
cout &lt;&lt; f3(10) &lt;&lt; endl;
}; // lambda f2 // 使用注释显式说明表达式结束
```
你可能注意到了在lambda表达式赋值的时候我总是使用auto来推导类型。这是因为在C++里每个lambda表达式都会有一个独特的类型而这个类型只有编译器才知道我们是无法直接写出来的所以必须用auto。
不过因为lambda表达式毕竟不是普通的变量所以C++也鼓励程序员**尽量“匿名”使用lambda表达式**。也就是说,它不必显式赋值给一个有名字的变量,直接声明就能用,免去你费力起名的烦恼。
这样不仅可以让代码更简洁而且因为“匿名”lambda表达式调用完后也就不存在了也有被拷贝保存的可能这就最小化了它的影响范围让代码更加安全。
```
vector&lt;int&gt; v = {3, 1, 8, 5, 0}; // 标准容器
cout &lt;&lt; *find_if(begin(v), end(v), // 标准库里的查找算法
[](int x) // 匿名lambda表达式不需要auto赋值
{
return x &gt;= 5; // 用做算法的谓词判断条件
} // lambda表达式结束
)
&lt;&lt; endl; // 语句执行完lambda表达式就不存在了
```
**2.lambda的变量捕获**
lambda的“捕获”功能需要在“[]”里做文章,由于实际的规则太多太细,记忆、理解的成本高,所以我只说几个要点,帮你快速掌握它们:
- “[=]”表示按值捕获所有外部变量,表达式内部是值的拷贝,并且不能修改;
- “[&amp;]”是按引用捕获所有外部变量,内部以引用的方式使用,可以修改;
- 你也可以在“[]”里明确写出外部变量名指定按值或者按引用捕获C++在这里给予了非常大的灵活性。
```
int x = 33; // 一个外部变量
auto f1 = [=]() // lambda表达式用“=”按值捕获
{
//x += 10; // x只读不允许修改
};
auto f2 = [&amp;]() // lambda表达式用“&amp;”按引用捕获
{
x += 10; // x是引用可以修改
};
auto f3 = [=, &amp;x]() // lambda表达式用“&amp;”按引用捕获x其他的按值捕获
{
x += 20; // x是引用可以修改
};
```
“捕获”也是使用lambda表达式的一个难点关键是要理解“**外部变量**”的含义。
我建议,你可以简单地按照其他语言的习惯,称之为“**upvalue**”也就是在lambda表达式定义之前所有出现的变量不管它是局部的还是全局的。
这就有一个变量生命周期的问题。
使用“[=]”按值捕获的时候lambda表达式使用的是变量的独立副本非常安全。而使用“[&amp;]”的方式捕获引用就存在风险当lambda表达式在离定义点“很远的地方”被调用的时候引用的变量可能发生了变化甚至可能会失效导致难以预料的后果。
所以我建议你在使用捕获功能的时候要小心对于“就地”使用的小lambda表达式可以用“[&amp;]”来减少代码量保持整洁而对于非本地调用、生命周期较长的lambda表达式应慎用“[&amp;]”捕获引用,而且,最好是在“[]”里显式写出变量列表,避免捕获不必要的变量。
```
class DemoLambda final
{
private:
int x = 0;
public:
auto print() // 返回一个lambda表达式供外部使用
{
return [this]() // 显式捕获this指针
{
cout &lt;&lt; &quot;member = &quot; &lt;&lt; x &lt;&lt; endl;
};
}
};
```
**3.泛型的lambda**
在C++14里lambda表达式又多了一项新本领可以实现“**泛型化**”相当于简化了的模板函数具体语法还是利用了“多才多艺”的auto
```
auto f = [](const auto&amp; x) // 参数使用auto声明泛型化
{
return x + x;
};
cout &lt;&lt; f(3) &lt;&lt; endl; // 参数类型是int
cout &lt;&lt; f(0.618) &lt;&lt; endl; // 参数类型是double
string str = &quot;matrix&quot;;
cout &lt;&lt; f(str) &lt;&lt; endl; // 参数类型是string
```
这个新特性在写泛型函数的时候非常方便摆脱了冗长的模板参数和函数参数列表。如果你愿意的话可以尝试在今后的代码里都使用lambda来代替普通函数能够少写很多代码。
## 小结
今天我讲了lambda表达式。它不仅仅是对旧有函数对象的简单升级而是更高级的“闭包”给C++带来了新的编程理念:函数式编程范式。
在C语言里函数是一个“静止”的代码块只能被动地接受输入然后输出。而lambda的出现则让函数“活”了起来极大地提升了函数的地位和灵活性。
比照“智能指针”的说法lambda完全可以称为是“智能函数”价值体现在就地定义、变量捕获等能力上它也给C++的算法、并发(线程、协程)等后续发展方向铺平了道路,在后面讲标准库的时候,我们还会多次遇到它。
虽然目前在C++里纯函数式编程还比较少见但“轻度”使用lambda表达式也能够改善代码比如用“map+lambda”的方式来替换难以维护的if/else/switch可读性要比大量的分支语句好得多。
小结一下今天的要点内容:
1. lambda表达式是一个闭包能够像函数一样被调用像变量一样被传递
1. 可以使用auto自动推导类型存储lambda表达式但C++鼓励尽量就地匿名使用,缩小作用域;
1. lambda表达式使用“[=]”的方式按值捕获,使用“[&amp;]”的方式按引用捕获,空的“[]”则是无捕获(也就相当于普通函数);
1. 捕获引用时必须要注意外部变量的生命周期,防止变量失效;
1. C++14里可以使用泛型的lambda表达式相当于简化的模板函数。
末了我再说一句和C++里的大多数新特性一样滥用lambda表达式的话就会产生一些难以阅读的代码比如多个函数的嵌套和串联、调用层次过深。这也需要你在实践中慢慢积累经验找到最适合你自己的使用方式。
## 课下作业
最后是课下作业时间,给你留两个思考题吧:
1. 你对函数式编程有什么样的理解和认识呢?
1. lambda表达式的形式非常简洁可以在很多地方代替普通函数那它能不能代替类的成员函数呢为什么
欢迎你在留言区写下你的思考和答案,如果觉得今天的内容对你有所帮助,也欢迎分享给你的朋友。我们下节课见。
<img src="https://static001.geekbang.org/resource/image/5a/0d/5ac283e096d87e582fed017597ba4e0d.jpg" alt="">

View File

@@ -0,0 +1,117 @@
<audio id="audio" title="开篇词 | 把C++从“神坛”上拉下来,这次咱这么学" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f3/e9/f36845c09c2a069398d70c45f44e26e9.mp3"></audio>
你好我是罗剑锋你叫我Chrono就好。
去年,我在极客时间开了一个[《透视HTTP协议》](https://time.geekbang.org/column/intro/100029001)的课程,有很多同学留言,希望能再听我讲讲其他领域的知识。
于是,在一年之后的今天,我给你带来了这个新课程:《罗剑锋的 C++ 实战笔记》。
## 为什么C++这么难学?
如果你之前看过那个课程就应该知道我的工作经历比较杂HTTP只能算是我的一个“副业”。这次要讲C++感觉终于回到了“老本行”。毕竟写了二十多年的C++代码,经手的大大小小的 C++项目不计其数,现在终于有机会把一点一滴积累起来的这些经验整理、分享出来,内心还是有点激动的。
一说到C++,几乎所有人的第一反应就是“出了名的难学难用”。的确如此,因为它实在是太复杂了,有太多的特性和细节。
随着标准版本的演进C++里包含的东西也越来越多。最早的C++98只有60来个关键字到C++11变成了70多个C++20则膨胀到了近百个。对比一下同级别的Java、Go等语言C++真称得上是“巨无霸”。而且这还仅仅是核心语言,外面还有更庞大的标准库在等着你。
不断膨胀的核心语言加上庞大的标准库让学习、使用C++的门槛无形中提高了很多不仅C++“新手”学起来很难就连C++“老手”也会觉得,用好它并不是一件容易的事情。
**Effective C++** 里有一句话,我觉得很有意思:
>
C++是一个威力十足的编程语言如果C带给你足够绞死自己的绳索C++就是间五金店,挤满了许多准备为你绑绳结的人。
这句话形象地说出了C++的难点:**它太接近底层C语言本身已经有很多“坑”了而C++又增加了更多的“坑”,一旦用不好,就很容易“作茧自缚”**。
其实这些年来C++标准委员会也意识到了这个难学难用的问题也做了很多工作尽量让C++对初学者友好朝着易学易用的方向去努力。但C++毕竟背着“兼容C语言”这个巨大的历史包袱说得重一点就是“原罪”无法做出彻底的改革在可以预见的将来语言里的那些“坑”还将长期存在。
针对这个问题,我的建议是,**先从C++11标准开始学起**。这个版本的C++虽然还是很复杂,但却添加了很多方便易用的新特性,更接近“现代编程语言”,可以少遇到一些传统编程方式的“坑”。
市面上有不少教授现代C++的书也都是专家、大师之作权威性毋庸置疑。但C++实在是太庞大了,相应的书都很厚,慢慢去“啃”、去“消化”实在是吃力。
而且,这些毕竟是纸面上的知识,离实际的开发还有一定的距离,你难免会有这样的感慨:
“**道理我都懂,可用起来还是会犯怵,要是身边能有个人来指点一下该多好。**”
不知道你在刚毕业的时候,公司有没有为你安排过一个“入职导师”的角色,他会制定培养计划,带你熟悉环境,指导你的工作,让你尽快成长为一名合格的职场新人。
C++书籍就好像是学校里的老师只能教你基本的知识。而学习C++最缺乏的就是一个“入职导师”,他能帮你跨越从课堂到现实的“鸿沟”,告诉你实际工作时会遇到哪些问题,又该怎么解决。
很可惜大多数人也包括我当初都没有遇到这样的好导师学C++的时候一切都要靠自己摸索。虽然说“实践出真知”,最终有所成就,但也浪费了不少大好年华。
所以接到极客时间的邀请之后我决定写这样一个能够担当“入职导师”“引路人”角色的课程从庞大的C++里裁剪出一个精致的子集挑选出最适合你自己的C++特性。我还会把踩过的坑、走过的弯路、收获的果实,都毫无保留地分享给你。
## 课程特点
既然要当“入职导师”那我的目标就是一切从实际出发只讲实实在在、脚踏实地的C++知识,而不会讲那些“高深”的理论和“玄乎”的技巧,更不会去教你那些“屠龙之术”。
另外因为C++的资料已经有很多了,我也不想变成标准规范的“复读机”,机械地重复那些接口定义。所以,在这个课程里,我通常只会简单提一下功能要点,不会详细解释调用方式,**重点是谈使用时的注意事项和经验教训**,具体怎么用你完全可以去查资料。
讲C++必然要写代码不过课程示例里的代码都很短也不复杂对C++水平的要求很低不需要你有太多的经验15年都可以保证让你一眼就能看明白。虽然代码可以说是“玩具”但里面蕴含的知识却绝不是“玩具”这就需要你看懂之后去细心领会了。
总之我想尽量降低这门课的学习门槛把C++从“神坛”上拉下来让它平易近人一些希望能够让你看到C++也有亲切的一面。
在这里请允许我适当引用并修改《设计模式》一书里的部分文字,来描述一下这门课的特点:
>
……并不要求使用独特的语言特性,也不采用那些足以使你的朋友或者老板大吃一惊的神奇的编程技巧。
>
……有经验的C++程序员的确能够做出良好的设计写出优秀的代码而新手则面对众多选择无从下手需要花费较长时间领会良好的C++代码是怎么回事。有经验的C++程序员显然知道一些新手所不知道的东西,这又是什么呢?
>
……课程里不会提出任何前所未见的新算法或者新程序设计技术,既没有给出一种严格的系统设计方法,也没有提出一套新的设计理论——它只是将现有的一些经验加以文档化。
>
……一旦你理解了C++并且有了一种“Aha而不是“Huh的应用经验和体验后你将用一种非同寻常的方式思考C++编程。
## 课程设计
按照这个思路我把我最有切身感受、最有实际意义的经验全部浓缩在了这个课程里。学会了这些“武艺”你一定能够用C++开发出优雅、高效的程序。
整个课程分为五个模块,**注重语言和库的“开发落地”基本不讲语法细节和内部实现原理而是用实例促使你更多地应用“现代C++”自然、直观的思维方式**。
C++与C是一脉相通的很多时候C++不过是C的高级解法。所以即使你的主力工作语言是C也可以过来看看了解一下新思路、新工具。
我先给你大概介绍一下这些模块吧。
在“**概论**”模块,我会从程序的**生命周期**和**编程范式**这两个独特的角度来审视它帮你看清楚C++复杂的本质透彻理解C++程序的运行机制和面向对象编程思想。
在“**语言特性**”模块我精选出了C++中的**自动类型推导**、**智能指针**、**Lambda表达式**等几个重要特性,帮你掌握惯用法,消灭代码里的隐患,用这些特性写出清晰、易读、安全的代码。
标准库是C++里占比非常大的一部分,重要性不亚于语言本身。所以在“**标准库**”模块,我会介绍其中最核心的四个部分:**字符串、容器、算法和并发**,让你用好这个最基本的库,学会泛型编程,提高程序的运行效率。
不过,标准库也不可能涵盖所有的开发领域,所以在“**技能进阶**”模块里我会介绍C++标准之外的一些第三方工具,带你一起去实现序列化、网络通信和性能分析等功能,解决实际开发中遇到的常见问题。
之后是“**总结**”模块我会结合C++讲讲设计模式并给出一个完整可用的C++服务端程序例子这里会与《透视HTTP协议》这门课有个小小的联动。这样“理论结合实际”把前面的所有知识点都串联起来让你看看在项目中C++是具体怎么思考、设计、落地的。你实际动手研究一下代码再试着改改就能够把C++的这些特性融会贯通了。
除此之外,我还特别设计了一个“**轻松话题**”单元和你聊些C++之外的东西,以避免因为课程安排得太紧凑,没有“喘息”的机会,让你学起来很累。这些话题涵盖的范围比较广,包括经典的学习资料、提高工作效率的工具等,让你在掌握核心硬技能的同时向外拓展知识面,“会工作,更要会生活”。
<img src="https://static001.geekbang.org/resource/image/1f/98/1f9de23ff1146623a643428cf9cba098.jpg" alt="">
## 学前勉言
在开课之前,我还想和你分享几句编程格言。这三条格言已经陪伴了我很久,一直指导着我的编程实践。
>
任何人都能写出机器能看懂的代码,但只有优秀的程序员才能写出人能看懂的代码。
>
有两种写程序的方式:一种是把代码写得非常复杂,以至于“看不出明显的错误”;另一种是把代码写得非常简单,以至于“明显看不出错误”。
>
“把正确的代码改快速”,要比“把快速的代码改正确”,容易得太多。
C++庞大、复杂是无法改变的事实,所以我们要把这三条格言铭记在心,对它保持一颗“敬畏”的心,在学习语言特性的同时,千万不要滥用特性,谦虚谨慎,戒骄戒躁。
我很喜欢15年前乔布斯在斯坦福大学演讲中的一句话觉得非常适合C++。所以,最后我想把它送给你,我们共勉,希望在接下来的这段时间里,我们一起:
Stay Hungry, Stay Foolish.

View File

@@ -0,0 +1,135 @@
<audio id="audio" title="课前准备 | 搭建实验环境" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/24/e9/247d3663da06a7e26bc4befd800c65e9.mp3"></audio>
你好我是Chrono。
在开始正式的学习之前,我们需要先做一点准备工作——在自己的电脑上搭建出课程使用的实验环境。
我会给你推荐具体的操作系统、编译器,带你一步步搭建环境,还会给你分享我的[GitHub链接](https://github.com/chronolaw/cpp_study),之后课程里的所有代码,你都可以在这里找到。
## 操作系统
首先说一下操作系统。
目前流行的操作系统有三种Windows、macOS和Linux。
Windows是应用得最广泛的一个系统了是绝对的主流但是作为C++开发环境来说Windows并不能算是首选。
一个原因是Windows上的标准C++开发工具Visual Studio不是免费的尽管它提供了可自由下载的社区版但有各种限制你可以看下社区版和其他版本的[差异](https://visualstudio.microsoft.com/zh-hans/vs/compare/)),用来做实验还行,如果要用来开发正式的软件,就不是那么合适了。
另一个更重要的原因是Windows现在已经不再是C++的“主战场”了现在开发Windows程序更多的是用C#、Java、TypeScript等其他语言在Windows上写C++程序,很难有大的作为,会有种“英雄无用武之地”的感觉。
macOS是苹果的专用系统比较“小众精英”用户比较少。必须要承认它是一个很高效易用的开发环境但也有点“曲高和寡”不是每个人都能有一台MacBook的。而且macOS虽然也是UNIX但它源自FreeBSD内部结构、使用方式与Linux有一些差异。
macOS也有与Windows相同的问题它的官方开发语言是Objective-C、Swift几乎没有纯粹使用C++开发出的Mac应用。
所以,**我建议你使用Linux操作系统**它是完全自由、免费的系统不受任何人的控制开发工具链非常完善而且目前差不多所有的商业网站的服务器当然还有Android上跑的都是Linux而C++也正好能在开发后台应用服务方面大显身手,两者可谓是“绝配”。
## 编译器
选好了Linux操作系统接下来就要选择编译器了。
虽然C++20马上就要发布了但现在C++标准的实际普及情况还是相当落后的。据我多年的观察很多企业因为各式各样的原因还在用着老旧的操作系统和编译器别说C++20了连C++17都很少用到。
所以,从应用现状出发,我不会使用那么“超前”的标准和编译器,而是选择更贴合实际、更“接地气”的编译器,让你在工作中能够立即用得上,不用苦等操作系统、编译器的升级。
好了,说一下我对编译器的要求:**至少要支持C++11支持到C++14最好对再往上的17/20则不强求在今后的课程中也会尽量不涉及17/20里的特性**。
把这几点要求落实到Linux上的默认编译器GCC就是4.6或者是 4.8版本越高越好。至于具体版本GCC对C++的支持程度,可以在它的[官网](https://gcc.gnu.org/projects/cxx-status.html)上找到。
GCC通常是和Linux系统绑定在一起的选编译器也就相当于选择Linux的版本。
Linux也是版本众多最常见的是**RedHat系的CentOS**和**Debian系的Ubuntu**。
这两个被很多企业广泛采用但CentOS通常比较“稳定”更新较慢像CentOS 6一直用的是GCC 4.4CentOS 7也才是4.8对C++标准的支持很不完善极大地限制了C++能力的发挥。
所以,**我建议使用的Linux操作系统是Ubuntu最低版本是16.04**。这个系统里的GCC版本是5.4完美支持C++11/14。当然你要是愿意安装更新的18.04、20.04也没有问题它们里面的GCC版本更高支持C++17只不过有点“功能过剩”。
## 搭建环境
确定了操作系统和编译器以后,该去哪里找一个这样的开发环境呢?
你的公司里或许就有现成的Linux服务器可以直接登录上去用但公司服务器的环境不一定满足刚才说的那几点要求而且还得小心别把系统搞乱了所以还是弄一个自己的实验环境最保险。
好在如今的虚拟技术非常成熟只要安装一个虚拟机软件再去Ubuntu官网下载一个光盘镜像然后按部就班地点点鼠标就行了。
这里我选择的是免费的VirtualBox版本任意Ubuntu则是[64位的桌面版](http://releases.ubuntu.com/16.04/ubuntu-16.04.6-desktop-amd64.iso)。至于它们俩具体的安装过程,网上有很多资料,而且我相信,即使没有资料,也难不倒你,所以我就不再多说了。
弄好VirtualBox和Ubuntu环境之后还有一个小小的“收尾”步骤。
Linux系统里通常默认只有GCC没有G++所以还要再执行一个apt-get命令
```
sudo apt-get install g++
```
安装完成后在命令行里敲一下“g++”命令,看一下它的版本号是否满足我们的要求:
```
g++ --version
g++ (Ubuntu 5.4.0-6ubuntu1~16.04.9) 5.4.0 20160609
Copyright (C) 2015 Free Software Foundation, Inc.
```
## GitHub项目
和之前的[《透视HTTP协议》](https://time.geekbang.org/column/intro/100029001)一样我也在GitHub上为这个课程建立了一个项目[cpp_study](https://github.com/chronolaw/cpp_study)里面有很多示例代码和有用的资料链接你可以在Linux环境里用“git clone”下载。
不过说起来不怕你笑话我写了二十来年的C++程序但一直不怎么会写Makefile。都说写Makefile是C++程序员的“基本功”,但我在这方面的确是“缺失”了,有点惭愧啊。
最早我用的是Windows+VC自带的工程文件后来转到Linux开发用的是Boost的构建工具b2Boost Build v2写的是jamfile再后来又定制开发Nginx用的是Nginx自成体系的Shell config。
你看这么多年来我就基本没正经写过Makefile而且也没怎么用过其他的构建工具比如cmake、scons。
所以呢在这个GitHub项目里我也就没有办法提供专门的Makefile只能麻烦你在命令行上手动敲GCC的编译命令了。好在示例代码都很短很小没有复杂的依赖关系简简单单就能搞定比如
```
g++ xxx.cpp -std=c++14 -o a.out
```
这里需要注意的是参数“**-std=c++14**”它告诉编译器在处理C++代码的时候使用C++14标准而不是11/17/20。
手敲命令还是挺麻烦的,所以,我在源码文件里还用注释的形式给出了编译命令,你可以直接拷贝粘贴使用,希望能够给你带来一点点的方便。
```
// Copyright (c) 2020 by Chrono
//
// g++ test.cpp -std=c++11 -o a.out;./a.out
// g++ test.cpp -std=c++14 -o a.out;./a.out
// g++ test.cpp -std=c++14 -I../common -o a.out;./a.out
```
在目录“section0”里有一个最基本的示例程序如果你能够正确地编译并运行就说明实验环境搭建成功了。
在我的虚拟机里,这个程序的输出是(使用-std=c++14
```
c++ version = 201402
gcc version = 5.4.0 20160609
gcc major = 5
gcc minor = 4
gcc patch = 0
libstdc++ = 20160609
```
显示使用的是C++14标准GCC版本是5.4.0标准库版本是20160609。
当然如果你是写Makefile的高手欢迎你给这个项目提Pull Request让其他同学都能用make来轻松地编译代码。
## 小结
作为正式开课前的“热身工作”,今天我介绍了课程使用的实验环境,简单小结一下:
1. 我们选择的操作系统是Linux具体是Ubuntu 16.04,也可以是更新的版本。
1. 我们选择的编译器是GCC最低要求是4.6或者4.8推荐使用5.4或者更高的版本。
1. 使用虚拟机软件搭建环境最方便完全“自主可控”推荐使用免费的VirtualBox。
1. GitHub上有配套的示例代码和参考资料可以下载后编译验证环境是否搭建成功。
那么行动起来吧下节课让我们在Linux上愉快地一起“玩耍”。
<img src="https://static001.geekbang.org/resource/image/a1/42/a130365df3f983716977b2e816df7c42.jpg" alt="">

View File

@@ -0,0 +1,89 @@
<audio id="audio" title="轻松话题(一) | 4本值得一读再读的经典好书" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/dc/a1/dca60c8f94c6ee76370f7136deb5c1a1.mp3"></audio>
你好我是Chrono。
著名的文学家高尔基说过一句名言:“书籍是人类进步的阶梯。”
利用碎片时间学习固然很重要,但静下心来认真地读书却更加重要,它能够让你去主动思考、主动探索,更系统、更完整、更深入地获取知识。
为了能够让你在工作之余充充电提高一下自己今天我就暂且岔开话题不讲C++语言,而是开列一个清单,精选出几本我认为值得一读再读的技术类好书。
顺便说一句,“读书”最好是看纸质实体书,而不是电子版。
在现在的环境下,手机或者平板电脑实在是太“浮躁”了,在用它们阅读时,经常会被跳出的通知、消息打扰,而且“人性”使然,也很难控制自己不去点开其他的应用玩玩小游戏、看看短视频。
另外,这些设备大多是“主动发光”型的,长时间看会导致视觉疲劳,影响眼睛健康,还是纸质的书更好。只要在一个合适的光照环境下,冲一杯茶或者咖啡,给自己留出一段充足的闲暇时间,你就可以抛却世俗的喧嚣和烦恼,尽情地畅游在书的海洋。
闲话不多说了Its showtime。
## 《设计模式:可复用面向对象软件的基础》
软件开发类图书浩如烟海,但如果让我只推荐一本,那就只能是《设计模式:可复用面向对象软件的基础》。它是在我心目中**永远排在第一位**的技术书籍。
这本书出版得比较早1994年到现在已经超过25年了但是仍然没有过时。
在上个世纪90年代软件的开发还是处于比较“混沌”的状态虽然自发地出现了一些“习语”“惯用法”却没有能够很好地指导软件设计的通用原则。
这本书一出现就获得了无数的赞誉,犹如黑夜里的一盏明灯,为所有开发者指明了道路。
书里系统地总结了专家的经验,开创性地提出了“设计模式”的概念,只要遵循“模式”,就能够得到良好的设计。
其中阐述的23个设计模式已经被无数的软件系统所验证并且成为了软件界的标准用语比如单件、工厂、代理、职责链、观察者、适配器等等。
无论你使用什么语言无论你使用哪种范式无论你开发何种形式的软件都免不了会用到这些模式。而且有些模式甚至就直接成为了编程语言的一部分例如C++的iterator、Java的Observer
作为软件开发历史上里程碑式的著作、模式运动的开路先锋,我认为它是每一个精益求精的程序员都必须拥有的宝典和圣经,值得放在手边经常翻阅,随时随地获取设计灵感。
这本书要说缺点也是有的,就是论述太严谨。毕竟四位作者都是博士出身,看起来太有“论文范”了,很多话都需要反复琢磨,才能理解。但从另一方面看,这也是它的优点,几乎没有多余的文字,可以说是字字珠玑,绝非那些“白话”“大话”之类的书籍可比。
## 《C++标准程序库》
再回到C++领域,我认为**一定要看**的就是《C++标准程序库》。
讲C++语言的书有很多但讲C++标准库的却是屈指可数。因为标准库的庞大和复杂程度远远超过了语言本身,能把它“啃”下来就已经很不容易了,要把它用通俗易懂的形式讲出来,更是难上加难。
而这本书却“举重若轻”,不仅完整全面地介绍了标准库,而且还由浅入深、条理清楚,对库中每个组件的优缺点都分析得丝丝入扣,让人心悦诚服。内容的安排组织也详略得当,千余页的大部头作品读起来却毫不费力,不得不叹服作者的至深功力。
十几年前C++资料非常匮乏,当时我还对标准一无所知,偶然看到了本书的第一版,顿时有种如获至宝的感觉,当即“拿下”,回家仔细、反复研读,真的是“手不释卷”。
经过了这本书的“洗礼”我才真正地“脱胎换骨”透彻地理解了C++,开启了泛型编程、函数式编程的新世界大门。而我今天能够以这种方式与你聊天,绝对有它的功劳。
虽然现在C++相关的资料已经很多了但如果你想要成为C++“大伽”那么这本《C++标准程序库》必然是你成长之路上的“良师益友”。
## 《C++语言的设计与演化》
接下来要说的这本《C++语言的设计与演化》比较特别。
特别之一在于它是由C++语言之父亲手撰写的,能够直接与“造物者”本人对话,机会难得。
特别之二在于它并非直接描述语言特性而是以“回忆录”的形式介绍了C++语言的发展历史和设计理念,同时坦诚地反思了一些由于历史局限而导致的缺点和失误,视角非常独特。
这两个特别之处让它从众多语言类书籍中脱颖而出能够解答很多学习C++过程中的困惑。比如为什么C++会变成这个样子为什么要引进class、template关键字为什么会设计出那些奇怪的语法……知道了前因后果你就可以更深刻地理解C++。
阅读这本书时你还能“读史以明志”学习先驱者的经验教训了解他们做决策时的思考方式领会语言设计背后的“哲思”这些技术之外的“软知识”也能够帮助你更好地使用C++。
唯一的遗憾是它出的时间太早都没有赶上C++98后来也没有重新修订到现在可能快“绝版”了如果你在旧书摊上遇到了一定不要错过。
## **C++ Primer**
最后一本要推荐的书是 **C++ Primer**
“Primer”的意思是“初级读本”不过在我看来这可能是作者的谦虚之语。
虽然这本书确实自视为“入门教材”全书的编排也是循序渐进例子浅显易懂但内容非常得全面、精准基本囊括了C++11的所有新特性和标准库组件C++老手完全可以把它当成是语言参考手册。
而且它还有一个独到之处就是把语言和库融合在一起讲解而不是像其他书那样割裂开。这对于C++初学者可算得上是“福音”可以一开始就接触到标准库学习现代C++编程方式,减少了很多入门的成本。
它的不足之处是没有涉及标准库里的线程部分不过考虑到这本书的名字“Primer”而多线程编程确实比较高级不讲也是情有可原的。
## 小结
今天就到这里,只介绍了四本书,好像有点少,不过我觉得应该“贵精不贵多”。
如果像“报菜名”那样一下子列出十本八本的,我倒是省事,但你可能根本看不过来,所以还是把有限的时间汇聚在少数“精品”上更好,尽量通读、透读。
其实,选这四本书我也是花了心思的,你留意一下就会发现,它们的定位各有特色:**面向对象**(《设计模式:可复用面向对象软件的基础》)、**泛型编程**《C++标准程序库》)、**历史读本**《C++语言的设计与演化》)和**教科全书****C++ Primer**。我觉得还是比较完整地覆盖了C++的知识面的,有广度有深度,有点也有面,你以为如何呢?
欢迎在留言里分享你的读书心得。如果你觉得有所收获,也欢迎你把今天的内容分享给你的朋友。
<img src="https://static001.geekbang.org/resource/image/34/a1/34963ab5fac6e1abb9c62b11de0d80a1.jpg" alt="">

View File

@@ -0,0 +1,108 @@
<audio id="audio" title="轻松话题(三) | 提高生活质量的App" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/57/80/57ffe50fb9114846dc12ba5f10516280.mp3"></audio>
你好我是Chrono。
今天这期“轻松话题”,我想换个领域,跟你聊聊手机,谈谈怎么才能利用好手里的这块小屏幕。
我来推荐几个不是那么常见但却非常有意思的App用好它们的话肯定可以解决你生活中的一些不大不小的麻烦提升你的生活质量。
需要注意的是下面我要说的都是iPhone上的应用不过Android上应该也有相同或者相似的产品请善用各大应用商店的搜索功能。
## Numbers
第一个要推荐的是苹果的独家AppiWork三件套之一Numbers。
iWork是苹果自家的办公应用套件对应的是微软的Office名气虽然没有Office那么大用户也不是很广泛但秉承了苹果一贯的精致特性做得很用心、很有“范儿”。
Numbers相当于Office里的Excel是一个电子表格应用。
看到“电子表格”你可能对它会“嗤之以鼻”“这有什么了不起的Excel天下无敌而且很多笔记应用也有表格功能难道仅仅是因为它出身于苹果豪门
还真是这样,虽然电子表格产品都“大同小异”,但差就差在这“小异”的地方。
Numbers没有选择与Excel正面竞争而是做出了差异化去除了Excel浓厚的“办公味道”添加了许多“生活气息”。
它最大的一个特点是:表格是有限的,而不是无限的。
所以在使用Numbers时就像是在使用现实中的A4纸在单个页面上可以画出好几个独立的小表格再加上批注、描绘、饼图什么的还可以用各种字体、色彩装饰一下打扮出一个漂漂亮亮、生动活泼的画幅就像是一份“手抄报”。
当然Numbers处理大表格也毫无问题。而且它还会自动固定标题栏和侧边栏滑动查阅时关键的抬头信息始终可见非常适合手机浏览。
另外Numbers也对触摸操作做了很好的优化。选择单元格可以直接插入当前时间、调整格式还有多种特制键盘快捷输入数字、日期比标准键盘省事得多。
总之在手机上使用Numbers会获得与在电脑上使用Excel时完全不同的体验。
那么Numbers该怎么用才能方便生活呢该用来记录什么呢
其实什么都可以只要存在“条目化”“拉清单”的可能都可以放进Numbers里比如银行卡、课程表、减肥记录、书刊杂志、家具衣物、水电开支、工资奖金、差旅报销、旅游美食、股票投资……太多了生活里的一切一切只要你想得到都可以放在Numbers里保存起来。
比如说,你可以做一个财务表,把工资按月份记录下来,把偶尔的补助、报销、奖金等其他收入,并列放在旁边的表格里,然后转换出折线图,这样就可以看出自己的工资增长幅度、年度收入了。再进一步的话,可以把前几年的数据也导进表格,还可以算出来新个税带来了多少实质性的好处。
给你分享一个我自己的实际例子用Numbers保存看电影的记录。下面这张截图展示的就是我去年看的一部分电影里面记下了电影名、观影时间/地点、票价,等等,还有简单的观后感。
有了这些数据之后,我就可以很容易地做一些分析,给好看的电影打个分,标注下哪些值得再看,还可以算算这一年下来,看了多少部电影、总共花了多少钱、均价是多少……也算是给自己做个总结,让时间留下一些值得怀念的记忆。
<img src="https://static001.geekbang.org/resource/image/d6/8b/d6b31457ca3e45a51b2a01d7ca9af48b.png" alt="">
## 有数
接下来要说的这个“有数”应用是一个计算器,听起来好像比电子表格还要“平凡”一些。
计算器应该是一个“大路货”的应用了,很常见,一般手机也都自带,但是,这些计算器应用大多千篇一律,数字键盘再加上一些函数功能,非常简陋。
而“有数”则突破了传统计算器的设计思路其理念与Numbers有些类似把整个屏幕当作一张“草稿纸”可以在上面任意书写算式就像是我们上学时在纸面上列式演算一样我把这称为“更高层次的拟物化”。
<img src="https://static001.geekbang.org/resource/image/d2/56/d238ff118ff87467a52846e2412d0756.png" alt="">
这样有什么好处呢?
好处就在于你不仅能够得到结果,还能直观地看到计算的全过程,随时“复盘”校验。而且,计算过程也可以反复重用,输入的数字可以任意修改或者引用到其他算式,整个算式也可以拷贝粘贴。
它的另一个“亮点”,是可以对计算过程加上文字注释说明,让数字不再枯燥,而是有了具体的含义,形成一份“算术文稿”。
比如说家里想做个简单的装修需要买瓷砖、地板、涂料还有各种人工费。那么你就可以在“有数”里把这些逐个列出来单价乘上个数最后再加起来。只要不到1分钟总价、细则就可以清清楚楚、一目了然。如果价格、数量有变动还可以立即修改生成新的报价。
虽然Numbers也可以做同样的事情但对于简单的算术来说有点“小题大做”在需要快速得出计算结果的时候还是“有数”更加实用。
这里要提醒你一下“有数”不是免费的在App Store里卖12元但我觉得它绝对值得这个价钱。这是我最喜爱的计算器相信你接触它后也会有同感。
## 倒数日
第三个要说的应用是“倒数日”Days Matter它应该是一个非常“老牌”的应用了有着七八年的历史由著名的Clover team出品。
其实,“倒数日”这个名字有点不太精确,它不仅能够“倒数”日期,也能够“正数”日期,怎么使用完全取决于你自己。
“倒数”的功能很简单,就是设定一个将来的日期,然后来个“倒计时”等待,比如到发工资还有多少天、到情人节还有多少天、到新年还有多少天。
你还可以基于这个设计一些更“高级”的玩法制订一些人生的“里程碑”比如说买辆自用车、入职BAT、年薪30万、创立自己的公司等等。
有了这些“小目标”,你就可以每天打开应用,用“倒计时”来提醒、鞭策自己,看看是不是随着努力正在一步步地接近这些理想。
“正数”的功能也很简单就是记录已经发生过的各种“事件纪念日”方便随时回忆那些美好的瞬间这也正符合了它的Slogan“从现在起不再忘记重要的事情。”
这方面我觉得值得记录的就更多了比如生日、相识纪念、结婚纪念、拿驾照、买Mac、换新房……
还是拿我自己来举个例子。下面的这张截图记的是我当年买iPhone6的日子这么一看这部手机已经用了差不多5年了。如果没有“倒数日”这个应用真的很难回想起来它是什么时候走进我生活里的又“忠诚”地跟了我多久。
<img src="https://static001.geekbang.org/resource/image/b6/8e/b6a6464aa1b5d10fc125cad8b291638e.png" alt="">
除了“倒数”和“正数”,这个应用里面另一个很有意思的功能是“历史上的今天”,每天看一下历史上曾经都发生过哪些大事要事,也是挺好玩的,可以在不知不觉间增长一些历史知识。
## 小结
好了,今天就闲聊到这里吧,我简单小结一下今天的内容。
1. 现在的手机功能很强大,如果只用它来上网、看视频、玩游戏,就有点浪费了。
1. “Numbers”是一个电子表格应用可以用来盘点生活中的各种事务。
1. “有数”是一个创新的计算器应用,可以实现“草稿纸”式演算。
1. “倒数日”是一个纪念日应用,可以用来记录人生中的各种重要时刻。
最后,我想说,这几个应用各自都有一些新颖的设计,所以才在众多竞品中脱颖而出。
我认为,它们的共同点就是不盲目“随大流”,真正关心用户的需求。很多应用都是在已有功能的基础上做些修修补补,同质化现象非常严重。但是,这些应用却善于从平凡的生活中挖掘更有价值的功能,甚至是大多数人都没有想到的功能,可以满足用户的多层次需求。无论是产品创意,还是微小的功能,都真正做到了“以人为本”。而**始终致力于更好地服务用户的产品,必定不会差**。
那么,这是否也给了你有些在写代码、做产品等方面上的启迪呢?
欢迎你在留言区分享你喜欢的App以及你从优秀的应用中得到的启发。
<img src="https://static001.geekbang.org/resource/image/ca/46/cae0c70e4a1e5c91c46a69f6e4d38e46.jpg" alt="">

View File

@@ -0,0 +1,151 @@
<audio id="audio" title="轻松话题(二) | 给你分享我的工作百宝箱" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/87/79/871d7c37a5203abbae70af90dcda0879.mp3"></audio>
你好我是Chrono。
今天我再来说点C++之外的话题。我来聊聊我的工作方式,给你介绍一下我用的开发环境,有快捷键、配置脚本、常用命令什么的。算不上什么“高效技巧”,但是也能从小处提高工作效率,希望能给你一点借鉴。
## Linux
我主要是在Linux上写程序经常要登录到内部的服务器但我常用的笔记本或者台式机上装的还是Windows所以就需要有一个“趁手”的客户端。
Windows上有很多这样的软件最早我用的是PuTTY但其他很多同事用的是XShell。不过现在的我已经都不用这些了。
你一定想知道难道还有什么比PuTTY、XShell更好更强大的远程终端吗
要说有也算有,要说没有也算是没有。
因为现在我就把Linux操作系统当成终端机来使用就用它内置的Terminal、ssh命令来实现远程登录。
具体的做法也很简单安装一个VirtualBox再最小化安装一个Ubuntu就可以了。
这么做的好处在哪里呢?
首先这个环境完全是免费的不需要注册或者破解。其次它本身就是Linux与开发环境相同可以用来在本地做试验、“练手”。再次Linux里有非常丰富的工具可以下载安装能够随心所欲地定制环境用起来非常舒心。
当然把Linux转换成一个高效的终端还是需要一点点“技巧”的接下来我就跟你说说我的做法要点就是“**全程键盘操作**”。
第一个用“Ctrl+Alt+T”可以直接打开命令行窗口而不必用鼠标去点图标然后用“Ctrl+Shift+T”可以开新标签页这样就可以很方便地实现多窗口登录不会像某些软件那样有数量的限制。
另外我选择的是Ubuntu 14.04在这个版本里可以用鼠标右键点标签页直接改标题名区分不同的窗口即使开多个标签也可以轻松管理但这个功能在后来的16.04、18.04却给去掉了只能额外写Shell脚本来实现有那么一点不爽
第二个修改Shell的配置文件“.bashrc”或者是“.profile”在里面加上一行“set -o vi”。
```
#.bashrc
set -o vi
```
这样你就可以在命令行里实现vi操作了按一下ESC键就进入到了vi模式可以用“/”快速查找之前的历史命令,而不必每次都要敲完整的命令。
比如说,之前输入了一条命令“`ssh chrono@10.1.1.25`”登录服务器那么下次再登录时就没有必要再敲一遍了只要按ESC然后输入“/25”回车Linux就可以帮你找到上次的这条命令。这时你就可以轻松愉快地登录了。
用Linux作为终端的唯一一个缺点是它无法自动填写登录密码每次都要手动敲这个的确比较烦人。所以只能把登录密码尽量改得简单好输入最好是键盘上的固定模式比如设置成“qazwsx”这样就可以在1秒内完成。
## Vim
写代码就要用到编辑器在Windows里常用的有VS Code、Sublime等等而在Linux里最佳的选择可能就是Vim了。
说是Vim但我更愿意称之为vi。一方面是早期的使用习惯我最早用的是AIX上面只有vi而不是Vim另一个更重要的原因是可以少打一个字符。可不要小看了这一点效率的提升想想每天你要说多少次、用多少次vi吧。
有的人可能还是习惯在Windows上的编辑器里写代码然后通过某种方式上传到Linux再编译和运行。我个人觉得这种做法不太可取既然是Linux开发就应该全程在Linux上工作而且很多时候会现场调试不可能有那么合适的编辑器。
所以,尽早抛弃“窗口+鼠标”式编辑器强迫自己只用vi就可以尽快熟悉vi的各种操作让你在Linux上“运指如飞”。
另外你可能知道vi也有很多的插件比如ctags搭配上众多的插件会让vi更“现代化”。但对于服务器开发来说还是那个问题不是每台服务器都会给你配置得那么完善的。与其倒腾那些“花里胡哨”的插件不如“离开舒适区”练好vi的基本功到哪里都能吃得开。
最基本的vi操作我就不多谈了网上一搜一大堆我来说几个写代码时比较实用的命令。
1.“:tabnew”新建一个编辑窗口也就是支持多标签操作多个标签可以用“gt”切换。
2.“Ctrl+V”“Shift+V”的整列整行选择然后就可以用“x”剪切、“p”粘贴。
“Ctrl+V”的列选择功能还有一个衍生的方便技巧选择多列后按“I”再输入“//”按ESC就可以在每行前面都插入“//”,轻松地实现大段代码的工整注释。
3.“Ctrl+P”是vi内置的“代码补全”功能对我们程序员来说特别有用。只要写上开头的一两个字符再按“Ctrl+P”vi就可以提示出文件里曾经出现的词这样在写长名字时就再也不用害怕了。
不过vi的“代码补全”功能还是比较弱的不是基于语法分析而是简单的文本分词但我们也不能太苛求。
4.可以随时用“Ctrl+Z”暂停vi把它放到后台然后执行各种Shell操作在需要的时候只要敲一个“fg”命令就可以把vi恢复回来。
这在调试的时候非常有用改改代码运行一下看看情况再切回来继续改不用每次重复vi打开源文件而且可以保留编辑的“现场”。
除了刚才的这四点操作技巧想要用好vi还必须要对它做适当的配置比如显示行号、控制缩进等等。下面就是我常用的“.vimrc”非常短小基本上我每登录一台新服务器就会把这个配置复制过去这样无论在哪里vi都会是我熟悉的环境。
```
#.vimrc
set nu
sy on
set ruler
set smartindent shiftwidth=4
set tabstop=4
set expandtab
set listchars=tab:&gt;-,trail:~
set list
colorscheme desert
```
## Git
写完了程序,还要用适当的版本控制系统把它管理起来,否则源码丢失、版本回溯、多人协作等问题会把你弄得焦头烂额。
我最早用的是微软的VSSVisual Source Safe后来用过IBM的ClearCase再后来又用SVN现在则是Git的“铁杆粉丝”。
Git的好处实在太多了分布式、轻量级、可离线、开分支成本低……还有围绕着它的GitHub/GitLab等高级团队工作平台绝对是最先进的版本控制系统。
如果在2020年的今天你所在的公司还在用SVN这样的“上古”软件可真的是要考虑一下项目的前景了。
Git有许多高级用法有的也很复杂我不可能也没必要把那些讲清楚。所以我只介绍一个能够简化Git操作的小技巧**命令别名**。
Git的命令含义明确但缺点是单词太长多次操作输入就显得很繁琐这点就不如SVN命令那么简单明了。好在我们可以在Git的配置文件“.gitconfig”里为这些命令起别名比如把“status”改成“st”把“commit”改成“ci”。
下面这个就是我常用的一个Git配置里面还有个特别的地方是在“diff”的时候使用“vimdiff”用可视化的方式来比较文件的差异比原始的“diff”更好。
```
[alias]
st = status
ci = commit
br = branch
co = checkout
au = add -u .
ll = log --oneline --graph
d = difftool
[diff]
tool = vimdiff
```
## GDB
最后来说一下调试工具GDB吧它应该是Linux程序员最得力的一个帮手了。
标准的GDB是纯命令行式的但也有一些基于它的图形化工具比如DDD、Data Display Debugger但用好GDB命令行调试还是我们的一项基本素质。
**GDB不仅是一个调试工具它也是一个学习源码的好工具**
单纯的源码是静态的,虽然你可以分析它的整体架构,在头脑里模拟出它的工作流程,但计算机实在是太复杂了,内外部环境因素很多,仅靠“人肉分析”很难完全理解它的逻辑。
这个时候GDB就派上用场了以调试模式启动任意设定外部条件从指定的入口运行把程序放慢几万倍细致地观察每个变量的值跟踪代码的分支和数据的流向这样走上几个来回之后再结合源码就能够对程序的整体情况“了然于胸”。
GDB用得久了差不多每个人都会有一些自己的心得。我列出一些我觉得能够提高调试效率、最有价值的命令。
- pt查看变量的真实类型不受typedef的影响。
- up/down在函数调用栈里上下移动。
- fin直接运行到函数结束。
- i b查看所有的断点信息。
- wh启动“可视化调试”。这个是我最喜欢的命令可以把屏幕分成上下两个窗口上面显示源码下面是GDB命令输出不必再用“l”频繁地列出源码了能够大大提高调试的效率。
## 小结
好了,今天的话题就到这里,简单小结一下我的工作环境,给你一个参考:
1. 我选择Linux虚拟机作为登录服务器的终端可以很容易开多窗口操作
1. 我选择Vim作为编辑器熟记常用命令后写代码也很方便
1. 我选择Git作为版本管理工具使用别名来简化命令
1. GDB是调试C++程序的利器,也可以用来学习源码。
那么,你是否也有一些工作中的实用小技巧呢?欢迎一起来分享。
<img src="https://static001.geekbang.org/resource/image/f6/87/f6b5366fe5a70ba18517aa6bc51f5287.jpg" alt="">

View File

@@ -0,0 +1,120 @@
<audio id="audio" title="轻松话题(四) | 真正高效的生活,是张弛有度" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c8/19/c8720ea02700a606eec6585f74cd4c19.mp3"></audio>
你好我是Chrono。
今天的轻松话题我再说点C++之外的事情,主题是“时间管理”。
古语说得好,“一寸光阴一寸金,寸金难买寸光阴”。时间无法再生、重用,无疑是人生最宝贵的资产,却也往往是最不被珍惜、最容易被浪费的资产。
仔细想想一天只有24个小时扣掉吃饭、睡觉的时间可用的也就14~16个小时。如何才能合理、高效地利用这些时间是我们每个人都应该认真思考的问题。
所以今天呢,我就拿我的一个工作日来举例子,聊聊我是怎么管理、分配时间的,给你一个参考。为了方便,我把工作时间和非工作时间分开来进行。
## 工作时间
我个人有早起的习惯。毕竟“一天之计在于晨”。所以我一般会在上午9点之前就到公司比公司里的大多数人都要早。
到了公司,我会先打开邮件和即时通讯工具,把一些和工作不相关的琐事快速处理掉。完成这些之后,有了一个“干净”的起点,就可以正式开始工作了。
这里,就不得不提我多年来的一个工作习惯了,那就是写工作日志。其实,这就像是记日记一样,时间长了,会积累下很多的工作经验和知识,也算是“人生财富”吧。我会用到一个非常好的工具,就是“**云笔记**”比如Evernote或者GitHub私人仓库都可以。
工作日志的格式和普通日记也差不多。首先,我会写下当天的日期,然后花几分钟整理一下工作思路,按优先级列一下今天要做的事情。
关于优先级,我有几个衡量标准:
- 上级领导安排的、工期紧的优先;
- 突发事件、hotfix优先
- 与外部门沟通、要出文档/说明的优先;
- 容易做的、好完成的优先;
- 与别人合作的优先;
- 自己能独立完成的放在最后。
简单来说,就是先看紧急程度,然后先外后内,先人后己,先小后大,先易后难,有点像是早期大型计算机的批处理任务排队。
有了优先级,我还要再为每件事估算一个大概的时间。该怎么估算呢?
如果事情比较小、比较简单,我就会以半个或者一个小时为单位进行安排;如果事情比较大、难度比较高,我就会把时间片划得略大一些,比如两个小时。
然后我会为每个时间片定一两个粗略的目标细化一下具体的任务比如完成一个功能点、修复Bug、画出UML图、写出设计文档、开会定技术方向等等。
写完这些之后基本上就把当天的工作日程安排好了。不过我通常也留有一点余地也就是buffer不会把8个小时全排满。因为计划总是赶不上变化通常来说制定出**6个小时左右**的时间表就差不多了。
有了这个schedule当天的工作也就心里有数了不会慌慌张张可以有条不紊地按照计划去执行。由于在定计划的时候预留了一两个小时的缓冲时间所以即使偶尔有突发事件或者难点也不会影响计划这一天的工作就可以比较轻松、顺利地完成很有成就感。
当然了,工作不可能完全按计划来,到快下班的时候,可能还是会有未完成的工作。
所以呢,临下班前,我会再花几分钟的时间,在云笔记的工作日志里做个小结,列一下工作的完成情况、心得、难点,同时把可用的参考资料也记下来。
我是不提倡过度加班的,因为脑力劳动很辛苦,加班的效率比较低。如果事情没做完,又不是特别急,那就提前做好明天的规划,想一下明天大概要怎么做、要找哪些人协调。安排妥当之后,就可以回家睡个好觉,休息好了第二天再继续做,效果可能比加班更好。
不过,如果事情比较急的话,加班就不可避免了。这个时候千万不能慌,要先确定加班的目标,再预估一下所需的时间和资源,制订临时的小计划以及大概的执行步骤。做完这些准备工作,就可以去吃个晚饭,整理一下心情,准备接下来的“苦战”了。
一天不停地工作对脑力、体力都消耗比较大,在工作的间隙,适当的休息有助于提高效率。下面我再给你分享几个我工作时的小技巧吧。
**1.不要久坐**
我的智能手表Apple Watch上有站立提醒功能如果久坐时间超过一个小时它就会提醒我起来活动一下在Windows、macOS上也有类似的提醒应用你可以自己找找
我觉得这个功能对于我们程序员来说非常有用。它有点像是学校里的下课闹铃,给你一个强制的休息机会。你可以站起来喝点水,伸个懒腰,舒展一下筋骨,或者去洗手间打把水洗洗脸,让大脑有一个短暂的“空档”,也许就能获得意外的编程灵感。
**2.午休时出去走走**
中午的吃饭时间也是一个很好的休息机会。吃完午饭后,我一般还会走出公司,在周围随便转转,散散步,看看蓝天白云、绿树红花,呼吸一下新鲜空气,脑子里再顺便想想工作上的事情。这个时段是比较自由、放松的,用来调整思绪、考虑问题都非常合适。
**3.用好茶歇时间**
很多公司都会有下午茶歇时间,提供一些水果、点心什么的。这项福利虽然很小,但也很有用,一边吃着薯片、蛋糕,一边敲键盘码字,还是很惬意的。这个时候,我的生产效率也是最高的。
**4.深呼吸**
最后要说的这个小技巧是最简单,但是也是最容易被忽略的。
在工作非常紧张的情况下比如编码开发、debug到了最关键的阶段可能稍微一活动就会扰乱思路实在是不愿意动但身体又确实会感觉很累。
这个时候呢,我一般就会坐在椅子上,双手用力揉揉脸,再伸伸胳膊,腿也配合着用力舒展几下,然后闭上眼睛,做几个深呼吸。
这个动作大概只要一分钟就足够了,但是缓解疲劳、放松大脑的效果非常不错,能够为我再争取出十来分钟的奋斗时间。
说到这里啊,你可能看出来了,其实,我的这个工作方式就是经典的“**番茄工作法**”,只不过没有那么严格,我根据自己的情况做了点改造,更随意一些,执行起来很容易。
## 非工作时间
说完了上班的时间安排再来说说8小时之外吧。
我住得离公司比较远通勤时间比较长一般都要一个多小时。所以为了不浪费时间我会在手机上看看资料、业界资讯当然最常用的就是极客时间这样的学习App了。如果看到了有用的知识点我就会记在云笔记里。
不过,我很少在交通工具上使用耳机,因为周围的环境太嘈杂了,耳机声音小的话,会听不清楚,声音大,又对听力有伤害,所以,我大多以阅读文字为主。还有一个,因为通常一整个白天都在用电脑,看屏幕,眼睛还是比较累的,所以,手机我也不会长看。特别是在换乘走路的时候,我建议你绝对不要看手机。
一般我下班后到家的时候就8点左右了比较晚。第一件事当然是吃饭大概会花十来分钟。我的晚饭比较简单也不会吃太多。
然后我会休息一下逗逗孩子跟父母唠唠家常读点小说玩会儿PS4放松一下紧张的工作情绪。我建议你**一定要给自己和家人留出足够的时间和空间,不要让工作占满了自己的全部时间,毕竟,我们工作的目的是为了更好地生活**。
通常我会休息到晚上9、10点钟然后学习半个小时左右再睡觉。
这一小段时间的学习纯粹是发散式的没有什么功利的目的比如上GitHub、Stack Overflow、InfoQ、Nginx等网站看看有什么新技术、新动向如果有感兴趣的开源项目就clone下来慢慢看。
不过在学习得非常投入的时候我也会相应地延长时间但最晚一般不超过11点半。毕竟第二天还是要上班的学得太晚就会睡眠不足影响第二天的工作质量。
顺便我再说说对睡眠和休息的看法吧。
可能很多人都有临睡前看会儿手机的习惯,这个时候,躺在床上,最安静、最放松,完全是属于自己的时间。
不过,从健康的角度来说,我不是太推荐这样做。因为在黑暗的环境下,手机屏幕的强光对眼睛和大脑的刺激程度都比较高,看得时间长了,就会影响睡眠。最好是控制一下自己,尽量在熄灯前把手机上要看的看完,然后老老实实地睡觉。
关于入睡,我也给你分享一个我自己的小经验。如果你失眠的话,可以尝试一下,应该会对你有所帮助。
方法很简单,就是尽量放松。先放松身体,再放松大脑,不要去想工作上的事情(否则可能会越想越投入、兴奋),而是慢慢地想吃饭、休息这样生活上的事,再把注意力轻轻地集中在呼吸上,让呼吸保持均匀,最后逐渐放空思绪,大概就可以进入“冥想”的状态了。
## 小结
好了,我一天的时间安排大概就是这样了,感觉还算是“有张有弛”吧。
我再简单总结一下我的几条小建议。
1. 有明确的工作计划(日度/月度),就可以规划好时间,但要留出一定的缓冲。
1. 在规划任务时间时,可以使用“番茄工作法”,但时间片不宜切得太细,否则执行的时候容易出现偏差。
1. 工作中要有适当的休息间隔,调整工作节奏,缓解工作压力。
1. 不要浪费通勤时间,多利用碎片时间学习和充电。
1. 要平衡好工作和生活,要有休息有娱乐,不要变成只会加班的“机器人”。
那么,我的这些经验是否对你有所启发呢?你有没有什么好的时间管理小窍门呢?欢迎一起来分享,把握好我们自己的时间。