mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-17 06:33:48 +08:00
del
This commit is contained in:
146
极客时间专栏/geek/罗剑锋的C++实战笔记/总结篇/19 | 设计模式(上):C++与设计模式有啥关系?.md
Normal file
146
极客时间专栏/geek/罗剑锋的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
极客时间专栏/geek/罗剑锋的C++实战笔记/总结篇/20 | 设计模式(下):C++是怎么应用设计模式的?.md
Normal file
167
极客时间专栏/geek/罗剑锋的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
极客时间专栏/geek/罗剑锋的C++实战笔记/总结篇/21 | 知识串讲(上):带你开发一个书店应用.md
Normal file
356
极客时间专栏/geek/罗剑锋的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
极客时间专栏/geek/罗剑锋的C++实战笔记/总结篇/22 | 知识串讲(下):带你开发一个书店应用.md
Normal file
372
极客时间专栏/geek/罗剑锋的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
极客时间专栏/geek/罗剑锋的C++实战笔记/总结篇/期末测试 | 这些C++核心知识,你都掌握了吗?.md
Normal file
10
极客时间专栏/geek/罗剑锋的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)
|
||||
Reference in New Issue
Block a user