130-罗剑锋的C++实战笔记

开篇词 | 把C++从“神坛”上拉下来，这次咱这么学

你好，我是罗剑锋，你叫我 Chrono 就好。

去年，我在极客时间开了一个《透视 HTTP 协议》的课程，有很多同学留言，希望能再听我讲讲其他领域的知识。

于是，在一年之后的今天，我给你带来了这个新课程：《罗剑锋的 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++ 之外的东西，以避免因为课程安排得太紧凑，没有“喘息”的机会，让你学起来很累。这些话题涵盖的范围比较广，包括经典的学习资料、提高工作效率的工具等，让你在掌握核心硬技能的同时向外拓展知识面，“会工作，更要会生活”。

学前勉言

在开课之前，我还想和你分享几句编程格言。这三条格言已经陪伴了我很久，一直指导着我的编程实践。

任何人都能写出机器能看懂的代码，但只有优秀的程序员才能写出人能看懂的代码。

有两种写程序的方式：一种是把代码写得非常复杂，以至于“看不出明显的错误”；另一种是把代码写得非常简单，以至于“明显看不出错误”。

“把正确的代码改快速”，要比“把快速的代码改正确”，容易得太多。

C++ 庞大、复杂是无法改变的事实，所以我们要把这三条格言铭记在心，对它保持一颗“敬畏”的心，在学习语言特性的同时，千万不要滥用特性，谦虚谨慎，戒骄戒躁。

我很喜欢 15 年前乔布斯在斯坦福大学演讲中的一句话，觉得非常适合 C++。所以，最后我想把它送给你，我们共勉，希望在接下来的这段时间里，我们一起：

Stay Hungry, Stay Foolish.

课前准备 | 搭建实验环境

你好，我是 Chrono。

在开始正式的学习之前，我们需要先做一点准备工作——在自己的电脑上搭建出课程使用的实验环境。

我会给你推荐具体的操作系统、编译器，带你一步步搭建环境，还会给你分享我的GitHub 链接，之后课程里的所有代码，你都可以在这里找到。

操作系统

首先说一下操作系统。

目前流行的操作系统有三种：Windows、macOS 和 Linux。

Windows 是应用得最广泛的一个系统了，是绝对的主流，但是，作为 C++ 开发环境来说，Windows 并不能算是首选。

一个原因是，Windows 上的标准 C++ 开发工具 Visual Studio 不是免费的，尽管它提供了可自由下载的社区版，但有各种限制（你可以看下社区版和其他版本的差异），用来做实验还行，如果要用来开发正式的软件，就不是那么合适了。

另一个更重要的原因是，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++ 的支持程度，可以在它的官网上找到。

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 位的桌面版。至于它们俩具体的安装过程，网上有很多资料，而且我相信，即使没有资料，也难不倒你，所以我就不再多说了。

弄好 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 协议》一样，我也在 GitHub 上为这个课程建立了一个项目：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 来轻松地编译代码。

小结

作为正式开课前的“热身工作”，今天我介绍了课程使用的实验环境，简单小结一下：

我们选择的操作系统是 Linux，具体是 Ubuntu 16.04，也可以是更新的版本。

我们选择的编译器是 GCC，最低要求是 4.6 或者 4.8，推荐使用 5.4 或者更高的版本。

使用虚拟机软件搭建环境最方便，完全“自主可控”，推荐使用免费的 VirtualBox。

GitHub 上有配套的示例代码和参考资料，可以下载后编译验证环境是否搭建成功。

那么，行动起来吧，下节课让我们在 Linux 上愉快地一起“玩耍”。

01 | 重新认识C++：生命周期和编程范式

你好，我是 Chrono。

今天是专栏的第一节正式课。我想，既然你选择了这个专栏，你就应该已经对 C++ 有所了解了，而且有过一些开发经验，甚至还掌握了一两种其他的语言。

苏轼有诗云：“不识庐山真面目，只缘身在此山中。”学习 C++ 很容易被纷繁复杂的语法细节所吸引、迷惑，所以，我决定从“生命周期”和“编程范式”这两个不太常见的角度来“剖析”一下 C++，站在一个更高的层次上审视这门“历久弥新”的编程语言，帮你认清楚 C++ 最本质的东西。

这样，今后在写程序的时候，你也会有全局观或者说是大局观，更能从整体上把握程序架构，而不会迷失在那些琐碎的细枝末节里。

现在，我们先来了解下 C++ 的生命周期。

C++ 程序的生命周期

如果你学过一点软件工程的知识，就一定知道“瀑布模型”，它定义了软件或者是项目的生命周期——从需求分析开始，经过设计、开发、测试等阶段，直到最终交付给用户。

“瀑布模型”把软件的生命周期分成了多个阶段，每个阶段之间分工明确，相互独立，而且有严格的先后次序，是一个经典的开发模型。虽然它已经不再适合瞬息万变的互联网产业了，但仍然有许多值得借鉴和参考的地方。

那么，说了半天，“瀑布模型”跟 C++ 程序有什么关系呢？

其实，从软件工程的视角来看，一个 C++ 程序的生命周期也是“瀑布”形态的，也可以划分出几个明确的阶段，阶段之间顺序衔接，使用类似的方法，就可以更好地理解 C++ 程序的运行机制，帮助我们写出更好的代码。

不过，因为 C++ 程序本身就已经处在“开发”阶段了，所以不会有“需求分析”“设计”这样的写文档过程。所以，一个 C++ 程序从“诞生”到“消亡”，要经历这么几个阶段：编码（Coding）、预处理（Pre-processing）、编译（Compiling）和运行（Running）。

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 调试、日志追踪、性能分析等，然后收集动态的数据、调整设计思路，再返回编码阶段，重走这个“瀑布模型”，实现“螺旋上升式”的开发。

好了，梳理清楚了 C++ 程序生命周期的四个阶段，你可以看到，这和软件工程里的“瀑布模型”很相似，这些阶段也是职责明确的，前一个阶段的输出作为后一个阶段的输入，而且每个阶段都有自己的工作特点，我们可以有针对性地去做编程开发。

还有，别忘了软件工程里的“蝴蝶效应”“混沌理论”，大概意思是：一个 Bug 在越早的阶段发现并解决，它的价值就越高；一个 Bug 在越晚的阶段发现并解决，它的成本就越高。

所以，依据这个生命周期模型，我们应该在“编码”“预处理”“编译”这前面三个阶段多下功夫，消灭 Bug，优化代码，尽量不要让 Bug 在“运行”阶段才暴露出来，也就是所谓的“把问题扼杀在萌芽期”。

C++ 语言的编程范式

说完了 C++ 程序的生命周期，再来看看 C++ 的编程范式（Paradigm）。

什么是编程范式呢？

关于这个概念，没有特别权威的定义，我给一个比较通俗的解释：“编程范式”是一种“方法论”，就是指导你编写代码的一些思路、规则、习惯、定式和常用语。

编程范式和编程语言不同，有的范式只能用于少数特定的语言，有的范式却适用于大多数语言；有的语言可能只支持一种范式，有的语言却可能支持多种范式。

那么，你一定知道或者听说过，C++ 是一种多范式的编程语言。具体来说，现代 C++（11/14 以后）支持“面向过程”“面向对象”“泛型”“模板元”“函数式”这五种主要的编程范式。

其中，“面向过程”“面向对象”是基础，支撑着后三种范式。我画了一个“五环图”，圆环重叠表示有的语言特性会同时应用于多种范式，可以帮你理解它们的关系。

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++，做个简单小结：

C++ 程序的生命周期包括编码、预处理、编译、运行四个阶段，它们都有各自的特点；

虽然我们只写了一个 C++ 程序，但里面的代码可能会运行在不同的阶段，分别由预处理器、编译器和 CPU 执行；

C++ 支持面向过程、面向对象、泛型、模板元、函数式共五种主要的编程范式；

在 C++ 里可以“无缝”混用多范式编程，但因为范式的差异比较大，必须小心谨慎，避免导致混乱。

课下作业

最后是课下作业时间，给你留两个思考题：

你是怎么理解 C++ 程序的生命周期和编程范式的？

试着从程序的生命周期和编程范式的角度，把 C++ 和其他语言（例如 Java、Python）做个比较，说说 C++ 的优点和缺点分别是什么。

欢迎你在留言区写下你的思考和答案，如果觉得对你有所帮助，也欢迎把今天的内容分享给你的朋友，我们下节课见。

02 | 编码阶段能做什么：秀出好的code style

你好，我是 Chrono。

上节课我介绍了 C++ 程序的生命周期和编程范式，今天我就接着展开来讲，看看在编码阶段我们能做哪些事情。

在编码阶段，我们的核心任务就是写出在预处理、编译和运行等不同阶段里执行的代码，还记得那句编程格言吗：

“任何人都能写出机器能看懂的代码，但只有优秀的程序员才能写出人能看懂的代码。”

所以，我们在编码阶段的首要目标，不是实现功能，而是要写出清晰易读的代码，也就是要有好的 code style。

怎么样才能写出 human readable 的好代码呢？

这就需要有一些明确的、经过实践验证的规则来指导，只要自觉遵守、合理运用这些规则，想把代码写“烂”都很难。

在此，我强烈推荐一份非常棒的指南，它来自 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 都是这样。

那么，你该选用哪种命名风格呢？

我的建议是“取百家之长”，混用这几种中能够让名字辨识度最高的那些优点，就是四条规则：

变量、函数名和名字空间用 snake_case，全局变量加“g_”前缀；

自定义类名用 CamelCase，成员函数用 snake_case，成员变量加“m_”前缀；

宏和常量应当全大写，单词之间用下划线连接；

尽量不要用下划线作为变量的前缀或者后缀（比如 _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

总的来说，要写好注释，你要时刻“换位思考”，设身处地去想别人怎么看你的代码，这样的话，上面的那些细则也就不难实施了。

小结

在编码阶段，拥有一个良好的编程习惯和态度是非常重要的（我见过太多对此漫不经心的“老”程序员）。今天，我只介绍了三个最基本的部分，再来“敲黑板”画个重点：

用好空格和空行，多留白，让写代码就像写诗一样；

给变量、函数、类起个好名字，你的代码就成功了一半；

给变量、函数、类再加上注释，让代码自带文档，就成了“人能够看懂的代码”。

有了这个基础，你还可以更进一步，使用其他高级规则写出更好的代码，比如函数体不能太长、入口参数不宜过多，避免使用 else/switch 导致层次太深（圈复杂度），等等，这些虽然也很有用但比较琐碎，暂时就不细说了。

对了，还有一招“必杀技”：善用 code review，和你周围的同事互相审查代码，可以迅速改善自己的 code style。

课下作业

最后是课下作业时间，给你留两个思考题：

你用过哪些好的 code style，你最喜欢今天介绍的哪几条？

注释在代码里通常的作用是“说明文档”，但它还有另外一个重要的用法，你知道吗？

欢迎你在留言区写下你的思考和答案，如果觉得对你有所帮助，也欢迎分享给你的朋友，我们下节课见。

03 | 预处理阶段能做什么：宏定义和条件编译

你好，我是 Chrono。

上一次我讲了在编码阶段要有好的 code style，尽量写出“人能够看懂的代码”。今天，我就继续讲讲编码后的预处理阶段，看看这个阶段我们能做哪些事情。

预处理编程

其实，只要写 C/C++ 程序，就会用到预处理，只是大多数时候，你只用到了它的一点点功能。比如，在文件开头写上“#include”这样的语句，或者用“#define”定义一些常数。只是这些功能都太简单了，没有真正发挥出预处理器的本领，所以你可能几乎感觉不到它的存在。

