mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-16 14:13:46 +08:00
mod
This commit is contained in:
146
极客时间专栏/罗剑锋的C++实战笔记/总结篇/19 | 设计模式(上):C++与设计模式有啥关系?.md
Normal file
146
极客时间专栏/罗剑锋的C++实战笔记/总结篇/19 | 设计模式(上):C++与设计模式有啥关系?.md
Normal 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<char>`)。
|
||||
|
||||
第二个是**开闭原则**,它也许是最“模糊”的设计原则了,通常的表述是“**对扩展开放,对修改关闭**”,但没有说具体该怎么做,跟没说一样。
|
||||
|
||||
我觉得,你可以反过来理解这个原则,在设计类的时候问一下自己,这个类封装得是否足够好,是否可以不改变源码就能够增加新功能。如果答案是否定的(要改源码),那就说明违反了开闭原则。
|
||||
|
||||
**应用开闭原则的关键是做好封装**,隐藏内部的具体实现细节,然后开放足够的接口,这样外部的客户代码就可以只通过接口去扩展功能,而不必侵入类的内部。
|
||||
|
||||
你可以在一些结构型模式和行为模式里找到开闭原则的“影子”:比如桥接模式让接口保持稳定,而另一边的实现任意变化;又比如迭代器模式让集合保持稳定,改变访问集合的方式只需要变动迭代器。
|
||||
|
||||
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这五个之外,我觉得还有两个比较有用:DRY(Don’t Repeate Yourself)和KISS(Keep 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="">
|
||||
167
极客时间专栏/罗剑锋的C++实战笔记/总结篇/20 | 设计模式(下):C++是怎么应用设计模式的?.md
Normal file
167
极客时间专栏/罗剑锋的C++实战笔记/总结篇/20 | 设计模式(下):C++是怎么应用设计模式的?.md
Normal 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& instance() // 生产单件对象的函数
|
||||
{
|
||||
static T obj; // 静态变量
|
||||
return obj; // 返回对象的引用
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
说完了单件,再来看工厂模式吧。
|
||||
|
||||
工厂模式是我个人的“笼统”说法,指的是抽象工厂、工厂方法这两个模式,因为它们就像是现实世界里的工厂一样,专门用来生产对象。
|
||||
|
||||
抽象工厂是一个类,而工厂方法是一个函数,在纯面向对象范式里,两者的区别很大。而C++支持泛型编程,不需要特意派生出子类,只要接口相同就行,所以,这两个模式在C++里用起来也就更自由一些,界限比较模糊。
|
||||
|
||||
为什么非要用工厂来创建对象呢?这样做的好处在哪里呢?
|
||||
|
||||
我觉得,你可以用DRY(Don’t 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<int>(42);
|
||||
auto ptr2 = make_shared<string>("metroid");
|
||||
|
||||
```
|
||||
|
||||
还有之前课程里的用函数抛出异常([第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<int, 5> 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="">
|
||||
356
极客时间专栏/罗剑锋的C++实战笔记/总结篇/21 | 知识串讲(上):带你开发一个书店应用.md
Normal file
356
极客时间专栏/罗剑锋的C++实战笔记/总结篇/21 | 知识串讲(上):带你开发一个书店应用.md
Normal 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 <cassert> // C++标准头文件
|
||||
...
|
||||
|
||||
#endif //_CPP_LANG_HPP
|
||||
|
||||
```
|
||||
|
||||
在核心头文件里,我们还可以利用预处理编程,使用宏定义、条件编译来屏蔽操作系统、语言版本的差异,增强程序的兼容性。
|
||||
|
||||
比如,这里我就检查了C++的版本号,然后定义了简化版的“deprecated”和“static_assert”:
|
||||
|
||||
```
|
||||
// must be C++11 or later
|
||||
#if __cplusplus < 201103
|
||||
# error "C++ is too old"
|
||||
#endif // __cplusplus < 201103
|
||||
|
||||
// [[deprecated]]
|
||||
#if __cplusplus >= 201402
|
||||
# define CPP_DEPRECATED [[deprecated]]
|
||||
#else
|
||||
# define CPP_DEPRECATED [[gnu::deprecated]]
|
||||
#endif // __cplusplus >= 201402
|
||||
|
||||
// static_assert
|
||||
#if __cpp_static_assert >= 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&) = delete; // 禁止拷贝
|
||||
SpinLock& operator=(const this_type&) = 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&) = delete; // 禁止拷贝
|
||||
SpinLockGuard& operator=(const this_type&) = delete;
|
||||
public:
|
||||
SpinLockGuard(spin_lock_type& lock) noexcept
|
||||
: m_lock(lock)
|
||||
{
|
||||
m_lock.lock();
|
||||
}
|
||||
|
||||
~SpinLockGuard() noexcept
|
||||
{
|
||||
m_lock.unlock();
|
||||
}
|
||||
private:
|
||||
spin_lock_type& m_lock;
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
这样自旋锁就完成了,有了它就可以在多线程应用里保护共享的数据,避免数据竞争。
|
||||
|
||||
## 网络通信
|
||||
|
||||
自旋锁比较简单,但多线程只是书店程序的基本特性,它的核心关键词是“网络”,所以下面就来看看服务里的“重头”部分:网络通信。
|
||||
|
||||
正如我之前说的,在现代C++里,应当避免直接使用原生Socket来编写网络通信程序([第16讲](https://time.geekbang.org/column/article/245900))。这里我选择ZMQ作为底层通信库,它不仅方便易用,而且能够保证消息不丢失、完整可靠地送达目的地。
|
||||
|
||||
程序里使用ZmqContext类来封装底层接口(包装外观),它是一个模板类,整数模板参数用来指定线程数,在编译阶段就固定了ZMQ的多线程处理能力。
|
||||
|
||||
对于ZMQ必需的运行环境变量(单件),我使用了一个小技巧:**以静态成员函数来代替静态成员变量**。这样就绕过了C++的语言限制,不必在实现文件“*.cpp”里再写一遍变量定义,全部的代码都可以集中在hpp头文件里:
|
||||
|
||||
```
|
||||
template<int thread_num = 1> // 使用整数模板参数来指定线程数
|
||||
class ZmqContext final
|
||||
{
|
||||
public:
|
||||
static // 静态成员函数代替静态成员变量
|
||||
zmq_context_type& 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 = "tcp://127.0.0.1:5555",
|
||||
|
||||
-- see http_study's lua code
|
||||
http_addr = "http://localhost/cpp_study?token=cpp@2020",
|
||||
|
||||
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<lua_State>; // 类型别名
|
||||
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("failed to parse config");
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
为了访问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"(^(\w+)\.(\w+)$)"};
|
||||
mutable match_type m_what; // 注意是mutable
|
||||
|
||||
```
|
||||
|
||||
在C++正则库的帮助下,处理字符串就太轻松了,拿到两个key,再调用LuaBridge就可以获得Lua脚本里的配置项。
|
||||
|
||||
不过,为了进一步简化客户代码,我把get()函数改成了模板函数,显式转换成int、string等C++标准类型,可读性、可维护性会更好。
|
||||
|
||||
```
|
||||
public:
|
||||
template<typename T> // 转换配置值的类型
|
||||
T get(string_view_type key) const // const常函数
|
||||
{
|
||||
if (!std::regex_match(key, m_what, m_reg)) { // 正则匹配
|
||||
throw std::runtime_error("config key error");// 格式错误抛异常
|
||||
}
|
||||
|
||||
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<T>(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="">
|
||||
372
极客时间专栏/罗剑锋的C++实战笔记/总结篇/22 | 知识串讲(下):带你开发一个书店应用.md
Normal file
372
极客时间专栏/罗剑锋的C++实战笔记/总结篇/22 | 知识串讲(下):带你开发一个书店应用.md
Normal 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&;
|
||||
using uint_type = unsigned int;
|
||||
using currency_type = double;
|
||||
|
||||
STATIC_ASSERT(sizeof(uint_type) >= 4); // 静态断言
|
||||
STATIC_ASSERT(sizeof(currency_type) >= 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&) = default;
|
||||
SalesData& operator=(const this_type&) = default;
|
||||
|
||||
SalesData(this_type&& s) = default; // 显式转移构造
|
||||
SalesData& operator=(this_type&& s) = default;
|
||||
private:
|
||||
string_type m_id = ""; // 成员变量初始化
|
||||
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<string_type, sales_type>;
|
||||
using minmax_sales_type =
|
||||
std::pair<string_type, string_type>;
|
||||
public:
|
||||
Summary() = default; // 显式default
|
||||
~Summary() = default;
|
||||
|
||||
Summary(const this_type&) = delete; // 显式delete
|
||||
Summary& operator=(const this_type&) = 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& s) // 非const
|
||||
{
|
||||
lock_guard_type guard(m_lock); // 自动锁定,自动解锁
|
||||
|
||||
const auto& 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& a, const auto& b) // 匿名lambda表达式
|
||||
{
|
||||
return a.second.sold() < b.second.sold();
|
||||
});
|
||||
|
||||
auto min_pos = ret.first; // 返回的是两个迭代器位置
|
||||
auto max_pos = ret.second;
|
||||
|
||||
return {min_pos->second.id(), max_pos->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("./conf.lua"); // 解析配置文件
|
||||
|
||||
Summary sum; // 数据存储和统计
|
||||
std::atomic_int count {0}; // 计数用的原子变量
|
||||
|
||||
```
|
||||
|
||||
接下来的服务器主循环,我使用了lambda表达式,引用捕获上面的那些变量:
|
||||
|
||||
```
|
||||
auto recv_cycle = [&]() // 主循环lambda表达式
|
||||
{
|
||||
...
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
主要的业务逻辑其实很简单,就是ZMQ接收数据,然后MessagePack反序列化,存储数据。
|
||||
|
||||
不过为了避免阻塞、充分利用多线程,我在收到数据后,就把它包装进智能指针,再扔到另外一个线程里去处理了。这样主循环就只接收数据,不会因为反序列化、插入、排序等大计算量的工作而阻塞。
|
||||
|
||||
我在代码里加上了详细的注释,你一定要仔细看、认真理解:
|
||||
|
||||
```
|
||||
auto recv_cycle = [&]() // 主循环lambda表达式
|
||||
{
|
||||
using zmq_ctx = ZmqContext<1>; // ZMQ的类型别名
|
||||
|
||||
auto sock = zmq_ctx::recv_sock(); // 自动类型推导获得接收Socket
|
||||
|
||||
sock.bind( // 绑定ZMQ接收端口
|
||||
conf.get<string>("config.zmq_ipc_addr")); // 读取Lua配置文件
|
||||
|
||||
for(;;) { // 服务器无限循环
|
||||
auto msg_ptr = // 自动类型推导获得智能指针
|
||||
std::make_shared<zmq_message_type>();
|
||||
|
||||
sock.recv(msg_ptr.get()); // ZMQ阻塞接收数据
|
||||
|
||||
++count; // 增加原子计数
|
||||
|
||||
std::thread( // 再启动一个线程反序列化存储,没有用async
|
||||
[&sum, msg_ptr]() // 显式捕获,注意!!
|
||||
{
|
||||
SalesData book;
|
||||
|
||||
auto obj = msgpack::unpack( // 反序列化
|
||||
msg_ptr->data<char>(), msg_ptr->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 = [&]() // 外发循环lambda表达式
|
||||
{
|
||||
// 获取Lua配置文件里的配置项
|
||||
auto http_addr = conf.get<string>("config.http_addr");
|
||||
auto time_interval = conf.get<int>("config.time_interval");
|
||||
|
||||
for(;;) { // 无限循环
|
||||
std::this_thread::sleep_for(time_interval * 1s); // 线程睡眠等待
|
||||
|
||||
json_t j; // JSON序列化数据
|
||||
j["count"] = static_cast<int>(count);
|
||||
j["minmax"] = 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 << "http post failed" << 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="">
|
||||
10
极客时间专栏/罗剑锋的C++实战笔记/总结篇/期末测试 | 这些C++核心知识,你都掌握了吗?.md
Normal file
10
极客时间专栏/罗剑锋的C++实战笔记/总结篇/期末测试 | 这些C++核心知识,你都掌握了吗?.md
Normal 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&exam_id=445)
|
||||
377
极客时间专栏/罗剑锋的C++实战笔记/技能进阶/15 | 序列化:简单通用的数据交换格式有哪些?.md
Normal file
377
极客时间专栏/罗剑锋的C++实战笔记/技能进阶/15 | 序列化:简单通用的数据交换格式有哪些?.md
Normal 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["age"] = 23; // "age":23
|
||||
j["name"] = "spiderman"; // "name":"spiderman"
|
||||
j["gear"]["suits"] = "2099"; // "gear":{"suits":"2099"}
|
||||
j["jobs"] = {"superhero"}; // "jobs":["superhero"]
|
||||
|
||||
vector<int> v = {1,2,3}; // vector容器
|
||||
j["numbers"] = v; // "numbers":[1,2,3]
|
||||
|
||||
map<string, int> m = // map容器
|
||||
{{"one",1}, {"two", 2}}; // 初始化列表
|
||||
j["kv"] = m; // "kv":{"one":1,"two":2}
|
||||
|
||||
```
|
||||
|
||||
添加完之后,用成员函数dump()就可以序列化,得到它的JSON文本形式。默认的格式是紧凑输出,没有缩进,如果想要更容易阅读的话,可以加上指示缩进的参数:
|
||||
|
||||
```
|
||||
cout << j.dump() << endl; // 序列化,无缩进
|
||||
cout << j.dump(2) << endl; // 序列化,有缩进,2个空格
|
||||
|
||||
```
|
||||
|
||||
json_t的反序列化功能同样也很简单,只要调用静态成员函数parse()就行,直接得到JSON对象,而且可以用auto自动推导类型:
|
||||
|
||||
```
|
||||
string str = R"({ // JSON文本,原始字符串
|
||||
"name": "peter",
|
||||
"age" : 23,
|
||||
"married" : true
|
||||
})";
|
||||
|
||||
auto j = json_t::parse(str); // 从字符串反序列化
|
||||
assert(j["age"] == 23); // 验证序列化是否正确
|
||||
assert(j["name"] == "peter");
|
||||
|
||||
```
|
||||
|
||||
json_t使用异常来处理解析时可能发生的错误,如果你不能保证JSON数据的完整性,就要使用try-catch来保护代码,防止错误数据导致程序崩溃:
|
||||
|
||||
```
|
||||
auto txt = "bad:data"s; // 不是正确的JSON数据
|
||||
|
||||
try // try保护代码
|
||||
{
|
||||
auto j = json_t::parse(txt);// 从字符串反序列化
|
||||
}
|
||||
catch(std::exception& e) // 捕获异常
|
||||
{
|
||||
cout << e.what() << 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<int> v = {1,2,3,4,5}; // vector容器
|
||||
|
||||
msgpack::sbuffer sbuf; // 输出缓冲区
|
||||
msgpack::pack(sbuf, v); // 序列化
|
||||
|
||||
```
|
||||
|
||||
从代码里你可以看到,它的用法不像JSON那么简单直观,**必须同时传递序列化的输出目标和被序列化的对象**。
|
||||
|
||||
输出目标sbuffer是个简单的缓冲区,你可以把它理解成是对字符串数组的封装,和`vector<char>`很像,也可以用data()和size()方法获取内部的数据和长度。
|
||||
|
||||
```
|
||||
cout << sbuf.size() << 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<int> v2; // vector容器
|
||||
obj.convert(v2); // 转换反序列化的数据
|
||||
|
||||
assert(std::equal( // 算法比较两个容器
|
||||
begin(v), end(v), begin(v2)));
|
||||
|
||||
```
|
||||
|
||||
因为MessagePack不能直接打包复杂数据,所以用起来就比JSON麻烦一些,你必须自己把数据逐个序列化,连在一起才行。
|
||||
|
||||
好在MessagePack又提供了一个packer类,可以实现串联的序列化操作,简化代码:
|
||||
|
||||
```
|
||||
msgpack::sbuffer sbuf; // 输出缓冲区
|
||||
msgpack::packer<decltype(sbuf)> packer(sbuf); // 专门的序列化对象
|
||||
|
||||
packer.pack(10).pack("monado"s) // 连续序列化多个数据
|
||||
.pack(vector<int>{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<string> tags;
|
||||
public:
|
||||
MSGPACK_DEFINE(id, title, tags); // 实现序列化功能的宏
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
它可以直接用于pack()和unpack(),基本上和JSON差不多了:
|
||||
|
||||
```
|
||||
Book book1 = {1, "1984", {"a","b"}}; // 自定义类
|
||||
|
||||
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 << book2.title << endl;
|
||||
|
||||
```
|
||||
|
||||
使用MessagePack的时候,你也要注意数据不完整的问题,必须要用try-catch来保护代码,捕获异常:
|
||||
|
||||
```
|
||||
auto txt = ""s; // 空数据
|
||||
try // try保护代码
|
||||
{
|
||||
auto handle = msgpack::unpack( // 反序列化
|
||||
txt.data(), txt.size());
|
||||
}
|
||||
catch(std::exception& e) // 捕获异常
|
||||
{
|
||||
cout << e.what() << 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)**,必须要先写一个IDL(Interface Description Language)文件,在里面定义好数据结构,只有预先定义了的数据结构,才能被序列化和反序列化。
|
||||
|
||||
这个特点既有好处也有坏处:一方面,接口就是清晰明确的规范文档,沟通交流简单无歧义;而另一方面,就是缺乏灵活性,改接口会导致一连串的操作,有点繁琐。
|
||||
|
||||
下面是一个简单的PB定义:
|
||||
|
||||
```
|
||||
syntax = "proto2"; // 使用第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("sony");
|
||||
v.set_valid(true);
|
||||
|
||||
assert(v.IsInitialized()); // required等字段都设置了,数据完整
|
||||
assert(v.has_id() && v.id() == 1);
|
||||
assert(v.has_name() && v.name() == "sony");
|
||||
assert(v.has_valid() && v.valid());
|
||||
|
||||
cout << v.DebugString() << endl; // 输出调试字符串
|
||||
|
||||
string enc;
|
||||
v.SerializeToString(&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="">
|
||||
325
极客时间专栏/罗剑锋的C++实战笔记/技能进阶/16 | 网络通信:我不想写原生Socket.md
Normal file
325
极客时间专栏/罗剑锋的C++实战笔记/技能进阶/16 | 网络通信:我不想写原生Socket.md
Normal 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 <curl/curl.h> // 包含头文件
|
||||
|
||||
auto curl = curl_easy_init(); // 创建CURL句柄
|
||||
assert(curl);
|
||||
|
||||
curl_easy_setopt(curl, CURLOPT_URL, "http://nginx.org"); // 设置请求URI
|
||||
|
||||
auto res = curl_easy_perform(curl); // 发送数据
|
||||
if (res != CURLE_OK) { // 检查是否执行成功
|
||||
cout << curl_easy_strerror(res) << 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(&write_callback)) // decltype获取函数指针类型,显式转换
|
||||
[](char *ptr, size_t size, size_t nmemb, void *userdata)// lambda
|
||||
{
|
||||
cout << "size = " << size * nmemb << 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 && make install
|
||||
|
||||
```
|
||||
|
||||
和libcurl相比,cpr用起来真的是太轻松了,不需要考虑什么初始化、设置参数、清理等杂事,一句话就能发送HTTP请求:
|
||||
|
||||
```
|
||||
#include <cpr/cpr.h> // 包含头文件
|
||||
|
||||
auto res = cpr::Get( // GET请求
|
||||
cpr::Url{"http://openresty.org"} // 传递URL
|
||||
);
|
||||
|
||||
```
|
||||
|
||||
你也不用写回调函数,HTTP响应就是函数的返回值,用成员变量url、header、status_code、text就能够得到报文的各个组成部分:
|
||||
|
||||
```
|
||||
cout << res.elapsed << endl; // 请求耗费的时间
|
||||
|
||||
cout << res.url << endl; // 请求的URL
|
||||
cout << res.status_code << endl; // 响应的状态码
|
||||
cout << res.text.length() << endl; // 响应的body数据
|
||||
|
||||
for(auto& x : res.header) { // 响应的头字段
|
||||
cout << x.first << "=>" // 类似map的结构
|
||||
<< x.second << 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 = "http://openresty.org"s; // 访问的URL
|
||||
|
||||
auto res1 = cpr::Head( // 发送HEAD请求
|
||||
cpr::Url{url} // 传递URL
|
||||
);
|
||||
|
||||
auto res2 = cpr::Get( // 发送GET请求
|
||||
cpr::Url{url}, // 传递URL
|
||||
cpr::Parameters{ // 传递URL参数
|
||||
{"a", "1"}, {"b", "2"}}
|
||||
);
|
||||
|
||||
auto res3 = cpr::Post( // 发送POST请求
|
||||
cpr::Url{url}, // 传递URL
|
||||
cpr::Header{ // 定制请求头字段
|
||||
{"x", "xxx"},{"expect",""}},
|
||||
cpr::Body{"post data"}, // 传递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{"http://openresty.org"}
|
||||
);
|
||||
|
||||
auto res = f.get(); // 等待响应结果
|
||||
cout << res.elapsed << 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 = [&](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 = "ipc:///dev/shm/zmq.sock"s; // 通信地址
|
||||
|
||||
auto receiver = [=]() // lambda表达式接收数据
|
||||
{
|
||||
auto sock = make_sock(ZMQ_PULL); // 创建ZMQ套接字,拉数据
|
||||
|
||||
sock.bind(addr); // 绑定套接字
|
||||
assert(sock.connected());
|
||||
|
||||
zmq::message_t msg;
|
||||
sock.recv(&msg); // 接收消息
|
||||
|
||||
string s = {msg.data<char>(), msg.size()};
|
||||
cout << s << endl;
|
||||
};
|
||||
|
||||
auto sender = [=]() // lambda表达式发送数据
|
||||
{
|
||||
auto sock = make_sock(ZMQ_PUSH); // 创建ZMQ套接字,推数据
|
||||
|
||||
sock.connect(addr); // 连接到对端
|
||||
assert(sock.connected());
|
||||
|
||||
string s = "hello zmq";
|
||||
sock.send(s.data(), s.size()); // 发送消息
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
这段代码实现了两个最基本的客户端和服务器,看起来好像没什么特别的。但你应该注意到,使用ZMQ完全不需要考虑底层的TCP/IP通信细节,它会保证消息异步、安全、完整地到达服务器,让你关注网络通信之上更有价值的业务逻辑。
|
||||
|
||||
ZMQ的用法就是这么简单,但想要进一步发掘它的潜力,处理大流量的数据还是要去看[它的文档](http://wiki.zeromq.org/),选择合适的工作模式,再仔细调节各种参数。
|
||||
|
||||
接下来,我再给你分享两个实际工作中会比较有用的细节吧。
|
||||
|
||||
一个是**ZMQ环境的线程数**。它的默认值是1,太小了,适当增大一些就可以提高ZMQ的并发处理能力。我一般用的是4~6,具体设置为多少最好还是通过性能测试来验证下。
|
||||
|
||||
另一个是**收发消息时的本地缓存数量**,ZMQ的术语叫High Water Mark。如果收发的数据过多,数量超过HWM,ZMQ要么阻塞,要么丢弃消息。
|
||||
|
||||
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="">
|
||||
367
极客时间专栏/罗剑锋的C++实战笔记/技能进阶/17 | 脚本语言:搭建高性能的混合系统.md
Normal file
367
极客时间专栏/罗剑锋的C++实战笔记/技能进阶/17 | 脚本语言:搭建高性能的混合系统.md
Normal 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 <pybind11/pybind11.h> // pybind11的头文件
|
||||
|
||||
PYBIND11_MODULE(pydemo, m) // 定义Python模块pydemo
|
||||
{
|
||||
m.doc() = "pybind11 demo doc"; // 模块的说明文档
|
||||
} // 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
|
||||
>>> import pydemo
|
||||
>>> help(pydemo)
|
||||
|
||||
```
|
||||
|
||||
刚才的代码非常简单,只是个空模块,里面什么都没有,现在,我们来看看怎么把C++的函数导入Python。
|
||||
|
||||
你需要用的是**def()函数**,传递一个Python函数名和C++的函数、函数对象或者是lambda表达式,形式上和Python的函数也差不多:
|
||||
|
||||
```
|
||||
namespace py = pybind11; // 名字空间别名,简化代码
|
||||
|
||||
PYBIND11_MODULE(pydemo, m) // 定义Python模块pydemo
|
||||
{
|
||||
m.def("info", // 定义Python函数
|
||||
[]() // 定义一个lambda表达式
|
||||
{
|
||||
py::print("c++ version =", __cplusplus); // pybind11自己的打印函数
|
||||
py::print("gcc version =", __VERSION__);
|
||||
py::print("libstdc++ =", __GLIBCXX__);
|
||||
}
|
||||
);
|
||||
|
||||
m.def("add", // 定义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 <pybind11/stl.h> // 转换标准容器必须的头文件
|
||||
|
||||
PYBIND11_MODULE(pydemo, m) // 定义Python模块pydemo
|
||||
{
|
||||
m.def("use_str", // 定义Python函数
|
||||
[](const string& str) // 入参是string
|
||||
{
|
||||
py::print(str);
|
||||
return str + "!!"; // 返回string
|
||||
}
|
||||
);
|
||||
|
||||
m.def("use_tuple", // 定义Python函数
|
||||
[](tuple<int, int, string> x) // 入参是tuple
|
||||
{
|
||||
get<0>(x)++;
|
||||
get<1>(x)++;
|
||||
get<2>(x)+= "??";
|
||||
return x; // 返回元组
|
||||
}
|
||||
);
|
||||
|
||||
m.def("use_list", // 定义Python函数
|
||||
[](const vector<int>& v) // 入参是vector
|
||||
{
|
||||
auto vv = v;
|
||||
py::print("input :", 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_<Point>(m, "Point") // 定义Python类
|
||||
.def(py::init()) // 导出构造函数
|
||||
.def(py::init<int>()) // 导出构造函数
|
||||
.def("get", &Point::get) // 导出成员函数
|
||||
.def("set", &Point::set) // 导出成员函数
|
||||
;
|
||||
|
||||
```
|
||||
|
||||
对于一般的成员函数来说,定义的方式和普通函数一样,只是你必须加上取地址操作符“&”,把它写成函数指针的形式。而构造函数则比较特殊,必须调用init()函数来表示,如果有参数,还需要在init()函数的模板参数列表里写清楚。
|
||||
|
||||
pybind11的功能非常丰富,我们不可能一下子学完全部的功能,刚才说的这些只是最基本,也是非常实用的功能。除了这些,它还支持异常、枚举、智能指针等很多C++特性,你可以再参考一下它的[文档](https://github.com/pybind/pybind11),学习一下具体的方法,挖掘出它的更多价值。
|
||||
|
||||
如果你在工作中重度使用Python,那么pybind11绝对是你的得力助手,它能够让C++紧密地整合进Python应用里,让Python跑得更快、更顺畅,建议你有机会就尽量多用。
|
||||
|
||||
## Lua
|
||||
|
||||
接下来我要说的第二个脚本语言是小巧高效的Lua,号称是“最快的脚本语言”。
|
||||
|
||||
你可能对Lua不太了解,但你一定听说过《魔兽世界》《愤怒的小鸟》吧,它们就在内部大量使用了Lua来编写逻辑。在游戏开发领域,Lua可以说是一种通用的工作语言。
|
||||
|
||||
Lua与其他语言最大的不同点在于它的设计目标:不追求“大而全”,而是“小而美”。Lua自身只有很小的语言核心,能做的事情很少。但正是因为它小,才能够很容易地嵌入到其他语言里,为“宿主”添加脚本编程的能力,让“宿主”更容易扩展和定制。
|
||||
|
||||
标准的Lua(PUC-Rio Lua)使用解释器运行,速度虽然很快,但和C/C++比起来还是有差距的。所以,你还可以选择另一个兼容的项目:LuaJIT([https://luajit.org/](https://luajit.org/))。它使用了JIT(Just 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 && 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 "C" { // 使用纯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 "ffi" -- 加载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("./liblua_shared.so") -- 加载动态库
|
||||
|
||||
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<lua_State> 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, "package"); // 获取内置的package模块
|
||||
|
||||
string path = package["path"]; // 默认的lua脚本搜索路径
|
||||
string cpath = package["cpath"]; // 默认的动态库搜索路径
|
||||
|
||||
```
|
||||
|
||||
你还可以调用**luaL_dostring()和luaL_dofile()**这两个函数,直接执行Lua代码片段或者外部的脚本文件。注意,luaL_dofile()每次调用都会从磁盘载入文件,所以效率较低。如果是频繁调用,最好把代码读进内存,存成一个字符串,再用luaL_dostring()运行:
|
||||
|
||||
```
|
||||
luaL_dostring(L, "print('hello lua')"); // 执行Lua代码片段
|
||||
luaL_dofile(L, "./embedded.lua"); // 执行外部的脚本文件
|
||||
|
||||
```
|
||||
|
||||
在C++里嵌入Lua,还有另外一种方式:**提前在脚本里写好一些函数,加载后在C++里逐个调用**,这种方式比执行整个脚本更灵活。
|
||||
|
||||
具体的做法也很简单,先用luaL_dostring()或者luaL_dofile()加载脚本,然后调用getGlobal()从全局表里获得封装的LuaRef对象,就可以像普通函数一样执行了。由于Lua是动态语言,变量不需要显式声明类型,所以写起来就像是C++的泛型函数,但却更简单:
|
||||
|
||||
```
|
||||
string chunk = R"( -- Lua代码片段
|
||||
function say(s) -- Lua函数1
|
||||
print(s)
|
||||
end
|
||||
function add(a, b) -- Lua函数2
|
||||
return a + b
|
||||
end
|
||||
)";
|
||||
|
||||
luaL_dostring(L, chunk.c_str()); // 执行Lua代码片段
|
||||
|
||||
auto f1 = getGlobal(L, "say"); // 获得Lua函数
|
||||
f1("say something"); // 执行Lua函数
|
||||
|
||||
auto f2 = getGlobal(L, "add"); // 获得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="">
|
||||
239
极客时间专栏/罗剑锋的C++实战笔记/技能进阶/18 | 性能分析:找出程序的瓶颈.md
Normal file
239
极客时间专栏/罗剑锋的C++实战笔记/技能进阶/18 | 性能分析:找出程序的瓶颈.md
Normal 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”,然后用“<>”手动选择排序的列,这样查看起来更自由。
|
||||
|
||||
我曾经做过一个“魔改”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& filename) // 传入性能分析的数据文件名
|
||||
{
|
||||
ProfilerStart(filename.c_str()); // 启动性能分析
|
||||
ProfilerRegisterThread(); // 对线程做性能分析
|
||||
|
||||
return std::shared_ptr<void>( // 返回智能指针
|
||||
nullptr, // 空指针,只用来占位
|
||||
[](void*){ // 删除函数执行停止动作
|
||||
ProfilerStop(); // 停止性能分析
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
```
|
||||
|
||||
下面我写一小段代码,测试正则表达式处理文本的性能:
|
||||
|
||||
```
|
||||
auto cp = make_cpu_profiler("case1.perf"); // 启动性能分析
|
||||
auto str = "neir:automata"s;
|
||||
|
||||
for(int i = 0; i < 1000; i++) { // 循环一千次
|
||||
auto reg = make_regex(R"(^(\w+)\:(\w+)$)");// 正则表达式对象
|
||||
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 > 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 > case1.svg
|
||||
|
||||
pprof --collapsed ./a.out case1.perf > case1.cbt
|
||||
flamegraph.pl case1.cbt > flame.svg
|
||||
flamegraph.pl --invert --color aqua case1.cbt > 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"(^(\w+)\:(\w+)$)"); // 正则表达式对象
|
||||
auto what = make_match();
|
||||
|
||||
for(int i = 0; i < 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="">
|
||||
308
极客时间专栏/罗剑锋的C++实战笔记/标准库/11 | 一枝独秀的字符串:C++也能处理文本?.md
Normal file
308
极客时间专栏/罗剑锋的C++实战笔记/标准库/11 | 一枝独秀的字符串:C++也能处理文本?.md
Normal 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<char>; // string其实是一个类型别名
|
||||
|
||||
```
|
||||
|
||||
这个特化是什么意思呢?
|
||||
|
||||
所谓的字符串,就是字符的序列。字符是人类语言、文字的计算机表示,而人类语言、文字又有很多种,相应的编码方式也有很多种。所以,C++就为字符串设计出了模板类basic_string,再用模板来搭配不同的字符类型,就能够更有“弹性”地处理各种文字了。
|
||||
|
||||
说到字符和编码,就不能不提到Unicode,它的目标是用一种编码方式统一处理人类语言文字,使用32位(4个字节)来保证能够容纳过去或者将来所有的文字。
|
||||
|
||||
但这就与C++产生了矛盾。因为C++的字符串源自C,而C里的字符都是单字节的char类型,无法支持Unicode。
|
||||
|
||||
为了解决这个问题,C++就又新增了几种字符类型。C++98定义了wchar_t,到了C++11,为了适配UTF-16、UTF-32,又多了char16_t、char32_t。于是,basic_string在模板参数里换上这些字符类型之后,就可以适应不同的编码方式了。
|
||||
|
||||
```
|
||||
using wstring = std::basic_string<wchar_t>;
|
||||
using u16string = std::basic_string<char16_t>;
|
||||
using u32string = std::basic_string<char32_t>;
|
||||
|
||||
```
|
||||
|
||||
不过在我看来,虽然C++做了这些努力,但其实收效并不大。因为字符编码和国际化的问题实在是太复杂了,仅有这几个基本的字符串类型根本不够,而C++一直没有提供处理编码的配套工具,我们只能“自己造轮子”,用不好反而会把编码搞得一团糟。
|
||||
|
||||
这就导致wstring等新字符串基本上没人用,大多数程序员为了不“自找麻烦”,还是选择最基本的string。万幸的是Unicode还有一个UTF-8编码方式,与单字节的char完全兼容,用string也足以适应大多数的应用场合。
|
||||
|
||||
所以,我也建议你只用string,而且在涉及Unicode、编码转换的时候,尽量不要用C++,目前它还不太擅长做这种工作,可能还是改用其他语言来处理更好。接下来,我就讲一讲,该怎么用好String。
|
||||
|
||||
## 用好字符串
|
||||
|
||||
string在C++标准库里的身份也是比较特殊,虽然批评它的声音有不少,比如接口复杂、成本略高,但不像容器、算法,直到现在,仍然有且只有这么一个字符串类,“只此一家,别无分号”。
|
||||
|
||||
所以,在这种“别无选择”的情况下,我们就要多了解它的优缺点,尽量用好它。
|
||||
|
||||
首先你要看到,string是一个功能比较齐全的字符串类,可以提取子串、比较大小、检查长度、搜索字符……基本满足一般人对字符串的“想象”。
|
||||
|
||||
```
|
||||
string str = "abc";
|
||||
|
||||
assert(str.length() == 3);
|
||||
assert(str < "xyz");
|
||||
assert(str.substr(0, 1) == "a");
|
||||
assert(str[1] == 'b');
|
||||
assert(str.find("1") == string::npos);
|
||||
assert(str + "d" == "abcd");
|
||||
|
||||
```
|
||||
|
||||
刚才也说了,string的接口比较复杂,除了字符串操作,还有size()、begin()、end()、push_back()等类似容器的操作,这很容易让人产生“联想”,把它当成是一个“字符容器”。
|
||||
|
||||
但我不建议你这样做。**字符串和容器完全是两个不同的概念**。
|
||||
|
||||
字符串是“文本”,里面的字符之间是强关系,顺序不能随便调换,否则就失去了意义,通常应该视为一个整体来处理。而容器是“集合”,里面的元素之间没有任何关系,可以随意增删改,对容器更多地是操作里面的单个元素。
|
||||
|
||||
理解了这一点,**把每个字符串都看作是一个不可变的实体,你才能在C++里真正地用好字符串**。
|
||||
|
||||
但有的时候,我们也确实需要存储字符的容器,比如字节序列、数据缓冲区,这该怎么办呢?
|
||||
|
||||
这个时候,我建议你**最好改用`vector<char>`**,它的含义十分“纯粹”,只存储字符,没有string那些不必要的成本,用起来也就更灵活一些。
|
||||
|
||||
接下来我们再看看string的一些小技巧。
|
||||
|
||||
**1.字面量后缀**
|
||||
|
||||
C++14为方便使用字符串,新增了一个字面量的**后缀“s”**,明确地表示它是string字符串类型,而不是C字符串,这就可以利用auto来自动类型推导,而且在其他用到字符串的地方,也可以省去声明临时字符串变量的麻烦,效率也会更高:
|
||||
|
||||
```
|
||||
using namespace std::literals::string_literals; //必须打开名字空间
|
||||
|
||||
auto str = "std string"s; // 后缀s,表示是标准字符串,直接类型推导
|
||||
|
||||
assert("time"s.size() == 4); // 标准字符串可以直接调用成员函数
|
||||
|
||||
```
|
||||
|
||||
不过要提醒你的是,**为了避免与用户自定义字面量的冲突,后缀“s”不能直接使用,必须用using打开名字空间才行**,这是它的一个小缺点。
|
||||
|
||||
**2.原始字符串**
|
||||
|
||||
C++11还为字面量增加了一个“**原始字符串**”(Raw string literal)的新表示形式,比原来的引号多了一个大写字母R和一对圆括号,就像下面这样:
|
||||
|
||||
```
|
||||
auto str = R"(nier:automata)"; // 原始字符串:nier:automata
|
||||
|
||||
```
|
||||
|
||||
这种形式初看上去显得有点多余,它有什么好处呢?
|
||||
|
||||
你一定知道,C++的字符有“转义”的用法,在字符前面加上一个“\”,就可以写出“\n”“\t”来表示回车、跳格等不可打印字符。
|
||||
|
||||
但这个特性也会带来麻烦,有时我们不想转义,只想要字符串的“原始”形式,在C++里写起来就很难受了。特别是在用正则表达式的时候,由于它也有转义,两个转义效果“相乘”,就很容易出错。
|
||||
|
||||
比如说,我要在正则里表示“`\$`”,需要写成"`\\\$`",而在C++里需要对“\”再次转义,就是“`\\\\\\$`”,你能数出来里面到底有多少个“\”吗?
|
||||
|
||||
如果使用原始字符串的话,就没有这样的烦恼了,它不会对字符串里的内容做任何转义,完全保持了“原始风貌”,即使里面有再多的特殊字符都不怕:
|
||||
|
||||
```
|
||||
auto str1 = R"(char""'')"; // 原样输出:char""''
|
||||
auto str2 = R"(\r\n\t\")"; // 原样输出:\r\n\t\"
|
||||
auto str3 = R"(\\\$)"; // 原样输出:\\\$
|
||||
auto str4 = "\\\\\\$"; // 转义后输出:\\\$
|
||||
|
||||
```
|
||||
|
||||
不过,想要在原始字符串里面写引号+圆括号的形式该怎么办呢?
|
||||
|
||||
对于这个问题,C++也准备了应对的办法,就是在圆括号的两边加上最多16个字符的特别“界定符”(delimiter),这样就能够保证不与字符串内容发生冲突:
|
||||
|
||||
```
|
||||
auto str5 = R"==(R"(xxx)")==";// 原样输出:R"(xxx)"
|
||||
|
||||
```
|
||||
|
||||
**3.字符串转换函数**
|
||||
|
||||
在处理字符串的时候,我们还会经常遇到与数字互相转换的事情,以前只能用C函数atoi()、atol(),它们的参数是C字符串而不是string,用起来就比较麻烦,于是,C++11就增加了几个新的转换函数:
|
||||
|
||||
- stoi()、stol()、stoll()等把字符串转换成整数;
|
||||
- stof()、stod()等把字符串转换成浮点数;
|
||||
- to_string()把整数、浮点数转换成字符串。
|
||||
|
||||
这几个小函数在处理用户数据、输入输出的时候,非常方便:
|
||||
|
||||
```
|
||||
assert(stoi("42") == 42); // 字符串转整数
|
||||
assert(stol("253") == 253L); // 字符串转长整数
|
||||
assert(stod("2.0") == 2.0); // 字符串转浮点数
|
||||
|
||||
assert(to_string(1984) == "1984"); // 整数转字符串
|
||||
|
||||
```
|
||||
|
||||
**4.字符串视图类**
|
||||
|
||||
再来说一下string的成本问题。它确实有点“重”,大字符串的拷贝、修改代价很高,所以我们通常都尽量用const string&,但有的时候还是无法避免(比如使用C字符串、获取子串)。如果你对此很在意,就有必要找一个“轻量级”的替代品。
|
||||
|
||||
在C++17里,就有这么一个完美满足所有需求的东西,叫string_view。顾名思义,它是一个字符串的视图,成本很低,内部只保存一个指针和长度,无论是拷贝,还是修改,都非常廉价。
|
||||
|
||||
唯一的遗憾是,它只出现在C++17里,不过你也可以参考它的接口,自己在C++11里实现一个简化版本。下面我给你一个简单的示范,你可以课下去扩展:
|
||||
|
||||
```
|
||||
class my_string_view final // 简单的字符串视图类,示范实现
|
||||
{
|
||||
public:
|
||||
using this_type = my_string_view; // 各种内部类型定义
|
||||
using string_type = std::string;
|
||||
using string_ref_type = const std::string&;
|
||||
|
||||
using char_ptr_type = const char*;
|
||||
using size_type = size_t;
|
||||
private:
|
||||
char_ptr_type ptr = nullptr; // 字符串指针
|
||||
size_type len = 0; // 字符串长度
|
||||
public:
|
||||
my_string_view() = default;
|
||||
~my_string_view() = default;
|
||||
|
||||
my_string_view(string_ref_type str) noexcept
|
||||
: ptr(str.data()), len(str.length())
|
||||
{}
|
||||
public:
|
||||
char_ptr_type data() const // 常函数,返回字符串指针
|
||||
{
|
||||
return ptr;
|
||||
}
|
||||
|
||||
size_type size() const // 常函数,返回字符串长度
|
||||
{
|
||||
return len;
|
||||
}
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
## 正则表达式
|
||||
|
||||
说了大半天,其实我们还是没有回答这节课开头提出的疑问,也就是“在C++里该怎么处理文本”。string只是解决了文本的表示和存储问题,要对它做大小写转换、判断前缀后缀、模式匹配查找等更复杂的处理,要如何做呢?
|
||||
|
||||
使用标准算法显然是不行的,因为算法的工作对象是容器,而刚才我就说了,字符串与容器是两个完全不同的东西,大部分算法都无法直接套用到字符串上,所以文本处理也一直是C++的“软肋”。
|
||||
|
||||
好在C++11终于在标准库里加入了正则表达式库regex(虽然有点晚),利用它的强大能力,你就能够任意操作文本、字符串。
|
||||
|
||||
很多语言都支持正则表达式,关于它的语法规则我也就不细说了(课下你可以参考下这个链接:[https://www.pcre.org/](https://www.pcre.org/)),我就重点介绍一下在C++里怎么用。
|
||||
|
||||
C++正则表达式主要有两个类。
|
||||
|
||||
- regex:表示一个正则表达式,是basic_regex的特化形式;
|
||||
- smatch:表示正则表达式的匹配结果,是match_results的特化形式。
|
||||
|
||||
C++正则匹配有三个算法,注意它们都是“只读”的,不会变动原字符串。
|
||||
|
||||
- regex_match():完全匹配一个字符串;
|
||||
- regex_search():在字符串里查找一个正则匹配;
|
||||
- regex_replace():正则查找再做替换。
|
||||
|
||||
所以,你只要用regex定义好一个表达式,然后再调用匹配算法,就可以立刻得到结果,用起来和其他语言差不多。不过,在写正则的时候,记得最好要用“原始字符串”,不然转义符绝对会把你折腾得够呛。
|
||||
|
||||
下面我举个例子:
|
||||
|
||||
```
|
||||
auto make_regex = [](const auto& txt) // 生产正则表达式
|
||||
{
|
||||
return std::regex(txt);
|
||||
};
|
||||
|
||||
auto make_match = []() // 生产正则匹配结果
|
||||
{
|
||||
return std::smatch();
|
||||
};
|
||||
|
||||
auto str = "neir:automata"s; // 待匹配的字符串
|
||||
auto reg =
|
||||
make_regex(R"(^(\w+)\:(\w+)$)"); // 原始字符串定义正则表达式
|
||||
auto what = make_match(); // 准备获取匹配的结果
|
||||
|
||||
```
|
||||
|
||||
这里我先定义了两个简单的lambda表达式,生产正则对象,主要是为了方便用auto自动类型推导。当然,同时也隐藏了具体的类型信息,将来可以随时变化(这也有点函数式编程的味道了)。
|
||||
|
||||
然后我们就可以调用regex_match()检查字符串,函数会返回bool值表示是否完全匹配正则。如果匹配成功,结果存储在what里,可以像容器那样去访问,第0号元素是整个匹配串,其他的是子表达式匹配串:
|
||||
|
||||
```
|
||||
assert(regex_match(str, what, reg)); // 正则匹配
|
||||
|
||||
for(const auto& x : what) { // for遍历匹配的子表达式
|
||||
cout << x << ',';
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
regex_search()、regex_replace()的用法也都差不多,很好理解,直接看代码吧:
|
||||
|
||||
```
|
||||
auto str = "god of war"s; // 待匹配的字符串
|
||||
|
||||
auto reg =
|
||||
make_regex(R"((\w+)\s(\w+))"); // 原始字符串定义正则表达式
|
||||
auto what = make_match(); // 准备获取匹配的结果
|
||||
|
||||
auto found = regex_search( // 正则查找,和匹配类似
|
||||
str, what, reg);
|
||||
|
||||
assert(found); // 断言找到匹配
|
||||
assert(!what.empty()); // 断言有匹配结果
|
||||
assert(what[1] == "god"); // 看第一个子表达式
|
||||
assert(what[2] == "of"); // 看第二个子表达式
|
||||
|
||||
auto new_str = regex_replace( // 正则替换,返回新字符串
|
||||
str, // 原字符串不改动
|
||||
make_regex(R"(\w+$)"), // 就地生成正则表达式对象
|
||||
"peace" // 需要指定替换的文字
|
||||
);
|
||||
|
||||
cout << new_str << endl; // 输出god of peace
|
||||
|
||||
```
|
||||
|
||||
这段代码的regex_search()搜索了两个连续的单词,然后在匹配结果里以数组下标的形式输出。
|
||||
|
||||
regex_replace()不需要匹配结果,而是要提供一个替换字符串,因为算法是“只读”的,所以它会返回修改后的新字符串。利用这一点,就可以把它的输出作为另一个函数的输入,用“函数套函数”的形式实现“函数式编程”。
|
||||
|
||||
在使用regex的时候,还要注意正则表达式的成本。因为正则串只有在运行时才会处理,检查语法、编译成正则对象的代价很高,所以**尽量不要反复创建正则对象,能重用就重用**。在使用循环的时候更要特别注意,一定要把正则提到循环体外。
|
||||
|
||||
regex库的功能非常强大,我们没有办法把方方面面的内容都涉及到,刚刚我讲的都是最实用的方法。像大小写敏感、优化匹配引擎、扩展语法、正则迭代/切分等其他高级的功能,建议你课下多努力,参考一下[GitHub](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="">
|
||||
317
极客时间专栏/罗剑锋的C++实战笔记/标准库/12 | 三分天下的容器:恰当选择,事半功倍.md
Normal file
317
极客时间专栏/罗剑锋的C++实战笔记/标准库/12 | 三分天下的容器:恰当选择,事半功倍.md
Normal 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<int, 2> arr; // 初始一个array,长度是2
|
||||
assert(arr.size() == 2); // 静态数组的长度总是2
|
||||
|
||||
vector<int> v(2); // 初始一个vector,长度是2
|
||||
for(int i = 0; i < 10; i++) {
|
||||
v.emplace_back(i); // 追加多个元素
|
||||
}
|
||||
assert(v.size() == 12); // 长度动态增长到12
|
||||
|
||||
```
|
||||
|
||||
deque也是一种可以动态增长的数组,它和vector的区别是,它可以在两端高效地插入删除元素,这也是它的名字double-end queue的来历,而vector则只能用push_back在末端追加元素。
|
||||
|
||||
```
|
||||
deque<int> 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<
|
||||
class T // 模板参数只有一个元素类型
|
||||
> class vector; // vector
|
||||
|
||||
template<
|
||||
class Key, // 模板参数是key类型,即元素类型
|
||||
class Compare = std::less<Key> // 比较函数
|
||||
> class set; // 集合
|
||||
|
||||
template<
|
||||
class Key, // 第一个模板参数是key类型
|
||||
class T, // 第二个模板参数是元素类型
|
||||
class Compare = std::less<Key> // 比较函数
|
||||
> class map; // 关联数组
|
||||
|
||||
```
|
||||
|
||||
C++里的int、string等基本类型都支持比较排序,放进有序容器里毫无问题。但很多自定义类型没有默认的比较函数,要作为容器的key就有点麻烦。虽然这种情况不多见,但有的时候还真是个“刚性需求”。
|
||||
|
||||
**解决这个问题有两种办法:一个是重载“<”,另一个是自定义模板参数**。
|
||||
|
||||
比如说我们有一个Point类,它是没有大小概念的,但只要给它重载“<”操作符,就可以放进有序容器里了:
|
||||
|
||||
```
|
||||
bool operator<(const Point& a, const Point& b)
|
||||
{
|
||||
return a.x < b.x; // 自定义比较运算
|
||||
}
|
||||
|
||||
set<Point> s; // 现在就可以正确地放入有序容器
|
||||
s.emplace(7);
|
||||
s.emplace(3);
|
||||
|
||||
```
|
||||
|
||||
另一种方式是编写专门的函数对象或者lambda表达式,然后在容器的模板参数里指定。这种方式更灵活,而且可以实现任意的排序准则:
|
||||
|
||||
```
|
||||
set<int> s = {7, 3, 9}; // 定义集合并初始化3个元素
|
||||
|
||||
for(auto& x : s) { // 范围循环输出元素
|
||||
cout << x << ","; // 从小到大排序,3,7,9
|
||||
}
|
||||
|
||||
auto comp = [](auto a, auto b) // 定义一个lambda,用来比较大小
|
||||
{
|
||||
return a > b; // 定义大于关系
|
||||
};
|
||||
|
||||
set<int, decltype(comp)> gs(comp) // 使用decltype得到lambda的类型
|
||||
|
||||
std::copy(begin(s), end(s), // 拷贝算法,拷贝数据
|
||||
inserter(gs, gs.end())); // 使用插入迭代器
|
||||
|
||||
for(auto& x : gs) { // 范围循环输出元素
|
||||
cout << x << ","; // 从大到小排序,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<int, string>; // 使用无序关联数组
|
||||
|
||||
map_type dict; // 定义一个无序关联数组
|
||||
|
||||
dict[1] = "one"; // 添加三个元素
|
||||
dict.emplace(2, "two");
|
||||
dict[10] = "ten";
|
||||
|
||||
for(auto& x : dict) { // 遍历输出
|
||||
cout << x.first << "=>" // 顺序不确定
|
||||
<< x.second << ","; // 既不是插入顺序,也不是大小序
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
无序容器虽然不要求顺序,但是对key的要求反而比有序容器更“苛刻”一些,拿unordered_map的声明来看一下:
|
||||
|
||||
```
|
||||
template<
|
||||
class Key, // 第一个模板参数是key类型
|
||||
class T, // 第二个模板参数是元素类型
|
||||
class Hash = std::hash<Key>, // 计算散列值的函数对象
|
||||
class KeyEqual = std::equal_to<Key> // 相等比较函数
|
||||
> class unordered_map;
|
||||
|
||||
```
|
||||
|
||||
它要求key具备两个条件,一是**可以计算hash值**,二是**能够执行相等比较操作**。第一个是因为散列表的要求,只有计算hash值才能放入散列表,第二个则是因为hash值可能会冲突,所以当hash值相同时,就要比较真正的key值。
|
||||
|
||||
与有序容器一样,要把自定义类型作为key放入无序容器,必须要实现这两个函数。
|
||||
|
||||
“==”函数比较简单,可以用与“<”函数类似的方式,通过重载操作符来实现:
|
||||
|
||||
```
|
||||
bool operator==(const Point& a, const Point& b)
|
||||
{
|
||||
return a.x == b.x; // 自定义相等比较运算
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
散列函数就略麻烦一点,你可以用函数对象或者lambda表达式实现,内部最好调用标准的std::hash函数对象,而不要自己直接计算,否则很容易造成hash冲突:
|
||||
|
||||
```
|
||||
auto hasher = [](const auto& p) // 定义一个lambda表达式
|
||||
{
|
||||
return std::hash<int>()(p.x); // 调用标准hash函数对象计算
|
||||
};
|
||||
|
||||
|
||||
```
|
||||
|
||||
有了相等函数和散列函数,自定义类型也就可以放进无序容器了:
|
||||
|
||||
```
|
||||
unordered_set<Point, decltype(hasher)> 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="">
|
||||
384
极客时间专栏/罗剑锋的C++实战笔记/标准库/13 | 五花八门的算法:不要再手写for循环了.md
Normal file
384
极客时间专栏/罗剑锋的C++实战笔记/标准库/13 | 五花八门的算法:不要再手写for循环了.md
Normal 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<int> 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 > 2; // 判断条件
|
||||
}
|
||||
); // 大函数里面套了三个小函数
|
||||
|
||||
```
|
||||
|
||||
## 认识迭代器
|
||||
|
||||
在详细介绍算法之前,还有一个必须要了解的概念,那就是迭代器(iterator),它相当于算法的“手脚”。
|
||||
|
||||
虽然刚才我说算法操作容器,但实际上它看到的并不是容器,而是指向起始位置和结束位置的迭代器,算法只能通过迭代器去“**间接**”访问容器以及元素,算法的能力是由迭代器决定的。
|
||||
|
||||
这种间接的方式有什么好处呢?
|
||||
|
||||
这就是泛型编程的理念,与面向对象正好相反,**分离了数据和操作**。算法可以不关心容器的内部结构,以一致的方式去操作元素,适用范围更广,用起来也更灵活。
|
||||
|
||||
当然万事无绝对,这种方式也有弊端。因为算法是通用的,免不了对有的数据结构虽然可行但效率比较低。所以,对于merge、sort、unique等一些特别的算法,容器就提供了专门的替代成员函数(相当于特化),这个稍后我会再提一下。
|
||||
|
||||
C++里的迭代器也有很多种,比如输入迭代器、输出迭代器、双向迭代器、随机访问迭代器,等等,概念解释起来不太容易。不过,你也没有必要把它们搞得太清楚,因为常用的迭代器用法都是差不多的。你可以把它简单地理解为另一种形式的“智能指针”,只是它**强调的是对数据的访问**,而不是生命周期管理。
|
||||
|
||||
容器一般都会提供begin()、end()成员函数,调用它们就可以得到表示两个端点的迭代器,具体类型最好用auto自动推导,不要过分关心:
|
||||
|
||||
```
|
||||
vector<int> 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<int, 5> 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<int> v = {3,5,1,7,10}; // vector容器
|
||||
|
||||
for(const auto& x : v) { // range for循环
|
||||
cout << x << ",";
|
||||
}
|
||||
|
||||
auto print = [](const auto& x) // 定义一个lambda表达式
|
||||
{
|
||||
cout << x << ",";
|
||||
};
|
||||
for_each(cbegin(v), cend(v), print);// for_each算法
|
||||
|
||||
for_each( // for_each算法,内部定义lambda表达式
|
||||
cbegin(v), cend(v), // 获取常量迭代器
|
||||
[](const auto& x) // 匿名lambda表达式
|
||||
{
|
||||
cout << x << ",";
|
||||
}
|
||||
);
|
||||
|
||||
```
|
||||
|
||||
初看上去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& x) // lambda表达式输出元素
|
||||
{
|
||||
cout << x << ",";
|
||||
};
|
||||
|
||||
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 << "median is " << *mid_iter << endl;
|
||||
|
||||
// partition
|
||||
auto pos = std::partition( // 找出所有大于9的数
|
||||
begin(v), end(v),
|
||||
[](const auto& x) // 定义一个lambda表达式
|
||||
{
|
||||
return x > 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<int> 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( // 找到第一个>=7的位置
|
||||
cbegin(v), cend(v), 7
|
||||
);
|
||||
found = (pos != cend(v)) && (*pos == 7); // 可能找不到,所以必须要判断
|
||||
assert(found); // 7在容器里
|
||||
|
||||
pos = std::lower_bound( // 找到第一个>=9的位置
|
||||
cbegin(v), cend(v), 9
|
||||
);
|
||||
found = (pos != cend(v)) && (*pos == 9); // 可能找不到,所以必须要判断
|
||||
assert(!found); // 9不在容器里
|
||||
|
||||
```
|
||||
|
||||
lower_bound的返回值是一个迭代器,所以就要做一点判断工作,才能知道是否真的找到了。判断的条件有两个,一个是迭代器是否有效,另一个是迭代器的值是不是要找的值。
|
||||
|
||||
注意lower_bound的查找条件是“**大于等于**”,而不是“等于”,所以它的真正含义是“大于等于值的第一个位置”。相应的也就有“大于等于值的最后一个位置”,算法叫upper_bound,返回的是第一个“**大于**”值的元素。
|
||||
|
||||
```
|
||||
pos = std::upper_bound( // 找到第一个>9的位置
|
||||
cbegin(v), cend(v), 9
|
||||
);
|
||||
|
||||
```
|
||||
|
||||
因为这两个算法不是简单的判断相等,作用有点“绕”,不太好掌握,我来给你解释一下。
|
||||
|
||||
它俩的返回值构成一个区间,这个区间往前就是所有比被查找值小的元素,往后就是所有比被查找值大的元素,可以写成一个简单的不等式:
|
||||
|
||||
```
|
||||
begin < x <= lower_bound < upper_bound < end
|
||||
|
||||
```
|
||||
|
||||
比如,在刚才的这个例子里,对数字9执行lower_bound和upper_bound,就会返回[10,10]这样的区间。
|
||||
|
||||
对于有序容器set/map,就不需要调用这三个算法了,它们有等价的成员函数find/lower_bound/upper_bound,效果是一样的。
|
||||
|
||||
不过,你要注意find与binary_search不同,它的返回值不是bool而是迭代器,可以参考下面的示例代码:
|
||||
|
||||
```
|
||||
multiset<int> 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<int> 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<int, 2> 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="">
|
||||
301
极客时间专栏/罗剑锋的C++实战笔记/标准库/14 | 十面埋伏的并发:多线程真的很难吗?.md
Normal file
301
极客时间专栏/罗剑锋的C++实战笔记/标准库/14 | 十面埋伏的并发:多线程真的很难吗?.md
Normal 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 << "tid=" <<
|
||||
this_thread::get_id() << 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 << "only once" << 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 = [&](int x) // 在线程里运行的lambda表达式,捕获引用
|
||||
{
|
||||
n += x; // 使用线程局部变量,互不影响
|
||||
cout << 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<bool>; // 原子化的bool
|
||||
using atomic_int = std::atomic<int>; // 原子化的int
|
||||
using atomic_long = std::atomic<long>; // 原子化的long
|
||||
|
||||
```
|
||||
|
||||
这些原子变量都是模板类atomic的特化形式,包装了原始的类型,具有相同的接口,用起来和bool、int几乎一模一样,但却是原子化的,多线程读写不会出错。
|
||||
|
||||
注意,我说了“几乎”这个词。它还是有些不同的,一个重要的区别是,**原子变量禁用了拷贝构造函数,所以在初始化的时候不能用“=”的赋值形式,只能用圆括号或者花括号**:
|
||||
|
||||
```
|
||||
atomic_int x {0}; // 初始化,不能用=
|
||||
atomic_long y {1000L}; // 初始化,只能用圆括号或者花括号
|
||||
|
||||
assert(++x == 1); // 自增运算
|
||||
|
||||
y += 200; // 加法运算
|
||||
assert(y < 2000); // 比较运算
|
||||
|
||||
```
|
||||
|
||||
除了模拟整数运算,原子变量还有一些特殊的原子操作,比如store、load、fetch_add、fetch_sub、exchange、compare_exchange_weak/compare_exchange_strong,最后一组就是著名的CAS(Compare And Swap)操作。
|
||||
|
||||
而另一个同样著名的TAS(Test 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 = [&]() // 在线程里运行的lambda表达式,捕获引用
|
||||
{
|
||||
auto value = flag.test_and_set(); // TAS检查原子标志量
|
||||
|
||||
if (value) {
|
||||
cout << "flag has been set." << endl;
|
||||
} else {
|
||||
cout << "set flag by " <<
|
||||
this_thread::get_id() << endl; // 输出线程id
|
||||
}
|
||||
|
||||
n += 100; // 原子变量加法运算
|
||||
|
||||
this_thread::sleep_for( // 线程睡眠
|
||||
n.load() * 10ms); // 使用时间字面量
|
||||
cout << n << 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 << "sleep for " << x << endl;
|
||||
return x;
|
||||
};
|
||||
|
||||
auto f = std::async(task, 10); // 启动一个异步任务
|
||||
f.wait(); // 等待任务完成
|
||||
|
||||
assert(f.valid()); // 确实已经完成了任务
|
||||
cout << f.get() << 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="">
|
||||
145
极客时间专栏/罗剑锋的C++实战笔记/概论/01 | 重新认识C++:生命周期和编程范式.md
Normal file
145
极客时间专栏/罗剑锋的C++实战笔记/概论/01 | 重新认识C++:生命周期和编程范式.md
Normal 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="">
|
||||
232
极客时间专栏/罗剑锋的C++实战笔记/概论/02 | 编码阶段能做什么:秀出好的code style.md
Normal file
232
极客时间专栏/罗剑锋的C++实战笔记/概论/02 | 编码阶段能做什么:秀出好的code style.md
Normal 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("xxx")){
|
||||
LOGIT(WARNING,"value is incomplete.\n")
|
||||
return;
|
||||
}
|
||||
char suffix[16]="xxx";
|
||||
int data_len = 100;
|
||||
if(!value.empty()&&value.contains("tom")){
|
||||
const char* name=value.c_str();
|
||||
for(int i=0;i<MAX_LEN;i++){
|
||||
... // do something
|
||||
}
|
||||
int count=0;
|
||||
for(int i=0;i<strlen(name);i++){
|
||||
... // do something
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这段代码真可谓是“高密度”,密密麻麻一大堆,看着“有如滔滔江水,连绵不绝”,读起来让人“窒息”,code style非常糟糕。
|
||||
|
||||
应用“留白的艺术”,代码就变成了下面的样子:
|
||||
|
||||
```
|
||||
if (!value.contains("xxx")) { // if后{前有空格
|
||||
LOGIT(WARNING, "value is incomplete.\n") // 逗号后有空格
|
||||
return; // 逻辑联系紧密就不用加空行
|
||||
}
|
||||
// 新增空行分隔段落
|
||||
char suffix[16] = "xxx"; // 等号两边有空格
|
||||
int data_len = 100; // 逻辑联系紧密就不用加空行
|
||||
// 新增空行分隔段落
|
||||
if (!value.empty() && value.contains("tom")) { // &&两边有空格
|
||||
const char* name = value.c_str(); // 等号两边有空格
|
||||
// 新增空行分隔段落
|
||||
for(int i = 0; i < MAX_LEN; i++){ // =;<处有空格
|
||||
... // do something
|
||||
}
|
||||
// 新增空行分隔段落
|
||||
int count = 0; // 等号两边有空格
|
||||
// 新增空行分隔段落
|
||||
for(int i = 0; i < strlen(name); i++){ // =;<处有空格
|
||||
... // 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& str); // 函数,全小写
|
||||
private:
|
||||
string m_path; // 成员变量,m_前缀
|
||||
int m_level; // 成员变量,m_前缀
|
||||
};
|
||||
|
||||
|
||||
|
||||
```
|
||||
|
||||
命名另一个相关的问题是“名字的长度”,有人喜欢写得长,有人喜欢写得短,我觉得都可以,只要易读易写就行。
|
||||
|
||||
不过一个被普遍认可的原则是:**变量/函数的名字长度与它的作用域成正比**,也就是说,局部变量/函数名可以短一点,而全局变量/函数名应该长一点。
|
||||
|
||||
想一下,如果你辛辛苦苦起了一个包含四五个单词的长名字,却只能在短短十几行的循环体里使用,岂不是太浪费了?
|
||||
|
||||
## 用好注释
|
||||
|
||||
写出了有好名字的变量、函数和类还不够,要让其他人能“一眼”看懂代码,还需要加上注释。
|
||||
|
||||
“注释”在任何编程语言里都是一项非常重要的功能,甚至在编程语言之外,比如配置文件(ini、yml)、标记语言(html、xml),都有注释。一个突出的反例就是JSON,没有注释功能让许多人都很不适应。
|
||||
|
||||
注释表面上的功能很简单,就是给代码配上额外的文字,起到注解、补充说明的作用。但就像是写文章一样,写些什么、写多写少、写成什么样子,都是大有讲究的。
|
||||
|
||||
你可能也有不少写注释的经验了,一般来说,注释可以用来阐述目的、用途、工作原理、注意事项等代码本身无法“自说明”的那些东西。
|
||||
|
||||
但要小心,注释必须要正确、清晰、有效,尽量言简意赅、点到为止,不要画蛇添足,更不能写出含糊、错误的注释。
|
||||
|
||||
比如说,有这么一个模板函数get_value:
|
||||
|
||||
```
|
||||
template<typename T>
|
||||
int get_value(const T& 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<typename T>
|
||||
int get_value(const T& 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="">
|
||||
297
极客时间专栏/罗剑锋的C++实战笔记/概论/03 | 预处理阶段能做什么:宏定义和条件编译.md
Normal file
297
极客时间专栏/罗剑锋的C++实战笔记/概论/03 | 预处理阶段能做什么:宏定义和条件编译.md
Normal 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 "a.out" // 完全合法的预处理包含指令,你可以试试
|
||||
|
||||
```
|
||||
|
||||
可以看到,“#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 "calc_values.inc" // 非常大的一个数组,细节被隐藏
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
## 宏定义(#define/#undef)
|
||||
|
||||
接下来要说的是预处理编程里最重要、最核心的指令“#define”,它用来定义一个源码级别的“**文本替换**”,也就是我们常说的“**宏定义**”。
|
||||
|
||||
“#define”可谓“无所不能”,在预处理阶段可以无视C++语法限制,替换任何文字,定义常量/变量,实现函数功能,为类型起别名(typedef),减少重复代码……
|
||||
|
||||
不过,也正是因为它太灵活,如果过于随意地去使用宏来写程序,就有可能把正常的C++代码搞得“千疮百孔”,替换来替换去,都不知道真正有效的代码是什么样子了。
|
||||
|
||||
所以,**使用宏的时候一定要谨慎,时刻记着以简化代码、清晰易懂为目标,不要“滥用”,避免导致源码混乱不堪,降低可读性。**
|
||||
|
||||
下面,我就说几个注意事项,帮你用好宏定义。
|
||||
|
||||
首先,因为宏的展开、替换发生在预处理阶段,不涉及函数调用、参数传递、指针寻址,没有任何运行期的效率损失,所以对于一些调用频繁的小代码片段来说,用宏来封装的效果比inline关键字要更好,因为它真的是源码级别的无条件内联。
|
||||
|
||||
下面有几个示例,摘自Nginx,你可以作为参考:
|
||||
|
||||
```
|
||||
#define ngx_tolower(c) ((c >= 'A' && c <= 'Z') ? (c | 0x20) : c)
|
||||
#define ngx_toupper(c) ((c >= 'a' && c <= 'z') ? (c & ~0x20) : c)
|
||||
|
||||
#define ngx_memzero(buf, n) (void) memset(buf, 0, n)
|
||||
|
||||
```
|
||||
|
||||
其次,你要知道,**宏是没有作用域概念的,永远是全局生效**。所以,对于一些用来简化代码、起临时作用的宏,最好是用完后尽快用“#undef”取消定义,避免冲突的风险。像下面这样:
|
||||
|
||||
```
|
||||
#define CUBE(a) (a) * (a) * (a) // 定义一个简单的求立方的宏
|
||||
|
||||
cout << CUBE(10) << endl; // 使用宏简化代码
|
||||
cout << CUBE(15) << endl; // 使用宏简化代码
|
||||
|
||||
#undef CUBE // 使用完毕后立即取消定义
|
||||
|
||||
```
|
||||
|
||||
另一种做法是**宏定义前先检查**,如果之前有定义就先undef,然后再重新定义:
|
||||
|
||||
```
|
||||
#ifdef AUTH_PWD // 检查是否已经有宏定义
|
||||
# undef AUTH_PWD // 取消宏定义
|
||||
#endif // 宏定义检查结束
|
||||
#define AUTH_PWD "xxx" // 重新宏定义
|
||||
|
||||
```
|
||||
|
||||
再次,你可以适当使用宏来定义代码中的常量,消除“魔术数字”“魔术字符串”(magic number)。
|
||||
|
||||
虽然不少人认为,定义常量更应该使用enum或者const,但我觉得宏定义毕竟用法简单,也是源码级的真正常量,而且还是从C继承下来的传统,用在头文件里还是有些优势的。
|
||||
|
||||
这种用法非常普遍,你可能也经常用,我就简单举两个例子吧:
|
||||
|
||||
```
|
||||
#define MAX_BUF_LEN 65535
|
||||
#define VERSION "1.0.18"
|
||||
|
||||
```
|
||||
|
||||
不过你要注意,关键是要“适当”,自己把握好分寸,不要把宏弄得“满天飞”。
|
||||
|
||||
除了上面说的三个,如果你开动脑筋,用好“文本替换”的功能,也能发掘出许多新颖的用法。我有一个比较实际的例子,用宏来代替直接定义名字空间(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 "C" { // 函数按照C的方式去处理
|
||||
#endif
|
||||
void a_c_function(int a);
|
||||
#ifdef __cplusplus // 检查是否是C++编译
|
||||
} // extern "C" 结束
|
||||
#endif
|
||||
|
||||
#if __cplusplus >= 201402 // 检查C++标准的版本号
|
||||
cout << "c++14 or later" << endl; // 201402就是C++14
|
||||
#elif __cplusplus >= 201103 // 检查C++标准的版本号
|
||||
cout << "c++11 or before" << endl; // 201103是C++11
|
||||
#else // __cplusplus < 201103 // 199711是C++98
|
||||
# error "c++ is too old" // 太低则预处理报错
|
||||
#endif // __cplusplus >= 201402 // 预处理语句结束
|
||||
|
||||
```
|
||||
|
||||
除了“__cplusplus”,C++里还有很多其他预定义的宏,像源文件信息的“**FILE**”“ **LINE**”“ **DATE**”,以及一些语言特性测试宏,比如“__cpp_decltype” “__cpp_decltype_auto” “__cpp_lib_make_unique”等。
|
||||
|
||||
不过,与优化更密切相关的底层系统信息在C++语言标准里没有定义,但编译器通常都会提供,比如GCC可以使用一条简单的命令查看:
|
||||
|
||||
```
|
||||
g++ -E -dM - < /dev/null
|
||||
|
||||
#define __GNUC__ 5
|
||||
#define __unix__ 1
|
||||
#define __x86_64__ 1
|
||||
#define __UINT64_MAX__ 0xffffffffffffffffUL
|
||||
...
|
||||
|
||||
|
||||
```
|
||||
|
||||
基于它们,你就可以更精细地根据具体的语言、编译器、系统特性来改变源码,有,就用新特性;没有,就采用变通实现:
|
||||
|
||||
```
|
||||
#if defined(__cpp_decltype_auto) //检查是否支持decltype(auto)
|
||||
cout << "decltype(auto) enable" << endl;
|
||||
#else
|
||||
cout << "decltype(auto) disable" << endl;
|
||||
#endif //__cpp_decltype_auto
|
||||
|
||||
#if __GNUC__ <= 4
|
||||
cout << "gcc is too old" << endl;
|
||||
#else // __GNUC__ > 4
|
||||
cout << "gcc is good enough" << endl;
|
||||
#endif // __GNUC__ <= 4
|
||||
|
||||
#if defined(__SSE4_2__) && defined(__x86_64)
|
||||
cout << "we can do more optimization" << endl;
|
||||
#endif // defined(__SSE4_2__) && defined(__x86_64)
|
||||
|
||||
|
||||
```
|
||||
|
||||
除了这些内置宏,你也可以用其他手段自己定义更多的宏来实现条件编译。比如,Nginx就使用Shell脚本检测外部环境,生成一个包含若干宏的源码配置文件,再条件编译包含不同的头文件,实现操作系统定制化:
|
||||
|
||||
```
|
||||
#if (NGX_FREEBSD)
|
||||
# include <ngx_freebsd.h>
|
||||
|
||||
#elif (NGX_LINUX)
|
||||
# include <ngx_linux.h>
|
||||
|
||||
#elif (NGX_SOLARIS)
|
||||
# include <ngx_solaris.h>
|
||||
|
||||
#elif (NGX_DARWIN)
|
||||
# include <ngx_darwin.h>
|
||||
#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="">
|
||||
231
极客时间专栏/罗剑锋的C++实战笔记/概论/04 | 编译阶段能做什么:属性和静态断言.md
Normal file
231
极客时间专栏/罗剑锋的C++实战笔记/概论/04 | 编译阶段能做什么:属性和静态断言.md
Normal 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<int N>
|
||||
struct fib // 递归计算斐波那契数列
|
||||
{
|
||||
static const int value =
|
||||
fib<N - 1>::value + fib<N - 2>::value;
|
||||
};
|
||||
|
||||
template<>
|
||||
struct fib<0> // 模板特化计算fib<0>
|
||||
{
|
||||
static const int value = 1;
|
||||
};
|
||||
|
||||
template<>
|
||||
struct fib<1> // 模板特化计算fib<1>
|
||||
{
|
||||
static const int value = 1;
|
||||
};
|
||||
|
||||
// 调用后输出2,3,5,8
|
||||
cout << fib<2>::value << endl;
|
||||
cout << fib<3>::value << endl;
|
||||
cout << fib<4>::value << endl;
|
||||
cout << fib<5>::value << 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("XXX");
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
不过,在C++11里只定义了两个属性:“noreturn”和“carries_dependency”,它们基本上没什么大用处。
|
||||
|
||||
C++14的情况略微好了点,增加了一个比较实用的属性“deprecated”,用来标记不推荐使用的变量、函数或者类,也就是被“废弃”。
|
||||
|
||||
比如说,你原来写了一个函数old_func(),后来觉得不够好,就另外重写了一个完全不同的新函数。但是,那个老函数已经发布出去被不少人用了,立即删除不太可能,该怎么办呢?
|
||||
|
||||
这个时候,你就可以让“属性”发挥威力了。你可以给函数加上一个“deprecated”的编译期标签,再加上一些说明文字:
|
||||
|
||||
```
|
||||
[[deprecated("deadline:2020-12-31")]] // 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 > 0 && "i must be greater than zero");
|
||||
assert(p != nullptr);
|
||||
assert(!str.empty());
|
||||
|
||||
```
|
||||
|
||||
当程序(也就是CPU)运行到assert语句时,就会计算表达式的值,如果是false,就会输出错误消息,然后调用abort()终止程序的执行。
|
||||
|
||||
注意,assert虽然是一个宏,但在预处理阶段不生效,而是在运行阶段才起作用,所以又叫“**动态断言**”。
|
||||
|
||||
有了“动态断言”,那么相应的也就有“静态断言”,名字也很像,叫“**static_assert**”,不过它是一个专门的关键字,而不是宏。因为它只在编译时生效,运行阶段看不见,所以是“静态”的。
|
||||
|
||||
“静态断言”有什么用呢?
|
||||
|
||||
类比一下assert,你就可以理解了。它是编译阶段里检测各种条件的“断言”,编译器看到static_assert也会计算表达式的值,如果值是false,就会报错,导致编译失败。
|
||||
|
||||
比如说,这节课刚开始时的斐波拉契数列计算函数,可以用静态断言来保证模板参数必须大于等于零:
|
||||
|
||||
```
|
||||
template<int N>
|
||||
struct fib
|
||||
{
|
||||
static_assert(N >= 0, "N >= 0");
|
||||
|
||||
static const int value =
|
||||
fib<N - 1>::value + fib<N - 2>::value;
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
再比如说,要想保证我们的程序只在64位系统上运行,可以用静态断言在编译阶段检查long的大小,必须是8个字节(当然,你也可以换个思路用预处理编程来实现)。
|
||||
|
||||
```
|
||||
static_assert(
|
||||
sizeof(long) >= 8, "must run on x64");
|
||||
|
||||
static_assert(
|
||||
sizeof(int) == 4, "int must be 32bit");
|
||||
|
||||
```
|
||||
|
||||
这里你一定要注意,static_assert运行在编译阶段,只能看到编译时的常数和类型,看不到运行时的变量、指针、内存数据等,是“静态”的,所以不要简单地把assert的习惯搬过来用。
|
||||
|
||||
比如,下面的代码想检查空指针,由于变量只能在运行阶段出现,而在编译阶段不存在,所以静态断言无法处理。
|
||||
|
||||
```
|
||||
char* p = nullptr;
|
||||
static_assert(p == nullptr, "some error."); // 错误用法
|
||||
|
||||
```
|
||||
|
||||
说到这儿,你大概对static_assert的“编译计算”有点感性认识了吧。在用“静态断言”的时候,你就要在脑子里时刻“绷紧一根弦”,把自己代入编译器的角色,**像编译器那样去思考**,看看断言的表达式是不是能够在编译阶段算出结果。
|
||||
|
||||
不过这句话说起来容易做起来难,计算数字还好说,在泛型编程的时候,怎么检查模板类型呢?比如说,断言是整数而不是浮点数、断言是指针而不是引用、断言类型可拷贝可移动……
|
||||
|
||||
这些检查条件表面上看好像是“不言自明”的,但要把它们用C++语言给精确地表述出来,可就没那么简单了。所以,想要更好地发挥静态断言的威力,还要配合标准库里的“type_traits”,它提供了对应这些概念的各种编译期“函数”。
|
||||
|
||||
```
|
||||
// 假设T是一个模板参数,即template<typename T>
|
||||
|
||||
static_assert(
|
||||
is_integral<T>::value, "int");
|
||||
|
||||
static_assert(
|
||||
is_pointer<T>::value, "ptr");
|
||||
|
||||
static_assert(
|
||||
is_default_constructible<T>::value, "constructible");
|
||||
|
||||
|
||||
```
|
||||
|
||||
你可能看到了,“static_assert”里的表达式样子很奇怪,既有模板符号“<>”,又有作用域符号“::”,与运行阶段的普通表达式大相径庭,初次见到这样的代码一定会吓一跳。
|
||||
|
||||
这也是没有办法的事情。因为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="">
|
||||
253
极客时间专栏/罗剑锋的C++实战笔记/概论/05 | 面向对象编程:怎样才能写出一个“好”的类?.md
Normal file
253
极客时间专栏/罗剑锋的C++实战笔记/概论/05 | 面向对象编程:怎样才能写出一个“好”的类?.md
Normal 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&) = delete; // 禁止拷贝构造
|
||||
DemoClass& operator=(const DemoClass&) = delete; // 禁止拷贝赋值
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
因为C++有隐式构造和隐式转型的规则,如果你的类里有单参数的构造函数,或者是转型操作符函数,为了防止意外的类型转换,保证安全,就要使用“**explicit**”将这些函数标记为“显式”。
|
||||
|
||||
```
|
||||
class DemoClass final
|
||||
{
|
||||
public:
|
||||
explicit DemoClass(const string_type& 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& s) : // 字符串参数构造函数
|
||||
DemoDelegating(stoi(s)) // 转换成整数,再委托给第一个构造函数
|
||||
{}
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
第二个是“**成员变量初始化**”(In-class member initializer)。
|
||||
|
||||
如果你的类有很多成员变量,那么在写构造函数的时候就比较麻烦,必须写出一长串的名字来逐个初始化,不仅不美观,更危险的是,容易“手抖”,遗漏成员,造成未初始化的隐患。
|
||||
|
||||
而在C++11里,你可以在类里声明变量的同时给它赋值,实现初始化,这样**不但简单清晰,也消除了隐患。**
|
||||
|
||||
```
|
||||
class DemoInit final // 有很多成员变量的类
|
||||
{
|
||||
private:
|
||||
int a = 0; // 整数成员,赋值初始化
|
||||
string s = "hello"; // 字符串成员,赋值初始化
|
||||
vector<int> 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<int>; // 集合类型别名
|
||||
using vector_type = std::vector<std::string>;// 容器类型别名
|
||||
|
||||
private:
|
||||
string_type m_name = "tom"; // 使用类型别名声明变量
|
||||
uint32_type m_age = 23; // 使用类型别名声明变量
|
||||
set_type m_books; // 使用类型别名声明变量
|
||||
|
||||
private:
|
||||
kafka_conf_type m_conf; // 使用类型别名声明变量
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
类型别名不仅能够让代码规范整齐,而且因为引入了这个“语法层面的宏定义”,将来在维护时还可以随意改换成其他的类型。比如,把字符串改成string_view(C++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="">
|
||||
85
极客时间专栏/罗剑锋的C++实战笔记/结束语/结束语 | 路远,未有穷期.md
Normal file
85
极客时间专栏/罗剑锋的C++实战笔记/结束语/结束语 | 路远,未有穷期.md
Normal 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++已经走过了四十多年,可以说是处于“不惑之年”了。在如今的编程语言界,和Java(1995)、Go(2009)、Rust(2010)等比起来,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),题目不多,希望你能抽出几分钟时间填写一下。我非常愿意听听你对这个课程的反馈和建议,你可以在问卷中畅所欲言。
|
||||
|
||||
好了,临别之际,在你上马、踏上新的征途之前,我再送你一句“老话”吧:
|
||||
|
||||
>
|
||||
一个人写出一个好程序不难,难的是一辈子只写好程序,不写坏程序。
|
||||
|
||||
|
||||
路远,未有穷期,期待我们的下次再会!
|
||||
267
极客时间专栏/罗剑锋的C++实战笔记/语言特性/06 | auto|decltype:为什么要有自动类型推导?.md
Normal file
267
极客时间专栏/罗剑锋的C++实战笔记/语言特性/06 | auto|decltype:为什么要有自动类型推导?.md
Normal 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 Stroustrup(B·S )早在C++诞生之初就设计并实现了它,但因为与早期C语言的语义有冲突,所以被“雪藏”了近三十年。直到C99消除了兼容性问题,C++11才让它再度登场亮相。
|
||||
|
||||
那为什么要重新引入这个“老特性”呢?为什么非要有“自动类型推导”呢?
|
||||
|
||||
我觉得,你可以先从字面上去理解,把这个词分解成三个部分:“自动”“类型”和“推导”。
|
||||
|
||||
- “自动”就是让计算机去做,而不是人去做,相对的是“手动”。
|
||||
- “类型”指的是操作目标,出来的是编译阶段的类型,而不是数值。
|
||||
- “推导”就是演算、运算,把隐含的值给算出来。
|
||||
|
||||
好,我们来看一看“自动类型推导”之外的其他几种排列组合,通过对比的方式来帮你理解它。
|
||||
|
||||
像计算“a = 1 + 1”,你可以在写代码的时候直接填上2,这就是“手动数值推导”。你也可以“偷懒”,只写上表达式,让电脑在运行时自己算,这就是“自动数值推导”。
|
||||
|
||||
“数值推导”对于人和计算机来说都不算什么难事,所以手动和自动的区别不大,只有快慢的差异。但“类型推导”就不同了。
|
||||
|
||||
因为C++是一种静态强类型的语言,任何变量都要有一个确定的类型,否则就不能用。在“自动类型推导”出现之前,我们写代码时只能“手动推导”,也就是说,在声明变量的时候,必须要明确地给出类型。
|
||||
|
||||
这在变量类型简单的时候还好说,比如int、double,但在泛型编程的时候,麻烦就来了。因为泛型编程里会有很多模板参数,有的类型还有内部子类型,一下子就把C++原本简洁的类型体系给搞复杂了,这就迫使我们去和编译器“斗智斗勇”,只有写对了类型,编译器才会“放行”(编译通过)。
|
||||
|
||||
```
|
||||
int i = 0; // 整数变量,类型很容易知道
|
||||
double x = 1.0; // 浮点数变量,类型很容易知道
|
||||
|
||||
std::string str = "hello"; // 字符串变量,有了名字空间,麻烦了一点
|
||||
|
||||
std::map<int, std::string> m = // 关联数组,名字空间加模板参数,很麻烦
|
||||
{{1,"a"}, {2,"b"}}; // 使用初始化列表的形式
|
||||
|
||||
std::map<int, std::string>::const_iterator // 内部子类型,超级麻烦
|
||||
iter = m.begin();
|
||||
|
||||
??? = bind1st(std::less<int>(), 2); // 根本写不出来
|
||||
|
||||
```
|
||||
|
||||
虽然你可以用typedef或者using来简化类型名,部分减轻打字的负担,但关键的“手动推导”问题还是没有得到解决,还是要去翻看类型定义,找到正确的声明。这时,C++的静态强类型的优势反而成为了劣势,阻碍了程序员的工作,降低了开发效率。
|
||||
|
||||
其实编译器是知道(而且也必须知道)这些类型的,但它却没有办法直接告诉你,这就很尴尬了。一边是急切地想知道答案,而另一边却只给判个对错,至于怎么错了、什么是正确答案,“打死了也不说”。
|
||||
|
||||
但有了“自动类型推导”,问题就迎刃而解了。这就像是在编译器紧闭的大门上开了道小口子,你跟它说一声,它就递过来张小纸条,具体是什么不重要,重要的是里面存了我们想要的类型。
|
||||
|
||||
这个“小口子”就是关键字**auto**,在代码里的作用像是个“占位符”(placeholder)。写上它,你就可以让编译器去自动“填上”正确的类型,既省力又省心。
|
||||
|
||||
```
|
||||
auto i = 0; // 自动推导为int类型
|
||||
auto x = 1.0; // 自动推导为double类型
|
||||
|
||||
auto str = "hello"; // 自动推导为const char [6]类型
|
||||
|
||||
std::map<int, std::string> m = {{1,"a"}, {2,"b"}}; // 自动推导不出来
|
||||
|
||||
auto iter = m.begin(); // 自动推导为map内部的迭代器类型
|
||||
|
||||
auto f = bind1st(std::less<int>(), 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 = &x; // 自动推导为long*
|
||||
auto z {&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、*、&这样的类型修饰符,得到新的类型。**
|
||||
|
||||
下面我举几个例子,你一看就能明白:
|
||||
|
||||
```
|
||||
auto x = 10L; // auto推导为long,x是long
|
||||
|
||||
auto& x1 = x; // auto推导为long,x1是long&
|
||||
auto* x2 = &x; // auto推导为long,x2是long*
|
||||
const auto& x3 = x; // auto推导为long,x3是const long&
|
||||
auto x4 = &x3; // auto推导为const long*,x4是const long*
|
||||
|
||||
```
|
||||
|
||||
## 认识decltype
|
||||
|
||||
前面我都在说auto,其实,C++的“自动类型推导”还有另外一个关键字:**decltype**。
|
||||
|
||||
刚才你也看到了,auto只能用于“初始化”,而这种“**向编译器索取类型**”的能力非常有价值,把它限制在这么小的场合,实在是有点“屈才”了。
|
||||
|
||||
“自动类型推导”要求必须从表达式推导,那在没有表达式的时候,该怎么办呢?
|
||||
|
||||
其实解决思路也很简单,就是“自己动手,丰衣足食”,自己带上表达式,这样就走到哪里都不怕了。
|
||||
|
||||
decltype的形式很像函数,后面的圆括号里就是可用于计算类型的表达式(和sizeof有点类似),其他方面就和auto一样了,也能加上const、*、&来修饰。
|
||||
|
||||
但因为它已经自带表达式,所以不需要变量后面再有表达式,也就是说可以直接声明变量。
|
||||
|
||||
```
|
||||
int x = 0; // 整型变量
|
||||
|
||||
decltype(x) x1; // 推导为int,x1是int
|
||||
decltype(x)& x2 = x; // 推导为int,x2是int&,引用必须赋值
|
||||
decltype(x)* x3; // 推导为int,x3是int*
|
||||
decltype(&x) x4; // 推导为int*,x4是int*
|
||||
decltype(&x)* x5; // 推导为int*,x5是int**
|
||||
decltype(x2) x6 = x2; // 推导为int&,x6是int&,引用必须赋值
|
||||
|
||||
```
|
||||
|
||||
把decltype和auto比较一下,简单来看,好像就是把表达式改到了左边而已,但实际上,在推导规则上,它们有一点细微且重要的区别:
|
||||
|
||||
**decltype不仅能够推导出值类型,还能够推导出引用类型,也就是表达式的“原始类型”**。
|
||||
|
||||
在示例代码中,我们可以看到,除了加上*和&修饰,decltype还可以直接从一个引用类型的变量推导出引用类型,而auto就会把引用去掉,推导出值类型。
|
||||
|
||||
所以,你完全可以把decltype看成是一个真正的类型名,用在变量声明、函数参数/返回值、模板参数等任何类型能出现的地方,只不过这个类型是在编译阶段通过表达式“计算”得到的。
|
||||
|
||||
如果不信的话,你可以用using类型别名来试一试。
|
||||
|
||||
```
|
||||
using int_ptr = decltype(&x); // int *
|
||||
using int_ref = decltype(x)&; // int &
|
||||
|
||||
```
|
||||
|
||||
既然decltype类型推导更精确,那是不是可以替代auto了呢?
|
||||
|
||||
实际上,它也有个缺点,就是写起来略麻烦,特别在用于初始化的时候,表达式要重复两次(左边的类型计算,右边的初始化),把简化代码的优势完全给抵消了。
|
||||
|
||||
所以,C++14就又增加了一个“**decltype(auto)**”的形式,既可以精确推导类型,又能像auto一样方便使用。
|
||||
|
||||
```
|
||||
int x = 0; // 整型变量
|
||||
|
||||
decltype(auto) x1 = (x); // 推导为int&,因为(expr)是引用类型
|
||||
decltype(auto) x2 = &x; // 推导为int*
|
||||
decltype(auto) x3 = x1; // 推导为int&
|
||||
|
||||
```
|
||||
|
||||
## 使用auto/decltype
|
||||
|
||||
现在,我已经讲完了“自动类型推导”的两个关键字:auto和decltype,那么,该怎么用好它们呢?
|
||||
|
||||
我觉得,因为auto写法简单,推导规则也比较好理解,所以,**在变量声明时应该尽量多用auto**。前面已经举了不少例子,这里就不再重复了。
|
||||
|
||||
auto还有一个“最佳实践”,就是“**range-based for**”,不需要关心容器元素类型、迭代器返回值和首末位置,就能非常轻松地完成遍历操作。不过,为了保证效率,最好使用“const auto&”或者“auto&”。
|
||||
|
||||
```
|
||||
vector<int> v = {2,3,5,7,11}; // vector顺序容器
|
||||
|
||||
for(const auto& i : v) { // 常引用方式访问元素,避免拷贝代价
|
||||
cout << i << ","; // 常引用不会改变元素的值
|
||||
}
|
||||
|
||||
for(auto& i : v) { // 引用方式访问元素
|
||||
i++; // 可以改变元素的值
|
||||
cout << i << ",";
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在C++14里,auto还新增了一个应用场合,就是能够推导函数返回值,这样在写复杂函数的时候,比如返回一个pair、容器或者迭代器,就会很省事。
|
||||
|
||||
```
|
||||
auto get_a_set() // auto作为函数返回值的占位符
|
||||
{
|
||||
std::set<int> 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(&signal) ;
|
||||
|
||||
```
|
||||
|
||||
在定义类的时候,因为auto被禁用了,所以这也是decltype可以“显身手”的地方。它可以搭配别名任意定义类型,再应用到成员变量、成员函数上,变通地实现auto的功能。
|
||||
|
||||
```
|
||||
class DemoClass final
|
||||
{
|
||||
public:
|
||||
using set_type = std::set<int>; // 集合类型别名
|
||||
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="">
|
||||
@@ -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 = "metroid";
|
||||
|
||||
```
|
||||
|
||||
但如果我们从C++程序的生命周期角度来看的话,就会发现,它和宏定义还是有本质区别的:**const定义的常量在预处理阶段并不存在,而是直到运行阶段才会出现**。
|
||||
|
||||
所以,准确地说,它实际上是运行时的“变量”,只不过不允许修改,是“只读”的(read only),叫“只读变量”更合适。
|
||||
|
||||
既然它是“变量”,那么,使用指针获取地址,再“强制”写入也是可以的。但这种做法破坏了“常量性”,绝对不提倡。这里,我只是给你做一个示范性质的实验,还要用到另外一个关键字volatile。
|
||||
|
||||
```
|
||||
// 需要加上volatile修饰,运行时才能看到效果
|
||||
const volatile int MAX_LEN = 1024;
|
||||
|
||||
auto ptr = (int*)(&MAX_LEN);
|
||||
*ptr = 2048;
|
||||
cout << MAX_LEN << 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& rx = x;
|
||||
const int* px = &x;
|
||||
|
||||
```
|
||||
|
||||
const &被称为**万能引用**,也就是说,它可以引用任何类型,即不管是值、指针、左引用还是右引用,它都能“照单全收”。
|
||||
|
||||
而且,它还会给变量附加上const特性,这样“变量”就成了“常量”,只能读、禁止写。编译器会帮你检查出所有对它的写操作,发出警告,在编译阶段防止有意或者无意的修改。这样一来,const常量用起来就非常安全了。
|
||||
|
||||
因此,**在设计函数的时候,我建议你尽可能地使用它作为入口参数,一来保证效率,二来保证安全**。
|
||||
|
||||
const用于指针的情况会略微复杂一点。常见的用法是,const放在声明的最左边,表示指向常量的指针。这个其实很好理解,指针指向的是一个“只读变量”,不允许修改:
|
||||
|
||||
```
|
||||
string name = "uncharted";
|
||||
const string* ps1 = &name; // 指向常量
|
||||
*ps1 = "spiderman"; // 错误,不允许修改
|
||||
|
||||
```
|
||||
|
||||
另外一种比较“恶心”的用法是,const在“*”的右边,表示指针不能被修改,而指向的变量可以被修改:
|
||||
|
||||
```
|
||||
string* const ps2 = &name; // 指向变量,但指针本身不能被修改
|
||||
*ps2 = "spiderman"; // 正确,允许修改
|
||||
|
||||
```
|
||||
|
||||
再进一步,那就是“*”两边都有const,你看看是什么意思呢:
|
||||
|
||||
```
|
||||
const string* const ps3 = &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 &”可以引用任何类型,是函数入口参数的最佳类型;
|
||||
- 它还可以修饰成员函数,表示函数是“只读”的,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="">
|
||||
284
极客时间专栏/罗剑锋的C++实战笔记/语言特性/08 | smart_ptr:智能指针到底“智能”在哪里?.md
Normal file
284
极客时间专栏/罗剑锋的C++实战笔记/语言特性/08 | smart_ptr:智能指针到底“智能”在哪里?.md
Normal 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,包装了裸指针,而且因为重载了*和->操作符,用起来和原始指针一模一样。
|
||||
|
||||
不仅如此,它还综合考虑了很多现实的应用场景,能够自动适应各种复杂的情况,防止误用指针导致的隐患,非常“聪明”,所以被称为“智能指针”。
|
||||
|
||||
常用的有两种智能指针,分别是**unique_ptr**和**shared_ptr**,下面我就来分别介绍一下。
|
||||
|
||||
## 认识unique_ptr
|
||||
|
||||
unique_ptr是最简单、最容易使用的一个智能指针,在声明的时候必须用模板参数指定类型:
|
||||
|
||||
```
|
||||
unique_ptr<int> ptr1(new int(10)); // int智能指针
|
||||
assert(*ptr1 == 10); // 可以使用*取内容
|
||||
assert(ptr1 != nullptr); // 可以判断是否为空指针
|
||||
|
||||
unique_ptr<string> ptr2(new string("hello")); // string智能指针
|
||||
assert(*ptr2 == "hello"); // 可以使用*取内容
|
||||
assert(ptr2->size() == 5); // 可以使用->调用成员函数
|
||||
|
||||
```
|
||||
|
||||
你需要注意的是,unique_ptr虽然名字叫指针,用起来也很像,但**它实际上并不是指针,而是一个对象。所以,不要企图对它调用delete,它会自动管理初始化时的指针,在离开作用域时析构释放内存。**
|
||||
|
||||
另外,它也没有定义加减运算,不能随意移动指针地址,这就完全避免了指针越界等危险操作,可以让代码更安全:
|
||||
|
||||
```
|
||||
ptr1++; // 导致编译错误
|
||||
ptr2 += 2; // 导致编译错误
|
||||
|
||||
```
|
||||
|
||||
除了调用delete、加减运算,初学智能指针还有一个容易犯的错误是把它当成普通对象来用,不初始化,而是声明后直接使用:
|
||||
|
||||
```
|
||||
unique_ptr<int> ptr3; // 未初始化智能指针
|
||||
*ptr3 = 42 ; // 错误!操作了空指针
|
||||
|
||||
```
|
||||
|
||||
未初始化的unique_ptr表示空指针,这样就相当于直接操作了空指针,运行时就会产生致命的错误(比如core dump)。
|
||||
|
||||
为了避免这种低级错误,你可以调用工厂函数**make_unique()**,强制创建智能指针的时候必须初始化。同时还可以利用自动类型推导([第6讲](https://time.geekbang.org/column/article/237964))的auto,少写一些代码:
|
||||
|
||||
```
|
||||
auto ptr3 = make_unique<int>(42); // 工厂函数创建智能指针
|
||||
assert(ptr3 && *ptr3 == 42);
|
||||
|
||||
auto ptr4 = make_unique<string>("god of war"); // 工厂函数创建智能指针
|
||||
assert(!ptr4->empty());
|
||||
|
||||
```
|
||||
|
||||
不过,make_unique()要求C++14,好在它的原理比较简单。如果你使用的是C++11,也可以自己实现一个简化版的make_unique(),可以参考下面的代码:
|
||||
|
||||
```
|
||||
template<class T, class... Args> // 可变参数模板
|
||||
std::unique_ptr<T> // 返回智能指针
|
||||
my_make_unique(Args&&... args) // 可变参数模板的入口参数
|
||||
{
|
||||
return std::unique_ptr<T>( // 构造智能指针
|
||||
new T(std::forward<Args>(args)...)); // 完美转发
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## unique_ptr的所有权
|
||||
|
||||
使用unique_ptr的时候还要特别注意指针的“**所有权**”问题。
|
||||
|
||||
正如它的名字,表示指针的所有权是“唯一”的,不允许共享,任何时候只能有一个“人”持有它。
|
||||
|
||||
为了实现这个目的,unique_ptr应用了C++的“转移”(move)语义,同时禁止了拷贝赋值,所以,在向另一个unique_ptr赋值的时候,要特别留意,必须用**std::move()**函数显式地声明所有权转移。
|
||||
|
||||
赋值操作之后,指针的所有权就被转走了,原来的unique_ptr变成了空指针,新的unique_ptr接替了管理权,保证所有权的唯一性:
|
||||
|
||||
```
|
||||
auto ptr1 = make_unique<int>(42); // 工厂函数创建智能指针
|
||||
assert(ptr1 && *ptr1 == 42); // 此时智能指针有效
|
||||
|
||||
auto ptr2 = std::move(ptr1); // 使用move()转移所有权
|
||||
assert(!ptr1 && ptr2); // ptr1变成了空指针
|
||||
|
||||
```
|
||||
|
||||
如果你对右值、转移这些概念不是太理解,也没关系,它们用起来也的确比较“微妙”,这里你只要记住,**尽量不要对unique_ptr执行赋值操作**就好了,让它“自生自灭”,完全自动化管理。
|
||||
|
||||
## 认识shared_ptr
|
||||
|
||||
接下来要说的是shared_ptr,它是一个比unique_ptr更“智能”的智能指针。
|
||||
|
||||
初看上去shared_ptr和unique_ptr差不多,也可以使用工厂函数来创建,也重载了*和->操作符,用法几乎一样——只是名字不同,看看下面的代码吧:
|
||||
|
||||
```
|
||||
shared_ptr<int> ptr1(new int(10)); // int智能指针
|
||||
assert(*ptr1 = 10); // 可以使用*取内容
|
||||
|
||||
shared_ptr<string> ptr2(new string("hello")); // string智能指针
|
||||
assert(*ptr2 == "hello"); // 可以使用*取内容
|
||||
|
||||
auto ptr3 = make_shared<int>(42); // 工厂函数创建智能指针
|
||||
assert(ptr3 && *ptr3 == 42); // 可以判断是否为空指针
|
||||
|
||||
auto ptr4 = make_shared<string>("zelda"); // 工厂函数创建智能指针
|
||||
assert(!ptr4->empty()); // 可以使用->调用成员函数
|
||||
|
||||
```
|
||||
|
||||
但shared_ptr的名字明显表示了它与unique_ptr的最大不同点:**它的所有权是可以被安全共享的**,也就是说支持拷贝赋值,允许被多个“人”同时持有,就像原始指针一样。
|
||||
|
||||
```
|
||||
auto ptr1 = make_shared<int>(42); // 工厂函数创建智能指针
|
||||
assert(ptr1 && ptr1.unique() ); // 此时智能指针有效且唯一
|
||||
|
||||
auto ptr2 = ptr1; // 直接拷贝赋值,不需要使用move()
|
||||
assert(ptr1 && ptr2); // 此时两个智能指针均有效
|
||||
|
||||
assert(ptr1 == ptr2); // shared_ptr可以直接比较
|
||||
|
||||
// 两个智能指针均不唯一,且引用计数为2
|
||||
assert(!ptr1.unique() && ptr1.use_count() == 2);
|
||||
assert(!ptr2.unique() && 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<this_type>;
|
||||
public:
|
||||
shared_type next; // 使用智能指针来指向下一个节点
|
||||
};
|
||||
|
||||
auto n1 = make_shared<Node>(); // 工厂函数创建智能指针
|
||||
auto n2 = make_shared<Node>(); // 工厂函数创建智能指针
|
||||
|
||||
assert(n1.use_count() == 1); // 引用计数为1
|
||||
assert(n2.use_count() == 1);
|
||||
|
||||
n1->next = n2; // 两个节点互指,形成了循环引用
|
||||
n2->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<this_type>;
|
||||
public:
|
||||
shared_type next; // 因为用了别名,所以代码不需要改动
|
||||
};
|
||||
|
||||
auto n1 = make_shared<Node>(); // 工厂函数创建智能指针
|
||||
auto n2 = make_shared<Node>(); // 工厂函数创建智能指针
|
||||
|
||||
n1->next = n2; // 两个节点互指,形成了循环引用
|
||||
n2->next = n1;
|
||||
|
||||
assert(n1.use_count() == 1); // 因为使用了weak_ptr,引用计数为1
|
||||
assert(n2.use_count() == 1); // 打破循环引用,不会导致内存泄漏
|
||||
|
||||
if (!n1->next.expired()) { // 检查指针是否有效
|
||||
auto ptr = n1->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="">
|
||||
265
极客时间专栏/罗剑锋的C++实战笔记/语言特性/09 | exception:怎样才能用好异常?.md
Normal file
265
极客时间专栏/罗剑锋的C++实战笔记/语言特性/09 | exception:怎样才能用好异常?.md
Normal 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++已经为处理异常设计了一个配套的异常类型体系,定义在标准库的<stdexcept>头文件里。
|
||||
|
||||
下面我画了个简单的示意图,你可以看一下。
|
||||
|
||||
<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 &”的形式,避免对象拷贝的代价:
|
||||
|
||||
```
|
||||
try
|
||||
{
|
||||
raise("error occured"); // 函数封装throw,抛出异常
|
||||
}
|
||||
catch(const exception& e) // const &捕获异常,可以用基类
|
||||
{
|
||||
cout << e.what() << 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 << "noexcept" << endl;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
不过你要注意,noexcept只是做出了一个“不可靠的承诺”,不是“强保证”,编译器无法彻底检查它的行为,标记为noexcept的函数也有可能抛出异常:
|
||||
|
||||
```
|
||||
void func_maybe_noexcept() noexcept // 声明绝不会抛出异常
|
||||
{
|
||||
throw "Oh My God"; // 但也可以抛出异常
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
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="">
|
||||
271
极客时间专栏/罗剑锋的C++实战笔记/语言特性/10 | lambda:函数式编程带来了什么?.md
Normal file
271
极客时间专栏/罗剑锋的C++实战笔记/语言特性/10 | lambda:函数式编程带来了什么?.md
Normal 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 << x*x << endl; // 函数的具体内容
|
||||
}
|
||||
|
||||
auto pfunc = &my_square; // 只能用指针去操作函数,指针不是函数
|
||||
(*pfunc)(3); // 可以用*访问函数
|
||||
pfunc(3); // 也可以直接调用函数指针
|
||||
|
||||
|
||||
```
|
||||
|
||||
所以,在面向过程编程范式里,函数和变量虽然是程序里最关键的两个组成部分,但却因为没有值、没有作用域而不能一致地处理。函数只能是函数,变量只能是变量,彼此之间虽不能说是“势同水火”,但至少是“泾渭分明”。
|
||||
|
||||
## 认识lambda
|
||||
|
||||
好了,搞清楚了函数,现在再来看看C++11引入的lambda表达式,下面是一个简单的例子:
|
||||
|
||||
```
|
||||
auto func = [](int x) // 定义一个lambda表达式
|
||||
{
|
||||
cout << x*x << 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 << x*n << 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表达式,三个括号“排排坐”,看起来有种奇特的美感,让人不由得想起那句经典台词:“一家人最要紧的就是整整齐齐。”(不过还是差了个尖括号<>)。
|
||||
|
||||
当然了,实际开发中不会有这么简单的lambda表达式,它的函数体里可能会有很多语句,所以一定**要有良好的缩进格式**——特别是有嵌套定义的时候,尽量让人能够一眼就看出lambda表达式的开始和结束,必要的时候可以用注释来强调。
|
||||
|
||||
```
|
||||
auto f2 = []() // 定义一个lambda表达式
|
||||
{
|
||||
cout << "lambda f2" << endl;
|
||||
|
||||
auto f3 = [](int x) // 嵌套定义lambda表达式
|
||||
{
|
||||
return x*x;
|
||||
};// lambda f3 // 使用注释显式说明表达式结束
|
||||
|
||||
cout << f3(10) << endl;
|
||||
}; // lambda f2 // 使用注释显式说明表达式结束
|
||||
|
||||
```
|
||||
|
||||
你可能注意到了,在lambda表达式赋值的时候,我总是使用auto来推导类型。这是因为,在C++里,每个lambda表达式都会有一个独特的类型,而这个类型只有编译器才知道,我们是无法直接写出来的,所以必须用auto。
|
||||
|
||||
不过,因为lambda表达式毕竟不是普通的变量,所以C++也鼓励程序员**尽量“匿名”使用lambda表达式**。也就是说,它不必显式赋值给一个有名字的变量,直接声明就能用,免去你费力起名的烦恼。
|
||||
|
||||
这样不仅可以让代码更简洁,而且因为“匿名”,lambda表达式调用完后也就不存在了(也有被拷贝保存的可能),这就最小化了它的影响范围,让代码更加安全。
|
||||
|
||||
```
|
||||
vector<int> v = {3, 1, 8, 5, 0}; // 标准容器
|
||||
|
||||
cout << *find_if(begin(v), end(v), // 标准库里的查找算法
|
||||
[](int x) // 匿名lambda表达式,不需要auto赋值
|
||||
{
|
||||
return x >= 5; // 用做算法的谓词判断条件
|
||||
} // lambda表达式结束
|
||||
)
|
||||
<< endl; // 语句执行完,lambda表达式就不存在了
|
||||
|
||||
```
|
||||
|
||||
**2.lambda的变量捕获**
|
||||
|
||||
lambda的“捕获”功能需要在“[]”里做文章,由于实际的规则太多太细,记忆、理解的成本高,所以我只说几个要点,帮你快速掌握它们:
|
||||
|
||||
- “[=]”表示按值捕获所有外部变量,表达式内部是值的拷贝,并且不能修改;
|
||||
- “[&]”是按引用捕获所有外部变量,内部以引用的方式使用,可以修改;
|
||||
- 你也可以在“[]”里明确写出外部变量名,指定按值或者按引用捕获,C++在这里给予了非常大的灵活性。
|
||||
|
||||
```
|
||||
int x = 33; // 一个外部变量
|
||||
|
||||
auto f1 = [=]() // lambda表达式,用“=”按值捕获
|
||||
{
|
||||
//x += 10; // x只读,不允许修改
|
||||
};
|
||||
|
||||
auto f2 = [&]() // lambda表达式,用“&”按引用捕获
|
||||
{
|
||||
x += 10; // x是引用,可以修改
|
||||
};
|
||||
|
||||
auto f3 = [=, &x]() // lambda表达式,用“&”按引用捕获x,其他的按值捕获
|
||||
{
|
||||
x += 20; // x是引用,可以修改
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
“捕获”也是使用lambda表达式的一个难点,关键是要理解“**外部变量**”的含义。
|
||||
|
||||
我建议,你可以简单地按照其他语言的习惯,称之为“**upvalue**”,也就是在lambda表达式定义之前所有出现的变量,不管它是局部的还是全局的。
|
||||
|
||||
这就有一个变量生命周期的问题。
|
||||
|
||||
使用“[=]”按值捕获的时候,lambda表达式使用的是变量的独立副本,非常安全。而使用“[&]”的方式捕获引用就存在风险,当lambda表达式在离定义点“很远的地方”被调用的时候,引用的变量可能发生了变化,甚至可能会失效,导致难以预料的后果。
|
||||
|
||||
所以,我建议你在使用捕获功能的时候要小心,对于“就地”使用的小lambda表达式,可以用“[&]”来减少代码量,保持整洁;而对于非本地调用、生命周期较长的lambda表达式应慎用“[&]”捕获引用,而且,最好是在“[]”里显式写出变量列表,避免捕获不必要的变量。
|
||||
|
||||
```
|
||||
class DemoLambda final
|
||||
{
|
||||
private:
|
||||
int x = 0;
|
||||
public:
|
||||
auto print() // 返回一个lambda表达式供外部使用
|
||||
{
|
||||
return [this]() // 显式捕获this指针
|
||||
{
|
||||
cout << "member = " << x << endl;
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
**3.泛型的lambda**
|
||||
|
||||
在C++14里,lambda表达式又多了一项新本领,可以实现“**泛型化**”,相当于简化了的模板函数,具体语法还是利用了“多才多艺”的auto:
|
||||
|
||||
```
|
||||
auto f = [](const auto& x) // 参数使用auto声明,泛型化
|
||||
{
|
||||
return x + x;
|
||||
};
|
||||
|
||||
cout << f(3) << endl; // 参数类型是int
|
||||
cout << f(0.618) << endl; // 参数类型是double
|
||||
|
||||
string str = "matrix";
|
||||
cout << f(str) << endl; // 参数类型是string
|
||||
|
||||
```
|
||||
|
||||
这个新特性在写泛型函数的时候非常方便,摆脱了冗长的模板参数和函数参数列表。如果你愿意的话,可以尝试在今后的代码里都使用lambda来代替普通函数,能够少写很多代码。
|
||||
|
||||
## 小结
|
||||
|
||||
今天我讲了lambda表达式。它不仅仅是对旧有函数对象的简单升级,而是更高级的“闭包”,给C++带来了新的编程理念:函数式编程范式。
|
||||
|
||||
在C语言里,函数是一个“静止”的代码块,只能被动地接受输入然后输出。而lambda的出现则让函数“活”了起来,极大地提升了函数的地位和灵活性。
|
||||
|
||||
比照“智能指针”的说法,lambda完全可以称为是“智能函数”,价值体现在就地定义、变量捕获等能力上,它也给C++的算法、并发(线程、协程)等后续发展方向铺平了道路,在后面讲标准库的时候,我们还会多次遇到它。
|
||||
|
||||
虽然目前在C++里,纯函数式编程还比较少见,但“轻度”使用lambda表达式也能够改善代码,比如用“map+lambda”的方式来替换难以维护的if/else/switch,可读性要比大量的分支语句好得多。
|
||||
|
||||
小结一下今天的要点内容:
|
||||
|
||||
1. lambda表达式是一个闭包,能够像函数一样被调用,像变量一样被传递;
|
||||
1. 可以使用auto自动推导类型存储lambda表达式,但C++鼓励尽量就地匿名使用,缩小作用域;
|
||||
1. lambda表达式使用“[=]”的方式按值捕获,使用“[&]”的方式按引用捕获,空的“[]”则是无捕获(也就相当于普通函数);
|
||||
1. 捕获引用时必须要注意外部变量的生命周期,防止变量失效;
|
||||
1. C++14里可以使用泛型的lambda表达式,相当于简化的模板函数。
|
||||
|
||||
末了我再说一句,和C++里的大多数新特性一样,滥用lambda表达式的话,就会产生一些难以阅读的代码,比如多个函数的嵌套和串联、调用层次过深。这也需要你在实践中慢慢积累经验,找到最适合你自己的使用方式。
|
||||
|
||||
## 课下作业
|
||||
|
||||
最后是课下作业时间,给你留两个思考题吧:
|
||||
|
||||
1. 你对函数式编程有什么样的理解和认识呢?
|
||||
1. lambda表达式的形式非常简洁,可以在很多地方代替普通函数,那它能不能代替类的成员函数呢?为什么?
|
||||
|
||||
欢迎你在留言区写下你的思考和答案,如果觉得今天的内容对你有所帮助,也欢迎分享给你的朋友。我们下节课见。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5a/0d/5ac283e096d87e582fed017597ba4e0d.jpg" alt="">
|
||||
117
极客时间专栏/罗剑锋的C++实战笔记/课前导读/开篇词 | 把C++从“神坛”上拉下来,这次咱这么学.md
Normal file
117
极客时间专栏/罗剑锋的C++实战笔记/课前导读/开篇词 | 把C++从“神坛”上拉下来,这次咱这么学.md
Normal 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++水平的要求很低,不需要你有太多的经验(1~5年都可以),保证让你一眼就能看明白。虽然代码可以说是“玩具”,但里面蕴含的知识却绝不是“玩具”,这就需要你看懂之后去细心领会了。
|
||||
|
||||
总之,我想尽量降低这门课的学习门槛,把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.
|
||||
135
极客时间专栏/罗剑锋的C++实战笔记/课前导读/课前准备 | 搭建实验环境.md
Normal file
135
极客时间专栏/罗剑锋的C++实战笔记/课前导读/课前准备 | 搭建实验环境.md
Normal 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.4,CentOS 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的构建工具b2(Boost 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="">
|
||||
89
极客时间专栏/罗剑锋的C++实战笔记/轻松话题/轻松话题(一) | 4本值得一读再读的经典好书.md
Normal file
89
极客时间专栏/罗剑锋的C++实战笔记/轻松话题/轻松话题(一) | 4本值得一读再读的经典好书.md
Normal 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++语言,而是开列一个清单,精选出几本我认为值得一读再读的技术类好书。
|
||||
|
||||
顺便说一句,“读书”最好是看纸质实体书,而不是电子版。
|
||||
|
||||
在现在的环境下,手机或者平板电脑实在是太“浮躁”了,在用它们阅读时,经常会被跳出的通知、消息打扰,而且“人性”使然,也很难控制自己不去点开其他的应用玩玩小游戏、看看短视频。
|
||||
|
||||
另外,这些设备大多是“主动发光”型的,长时间看会导致视觉疲劳,影响眼睛健康,还是纸质的书更好。只要在一个合适的光照环境下,冲一杯茶或者咖啡,给自己留出一段充足的闲暇时间,你就可以抛却世俗的喧嚣和烦恼,尽情地畅游在书的海洋。
|
||||
|
||||
闲话不多说了,It’s 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="">
|
||||
108
极客时间专栏/罗剑锋的C++实战笔记/轻松话题/轻松话题(三) | 提高生活质量的App.md
Normal file
108
极客时间专栏/罗剑锋的C++实战笔记/轻松话题/轻松话题(三) | 提高生活质量的App.md
Normal 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
|
||||
|
||||
好,第一个要推荐的是苹果的独家App,iWork三件套之一: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="">
|
||||
151
极客时间专栏/罗剑锋的C++实战笔记/轻松话题/轻松话题(二) | 给你分享我的工作百宝箱.md
Normal file
151
极客时间专栏/罗剑锋的C++实战笔记/轻松话题/轻松话题(二) | 给你分享我的工作百宝箱.md
Normal 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:>-,trail:~
|
||||
set list
|
||||
colorscheme desert
|
||||
|
||||
```
|
||||
|
||||
## Git
|
||||
|
||||
写完了程序,还要用适当的版本控制系统把它管理起来,否则源码丢失、版本回溯、多人协作等问题会把你弄得焦头烂额。
|
||||
|
||||
我最早用的是微软的VSS(Visual 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="">
|
||||
120
极客时间专栏/罗剑锋的C++实战笔记/轻松话题/轻松话题(四) | 真正高效的生活,是张弛有度.md
Normal file
120
极客时间专栏/罗剑锋的C++实战笔记/轻松话题/轻松话题(四) | 真正高效的生活,是张弛有度.md
Normal 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. 要平衡好工作和生活,要有休息有娱乐,不要变成只会加班的“机器人”。
|
||||
|
||||
那么,我的这些经验是否对你有所启发呢?你有没有什么好的时间管理小窍门呢?欢迎一起来分享,把握好我们自己的时间。
|
||||
Reference in New Issue
Block a user