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

View File

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

View File

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

View File

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

View File

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

View File

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