预处理只能用很少的几个指令，也没有特别严谨的“语法”，但它仍然是一套完整自洽的语言体系，使用预处理也能够实现复杂的编程，解决一些特别的问题——虽然代码可能会显得有些“丑陋”“怪异”。

那么，“预处理编程”到底能干什么呢？

你一定要记住：预处理阶段编程的操作目标是“源码”，用各种指令控制预处理器，把源码改造成另一种形式，就像是捏橡皮泥一样。

把上面的这句话多读几遍，仔细揣摩体会一下，理解了之后，你再去用那些预处理指令就会有不一样的感觉了。

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 等指令来实现文件包含、文本替换、条件编译，把编码阶段产生的源码改变为另外一种形式。适当使用的话，可以简化代码、优化性能，但如果是“炫技”式地过分使用，就会导致导致代码混乱，难以维护。

再简单小结一下今天的内容：

预处理不属于 C++ 语言，过多的预处理语句会扰乱正常的代码，除非必要，应当少用慎用；

“#include”可以包含任意文件，所以可以写一些小的代码片段，再引进程序里；

头文件应该加上“Include Guard”，防止重复包含；

“#define”用于宏定义，非常灵活，但滥用文本替换可能会降低代码的可读性；

“条件编译”其实就是预处理编程里的分支语句，可以改变源码的形态，针对系统生成最合适的代码。

课下作业

最后是课下作业时间，给你留两个思考题：

你认为宏的哪些用法可以用其他方式替代，哪些是不可替代的？

你用过条件编译吗？分析一下它的优点和缺点。

欢迎你在留言区写下你的思考和答案，如果觉得对你有所帮助，也欢迎分享给你的朋友，我们下节课见。

04 | 编译阶段能做什么：属性和静态断言

你好，我是 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）

“预处理编程”这一讲提到的 #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 文档）。

deprecated：与 C++14 相同，但可以用在 C++11 里。

unused：用于变量、类型、函数等，表示虽然暂时不用，但最好保留着，因为将来可能会用。

constructor：函数会在 main() 函数之前执行，效果有点像是全局对象的构造函数。

destructor：函数会在 main() 函数结束之后执行，有点像是全局对象的析构函数。

always_inline：要求编译器强制内联函数，作用比 inline 关键字更强。

hot：标记“热点”函数，要求编译器更积极地优化。

这几个属性的含义还是挺好理解的吧，我拿“unused”来举个例子。

在没有这个属性的时候，如果有暂时用不到的变量，我们只能用“(void) var;”的方式假装用一下，来“骗”过编译器，属于“不得已而为之”的做法。

那么现在，我们就可以用“unused”属性来清楚地告诉编译器：这个变量我暂时不用，请不要过度紧张，不要发出警告来烦我：

[[gnu::unused]]      // 声明下面的变量暂不使用，不是错误
int nouse;

GitHub 仓库里的示例代码里还展示了其他属性的用法，你可以在课下参考。

静态断言（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++ 程序，所以我们还是要充分信任它，不要过分干预它的工作，更不要有意与它作对。

我们来小结一下今天的要点。

“属性”相当于编译阶段的“标签”，用来标记变量、函数或者类，让编译器发出或者不发出警告，还能够手工指定代码的优化方式。

官方属性很少，常用的只有“deprecated”。我们也可以使用非官方的属性，需要加上名字空间限定。

static_assert 是“静态断言”，在编译阶段计算常数和类型，如果断言失败就会导致编译错误。它也是迈向模板元编程的第一步。

和运行阶段的“动态断言”一样，static_assert 可以在编译阶段定义各种前置条件，充分利用 C++ 静态类型语言的优势，让编译器执行各种检查，避免把隐患带到运行阶段。

课下作业

最后是课下作业时间，给你留两个思考题：

预处理阶段可以自定义宏，但编译阶段不能自定义属性标签，这是为什么呢？

你觉得，怎么用“静态断言”，才能更好地改善代码质量？

欢迎你在留言区写下你的思考和答案，如果觉得今天的内容对你有所帮助，也欢迎把它分享给你的朋友。我们下节课见。

05 | 面向对象编程：怎样才能写出一个“好”的类？

你好，我是 Chrono。

如果按照前几节课的惯例，今天应该是讲运行阶段的。但是，运行阶段跟前面的编码、预处理和编译阶段不同，它是动态的、实时的，内外部环境非常复杂，CPU、内存、磁盘、信号、网络套接字……各种资源交织在一起，可谓千变万化（正如我在第 1 节课里所说，每一个阶段的差异都非常大）。

解决这个阶段面临的问题已经不是编程技术了，更多的是要依靠各种调试、分析、日志工具，比如 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 画个类体系的示意图来辅助检查。如果继承深度超过三层，就说明有点“过度设计”了，需要考虑用组合关系替代继承关系，或者改用模板和泛型。

在设计类接口的时候，我们也要让类尽量简单、“短小精悍”，只负责单一的功能。

如果很多功能混在了一起，出现了“万能类”“意大利面条类”（有时候也叫 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，只要变动别名定义就行了，原代码不需要做任何改动。

小结

今天我们谈了“面向对象编程”，这节课的内容也比较多，我划一下重点。

“面向对象编程”是一种设计思想，要点是“抽象”和“封装”，“继承”“多态”是衍生出的特性，不完全符合现实世界。

在 C++ 里应当少用继承和虚函数，降低对象的成本，绕过那些难懂易错的陷阱。

使用特殊标识符“final”可以禁止类被继承，简化类的层次关系。

类有六大基本函数，对于重要的构造 / 析构函数，可以使用“= default”来显式要求编译器使用默认实现。

“委托构造”和“成员变量初始化”特性可以让创建对象的工作更加轻松。

使用 using 或 typedef 可以为类型起别名，既能够简化代码，还能够适应将来的变化。

所谓“仁者见仁智者见智”，今天我讲的也只能算是我自己的经验、体会。到底要怎么用，你还是要看自己的实际情况，千万不要完全照搬。

课下作业

这次的课下作业时间，我给你留两个思考题：

你对“面向对象编程”有哪些认识，是否赞同这节课的观点？（希望你大胆地说出来，如果意见有分歧，那也很正常，我们一起讨论。）

你觉得应用这节课讲到的准则和技巧能否写出一个“好”的类，还缺什么吗？

欢迎你在留言区写下你的思考和答案，如果觉得今天的内容对你有所帮助，也欢迎分享给你的朋友。我们下节课见。

06 | auto/decltype：为什么要有自动类型推导？

你好，我是 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 讲）有点像，但你不需要写出 typedef 或者 using，全由 auto“代劳”。

另外，你还应该认识到，“自动类型推导”实际上和“attribute”一样（第 4 讲），是编译阶段的特殊指令，指示编译器去计算类型。所以，它在泛型编程和模板元编程里还有更多的用处，后面我会陆续讲到。

认识 auto

刚才说了，auto 有时候会不如你设想的那样工作，因此在使用的时候，有一些需要特别注意的地方，下面我就给你捋一捋。

首先，你要知道，auto 的“自动推导”能力只能用在“初始化”的场合。

具体来说，就是赋值初始化或者花括号初始化（初始化列表、Initializer list），变量右边必须要有一个表达式（简单、复杂都可以）。这样你才能在左边放上 auto，编译器才能找到表达式，帮你自动计算类型。

如果不是初始化的形式，只是“纯”变量声明，那就无法使用 auto。因为这个时候没有表达式可以让 auto 去推导。

auto x = 0L;    // 自动推导为long
auto y = &x;    // 自动推导为long*
auto z {&x};    // 自动推导为long*

auto err;       // 错误，没有赋值表达式，不知道是什么类型

这里还有一个特殊情况，在类成员变量初始化的时候（第 5 讲），目前的 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++ 里的“自动类型推导”，简单小结一下今天的内容。

“自动类型推导”是给编译器下的指令，让编译器去计算表达式的类型，然后返回给程序员。

auto 用于初始化时的类型推导，总是“值类型”，也可以加上修饰符产生新类型。它的规则比较好理解，用法也简单，应该积极使用。

decltype 使用类似函数调用的形式计算表达式的类型，能够用在任意场合，因为它就是一个编译阶段的类型。

decltype 能够推导出表达式的精确类型，但写起来比较麻烦，在初始化时可以采用 decltype(auto) 的简化形式。

因为 auto 和 decltype 不是“硬编码”的类型，所以用好它们可以让代码更清晰，减少后期维护的成本。

课下作业

最后是课下作业时间，给你留两个思考题：

auto 和 decltype 虽然很方便，但用多了也确实会“隐藏”真正的类型，增加阅读时的理解难度，你觉得这算是缺点吗？是否有办法克服或者缓解？

说一下你对 auto 和 decltype 的认识。你认为，两者有哪些区别呢？（推导规则、应用场合等）

欢迎你在留言区写下你的思考和答案，如果觉得今天的内容对你有所帮助，也欢迎分享给你的朋友，我们下节课见。

07 | const/volatile/mutable：常量/变量究竟是怎么回事？

你好，我是 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。同样，保证了安全之后，编译器确认对象不会变，也可以去做更好的优化。

看到这里，你会不会觉得常量引用、常量指针、常量函数这些概念有些“绕”呢？别担心，我给你总结了一个表格，看了它，以后你写代码的时候就不会晕了。

这方面你还可以借鉴一下标准库，比如 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，让代码更安全。

这在多线程编程时尤其有用，让编译器帮你检查对象的所有操作，把“只读”属性持续传递出去，避免有害的副作用。

课下作业

最后是课下作业时间，给你留两个思考题：

学完了这节课，你觉得今后应该怎么用 const 呢？

给函数的返回值加上 const，也就是说返回一个常量对象，有什么好处？

欢迎你在留言区写下你的思考和答案，如果觉得文章对你有所帮助，也欢迎把文章分享给你的朋友，我们下节课见。

08 | smart_ptr：智能指针到底“智能”在哪里？

你好，我是 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 讲）的 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);
}

小结

好了，今天就先到这里。智能指针的话题很大，但是学习的时候我们不可能一下子把所有知识点都穷尽，而是要有优先级。所以我会捡最要紧的先介绍给你，剩下的接口函数等细节，还是需要你根据自己的情况，再去参考一些其他资料深入学习的。

我们来回顾一下这节课的重点。

智能指针是代理模式的具体应用，它使用 RAII 技术代理了裸指针，能够自动释放内存，无需程序员干预，所以被称为“智能指针”。

如果指针是“独占”使用，就应该选择 unique_ptr，它为裸指针添加了很多限制，更加安全。

如果指针是“共享”使用，就应该选择 shared_ptr，它的功能非常完善，用法几乎与原始指针一样。

应当使用工厂函数 make_unique()、make_shared() 来创建智能指针，强制初始化，而且还能使用 auto 来简化声明。

shared_ptr 有少量的管理成本，也会引发一些难以排查的错误，所以不要过度使用。

我还有一个很重要的建议：

既然你已经理解了智能指针，就尽量不要再使用裸指针、new 和 delete 来操作内存了。

如果严格遵守这条建议，用好 unique_ptr、shared_ptr，那么，你的程序就不可能出现内存泄漏，你也就不需要去费心研究、使用 valgrind 等内存调试工具了，生活也会更“美好”一点。

课下作业

最后是课下作业时间，给你留两个思考题：

你觉得 unique_ptr 和 shared_ptr 的区别有哪些？列举一下。

你觉得应该如何在程序里“消灭”new 和 delete？

欢迎你在留言区写下你的思考和答案，如果觉得今天的内容对你有所帮助，也欢迎分享给你的朋友，我们下节课见。

09 | exception：怎样才能用好异常？

你好，我是 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++ 代码混在一起的情景。）

错误码还有另一个更大的问题：它是可以被忽略的。也就是说，你完全可以不处理错误，“假装”程序运行正常，继续跑后面的代码，这就可能导致严重的安全隐患。（可能是无意的，因为你确实不知道发生了什么错误。）

“没有对比就没有伤害”，现在你就应该明白了，作为一种新的错误处理方式，异常就是针对错误码的缺陷而设计的，它有三个特点。

异常的处理流程是完全独立的，throw 抛出异常后就可以不用管了，错误处理代码都集中在专门的 catch 块里。这样就彻底分离了业务逻辑与错误逻辑，看起来更清楚。

异常是绝对不能被忽略的，必须被处理。如果你有意或者无意不写 catch 捕获异常，那么它会一直向上传播出去，直至找到一个能够处理的 catch 块。如果实在没有，那就会导致程序立即停止运行，明白地提示你发生了错误，而不会“坚持带病工作”。

异常可以用在错误码无法使用的场合，这也算是 C++ 的“私人原因”。因为它比 C 语言多了构造 / 析构函数、操作符重载等新特性，有的函数根本就没有返回值，或者返回值无法表示错误，而全局的 errno 实在是“太不优雅”了，与 C++ 的理念不符，所以也必须使用异常来报告错误。

记住这三个关键点，是在 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> 头文件里。

下面我画了个简单的示意图，你可以看一下。

标准异常的继承体系有点复杂，最上面是基类 exception，下面是几个基本的异常类型，比如 bad_alloc、bad_cast、runtime_error、logic_error，再往下还有更细致的错误类型，像 runtime_error 就有 range_error、overflow_error，等等。

我在第 5 节课讲过，如果继承深度超过三层，就说明有点“过度设计”，很明显现在就有这种趋势了。所以，我建议你最好选择上面的第一层或者第二层的某个类型作为基类，不要再加深层次。

比如说，你可以从 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 节课里的“属性”做编译阶段优化：

[[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）操作，如果异常出现的位置很深，但又没有被及时处理，或者频繁地抛出异常，就会对运行性能产生很大的影响。这个时候，程序全忙着去处理异常了，正常逻辑反而被搁置。

这种观点我认为是“暴饮暴食”，也不可取。

所以，第三种方式就是两者的折中：区分“非”错误、“轻微”错误和“严重”错误，谨慎使用异常。我认为这应该算是“均衡饮食”。

具体来说，就是要仔细分析程序中可能发生的各种错误情况，按严重程度划分出等级，把握好“度”。

对于正常的返回值，或者不太严重、可以重试 / 恢复的错误，我建议你不使用异常，把它们归到正常的流程里。

比如说字符串未找到（不是错误）、数据格式不对（轻微错误）、数据库正忙（可重试错误），这样的错误比较轻微，而且在业务逻辑里会经常出现，如果你用异常处理，就会“小题大做”，影响性能。

剩下的那些中级、高级错误也不是都必须用异常，你还要再做分析，尽量降低引入异常的成本。

我自己总结了几个应当使用异常的判断准则：

不允许被忽略的错误；

极少数情况下才会发生的错误；

严重影响正常流程，很难恢复到正常状态的错误；

无法本地处理，必须“穿透”调用栈，传递到上层才能被处理的错误。

规则听起来可能有点不好理解，我给你举几个例子。

比如说构造函数，如果内部初始化失败，无法创建，那后面的逻辑也就进行不下去了，所以这里就可以用异常来处理。

再比如，读写文件，通常文件系统很少会出错，总会成功，如果用错误码来处理不存在、权限错误等，就显得太啰嗦，这时也应该使用异常。

相反的例子就是 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++ 也没有在语言层面提出更好的机制，所以，你还要在编码阶段写好文档和注释，说清楚哪些函数、什么情况下会抛出什么样的异常，应如何处理，加上一些“软约束”。

再简单小结一下今天的内容：

异常是针对错误码的缺陷而设计的，它不能被忽略，而且可以“穿透”调用栈，逐层传播到其他地方去处理；

使用 try-catch 机制处理异常，能够分离正常流程与错误处理流程，让代码更清晰；

throw 可以抛出任何类型作为异常，但最好使用标准库里定义的 exception 类；

完全用或不用异常处理错误都不可取，而是应该合理分析，适度使用，降低异常的成本；

关键字 noexcept 标记函数不抛出异常，可以让编译器做更好的优化。

课下作业

最后是课下作业时间，给你留两个思考题：

结合自己的实际情况，谈一下使用异常有什么好处和坏处。

你觉得用好异常还有哪些要注意的地方？

欢迎你在留言区写下你的思考和答案，如果觉得今天的内容对你有所帮助，也欢迎分享给你的朋友，我们下节课见。

10 | lambda：函数式编程带来了什么？

你好，我是 Chrono。

在第 1 节课的时候，我就说到过“函数式编程”，但只是简单提了提，没有展开讲。

作为现代 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，可读性要比大量的分支语句好得多。

小结一下今天的要点内容：

lambda 表达式是一个闭包，能够像函数一样被调用，像变量一样被传递；

可以使用 auto 自动推导类型存储 lambda 表达式，但 C++ 鼓励尽量就地匿名使用，缩小作用域；

lambda 表达式使用“[=]”的方式按值捕获，使用“[&]”的方式按引用捕获，空的“[]”则是无捕获（也就相当于普通函数）；

捕获引用时必须要注意外部变量的生命周期，防止变量失效；

C++14 里可以使用泛型的 lambda 表达式，相当于简化的模板函数。

末了我再说一句，和 C++ 里的大多数新特性一样，滥用 lambda 表达式的话，就会产生一些难以阅读的代码，比如多个函数的嵌套和串联、调用层次过深。这也需要你在实践中慢慢积累经验，找到最适合你自己的使用方式。

课下作业

最后是课下作业时间，给你留两个思考题吧：

你对函数式编程有什么样的理解和认识呢？

lambda 表达式的形式非常简洁，可以在很多地方代替普通函数，那它能不能代替类的成员函数呢？为什么？

欢迎你在留言区写下你的思考和答案，如果觉得今天的内容对你有所帮助，也欢迎分享给你的朋友。我们下节课见。

11 | 一枝独秀的字符串：C++也能处理文本？

你好，我是 Chrono。

在第一个单元里，我们学习了 C++ 的生命周期和编程范式。在第二个单元里，我们学习了自动类型推导、智能指针、lambda 表达式等特性。今天，我们又要开始进入一个新的单元了，这就是 C++ 标准库。

以前，“C++”这个词还只是指编程语言，但是现在，“C++”早已变成了一个更大的概念——不单是词汇、语法，还必须要加上完备工整的标准库。只有语言、标准库“双剑合璧”，才能算是真正的 C++。反过来说，如果只单纯用语言，拒绝标准库，那就成了“天残地缺”。

看一下官方发布的标准文档吧（C++14），全文有 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++11 为方便使用字符串，新增了一个字面量的后缀“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/），我就重点介绍一下在 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仓库里的资料链接，深入研究它的接口和设置参数。

小结

好了，今天我讲了字符串类 string 和正则表达式库 regex，它们是 C++ 标准库里处理文本的唯一工具，虽然离完美还有距离，但我们也别无选择。目前我们能做的，就是充分掌握一些核心技巧，规避一些使用误区。这节课是我的经验总结，建议你多读几遍，希望可以进一步提升你的编码能力。

简单小结一下今天的内容：

C++ 支持多种字符类型，常用的 string 其实是模板类 basic_string 的特化形式；

目前 C++ 对 Unicode 的支持还不太完善，建议尽量避开国际化和编码转化，不要“自讨苦吃”；

应当把 string 视为一个完整的字符串来操作，不要把它当成容器来使用；

字面量后缀“s”表示字符串类，可以用来自动推导出 string 类型；

原始字符串不会转义，是字符串的原始形态，适合在代码里写复杂的文本；

处理文本应当使用正则表达式库 regex，它的功能非常强大，但需要花一些时间和精力才能掌握。

课下作业

最后是课下作业时间，给你留两个思考题：

你平时在使用字符串的时候有感觉到哪些不方便吗？如果有的话，是怎么解决的？

你觉得正则表达式能够应用在什么地方，解决哪些实际的问题？

欢迎你在留言区写下你的思考和答案，如果觉得今天的内容对你有所帮助，也欢迎分享给你的朋友。我们下节课见。

12 | 三分天下的容器：恰当选择，事半功倍

你好，我是 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 讲的内容，弄清楚这两个智能指针之间的差异，区分“独占语义”和“共享语义”。

一般情况下，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 了。

有序容器

顺序容器的特点是，元素的次序是由它插入的次序而决定的，访问元素也就按照最初插入的顺序。而有序容器则不同，它的元素在插入容器后就被按照某种规则自动排序，所以是“有序”的。

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。

再简单小结一下今天的内容：

标准容器可以分为三大类，即顺序容器、有序容器和无序容器；

所有容器中最优先选择的应该是 array 和 vector，它们的速度最快，开销最低；

list 是链表结构，插入删除的效率高，但查找效率低；

有序容器是红黑树结构，对 key 自动排序，查找效率高，但有插入成本；

无序容器是散列表结构，由 hash 值计算存储位置，查找和插入的成本都很低；

有序容器和无序容器都属于关联容器，元素有 key 的概念，操作元素实际上是在操作 key，所以要定义对 key 的比较函数或者散列函数。

我再教你一个使用这些容器的小技巧，就是多利用类型别名，而不要“写死”容器定义。因为容器的大部分接口是相同的，所以只要变动别名定义，就能够随意改换不同的容器，对于开发、测试都非常方便。

课下作业

最后是课下作业时间，给你留两个思考题：

试着用自己的语言说一下这些容器的优点、缺点和区别。

你最喜欢、最常用的是哪种容器？为什么？

欢迎在留言区写下你的思考和答案，如果觉得今天的内容对你有所帮助，也欢迎分享给你的朋友。我们下节课见。

13 | 五花八门的算法：不要再手写for循环了

你好，我是 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 循环。

你可以课后仔细阅读标准文档，对照自己的现有代码，看看哪些能用得上，再试着用算法来改写实现，体会一下算法的简洁和高效。

简单小结一下这次的内容：

算法是专门操作容器的函数，是一种“智能 for 循环”，它的最佳搭档是 lambda 表达式；

算法通过迭代器来间接操作容器，使用两个端点指定操作范围，迭代器决定了算法的能力；

for_each 算法是 for 的替代品，以函数式编程替代了面向过程编程；

有多种排序算法，最基本的是 sort，但应该根据实际情况选择其他更合适的算法，避免浪费；

在已序容器上可以执行二分查找，应该使用的算法是 lower_bound；

list/set/map 提供了等价的排序、查找函数，更适应自己的数据结构；

find/search 是通用的查找算法，效率不高，但不必排序也能使用。

和上节课一样，我再附送一个小技巧。

因为标准算法的名字实在是太普通、太常见了，所以建议你一定要显式写出“std::”名字空间限定，这样看起来更加醒目，也避免了无意的名字冲突。

课下作业

最后是课下作业时间，给你留两个思考题：

你觉得 for_each 算法能完全替代 for 循环吗？

试着自己总结归纳一下，这些排序和查找算法在实际开发中应该如何使用。

欢迎你在留言区写下你的思考和答案，如果觉得今天的内容对你有所帮助，也欢迎分享给你的朋友。我们下节课见。

14 | 十面埋伏的并发：多线程真的很难吗？

你好，我是 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 讲里的做法，多用 const 关键字，尽可能让操作都是只读的，为多线程打造一个坚实的基础。

然后，我要说一个多线程开发的原则，也是一句“自相矛盾”的话：

最好的并发就是没有并发，最好的多线程就是没有线程。

这又是什么意思呢？

简单来说，就是在大的、宏观的层面上“看得到”并发和线程，而在小的、微观的层面上“看不到”线程，减少死锁、同步等恶性问题的出现几率。

多线程开发实践

下面，我就来讲讲具体该怎么实践这个原则。在 C++ 里，有四个基本的工具：仅调用一次、线程局部存储、原子变量和线程对象。

仅调用一次

程序免不了要初始化数据，这在多线程里却是一个不大不小的麻烦。因为线程并发，如果没有某种同步手段来控制，会导致初始化函数多次运行。

为此，C++ 提供了“仅调用一次”的功能，可以很轻松地解决这个问题。

这个功能用起来很简单，你要先声明一个 once_flag 类型的变量，最好是静态、全局的（线程可见），作为初始化的标志：

static std::once_flag flag;        // 全局的初始化标志

然后调用专门的 call_once() 函数，以函数式编程的方式，传递这个标志和初始化函数。这样 C++ 就会保证，即使多个线程重入 call_once()，也只能有一个线程会成功运行初始化。

下面是一个简单的示例，使用了 lambda 表达式来模拟实际的线程函数。你可以把GitHub 仓库里的代码下到本地，实际编译运行看看效果：

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），没有 store、load 的操作，只用来实现 TAS，保证绝对无锁。

你能用这些原子变量做些什么呢？

最基本的用法是把原子变量当作线程安全的全局计数器或者标志位，这也算是“初心”吧。但它还有一个更重要的应用领域，就是实现高效的无锁数据结构（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++ 里的具体实现，用法上没太多新意，所以我就不再多介绍了。

小结

说了这么长时间，你可能会有些奇怪，这节课的标题里有线程，但我并没有讲太多线程相关的东西，更多的是在讲“不用线程”的思维方式。

所谓“当局者迷”，如果你一头扎进多线程的世界，全力去研究线程、互斥量、锁等细节，就很容易“钻进死胡同”“一条道走到黑”。

很多时候，我们应该跳出具体的编码，换个角度来看问题，也许就能够“柳暗花明又一村”，得到新的、优雅的解决办法。

好了，今天就到这里，做个小结：

多线程是并发最常用的实现方式，好处是任务并行、避免阻塞，坏处是开发难度高，有数据竞争、死锁等很多“坑”；

call_once() 实现了仅调用一次的功能，避免多线程初始化时的冲突；

thread_local 实现了线程局部存储，让每个线程都独立访问数据，互不干扰；

atomic 实现了原子化变量，可以用作线程安全的计数器，也可以实现无锁数据结构；

async() 启动一个异步任务，相当于开了一个线程，但内部通常会有优化，比直接使用线程更好。

我再告诉你一个好消息：C++20 正式加入了协程（关键字 co_wait/co_yield/co_return）。它是用户态的线程，没有系统级线程那么多的麻烦事，使用它就可以写出开销更低、性能更高的并发程序。让我们一起期待吧！

课下作业

最后是课下作业时间，给你留两个思考题：

你在多线程编程的时候遇到过哪些“坑”，有什么经验教训？

你觉得 async() 比直接用 thread 启动线程好在哪里？

欢迎在留言区写下你的思考和答案，如果觉得今天的内容对你有所帮助，也欢迎分享给你的朋友。我们下节课见。

15 | 序列化：简单通用的数据交换格式有哪些？

你好，我是 Chrono。

在前面的三个单元里，我们学习了 C++ 的语言特性和标准库，算是把 C++ 的编程范式、生命周期、核心特性、标准库的内容整体过了一遍。从今天起，我们的学习之旅又将开启一个新的篇章。

C++ 语言和标准库很强大，功能灵活，组件繁多，但也只能说是构建软件这座大厦的基石。想要仅凭它们去“包打天下”，不能说是绝对不可行，但至少是“吃力难讨好”。

还是那句老话：“不要重复发明轮子。”（Reinventing the wheel）虽然很多 C++ 程序员都热衷于此，但我觉得对于你我这样的“凡人”，还是要珍惜自己的时间和精力，把有限的资源投入到能有更多产出的事情上。

所以，接下来的这几节课，我会介绍一些第三方工具，精选出序列化 / 反序列化、网络通信、脚本语言混合编程和性能分析这四类工具，弥补标准库的不足，节约你的开发成本，让你的工作更有效率。

今天，我先来说一下序列化和反序列化。这两个功能在软件开发中经常遇到，你可能很熟悉了，所以我只简单解释一下。

序列化，就是把内存里“活的对象”转换成静止的字节序列，便于存储和网络传输；而反序列化则是反向操作，从静止的字节序列重新构建出内存里可用的对象。

我借用《三体》里的内容，打一个形象的比喻：序列化就是“三体人”的脱水，变成干纤维，在乱纪元方便存储运输；反序列化就是“三体人”的浸泡，在恒纪元由干纤维再恢复成活生生的人。（即使没读过《三体》，也是很好理解的吧？）

接下来，我就和你介绍三种既简单又高效的数据交换格式：JSON、MessagePack 和 ProtoBuffer，看看在 C++ 里怎么对数据做序列化和反序列化。

JSON

JSON 是一种轻量级的数据交换格式，采用纯文本表示，所以是“human readable”，阅读和修改都很方便。

由于 JSON 起源于“最流行的脚本语言”JavaScript，所以它也随之得到了广泛的应用，在 Web 开发领域几乎已经成为了事实上的标准，而且还渗透到了其他的领域。比如很多数据库就支持直接存储 JSON 数据，还有很多应用服务使用 JSON 作为配置接口。

在JSON 的官方网站上，你可以找到大量的 C++ 实现，不过用起来都差不多。因为 JSON 本身就是个 KV 结构，很容易映射到类似 map 的关联数组操作方式。

如果不是特别在意性能的话，选个你自己喜欢的就好。否则，你就要做一下测试，看哪一个更适合你的应用场景。

不过我觉得，JSON 格式注重的是方便易用，在性能上没有太大的优势，所以一般选择 JSON 来交换数据，通常都不会太在意性能（不然肯定会改换其他格式了），还是自己用着顺手最重要。

下面就来说说我的个人推荐：“JSON for Modern C++”这个库。

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、自定义类型转换等。如果你需要这些功能，可以去看它的文档，里面写得都很详细。

MessagePack

说完 JSON，再来说另外第二种格式：MessagePack。

它也是一种轻量级的数据交换格式，与 JSON 的不同之处在于它不是纯文本，而是二进制。所以 MessagePack 就比 JSON 更小巧，处理起来更快，不过也就没有 JSON 那么直观、易读、好修改了。

由于二进制这个特点，MessagePack 也得到了广泛的应用，著名的有 Redis、Pinterest。

MessagePack 支持几乎所有的编程语言，你可以在官网上找到它的 C++ 实现。

我常用的是官方库 msgpack-c，可以用 apt-get 直接安装。

apt-get install libmsgpack-dev

但这种安装方式有个问题，可能发行方仓库里的是老版本（像 Ubuntu 16.04 就是 0.57），缺失很多功能，所以最好是从GitHub上下载最新版，编译时手动指定包含路径：

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，通常简称为 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。

这三种数据格式各有特色，在很多领域都得到了广泛的应用，我来简单小结一下：

JSON 是纯文本，容易阅读，方便编辑，适用性最广；

MessagePack 是二进制，小巧高效，在开源界接受程度比较高；

ProtoBuffer 是工业级的数据格式，注重安全和性能，多用在大公司的商业产品里。

有很多开源库支持这些数据格式，官方的、民间的都有，你应该选择适合自己的高质量库，必要的时候可以做些测试。

再补充一点，除了今天说的这三种，你还可以尝试其他的数据格式，比较知名的有 Avro、Thrift，虽然它们有点冷门，但也有自己的独到之处（比如，天生支持 RPC、可选择多种序列化格式和传输方式）。

课下作业

最后是课下作业时间，给你留两个思考题：

为什么要有序列化和反序列化，直接 memcpy 内存数据行不行呢？

你最常用的是哪种数据格式？它有什么优缺点？

欢迎你在留言区写下你的思考和答案，如果觉得今天的内容对你有所帮助，也欢迎分享给你的朋友。我们下节课见。

16 | 网络通信：我不想写原生Socket

你好，我是 Chrono。

在上一节课，我讲了 JSON、MessagePack 和 ProtoBuffer 这三种数据交换格式。现在，我们手里有了这些跨语言、跨平台的通用数据，该怎么与外部通信交换呢？

你肯定首先想到的就是 Socket 网络编程，使用 TCP/IP 协议栈收发数据，这样不仅可以在本地的进程间通信，也可以在主机、机房之间异地通信。

大方向上这是没错的，但你也肯定知道，原生的 Socket API 非常底层，要考虑很多细节，比如 TIME_WAIT、CLOSE_WAIT、REUSEADDR 等，如果再加上异步就更复杂了。

虽然你可能看过、学过不少这方面的资料，对如何处理这些问题“胸有成竹”，但无论如何，像 Socket 建连 / 断连、协议格式解析、网络参数调整等，都要自己动手做，想要“凭空”写出一个健壮可靠的网络应用程序还是相当麻烦的。

所以，今天我就来谈谈 C++ 里的几个好用的网络通信库：libcurl、cpr 和 ZMQ，让你摆脱使用原生 Socket 编程的烦恼。

libcurl：高可移植、功能丰富的通信库

第一个要说的库是 libcurl，它来源于著名的curl 项目，也是 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 个：

使用 curl_easy_init() 创建一个句柄，类型是 CURL*。但我们完全没有必要关心句柄的类型，直接用 auto 推导就行。

使用 curl_easy_setopt() 设置请求的各种参数，比如请求方法、URL、header/body 数据、超时、回调函数等。这是最关键的操作。

使用 curl_easy_perform() 发送数据，返回的数据会由回调函数处理。

使用 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上获取 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 讲），和 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。

其实，ZMQ 不仅是一个单纯的网络通信库，更像是一个高级的异步并发框架。

从名字上就可以看出来，Zero Message Queue——零延迟的消息队列，意味着它除了可以收发数据外，还可以用作消息中间件，解耦多个应用服务之间的强依赖关系，搭建高效、有弹性的分布式系统，从而超越原生的 Socket。

作为消息队列，ZMQ 的另一大特点是零配置零维护零成本，不需要搭建额外的代理服务器，只要安装了开发库就能够直接使用，相当于把消息队列功能直接嵌入到你的应用程序里：

apt-get install libzmq3-dev

ZMQ 是用 C++ 开发的，但出于兼容的考虑，对外提供的是纯 C 接口。不过它也有很多 C++ 封装，这里我选择的是自带的cppzmq，虽然比较简单，但也基本够用了。

由于 ZMQ 把自身定位于更高层次的“异步消息队列”，所以它的用法就不像 Socket、HTTP 那么简单直白，而是定义了 5 种不同的工作模式，来适应实际中常见的网络通信场景。

我来大概说一下这 5 种模式：

原生模式（RAW），没有消息队列功能，相当于底层 Socket 的简单封装；

结对模式（PAIR），两个端点一对一通信；

请求响应模式（REQ-REP），也是两个端点一对一通信，但请求必须有响应；

发布订阅模式（PUB-SUB），一对多通信，一个端点发布消息，多个端点接收处理；

管道模式（PUSH-PULL），或者叫流水线，可以一对多，也可以多对一。

前四种模式类似 HTTP 协议、Client-Server 架构，很简单，就不多说了。我拿我在工作中比较常用的管道模式来给你示范一下 ZMQ 的用法，它非常适合进程间无阻塞传送海量数据，也有点 map-reduce 的意思。

在 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 的用法就是这么简单，但想要进一步发掘它的潜力，处理大流量的数据还是要去看它的文档，选择合适的工作模式，再仔细调节各种参数。

接下来，我再给你分享两个实际工作中会比较有用的细节吧。

一个是 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 就暂时说到这里，它还有很多强大的功能，你可以阅读官网上的教程和指南，里面非常详细地讨论了 ZMQ 的各种模式和要点。

小结

好了，我来给今天的内容做一个小结：

libcurl 是一个功能完善、稳定可靠的应用层通信库，最常用的就是 HTTP 协议；

cpr 是对 libcurl 的 C++ 封装，接口简单易用；

libcurl 和 cpr 都只能作为客户端来使用，不能编写服务器端应用；

ZMQ 是一个高级的网络通信库，支持多种通信模式，可以把消息队列功能直接嵌入应用程序，搭建出高效、灵活、免管理的分布式系统。

最后，再说说即将到来的 C++20，原本预计会加入期待已久的 networking 库，但现在已经被推迟到了下一个版本（C++23）。

networking 库基于已有多年实践的 boost.asio，采用前摄器模式（Proactor）统一封装了操作系统的各种异步机制（epoll、kqueue、IOCP），而且支持协程。有了它，我们的网络通信工作就会更加轻松。

课下作业

最后是课下作业时间，给你留两个思考题：

你在网络编程的时候都遇到过哪些“坑”，今天说的这几个库能否解决你的问题？

你觉得 ZMQ 能够在多大程度上代替原生 Socket？

欢迎你在留言区写下你的思考和答案，如果觉得今天的内容对你有所帮助，也欢迎分享给你的朋友。我们下节课见。

17 | 脚本语言：搭建高性能的混合系统

你好，我是 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。

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++ 特性，你可以再参考一下它的文档，学习一下具体的方法，挖掘出它的更多价值。

如果你在工作中重度使用 Python，那么 pybind11 绝对是你的得力助手，它能够让 C++ 紧密地整合进 Python 应用里，让 Python 跑得更快、更顺畅，建议你有机会就尽量多用。

Lua

接下来我要说的第二个脚本语言是小巧高效的 Lua，号称是“最快的脚本语言”。

你可能对 Lua 不太了解，但你一定听说过《魔兽世界》《愤怒的小鸟》吧，它们就在内部大量使用了 Lua 来编写逻辑。在游戏开发领域，Lua 可以说是一种通用的工作语言。

Lua 与其他语言最大的不同点在于它的设计目标：不追求“大而全”，而是“小而美”。Lua 自身只有很小的语言核心，能做的事情很少。但正是因为它小，才能够很容易地嵌入到其他语言里，为“宿主”添加脚本编程的能力，让“宿主”更容易扩展和定制。

标准的 Lua（PUC-Rio Lua）使用解释器运行，速度虽然很快，但和 C/C++ 比起来还是有差距的。所以，你还可以选择另一个兼容的项目：LuaJIT（https://luajit.org/）。它使用了 JIT（Just in time）技术，能够把 Lua 代码即时编译成机器码，速度几乎可以媲美原生 C/C++ 代码。

不过，LuaJIT 也有一个问题，它是一个个人项目，更新比较慢，最新的 2.1.0-beta3 已经是三年前的事情了。所以，我推荐你改用它的一个非官方分支：OpenResty-LuaJIT（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 让它运行速度极快。你可以结合自己的实际情况来选择，比如语言的熟悉程度、项目的功能 / 性能需求、开发的难易度，等等。

今天的内容也比较多，我简单小结一下要点：

C++ 高效、灵活，但开发周期长、成本高，在混合系统里可以辅助其他语言，编写各种底层模块提供扩展功能，从而扬长避短；

pybind11 是一个优秀的 C++/Python 绑定库，只需要写很简单的代码，就能够把函数、类等 C++ 要素导入 Python；

Lua 是另一种小巧快速的脚本语言，它的兼容项目 LuaJIT 速度更快；

使用 LuaBridge 可以导出 C++ 的函数、类，但直接用 LuaJIT 的 ffi 库更好；

使用 LuaBridge 也可以很容易地执行 Lua 脚本、调用 Lua 函数，让 Lua 跑在 C++ 里。

课下作业

最后是课下作业时间，给你留两个思考题：

你觉得使用脚本语言与 C++ 搭建混合系统有什么优势？

你觉得“把 C++ 嵌入脚本语言”和“把脚本语言嵌入 C++”有什么区别，哪种方式更好？

欢迎你在留言区写下你的思考和答案，如果觉得今天的内容对你有所帮助，也欢迎分享给你的朋友。我们下节课见。

18 | 性能分析：找出程序的瓶颈

你好，我是 Chrono。

今天是“技能进阶”单元的最后一节课，我也要兑现刚开始在“概论”里的承诺，讲一讲在运行阶段我们能做什么。

运行阶段能做什么

在编码阶段，你会运用之前学习的各种范式和技巧，写出优雅、高效的代码，然后把它交给编译器。经过预处理和编译这两个阶段，源码转换成了二进制的可执行程序，就能够在 CPU 上“跑”起来。

在运行阶段，C++ 静态程序变成了动态进程，是一个实时、复杂的状态机，由 CPU 全程掌控。但因为 CPU 的速度实在太快，程序的状态又实在太多，所以前几个阶段的思路、方法在这个时候都用不上。

所以，我认为，在运行阶段能做、应该做的事情主要有三件：调试（Debug）、测试（Test）和性能分析（Performance Profiling）。

调试你一定很熟悉了，常用的工具是 GDB，我在前面的“轻松话题”里也讲过一点它的使用技巧。它的关键是让高速的 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 查看的性能：

从 top 的输出结果里，你可以看到进程运行的概况，知道 CPU、内存的使用率。如果你发现某个指标超出了预期，就说明可能存在问题，接下来，你就应该采取更具体的措施去进一步分析。

比如说，这里面的一个进程 CPU 使用率太高，我怀疑有问题，那我就要深入进程内部，看看到底是哪些操作消耗了 CPU。

这时，我们可以选用两个工具：pstack 和 strace。

pstack 可以打印出进程的调用栈信息，有点像是给正在运行的进程拍了个快照，你能看到某个时刻的进程里调用的函数和关系，对进程的运行有个初步的印象。

下面这张截图显示了一个进程的部分调用栈，可以看到，跑了好几个 ZMQ 的线程在收发数据：

不过，pstack 显示的只是进程的一个“静态截面”，信息量还是有点少，而 strace 可以显示出进程的正在运行的系统调用，实时查看进程与系统内核交换了哪些信息：

把 pstack 和 strace 结合起来，你大概就可以知道，进程在用户空间和内核空间都干了些什么。当进程的 CPU 利用率过高或者过低的时候，我们有很大概率能直接发现瓶颈所在。

不过，有的时候，你也可能会“一无所获”，毕竟这两个工具获得的信息只是“表象”，数据的“含金量”太低，做不出什么有效的决策，还是得靠“猜”。要拿到更有说服力的“数字”，就得 perf 出场了。

perf 可以说是 pstack 和 strace 的“高级版”，它按照固定的频率去“采样”，相当于连续执行多次的 pstack，然后再统计函数的调用次数，算出百分比。只要采样的频率足够大，把这些“瞬时截面”组合在一起，就可以得到进程运行时的可信数据，比较全面地描述出 CPU 使用情况。

我常用的 perf 命令是“perf top -K -p xxx”，按 CPU 使用率排序，只看用户空间的调用，这样很容易就能找出最耗费 CPU 的函数。

比如，下面这张图显示的是大部分 CPU 时间都消耗在了 ZMQ 库上，其中，内存拷贝调用居然达到了近 30%，是不折不扣的“大户”。所以，只要能把这些拷贝操作减少一点，就能提升不少性能。

总之，使用 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上下载源码，然后用“--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上找到原图。

这张火焰图实际上是“倒置”的冰柱图，显示的是自顶向下查看函数的调用栈。

由于 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 用法就这么多了，你可以再去看它的官方文档了解更多的用法，比如使用环境变量和信号来控制启停性能分析，或者链接 tcmalloc 库，优化 C++ 的内存分配速度。

小结

好了，今天主要讲了运行阶段里的性能分析，它能够回答为什么系统“不够好”（not good enough），而调试和测试回答的是为什么系统“不好”（not good）。

简单小结一下今天的内容：

最简单的性能分析工具是 top，可以快速查看进程的 CPU、内存使用情况；

pstack 和 strace 能够显示进程在用户空间和内核空间的函数调用情况；

perf 以一定的频率采样分析进程，统计各个函数的 CPU 占用百分比；

gperftools 是“侵入”式的性能分析工具，能够生成文本或者图形化的分析报告，最直观的方式是火焰图。

性能分析与优化是一门艰深的课题，也是一个广泛的议题，CPU、内存、网络、文件系统、数据库等等，每一个方向都可以再引出无数的话题。

今天介绍的这些，是我挑选的对初学者最有用的内容，学习难度不高，容易上手，见效快。希望你能以此为契机，在今后的日子里多用、多实际操作，并且不断去探索、应用其他的分析工具，综合运用它们给程序“把脉”，才能让 C++ 在运行阶段跑得更好更快更稳，才能不辜负前面编码、预处理和编译阶段的苦心与努力。

课下作业

最后还是留两个思考题吧：

你觉得在运行阶段还能够做哪些事情？

你有性能分析的经验吗？听了今天的这节课之后，你觉得什么方式比较适合自己？

欢迎你在留言区写下你的思考和答案，如果觉得今天的内容对你有所帮助，也欢迎分享给你的朋友。我们下节课见。

19 | 设计模式（上）：C++与设计模式有啥关系？

你好，我是 Chrono。

今天，我们进入最后的“总结”单元，把前面学到的这些知识上升到“理论结合实践”的高度，做个归纳整理。我们先来了解一下设计模式和设计原则，然后再把理论“落地”，综合利用所有知识点，设计并开发出一个实际的服务器应用。

你可能会问了：我们这是个 C++ 的课程，为什么还要专门来讲设计模式呢？

我觉得，设计模式是一门通用的技术，是指导软件开发的“金科玉律”，它不仅渗透进了 C++ 语言和库的设计（当然也包括其他编程语言），而且也是成为高效 C++ 程序员必不可缺的“心法”和“武器”。

掌握了它们，理解了语言特性、库和工具后面的设计思想，你就可以做到“知其然，更知其所以然”，然后以好的用法为榜样，以坏的用法为警示，扬长避短，从而更好地运用 C++。

所以，我把我这些年的实践经验进行了提炼和总结，糅合成了两节课，帮你快速掌握，并且用好设计模式，写出高效、易维护的代码。这节课，我会先讲一讲学好设计模式的核心方法，下节课，我们再讲在 C++ 里具体应用了哪些设计模式。

为什么要有设计模式？

虽然 C++ 支持多范式编程，但面向对象毕竟还是它的根基，而且，面向对象编程也通用于当前各种主流的编程语言。所以，学好、用好面向对象，对于学好 C++ 来说，非常有用。

但是，想要得到良好的面向对象设计，并不是一件容易的事情。

因为每个人自身的能力、所在的层次、看问题的角度都不同，仅凭直觉“对现实建模”，很有可能会生成一些大小不均、职责不清、关系混乱的对象，最后搭建出一个虽然可以运行，但却难以理解、难以维护的系统。

所以，设计模式就是为此而生的。

它系统地描述了一些软件开发中的常见问题、应用场景和对应的解决方案，给出了专家级别的设计思路和指导原则。

按照设计模式去创建面向对象的系统，就像是由专家来“手把手”教你，不能说绝对是“最优解”，但至少是“次优解”。

而且，在应用设计模式的过程中，你还可以从中亲身体会这些经过实际证明的成功经验，潜移默化地影响你自己思考问题的方式，从长远来看，学习和应用设计模式能够提高你的面向对象设计水平。

学习、理解设计模式，才能用好面向对象的 C++

经典的《设计模式》一书里面介绍了 23 个模式，并依据设计目的把它们分成了三大类：创建型模式、结构型模式和行为模式。

这三类模式分别对应了开发面向对象系统的三个关键问题：如何创建对象、如何组合对象，以及如何处理对象之间的动态通信和职责分配。解决了这三大问题，软件系统的“架子”也就基本上搭出来了。

23 个模式看起来好像不是很多，但它们的内涵和外延都是非常丰富的，不然也不会有数不清的论文、书刊研究它们了，所以，我们要从多角度、多方面去评价、审视模式。

那该怎么做才好呢？

你可以看一下《设计模式》的原书，它用了一个很全面的体例来描述模式，包括名称、别名、动机、结构、示例、效果、相关模式，等等。

虽然显得有点琐碎、啰唆，但我们必须要承认，这种严谨、甚至是有些刻板的方式能够全方位、无死角地介绍模式，强迫你从横向、纵向、深层、浅层、抽象、具体等各个角度来研究、思考。只有在这个过程中，你才能真正掌握设计模式的内核。

模式里的结构和实现方式直接表现为代码，可能是最容易学习的部分，但我认为，其实这些反而是最不重要的。

你更应该去关注它的参与者、设计意图、面对的问题、应用的场合、后续的效果等代码之外的部分，它们通常比实现代码更重要。

因为代码是“死”的，只能限定由某几种语言实现，而模式发现问题、分析问题、解决问题的思路是“活”的，适用性更广泛，这种思考“What、Where、When、Why、How”并逐步得出结论的过程，才是设计模式专家经验的真正价值。

理解了这些内容，我们就可以应用在 C++ 面向对象编程里了。下节课，我会具体给你讲一讲在 C++ 里，这些该怎么用。

学习、理解设计原则，才能用好多范式的 C++

可能你在学习设计模式的时候还是有些困惑，设计模式是专家经验的总结不假，但专家们是如何察觉、发现、探索出这些模式的呢？

而且模式真的完全只是“模式”、固定的“套路”，有没有什么更一般的思想来指导我们呢？换句话说，有没有“设计‘设计模式’的模式”呢？

嗯，这个真的有（笑）。

其实，这些更高层次的指导思想你可能也听说过，它们被通称为“设计原则”。

最常用有 5 个原则，也就是常说的“SOLID”。

SRP，单一职责（Single ResponsibilityPrinciple）；

OCP，开闭（Open Closed Principle）；

LSP，里氏替换（Liskov Substitution Principle）；

ISP，接口隔离（Interface-Segregation Principle）；

DIP，依赖反转，有的时候也叫依赖倒置（Dependency Inversion Principle）。

不过可能是因为我最先接触、研究的是设计模式，所以后来再看到这些原则的时候，“认同感”就没有那么强烈了。

虽然它们都说得很对，但没有像设计模式那样给出完整、准确的论述。所以，我觉得它们有点“飘”，缺乏可操作性，在实践中不好把握使用的方式。

但另一方面，这些原则也确实提炼出了软件设计里最本质、最基本的东西，就好像是欧几里得五公设、牛顿三定律一样，初看上去似乎很浅显直白，但仔细品品，就会发现，可以应用到任何系统里，所以了解它们还是很有必要的。

下面我就来讲讲对设计原则的一些理解和看法，再结合 C++ 和设计模式，帮你来加深认识，进而在 C++ 里实际用好它们。

第一个，单一职责原则，简单来说就是“不要做多余的事”，更常见的说法就是“高内聚低耦合”。在设计类的时候，要尽量缩小“粒度”，功能明确单一，不要设计出“大而全”的类。

使用单一职责原则，经常会得到很多“短小精悍”的对象，这时候，就需要应用设计模式来组合、复用它们了，比如，使用工厂来分类创建对象、使用适配器、装饰、代理来组合对象、使用外观来封装批量的对象。

单一职责原则的一个反例是 C++ 标准库里的字符串类 string（参见第 11 讲），它集成了字符串和字符容器的双重身份，接口复杂，让人无所适从（所以，我们应该只把它当作字符串，而把字符容器的工作交给vector<char>）。

第二个是开闭原则，它也许是最“模糊”的设计原则了，通常的表述是“对扩展开放，对修改关闭”，但没有说具体该怎么做，跟没说一样。

我觉得，你可以反过来理解这个原则，在设计类的时候问一下自己，这个类封装得是否足够好，是否可以不改变源码就能够增加新功能。如果答案是否定的（要改源码），那就说明违反了开闭原则。

应用开闭原则的关键是做好封装，隐藏内部的具体实现细节，然后开放足够的接口，这样外部的客户代码就可以只通过接口去扩展功能，而不必侵入类的内部。

你可以在一些结构型模式和行为模式里找到开闭原则的“影子”：比如桥接模式让接口保持稳定，而另一边的实现任意变化；又比如迭代器模式让集合保持稳定，改变访问集合的方式只需要变动迭代器。

C++ 语言里的 final 关键字（第 5 讲）也是实践开闭原则的“利器”，把它用在类和成员函数上，就可以有效地防止子类的修改。

第三个原则是里氏替换原则，意思是子类必须能够完全替代父类。

这个原则还是比较好理解的，就是说子类不能改变、违反父类定义的行为。像在第 5 讲里说的正方形、鸟类的例子，它们就是违反了里氏替换原则。

不过，因为 C++ 支持泛型编程，而且我也不建议多用继承，所以在 C++ 里你只要了解一下它就好。

第四个是接口隔离原则，它和单一职责原则有点像，但侧重点是对外的接口而不是内部的功能，目标是尽量简化、归并给外界调用的接口，避免写出大而不当的“面条类”。

大多数结构型模式都可以用来实现接口隔离，比如，使用适配器来转换接口，使用装饰模式来增加接口，使用外观来简化复杂系统的接口。

第五个原则是依赖反转原则，个人觉得是一个比较难懂的原则，我的理解是上层要避免依赖下层的实现细节，下层要反过来依赖上层的抽象定义，说白了，大概就是“解耦”吧。

模板方法模式可以算是比较明显的依赖反转的例子，父类定义主要的操作步骤，子类必须遵照这些步骤去实现具体的功能。

如果单从“解耦”的角度来理解的话，存在上下级调用关系的设计模式都可以算成是依赖反转，比如抽象工厂、桥接、适配器。

除了 SOLID 这五个之外，我觉得还有两个比较有用：DRY（Don’t Repeate Yourself）和 KISS（Keep It Simple Stupid）。

它们的含义都是要让代码尽量保持简单、简洁，避免重复的代码，这在 C++ 里可以有很多方式去实现，比如用宏代替字面值，用 lambda 表达式就地定义函数，多使用容器、算法和第三方库。

小结

好了，今天就到这里吧，我从比较“宏观”的层面说了设计模式和设计原则。

其实这些就是对我们实际开发经验的高度浓缩和总结。理解掌握了这些经验，你就会始终保持着清醒的头脑，在写 C++ 代码的过程中有意识地去发现、应用模式，设计出好的结构，对坏的代码进行重构。

小结一下这节课的要点：

面向对象是主流编程范式，使用设计模式可以比较容易地得到良好的面向对象设计；

经典的设计模式有 23 个，分成三大类：创建型模式、结构型模式和行为模式；

应该从多角度、多方面去研究设计模式，多关注代码之外的部分，学习解决问题的思路；

设计原则是设计模式之上更高层面的指导思想，适用性强，但可操作性弱，需要多在实践中体会；

最常用的五个设计原则是“SOLID”，此外，还有“DRY”和“KISS”。

不过，我还要特别提醒你，设计模式虽然很好，但它绝不是包治百病的“灵丹妙药”。如果不论什么项目都套上设计模式，就很容易导致过度设计，反而会增加复杂度，僵化系统。

对于我们 C++ 程序员来说，更是要清楚地认识到这一点，因为在 C++ 里，不仅有面向对象编程，还有泛型编程和函数式编程等其他范式，所以领会它的思想，在恰当的时候改用模板 / 泛型 /lambda 来替换“纯”面向对象，才是使用设计模式的最佳做法。

课下作业

最后是课下作业时间，给你留两个思考题：

你觉得使用设计模式有什么好处？

你是怎么理解 SOLID 设计原则的？哪个对你最有指导意义？

欢迎你在留言区写下你的思考和答案，如果觉得今天的内容对你有所帮助，也欢迎分享给你的朋友。我们下节课见。

20 | 设计模式（下）：C++是怎么应用设计模式的？

你好，我是 Chrono。

上节课，我谈了设计模式和设计原则。今天，我就具体说说，在 C++ 里，该怎么应用单件、工厂、适配器、代理、职责链等这些经典的设计模式，用到的有 call_once()、make_unique()、async() 等 C++ 工具，希望能够给你一些在实际编码时的启发。

（在接下来学的时候，你也可以同时思考一下它们都符合哪些设计原则，把设计模式和设计原则结合起来学习。）

创建型模式

首先来看看创建型模式，它隐藏了类的实例化过程和细节，让对象的创建独立于系统的其他部分。

创建型模式不多，一共有 5 个，我觉得最有用的是单件和工厂。

单件很简单，要点在于控制对象的创建数量，只能有一个实例，就像是公司的 CEO 一样，有且唯一。

关于它的使用方式、应用场景，存在着一些争议，但我个人觉得，它很好地体现了设计模式的基本思想，足够简单，可以作为范例，用来好好学习模式里的各个要素。

关于单件模式，一个“老生常谈”的话题是“双重检查锁定”，你可能也有所了解，它可以用来避免在多线程环境里多次初始化单件，写起来特别繁琐。

使用第 14 讲里提到的 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 讲里说到的 make_unique()、make_shared() 这两个函数，就是工厂模式的具体应用，它们封装了创建的细节，看不见 new，直接返回智能指针对象，而且接口更简洁，内部有更多的优化。

auto ptr1 = make_unique<int>(42);
auto ptr2 = make_shared<string>("metroid");

还有之前课程里的用函数抛出异常（第 9 讲）、创建正则对象（第 11 讲）、创建 Lua 虚拟机（第 17 讲），其实也都是应用了工厂模式。这些你可以结合课程的具体内容，再回顾一下，我就不多说了。

使用工厂模式的关键，就是要理解它面对的问题和解决问题的思路，比如说创建专属的对象、创建成套的对象，重点是“如何创建对象、创建出什么样的对象”，用函数或者类会比单纯用 new 更灵活。

结构型模式

接下来说说结构型模式，它关注的是对象的静态联系，以灵活、可拆卸、可装配的方式组合出新的对象。

这里你要注意结构型模式的重要特点：虽然它会有多个参与者，但最后必定得到且使用的是“一个”对象，而不是“多个”对象。

结构型模式一共有 7 个，其中，我觉得在 C++ 里比较有用、常用的是适配器、外观和代理。

1. 适配器模式

适配器模式的目的是接口转换，不需要修改源码，就能够把一个对象转换成可以在本系统中使用的形式。

打个比方，就像是拿到了一个英式电源插头，无法插到国标插座上，但你不必拿工具去拆开插头改造，只要买个转换头就行。

适配器模式在 C++ 里多出现在有第三方库或者外部接口的时候，通常这些接口不会恰好符合我们自己的系统，功能很好，但不能直接用，想改源码很难，甚至是不可能的。所以，就需要用适配器模式给“适配”一下，让外部工具能够“match”我们的系统，而两边都不需要变动，“皆大欢喜”。

还记得第 12 讲里的容器 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 讲），它接管了原始指针，限制了某些危险操作，并且添加了自动生命周期管理，虽然少了些自由，但获得了更多的安全。

行为模式

看完了适配器、外观和代理这三个结构型模式，再来看行为模式，它描述了对象之间动态的消息传递，也就是对象的“行为”、工作的方式。

行为模式比较多，有 11 个，这是因为，面向对象的设计更注重运行时的组合，比静态的组合更能增加系统的灵活性和可扩展性。

因为行为模式都是在运行时才建立联系，所以通常都很复杂，不太好理解对象之间的关系和通信机制。

我觉得比较难用，或者说是要尽量避免使用的模式有解释器和中介者，它们的结构比较难懂，会增加系统的复杂度。而比较容易理解、容易使用的有职责链、命令和策略，所以我重点说说它们。

职责链和命令这两个模式经常联合起来使用。职责链把多个对象串成一个“链条”，让链条里的每个对象都有机会去处理请求。而请求通常使用的是命令模式，把相关的数据打包成一个对象，解耦请求的发送方和接收方。

其实，你仔细想一下就会发现，C++ 的异常处理机制（第 9 讲）就是“职责链 + 命令”的一个实际应用。

在异常处理的过程中，异常类 exception 就是一个命令对象，throw 抛出异常就是发起了一个请求处理流程。而一系列的 try-catch 块就构成了处理异常的职责链，异常会自下而上地走过函数调用栈——也就是职责链，直到在链条中找到一个能够处理的 catch 块。

策略模式的要点是“策略”这两个字，它封装了不同的算法，可以在运行的时候灵活地互相替换，从而在外部“非侵入”地改变系统的行为内核。

策略模式有点像装饰模式和状态模式，你可不要弄混了。跟它们相比，策略模式的的特点是不会改变类的外部表现和内部状态，只是动态替换一个很小的算法功能模块。

前面讲过的容器和算法用到的比较函数、散列函数，还有 for_each 算法里的 lambda 表达式，它们都可以算是策略模式的具体应用。

另外，策略模式也非常适合应用在有 if-else/switch-case 这样“分支决策”的代码里，你可以把每个分支逻辑都封装成类或者 lambda 表达式，再把它们存进容器，让容器来帮你查找最合适的处理策略。

小结

好了，今天说了几个我个人认为比较重要的模式，还列出了 C++ 里的具体例子，两者互相参照，你就能更好地理解设计模式和 C++ 语言。接下来你可以去借鉴这些用法，尝试看看自己以前写的程序，是不是能应用工厂、适配器、代理、策略等模式去重构，让代码更加优雅、灵活。

再小结一下今天的内容：

创建型模式里常用的有单件和工厂，封装了对象的创建过程，隔离了对象的生产和使用；

结构型模式里常用的有适配器、外观和代理，通过对象组合，得到一个新对象，目的是适配、简化或者控制，隔离了客户代码与原对象的接口；

行为模式里常用的有职责链、命令和策略，只有在运行时才会建立联系，封装、隔离了程序里动态变化的那部分。

按照这些模式的使用的难易程度，我又画了一个表格，你可以对比着看一下。

今天虽然说了不少，但除了这些经典的设计模式，还有很多其他的设计模式，比如对象池、空对象、反应器、前摄器、包装外观，等等。

虽然它们也流传比较广，但还不那么“权威”，用得也不是很多，你不需要重点掌握，所以我就不多讲了，课下你可以再去找些资料学习。

课下作业

最后是课下作业时间，给你留两个思考题：

你觉得创建型模式有什么好处？

你能说一下适配器、外观和代理这三个模式的相同点和不同点吗？

欢迎在留言区写下你的思考和答案，如果觉得今天的内容对你有所帮助，也欢迎分享给你的朋友。我们下节课见。

21 | 知识串讲（上）：带你开发一个书店应用

你好，我是 Chrono。

到今天为止，课程里的 C++ 知识就全部讲完了。前面我们总共学习了四大模块，我再带你做一个简略的回顾。

在“概论”单元，我带你从宏观的层面上重新认识了 C++，讲了它的四个生命周期和五个编程范式，分别介绍了在编码阶段、预处理阶段、编译阶段，C++ 能够做哪些事情，接着又重点说了在 C++ 里，运用哪些特性才能更好地实践面向对象编程。

在“语言特性”单元，我们一起研究了自动类型推导、常量、智能指针、异常、函数式编程这五个特性。这些特性是“现代”C++ 区别于“传统”C++ 的关键，掌握了它们，你就能够写出清晰、易读、安全的代码。

在“标准库”单元，我介绍了字符串、容器、算法和并发。它们是 C++ 标准库中最核心的部分，也是现代 C++ 和泛型编程的最佳应用和范例。学会了标准库，你才能说是真正理解了 C++。

在“技能进阶”单元，我为你挑选出了一些第三方工具，包括序列化、网络通信、脚本语言和性能分析，它们很好地补充完善了 C++ 语言和标准库，免去了我们“自己造轮子”的麻烦，让我们把精力集中在实现业务逻辑上。

除了上面的这“十八般武艺”，我还谈了谈能够帮你更好地运用 C++ 的设计模式和设计原则，介绍了几个比较重要、常用的模式，希望你在今后的实际开发工作中，能够有意识地写出灵活、可扩展的代码。

这么回顾下来，内容还真是不少啊。

为了让你更好地把这些知识融会贯通，接下来我会再用两节课的时间，从需求、设计，到开发编码、编译运行，再加上一些我自己的实用小技巧，详细讲解一个 C++ 程序的实际开发过程，把知识点都串联起来。

虽然说是“串讲”，但是你只要学过了前面的内容，就可以跟着我做出这个书店程序。不过，我担心有些知识点你可能忘记了，所以，涉及到具体的知识点时，我会给你标注出是在哪一节，你可以随时回去复习一下。

项目设计

那么，该用个什么样的例子来串讲 C++ 的这些知识点呢？

说实话，找出一个合适的例子真的很难。因为大多数 C++ 实际项目都很大、很底层，还有各种依赖或者内部库，不好直接学习研究。

所以我再三考虑，决定借鉴一下 C++ Primer 里的书店例子，修改一下它的需求，然后完全重新开发，作为我们这个课程的综合示例。

先介绍一下咱们这个书店程序。简单来说，就是销售记录管理，从多个渠道把书号、销售册数、销售额都汇总起来，做个统计分析，再把数据定期上报到后台。

C++ Primer 里的书店程序是本地运行的，为了演示课程里讲到的的 C++ 特性，我把它改成了网络版。不过，拓扑结构并不复杂，我画了张图，你可以看一下。

项目的前期需求就算是定下来了，接着就要开始做设计了，这就要用到设计模式和设计原则的知识了（第 19 讲、第 20 讲）。

不过这个系统还是比较简单的，不需要用什么复杂的分析手段，就能够得出设计，主要应用的是单一职责原则、接口隔离原则和包装外观模式。这里我也画了一个 UML 图，可以帮助你理解程序的架构。

下面我就对照这个 UML 类图，结合开发思路和源码，仔细说一下具体的 C++ 开发，完整的源码都放在了GitHub上，课下可以仔细地看一下。

核心头文件

首先要说的是我写 C++ 项目的一个习惯，定义核心头文件：cpplang.hpp。它集中了 C++ 标准头和语言相关的定义，被用于其他所有的源文件。

注意，在写它的时候，最好要有文件头注释（第 2 讲），而且要有“Include guard”（第 3 讲），就像下面这样：

// 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 讲）。

自旋锁被封装为一个 SpinLock 类，所以就要遵循一些 C++ 里常用的面向对象的设计准则（第 5 讲、第 19 讲），比如用 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 讲）：

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 讲）。这里我选择 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 讲）。

这么做的好处在哪里呢？

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 讲），因为封装在类里，所以，你要注意类型别名和成员变量初始化的用法（第 5 讲）：

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 讲）。

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 讲），然后再去查询 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 配置文件这些用法放到自己的实际项目里去试试。

简单小结一下今天的内容：

在项目起始阶段，应该认真做需求分析，然后应用设计模式和设计原则，得出灵活、可扩展的面向对象系统；

C++ 项目里最好要有一个核心头文件（cpplang.hpp），集中定义所有标准头和语言特性，规范源文件里的 C++ 使用方式；

使用原子变量（atomic）可以实现自旋锁，比互斥量的成本要低，更高效；

使用 ZMQ 可以简化网络通信，但要注意它使用了异常来处理错误；

使用 Lua 脚本作为配置文件的好处很多，是“可编程的配置文件”；

在编写代码时要理解、用好 C++ 特性，恰当地使用 final、default、const 等关键字，让代码更安全、更可读，有利于将来的维护。

今天，我们分析了需求，设计出了架构，开发了一些工具类，但还没有涉及业务逻辑代码，下节课，我会带你看看容器、算法、线程，还有 lambda 表达式的实践应用，看看它们是怎么服务于具体业务的。

课下作业

最后是课下作业时间，给你留一个思考题：你能说出，程序里是怎么应用设计模式和设计原则的吗？

欢迎你在留言区写下你的思考和答案，如果觉得今天的内容对你有所帮助，也欢迎分享给你的朋友。我们下节课见。

22 | 知识串讲（下）：带你开发一个书店应用

你好，我是 Chrono。

在上节课里，我给出了一个书店程序的例子，讲了项目设计、类图和自旋锁、Lua 配置文件解析等工具类，搭建出了应用的底层基础。

今天，我接着讲剩下的主要业务逻辑部分，也就是数据的表示与统计，还有数据的接收和发送主循环，最终开发出完整的应用程序。

这里我再贴一下项目的 UML 图，希望给你提个醒。借助图形，我们往往能够更好地把握程序的总体结构。

图中间标注为绿色的两个类 SalesData、Summary 和两个 lambda 表达式 recv_cycle、log_cycle 是今天这节课的主要内容，实现了书店程序的核心业务逻辑，所以需要你重点关注它。

数据定义

首先，我们来看一下怎么表示书本的销售记录。这里用的是 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 讲），我看重的是它小巧轻便的特性，而且用起来也很容易，只要在类定义里添加一个宏，就可以实现序列化：

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 讲）。又因为比较规则是销量，而不是 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上的源码，今天我主要讲服务器端。

在 main() 函数开头，首先要加载配置文件，然后是数据存储类 Summary，再定义一个用来计数的原子变量 count（第 14 讲），这些就是程序运行的全部环境数据：

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 讲）来启动服务循环：

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 讲在 Linux 上搭建这个后台服务。

log_cycle 其实就是一个简单的 HTTP 客户端，所以代码的处理逻辑比较好理解，要注意的知识点主要有三个，都是前面讲过的：

读取 Lua 配置中的 HTTP 服务器地址和周期运行时间（第 17 讲）；

JSON 序列化数据（第 15 讲）；

HTTP 客户端发送请求（第 16 讲）。

你如果有点忘了，可以回顾一下，再结合下面的代码来理解、学习：

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++ 特性，这些特性互相重叠、嵌套，紧凑地集成在了这个不是很大的程序里，代码整齐，逻辑清楚，很容易就实现了多线程、高性能的服务端程序，开发效率和运行效率都非常高。

我再对今天代码里的要点做个简单的小结：

编写类的时候要用好 final、default、using、const 等关键字，从代码细节着手提高效率和安全性；

对于中小型项目，序列化格式可以选择小巧高效的 MessagePack；

在存储数据时，应当选择恰当的容器，有序容器在插入元素时会自动排序，但注意排序的依据只能是 Key；

在使用 lambda 表达式的时候，要特别注意捕获变量的生命周期，如果是在线程里异步执行，应当尽量用智能指针的值捕获，虽然有点麻烦，但比较安全。

那么，这些代码是否对你的工作有一些启迪呢？你是否能够把这些知识点成功地应用到实际项目里呢？希望你能多学习我在课程里给你分享的开发技巧和经验建议，熟练地掌握它们，写出媲美甚至超越示例代码的 C++ 程序。

课下作业

最后是课下作业时间，这次就不是思考题，全是动手题，是时候检验你的编码实战能力了：

添加 try-catch，处理可能发生的异常（第 9 讲）；

写一个动态库，用 Lua/Python 调用 C++ 发送请求，以脚本的方式简化客户端测试（第 17 讲）；

把前端与服务器的数据交换格式改成 JSON 或者 ProtoBuf（第 15 讲），然后用工厂类封装序列化和反序列化功能，隔离接口（第 19 讲、第 20 讲）。

再补充一点，在动手实践的过程中，你还可以顺便练习一下 Git 的版本管理：不要直接在 master 分支上开发，而是开几个不同的 feature 分支，测试完确认没有问题后，再合并到主干上。

欢迎你在留言区写下你的思考和答案，如果觉得今天的内容对你有所帮助，也欢迎分享给你的朋友。我们下节课见。

期末测试 | 这些C++核心知识，你都掌握了吗？

你好，我是 Chrono。

课程的核心内容，我们已经学完了，今天，我们来进行一场期末考试。

我出了一套测试题，共有 5 道单选题和 15 道多选题，满分 100，核心考点都出自前面讲到的所有重要知识。我建议你认真地做一下这套题目。如果有做错的地方，可以及时复习一下。如果有不明白的，欢迎随时在留言区提问，我会尽快回复你的。

还等什么？我们一起来做下这套题吧！

结束语 | 路远，未有穷期

你好，我是 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。

课程即将结束，在漫长的学习道路上，它的作用就好像是一个小小的驿站，让你适时停下来休息，补充点食物和清水，为下一次冒险做好准备。希望在你将来的回忆里，还会记得有这么一个给你安心和舒适的地方。

这里我还给你准备了一份毕业问卷，题目不多，希望你能抽出几分钟时间填写一下。我非常愿意听听你对这个课程的反馈和建议，你可以在问卷中畅所欲言。

好了，临别之际，在你上马、踏上新的征途之前，我再送你一句“老话”吧：

一个人写出一个好程序不难，难的是一辈子只写好程序，不写坏程序。

路远，未有穷期，期待我们的下次再会！

轻松话题（一） | 4本值得一读再读的经典好书

你好，我是 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++ 的知识面的，有广度有深度，有点也有面，你以为如何呢？

欢迎在留言里分享你的读书心得。如果你觉得有所收获，也欢迎你把今天的内容分享给你的朋友。

轻松话题（三） | 提高生活质量的App

你好，我是 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 保存看电影的记录。下面这张截图展示的就是我去年看的一部分电影，里面记下了电影名、观影时间 / 地点、票价，等等，还有简单的观后感。

有了这些数据之后，我就可以很容易地做一些分析，给好看的电影打个分，标注下哪些值得再看，还可以算算这一年下来，看了多少部电影、总共花了多少钱、均价是多少……也算是给自己做个总结，让时间留下一些值得怀念的记忆。

有数

接下来要说的这个“有数”应用是一个计算器，听起来好像比电子表格还要“平凡”一些。

计算器应该是一个“大路货”的应用了，很常见，一般手机也都自带，但是，这些计算器应用大多千篇一律，数字键盘再加上一些函数功能，非常简陋。

而“有数”则突破了传统计算器的设计思路，其理念与 Numbers 有些类似，把整个屏幕当作一张“草稿纸”，可以在上面任意书写算式，就像是我们上学时在纸面上列式演算一样，我把这称为“更高层次的拟物化”。

这样有什么好处呢？

好处就在于你不仅能够得到结果，还能直观地看到计算的全过程，随时“复盘”校验。而且，计算过程也可以反复重用，输入的数字可以任意修改或者引用到其他算式，整个算式也可以拷贝粘贴。

它的另一个“亮点”，是可以对计算过程加上文字注释说明，让数字不再枯燥，而是有了具体的含义，形成一份“算术文稿”。

比如说，家里想做个简单的装修，需要买瓷砖、地板、涂料，还有各种人工费。那么，你就可以在“有数”里把这些逐个列出来，单价乘上个数，最后再加起来。只要不到 1 分钟，总价、细则就可以清清楚楚、一目了然。如果价格、数量有变动，还可以立即修改，生成新的报价。

虽然 Numbers 也可以做同样的事情，但对于简单的算术来说，有点“小题大做”，在需要快速得出计算结果的时候，还是“有数”更加实用。

这里要提醒你一下，“有数”不是免费的，在 App Store 里卖 12 元，但我觉得它绝对值得这个价钱。这是我最喜爱的计算器，相信你接触它后，也会有同感。

倒数日

第三个要说的应用是“倒数日”（Days Matter），它应该是一个非常“老牌”的应用了，有着七八年的历史，由著名的 Clover team 出品。

其实，“倒数日”这个名字有点不太精确，它不仅能够“倒数”日期，也能够“正数”日期，怎么使用完全取决于你自己。

“倒数”的功能很简单，就是设定一个将来的日期，然后来个“倒计时”等待，比如到发工资还有多少天、到情人节还有多少天、到新年还有多少天。

你还可以基于这个设计一些更“高级”的玩法，制订一些人生的“里程碑”，比如说买辆自用车、入职 BAT、年薪 30 万、创立自己的公司，等等。

有了这些“小目标”，你就可以每天打开应用，用“倒计时”来提醒、鞭策自己，看看是不是随着努力正在一步步地接近这些理想。

“正数”的功能也很简单，就是记录已经发生过的各种“事件纪念日”，方便随时回忆那些美好的瞬间，这也正符合了它的 Slogan：“从现在起，不再忘记重要的事情。”

这方面我觉得值得记录的就更多了，比如生日、相识纪念、结婚纪念、拿驾照、买 Mac、换新房……

还是拿我自己来举个例子。下面的这张截图，记的是我当年买 iPhone6 的日子，这么一看，这部手机已经用了差不多 5 年了。如果没有“倒数日”这个应用，真的很难回想起来它是什么时候走进我生活里的，又“忠诚”地跟了我多久。

除了“倒数”和“正数”，这个应用里面另一个很有意思的功能是“历史上的今天”，每天看一下历史上曾经都发生过哪些大事要事，也是挺好玩的，可以在不知不觉间增长一些历史知识。

小结

好了，今天就闲聊到这里吧，我简单小结一下今天的内容。

现在的手机功能很强大，如果只用它来上网、看视频、玩游戏，就有点浪费了。

“Numbers”是一个电子表格应用，可以用来盘点生活中的各种事务。

“有数”是一个创新的计算器应用，可以实现“草稿纸”式演算。

“倒数日”是一个纪念日应用，可以用来记录人生中的各种重要时刻。

最后，我想说，这几个应用各自都有一些新颖的设计，所以才在众多竞品中脱颖而出。

我认为，它们的共同点就是不盲目“随大流”，真正关心用户的需求。很多应用都是在已有功能的基础上做些修修补补，同质化现象非常严重。但是，这些应用却善于从平凡的生活中挖掘更有价值的功能，甚至是大多数人都没有想到的功能，可以满足用户的多层次需求。无论是产品创意，还是微小的功能，都真正做到了“以人为本”。而始终致力于更好地服务用户的产品，必定不会差。

那么，这是否也给了你有些在写代码、做产品等方面上的启迪呢？

欢迎你在留言区分享你喜欢的 App，以及你从优秀的应用中得到的启发。

轻松话题（二） | 给你分享我的工作百宝箱

你好，我是 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”频繁地列出源码了，能够大大提高调试的效率。

小结

好了，今天的话题就到这里，简单小结一下我的工作环境，给你一个参考：

我选择 Linux 虚拟机作为登录服务器的终端，可以很容易开多窗口操作；

我选择 Vim 作为编辑器，熟记常用命令后写代码也很方便；

我选择 Git 作为版本管理工具，使用别名来简化命令；

GDB 是调试 C++ 程序的利器，也可以用来学习源码。

那么，你是否也有一些工作中的实用小技巧呢？欢迎一起来分享。

轻松话题（四） | 真正高效的生活，是张弛有度

你好，我是 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 点半。毕竟，第二天还是要上班的，学得太晚就会睡眠不足，影响第二天的工作质量。

顺便我再说说对睡眠和休息的看法吧。

可能很多人都有临睡前看会儿手机的习惯，这个时候，躺在床上，最安静、最放松，完全是属于自己的时间。

不过，从健康的角度来说，我不是太推荐这样做。因为在黑暗的环境下，手机屏幕的强光对眼睛和大脑的刺激程度都比较高，看得时间长了，就会影响睡眠。最好是控制一下自己，尽量在熄灯前把手机上要看的看完，然后老老实实地睡觉。

关于入睡，我也给你分享一个我自己的小经验。如果你失眠的话，可以尝试一下，应该会对你有所帮助。

方法很简单，就是尽量放松。先放松身体，再放松大脑，不要去想工作上的事情（否则可能会越想越投入、兴奋），而是慢慢地想吃饭、休息这样生活上的事，再把注意力轻轻地集中在呼吸上，让呼吸保持均匀，最后逐渐放空思绪，大概就可以进入“冥想”的状态了。

小结

好了，我一天的时间安排大概就是这样了，感觉还算是“有张有弛”吧。

我再简单总结一下我的几条小建议。

有明确的工作计划（日度 / 月度），就可以规划好时间，但要留出一定的缓冲。

在规划任务时间时，可以使用“番茄工作法”，但时间片不宜切得太细，否则执行的时候容易出现偏差。

工作中要有适当的休息间隔，调整工作节奏，缓解工作压力。

不要浪费通勤时间，多利用碎片时间学习和充电。

要平衡好工作和生活，要有休息有娱乐，不要变成只会加班的“机器人”。

那么，我的这些经验是否对你有所启发呢？你有没有什么好的时间管理小窍门呢？欢迎一起来分享，把握好我们自己的时间。