mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-15 13:43:49 +08:00
mod
This commit is contained in:
328
极客时间专栏/现代C++实战30讲/实战篇/21 | 工具漫谈:编译、格式化、代码检查、排错各显身手.md
Normal file
328
极客时间专栏/现代C++实战30讲/实战篇/21 | 工具漫谈:编译、格式化、代码检查、排错各显身手.md
Normal file
@@ -0,0 +1,328 @@
|
||||
<audio id="audio" title="21 | 工具漫谈:编译、格式化、代码检查、排错各显身手" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/8a/79/8a343e94eeadf237b0e1158281769a79.mp3"></audio>
|
||||
|
||||
你好,我是吴咏炜。
|
||||
|
||||
现代 C++ 语言,我们讲到这里就告一段落了。今天我们正式开启了实战篇,先讲一个轻松些的话题——工具。
|
||||
|
||||
## 编译器
|
||||
|
||||
当然,轻松不等于不重要。毕竟,工欲善其事,必先利其器。我们做 C++ 开发,最基本的工具就是编译器,对其有些了解显然也是必要的。我们就先来看看我在专栏开头就提到的三种编译器,MSVC [1]、GCC [2] 和 Clang [3]。
|
||||
|
||||
### MSVC
|
||||
|
||||
三种编译器里最老资格的就是 MSVC 了。据微软员工在 2015 年的一篇博客,在 MSVC 的代码里还能找到 1982 年写下的注释 [4]。这意味着 MSVC 是最历史悠久、最成熟,但也是最有历史包袱的编译器。
|
||||
|
||||
微软的编译器在传统代码的优化方面做得一直不错,但对模板的支持则是它的软肋,在 Visual Studio 2015 之前尤其不行——之前模板问题数量巨大,之后就好多了。而 2018 年 11 月 MSVC 宣布终于能够编译 range-v3 库,也成了一件值得庆贺的事 [5]。当然,这件事情是值得高兴的,但考虑我在 2016 年的演讲里就已经用到了 range-v3,不能不觉得还是有点晚了。此外,我已经提过,微软对代码的“容忍度”一直有点太高(缺省情况下,不使用 `/Za` 选项),能接受 C++ 标准认为非法的代码,这至少对写跨平台的代码而言,绝不是一件好事。
|
||||
|
||||
MSVC 当然也有领先的地方。它对标准库的实现一直不算慢,较早就提供了比较健壮的线程([[第 19 讲]](https://time.geekbang.org/column/article/186689)、[[第 20 讲]](https://time.geekbang.org/column/article/186708))、正则表达式([6])等标准库。在并发 [7] 方面,微软也是比较领先的,并主导了协程的技术规格书 [8]。微软一开始支持 C++ 标准的速度比较慢,但慢慢地,微软已经把全面支持 C++ 标准当作了目标,并在 2018 年宣布已全面支持 C++17 标准;虽然同时也承认仍有一些重大问题影响了其编译一些重要的开源 C++ 项目 [9]。
|
||||
|
||||
MSVC 有一个地方我一直比较喜欢,就是代码里可以写出要求链接具体什么库,而链接什么库的命令,可以是使用的第三方代码里直接给出的。这就使得在命令行上编译使用到第三方库(如 Boost)的代码变得非常容易。在使用 GCC 和 Clang 时,用到什么库,就必须在命令行上写出来,这就迫使程序员使用更规范、也更麻烦的管理方式了。具体而言,对于下面的这个最小的单元测试程序:
|
||||
|
||||
```
|
||||
#define BOOST_TEST_MAIN
|
||||
#include <boost/test/unit_test.hpp>
|
||||
|
||||
BOOST_AUTO_TEST_CASE(minimal_test)
|
||||
{
|
||||
BOOST_CHECK(1 + 1 == 2);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
使用 GCC 或 Clang 时你需要输入类似下面这样的命令:
|
||||
|
||||
>
|
||||
`g++ -DBOOST_TEST_DYN_LINK test.cpp -lboost_unit_test_framework`
|
||||
|
||||
|
||||
而 Windows 下使用 MSVC 你只需要输入:
|
||||
|
||||
>
|
||||
`cl /DBOOST_TEST_DYN_LINK /EHsc /MD test.cpp`
|
||||
|
||||
|
||||
一下子就简单多了。
|
||||
|
||||
另外,在免费的 C++ 集成开发环境里,Visual Studio Community Edition 恐怕可以算是最好的了,至少在 Windows 上是这样。在自动完成功能和调试功能上 Visual Studio 做得特别好,为其他的免费工具所不及。如果你开发的 C++ 程序主要在 Windows 上运行,那 MSVC 就应该是首选了。
|
||||
|
||||
### Clang
|
||||
|
||||
相反,在三个编译器里,最新的就是 Clang。作为 LLVM 项目的一部分,它的最早发布是在 2007 年,然后流行程度一路飙升,到现在成了一个通用的跨平台编译器。其中有不少苹果的支持——因为苹果对 GCC 的许可要求不满意,苹果把 LLVM 开发者 Chris Lattner 招致麾下(2005—2017),期间他除了为苹果设计开发了全新的语言 Swift,Clang 的 C++ 支持也得到了飞速的发展。
|
||||
|
||||
作为后来者,Clang 在错误信息易用性上做出了极大的改善。Clang 虽然一直在模拟 GCC 的功能和命令行,但错误信息的友好性是它的最大亮点。在语言层面,Clang 对 C++ 标准的支持也是飞速,正如下面这张图所展示的那样([10]):
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a6/71/a6432b0cbdc5ad6965402800f2057971.png" alt="">
|
||||
|
||||
可以看到,Clang 在 2011 异军突起,对 C++11 的支持程度在短时间甚至还超过了原先的领跑者 GCC。由于 Clang/LLVM 的模块化设计,在 Clang 上扩展新功能相当容易;而且动态库 libclang 直接向开发者暴露了分析 C++ 代码的接口,这也是 Clang 流行的一个主要原因。
|
||||
|
||||
即使在我主要使用 Windows 工作的时候,我在机器上也装了 Clang。我主要不是用它编译,而是利用它对 C++ 的理解,做代码的格式化(本讲下面会讲)和自动完成——对于文件数不多的项目,我还是喜欢使用 Vim [11],那机器上能不能用 clang_complete [12] 区别就很大了。有了 clang_complete,那 Vim 里也就有个不算太笨的 C++ 自动完成引擎了。顾名思义,clang_complete 主要依赖的就是 Clang 了,更精确地说,是 libclang。
|
||||
|
||||
另外,当我写出在 MSVC 下编译不过的代码时,我也会看看代码能不能在 Clang 下通过。如果能过,那我就比较有信心,我写出的代码是正确的,只不过是 MSVC 处理不了而已😈。
|
||||
|
||||
Clang 目前在 macOS 下是默认的 C/C++ 编译器。在 Linux 和 Windows 下当然也都能安装:这种情况下,Clang 会使用平台上的主流 C++ 库,也就是在 Linux 上使用 libstdc++,在 Windows 上使用 MSVC 的 C++ 运行时。只有在 macOS 上,Clang 才会使用其原生 C++ 库,libc++ [13]。顺便说一句,如果你想阅读一下现代 C++ 标准库的参考实现的话,libc++ 是可读性最好的——不过,任何一个软件产品的源代码都不是以可读性为第一考量,比起教科书、专栏里的代码例子,libc++ 肯定是要复杂多了。
|
||||
|
||||
最后一个关于版本号的说明:苹果开发工具里带的 Clang 的是苹果自己维护的一个分支,版本号和苹果的 Xcode 开发工具版本号一致,和开源项目 Clang 的版本号没有关系,显得比较乱。目前 Apple Clang 的最新版本是 11 了,但功能上落后于官方的 LLVM Clang 9.0 [14]。要想使用最新版本的 Clang,最方便的方式是使用 Homebrew [15] 安装 llvm:
|
||||
|
||||
>
|
||||
`brew install llvm`
|
||||
|
||||
|
||||
安装完之后,新的 clang 和 clang++ 工具在 /usr/local/opt/llvm/bin 目录下,和系统原有的命令不会发生冲突。你如果需要使用新的工具的话,需要改变路径的顺序,或者自己创建命令的别名(alias)。
|
||||
|
||||
### GCC
|
||||
|
||||
GCC 的第一个版本发布于 1987 年,是由自由软件运动的发起人 Richard Stallman(常常被缩写为 RMS)亲自写的。因而,从诞生伊始,GCC 就带着很强的意识形态,承担着振兴自由软件的任务。在 GNU/Linux 平台上,GCC 自然是首选的编译器。自由软件的开发者,大部分也选择了 GCC。由于 GCC 是用 GPL 发布的,任何对 GCC 的修改都必须以 GPL 协议发布。这就迫使想修改 GCC 的人要为 GCC 做出贡献。这对自由软件当然是件好事,但对一家公司来讲就未必了。此外,你想拆出 GCC 的一部分来做其他事情,比如对代码进行分析,也绝不是件容易的事。这些问题,实际上就是迫使苹果公司在 LLVM/Clang 上投资的动机了。
|
||||
|
||||
作为应用最广的自由软件之一,GCC 无疑是非常成熟的软件。某些实验性的功能,比如对概念的支持,也是最早在 GCC 上面出现的。对 C++ 标准的支持,GCC 一直跟得非常紧,但是,由于自由软件依靠志愿者的工作,而非项目经理或产品经理的管理,对不同功能的优先级跟商业产品往往不同,也造就了 GCC 和 MSVC 上各有不同的着重点,优化编译结果哪个性能更高也会依赖于具体的程序。当然 GCC 是跨平台的,这点上肯定是 MSVC 不及的。根据 GCC 的方式写出的代码,跨平台性就会更好。目前我已知的最主要例外是终端上的多语言支持:由于 GCC 在 Windows 上使用了 MSVC 的一个过时的运行库 MSVCRT.DLL,到现在为止 GCC 要在终端上显示中文经常会出现问题 [16]。
|
||||
|
||||
初期 GCC 在出错信息的友好程度上一直做得不太好。但 Clang 的出现刺激出了一种和 GCC 之间的良性竞争,到今天,GCC 的错误信息反而是最友好的了。我如果遇到程序编译出错在 Clang 里看不明白的话,我会试着用 GCC 再编译看看,在某些情况下,可能 GCC 的出错信息会更让人明白一些。
|
||||
|
||||
在可预见的将来,在自由/开源软件的开发上,GCC 一直会是编译器的标准。
|
||||
|
||||
## 格式化工具
|
||||
|
||||
### Clang-Format
|
||||
|
||||
我上面提到了 Clang 有着非常模块化的设计,容易被其他工具复用其代码分析功能。LLVM 团队自己也提供一些工具,其中我个人最常用的就是 Clang-Format [17]。
|
||||
|
||||
在使用 Clang-Format 之前,我也使用过一些其他的格式化工具。它们和 Clang-Format 的最大区别是,它们不理解 C++ 代码,在对付简单的 C 代码时还行,遇到复杂的 C++ 代码时就很容易出问题。此外,Clang-Format 还很智能,可以像人一样,根据具体情况和剩余空间来格式化,比如:
|
||||
|
||||
```
|
||||
void func(int arg1, int arg2,
|
||||
int arg3);
|
||||
|
||||
void long_func_name(int arg1,
|
||||
int arg2,
|
||||
int arg3);
|
||||
|
||||
void a_very_long_func_name(
|
||||
int arg1, int arg2, int arg3);
|
||||
|
||||
```
|
||||
|
||||
此外,它也提供了完善的配置项,你可以根据自己的需要来进行配置,如这是我的一个项目使用的格式化选项:
|
||||
|
||||
[https://github.com/adah1972/nvwa/blob/master/.clang-format](https://github.com/adah1972/nvwa/blob/master/.clang-format)
|
||||
|
||||
C++ 项目里放上这样一个文件,代码的格式化问题大家就不用瞎争了——大家确定这个文件的内容就行。
|
||||
|
||||
目前这个专栏的代码格式化选项也和上面的类似,最主要的区别就是行长限制(ColumnLimit)设成了 36,缩进宽度(IndentWidth)等选项基本减半,来适配手机的小显示屏。如果没有 Clang-Format,做代码的小屏适配就会累多了。
|
||||
|
||||
## 代码检查工具
|
||||
|
||||
### Clang-Tidy
|
||||
|
||||
Clang 项目也提供了其他一些工具,包括代码的静态检查工具 Clang-Tidy [18]。这是一个比较全面的工具,它除了会提示你危险的用法,也会告诉你如何去现代化你的代码。默认情况下,Clang-Tidy 只做基本的分析。你也可以告诉它你想现代化你的代码和提高代码的可读性:
|
||||
|
||||
>
|
||||
`clang-tidy --checks='clang-analyzer-*,modernize-*,readability-*' test.cpp`
|
||||
|
||||
|
||||
以下面简单程序为例:
|
||||
|
||||
```
|
||||
#include <iostream>
|
||||
#include <stddef.h>
|
||||
|
||||
using namespace std;
|
||||
|
||||
int sqr(int x) { return x * x; }
|
||||
|
||||
int main()
|
||||
{
|
||||
int a[5] = {1, 2, 3, 4, 5};
|
||||
int b[5];
|
||||
for (int i = 0; i < 5; ++i) {
|
||||
b[i] = sqr(a[i]);
|
||||
}
|
||||
for (int i : b) {
|
||||
cout << i << endl;
|
||||
}
|
||||
char* ptr = NULL;
|
||||
*ptr = '\0';
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
Clang-Tidy 会报告下列问题:
|
||||
|
||||
- <stddef.h> 应当替换成 <cstddef>
|
||||
- 函数形式 `int func(…)` 应当修改成 `auto func(…) -> int`
|
||||
- 不要使用 C 数组,应当改成 `std::array`
|
||||
- `5` 是魔术数,应当改成具名常数
|
||||
- `NULL` 应当改成 `nullptr`
|
||||
|
||||
前两条我不想听。这种情况下,使用配置文件来定制行为就必要了。配置文件叫 .clang-tidy,应当放在你的代码目录下或者代码的一个父目录下。Clang-Tidy 会使用最“近”的那个配置文件。下面的配置文件反映了我的偏好:
|
||||
|
||||
```
|
||||
Checks: 'clang-diagnostic-*,clang-analyzer-*,modernize-*,readability-*,-modernize-deprecated-headers,-modernize-use-trailing-return-type'
|
||||
|
||||
```
|
||||
|
||||
世界清静多了:我不想听到的唐僧式的啰唣就消失了。
|
||||
|
||||
使用 Clang-Tidy 还需要注意的地方是,额外的命令行参数应当跟在命令行最后的 `--` 后面。比如,如果我们要扫描一个 C++ 头文件 foo.h,我们就需要明确告诉 Clang-Tidy 这是 C++ 文件(默认 .h 是 C 文件)。然后,如果我们需要包含父目录下的 common 目录,语言标准使用了 C++17,命令行就应该是下面这个样子:
|
||||
|
||||
>
|
||||
`clang-tidy foo.h -- -x c++ -std=c++17 -I../common`
|
||||
|
||||
|
||||
你有没有注意到,上面 Clang-Tidy 实际上漏报告了些问题:它报告了一些不重要的问题,却漏过了真正严重的问题。这似乎是个实现相关的特殊问题,因为如果把前面那些行删掉的话,后面两行有问题的代码也还是会产生告警的。
|
||||
|
||||
### Cppcheck
|
||||
|
||||
Clang-Tidy 还是一个比较“重”的工具。它需要有一定的配置,需要能看到文件用到的头文件,运行的时间也会较长。而 Cppcheck [19] 就是一个非常轻量的工具了。它运行速度飞快,看不到头文件、不需要配置就能使用。它跟 Clang-Tidy 的重点也不太一样:它强调的是发现代码可能出问题的地方,而不太着重代码风格问题,两者功能并不完全重叠。有条件的情况下,这两个工具可以一起使用。
|
||||
|
||||
以上面的例子来为例,Cppcheck 会干脆地报告代码中最严重的问题——空指针的解引用。它的开销很低,却能发现潜在的安全性问题,因而我觉得这是个性价比很高的工具。
|
||||
|
||||
## 排错工具
|
||||
|
||||
排错工具当然也有很多种,我们今天介绍其中两个,Valgrind 和 nvwa::debug_new。
|
||||
|
||||
### Valgrind
|
||||
|
||||
Valgrind [20] 算是一个老牌工具了。它是一个非侵入式的排错工具。根据 Valgrind 的文档,它会导致可执行文件的速度减慢 20 至 30 倍。但它可以在不改变可执行文件的情况下,只要求你在编译时增加产生调试信息的命令行参数(`-g`),即可查出内存相关的错误。
|
||||
|
||||
以下面的简单程序为例:
|
||||
|
||||
```
|
||||
int main()
|
||||
{
|
||||
char* ptr = new char[20];
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在 Linux 上使用 `g++ -g test.cpp` 编译之后,然后使用 `valgrind --leak-check=full ./a.out` 检查运行结果,我们得到的输出会如下所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5c/50/5cb2060de012f04c4b30741c6e0deb50.png" alt="">
|
||||
|
||||
即其中包含了内存泄漏的信息,包括内存是从什么地方泄漏的。
|
||||
|
||||
Valgrind 的功能并不只是内存查错,也包含了多线程问题分析等其他功能。要进一步了解相关信息,请查阅其文档。
|
||||
|
||||
### nvwa::debug_new
|
||||
|
||||
在 nvwa [21] 项目里,我也包含了一个很小的内存泄漏检查工具。它的最大优点是小巧,并且对程序运行性能影响极小;缺点主要是不及 Valgrind 易用和强大,只能检查 `new` 导致的内存泄漏,并需要侵入式地对项目做修改。
|
||||
|
||||
需要检测内存泄漏时,你需要把 debug_new.cpp 加入到项目里。比如,可以简单地在命令行上加入这个文件:
|
||||
|
||||
>
|
||||
<p>`c++ test.cpp \`<br>
|
||||
`../nvwa/nvwa/debug_new.cpp`</p>
|
||||
|
||||
|
||||
下面是可能的运行时报错:
|
||||
|
||||
>
|
||||
<p>`Leaked object at 0x100302760 (size 20, 0x1000018a4)`<br>
|
||||
`*** 1 leaks found`</p>
|
||||
|
||||
|
||||
在使用 GCC 和 Clang 时,可以让它自动帮你找出内存泄漏点的位置。在命令行上需要加入可执行文件的名称,并产生调试信息:
|
||||
|
||||
>
|
||||
<p>`c++ -D_DEBUG_NEW_PROGNAME=\"a.out\" \`<br>
|
||||
`-g test.cpp \`<br>
|
||||
`../nvwa/nvwa/debug_new.cpp`</p>
|
||||
|
||||
|
||||
这样,我们就可以在运行时看到一个更明确的错误:
|
||||
|
||||
>
|
||||
<p>`Leaked object at 0x100302760 (size 20, main (in a.out) (test.cpp:3))`<br>
|
||||
`*** 1 leaks found`</p>
|
||||
|
||||
|
||||
这个工具的其他用法可以参见文档。
|
||||
|
||||
## 网页工具
|
||||
|
||||
### Compiler Explorer
|
||||
|
||||
编译器都有输出汇编代码的功能:在 MSVC 上可使用 `/Fa`,在 GCC 和 Clang 上可使用 `-S`。不过,要把源代码和汇编对应起来,就需要一定的功力了。在这点上,godbolt.org [22] 可以提供很大的帮助。它配置了多个不同的编译器,可以过滤掉编译器产生的汇编中开发者一般不关心的部分,并能够使用颜色和提示来帮助你关联源代码和产生的汇编。使用这个网站,你不仅可以快速查看你的代码在不同编译器里的优化结果,还能快速分享结果。比如,下面这个链接,就可以展示我们之前讲过的一个模板元编程代码的编译结果:
|
||||
|
||||
[https://godbolt.org/z/zPNEJ4](https://godbolt.org/z/zPNEJ4)
|
||||
|
||||
网页截图示意如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e1/a2/e1f3f1f2125c6b0679fbf0752d30cda2.jpg" alt="">
|
||||
|
||||
当然,作为一个网站,godbolt.org 对代码的复杂度有一定的限制,也不能任意使用你在代码里用到的第三方库(不过,它已经装了不少主流的 C++ 库,如我们后面会讲到的 Boost、Catch2、range-v3 和 cppcoro)。要解决这个问题,你可以在你自己的机器上本地安装它背后的引擎,compiler-explorer [23]。如果你的代码较复杂,或者有安全、隐私方面的顾虑的话,可以考虑这个方案。
|
||||
|
||||
### C++ Insights
|
||||
|
||||
如果你在上面的链接里点击了“CppInsights”按钮的话,你就会跳转到 C++ Insights [24] 网站,并且你贴在 godbolt.org 的代码也会一起被带过去。这个网站提供了另外一个编译器目前没有提供、但十分有用的功能:展示模板的展开过程。
|
||||
|
||||
回想我们在模板编程时的痛苦之一来自于我们需要在脑子中想象模板是如何展开的,而这个过程非常容易出错。当编译器出错时,我们得通过冗长的错误信息来寻找出错原因的蛛丝马迹;当编译器成功编译了一段我们不那么理解的模板代码时,我们在感到庆幸的同时,也往往会仍然很困惑——而使用这个网站,你就可以看到一个正确工作的模板是如何展开的。以[[第 18 讲]](https://time.geekbang.org/column/article/185899) 讨论的 `make_index_sequence` 为例,如果你把代码完整输入到网站上去、然后尝试展开 `make_index_sequence<5>`,你就会看到 `index_sequence_helper` 是这样展开的:
|
||||
|
||||
>
|
||||
<p>`index_sequence_helper<5>`<br>
|
||||
`index_sequence_helper<4, 4>`<br>
|
||||
`index_sequence_helper<3, 3, 4>`<br>
|
||||
`index_sequence_helper<2, 2, 3, 4>`<br>
|
||||
`index_sequence_helper<1, 1, 2, 3, 4>`<br>
|
||||
`index_sequence_helper<0, 0, 1, 2, 3, 4>`</p>
|
||||
|
||||
|
||||
如果我更早一点知道这个工具的话,我就会在讲编译期编程的时候直接建议大家用了,应该会更有助于模板的理解……
|
||||
|
||||
## 内容小结
|
||||
|
||||
在今天这一讲中,我们对各个编译器和一些常用的工具作了简单的介绍。用好工具,可以大大提升你的开发效率。
|
||||
|
||||
## 课后思考
|
||||
|
||||
哪些工具你觉得比较有用?哪些工具你已经在用了(除了编译器)?你个人还会推荐哪些工具?
|
||||
|
||||
欢迎留言和我分享。
|
||||
|
||||
## 参考资料
|
||||
|
||||
[1] Visual Studio. [https://visualstudio.microsoft.com/](https://visualstudio.microsoft.com/)
|
||||
|
||||
[2] GCC, the GNU Compiler Collection. [https://gcc.gnu.org/](https://gcc.gnu.org/)
|
||||
|
||||
[3] Clang: a C language family frontend for LLVM. [https://clang.llvm.org/](https://clang.llvm.org/)
|
||||
|
||||
[4] Jim Springfield, “Rejuvenating the Microsoft C/C++ compiler”. [https://devblogs.microsoft.com/cppblog/rejuvenating-the-microsoft-cc-compiler/](https://devblogs.microsoft.com/cppblog/rejuvenating-the-microsoft-cc-compiler/)
|
||||
|
||||
[5] Casey Carter, “Use the official range-v3 with MSVC 2017 version 15.9”. [https://devblogs.microsoft.com/cppblog/use-the-official-range-v3-with-msvc-2017-version-15-9/](https://devblogs.microsoft.com/cppblog/use-the-official-range-v3-with-msvc-2017-version-15-9/)
|
||||
|
||||
[6] cppreference.com, “std::regex”. [https://en.cppreference.com/w/cpp/regex/basic_regex](https://en.cppreference.com/w/cpp/regex/basic_regex)
|
||||
|
||||
[7] Microsoft, “Concurrency Runtime”. [https://docs.microsoft.com/en-us/cpp/parallel/concrt/concurrency-runtime](https://docs.microsoft.com/en-us/cpp/parallel/concrt/concurrency-runtime)
|
||||
|
||||
[8] ISO/IEC JTC1 SC22 WG21, “Programming languages—C++extensions for coroutines”. [http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2017/n4680.pdf](http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2017/n4680.pdf)
|
||||
|
||||
[9] Ulzii Luvsanbat, “Announcing: MSVC conforms to the C++ standard”. [https://devblogs.microsoft.com/cppblog/announcing-msvc-conforms-to-the-c-standard/](https://devblogs.microsoft.com/cppblog/announcing-msvc-conforms-to-the-c-standard/)
|
||||
|
||||
[10] Jonathan Adamczewski, “The growth of modern C++ support”. [http://brnz.org/hbr/?p=1404](http://brnz.org/hbr/?p=1404)
|
||||
|
||||
[11] Vim Online. [https://www.vim.org/](https://www.vim.org/)
|
||||
|
||||
[12] Xavier Deguillard, clang_complete. [https://github.com/xavierd/clang_complete](https://github.com/xavierd/clang_complete)
|
||||
|
||||
[13] “libc++” C++ Standard Library . [https://libcxx.llvm.org/](https://libcxx.llvm.org/)
|
||||
|
||||
[14] cppreference.com, “C++ compiler support”. [https://en.cppreference.com/w/cpp/compiler_support](https://en.cppreference.com/w/cpp/compiler_support)
|
||||
|
||||
[15] Homebrew. [https://brew.sh/](https://brew.sh/)
|
||||
|
||||
[16] 吴咏炜, “MSVCRT.DLL console I/O bug”. [https://yongweiwu.wordpress.com/2016/05/27/msvcrt-dll-console-io-bug/](https://yongweiwu.wordpress.com/2016/05/27/msvcrt-dll-console-io-bug/)
|
||||
|
||||
[17] ClangFormat. [https://clang.llvm.org/docs/ClangFormat.html](https://clang.llvm.org/docs/ClangFormat.html)
|
||||
|
||||
[18] Clang-Tidy. [https://clang.llvm.org/extra/clang-tidy/](https://clang.llvm.org/extra/clang-tidy/)
|
||||
|
||||
[19] Daniel Marjamäki, Cppcheck. [https://github.com/danmar/cppcheck](https://github.com/danmar/cppcheck)
|
||||
|
||||
[20] Valgrind Home. [https://valgrind.org/](https://valgrind.org/)
|
||||
|
||||
[21] 吴咏炜, nvwa. [https://github.com/adah1972/nvwa/](https://github.com/adah1972/nvwa/)
|
||||
|
||||
[22] Matt Godbolt, “Compiler Explorer”. [https://godbolt.org/](https://godbolt.org/)
|
||||
|
||||
[23] Matt Godbolt, compiler-explorer. [https://github.com/mattgodbolt/compiler-explorer](https://github.com/mattgodbolt/compiler-explorer)
|
||||
|
||||
[24] Andreas Fertig, “C++ Insights”. [https://cppinsights.io/](https://cppinsights.io/)
|
||||
@@ -0,0 +1,472 @@
|
||||
<audio id="audio" title="22 | 处理数据类型变化和错误:optional、variant、expected和Herbception" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a4/d1/a41f07d67a4fb7f363494d1b4875bbd1.mp3"></audio>
|
||||
|
||||
你好,我是吴咏炜。
|
||||
|
||||
我们之前已经讨论了异常是推荐的 C++ 错误处理方式。不过,C++ 里有另外一些结构也很适合进行错误处理,今天我们就来讨论一下。
|
||||
|
||||
## optional
|
||||
|
||||
在面向对象(引用语义)的语言里,我们有时候会使用空值 null 表示没有找到需要的对象。也有人推荐使用一个特殊的空对象,来避免空值带来的一些问题 [1]。可不管是空值,还是空对象,对于一个返回普通对象(值语义)的 C++ 函数都是不适用的——空值和空对象只能用在返回引用/指针的场合,一般情况下需要堆内存分配,在 C++ 里会引致额外的开销。
|
||||
|
||||
C++17 引入的 `optional` 模板 [2] 可以(部分)解决这个问题。语义上来说,`optional` 代表一个“也许有效”“可选”的对象。语法上来说,一个 `optional` 对象有点像一个指针,但它所管理的对象是直接放在 `optional` 里的,没有额外的内存分配。
|
||||
|
||||
构造一个 `optional<T>` 对象有以下几种方法:
|
||||
|
||||
1. 不传递任何参数,或者使用特殊参数 `std::nullopt`(可以和 `nullptr` 类比),可以构造一个“空”的 `optional` 对象,里面不包含有效值。
|
||||
1. 第一个参数是 `std::in_place`,后面跟构造 `T` 所需的参数,可以在 `optional` 对象上直接构造出 `T` 的有效值。
|
||||
1. 如果 `T` 类型支持拷贝构造或者移动构造的话,那在构造 `optional<T>` 时也可以传递一个 `T` 的左值或右值来将 `T` 对象拷贝或移动到 `optional` 中。
|
||||
|
||||
对于上面的第 1 种情况,`optional` 对象里是没有值的,在布尔值上下文里,会得到 `false`(类似于空指针的行为)。对于上面的第 2、3 两种情况,`optional` 对象里是有值的,在布尔值上下文里,会得到 `true`(类似于有效指针的行为)。类似的,在 `optional` 对象有值的情况下,你可以用 `*` 和 `->` 运算符去解引用(没值的情况下,结果是未定义行为)。
|
||||
|
||||
虽然 `optional` 是 C++17 才标准化的,但实际上这个用法更早就通行了。因为 `optional` 的实现不算复杂,有些库里就自己实现了一个版本。比如 cpptoml [3] 就给出了下面这样的示例(进行了翻译和重排版),用法跟标准的 `optional` 完全吻合:
|
||||
|
||||
```
|
||||
auto val = config->
|
||||
get_as<int64_t>("my-int");
|
||||
// val 是 cpptoml::option<int64_t>
|
||||
|
||||
if (val) {
|
||||
// *val 是 "my-int" 键下的整数值
|
||||
} else {
|
||||
// "my-int" 不存在或不是整数
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
cpptoml 里只是个缩微版的 `optional`,实现只有几十行,也不支持我们上面说的所有构造方式。标准库的 `optional` 为了方便程序员使用,除了我目前描述的功能,还支持下面的操作:
|
||||
|
||||
- 安全的析构行为
|
||||
- 显式的 `has_value` 成员函数,判断 `optional` 是否有值
|
||||
- `value` 成员函数,行为类似于 `*`,但在 `optional` 对象无值时会抛出异常 `std::bad_optional_access`
|
||||
- `value_or` 成员函数,在 `optional` 对象无值时返回传入的参数
|
||||
- `swap` 成员函数,和另外一个 `optional` 对象进行交换
|
||||
- `reset` 成员函数,清除 `optional` 对象包含的值
|
||||
- `emplace` 成员函数,在 `optional` 对象上构造一个新的值(不管成功与否,原值会被丢弃)
|
||||
- `make_optional` 全局函数,产生一个 `optional` 对象(类似 `make_pair`、`make_unique` 等)
|
||||
- 全局比较操作
|
||||
- 等等
|
||||
|
||||
如果我们认为无值就是数据无效,应当跳过剩下的处理,我们可以写出下面这样的高阶函数:
|
||||
|
||||
```
|
||||
template <typename T>
|
||||
constexpr bool has_value(
|
||||
const optional<T>& x) noexcept
|
||||
{
|
||||
return x.has_value();
|
||||
}
|
||||
|
||||
template <typename T,
|
||||
typename... Args>
|
||||
constexpr bool has_value(
|
||||
const optional<T>& first,
|
||||
const optional<
|
||||
Args>&... other) noexcept
|
||||
{
|
||||
return first.has_value() &&
|
||||
has_value(other...);
|
||||
}
|
||||
|
||||
template <typename F>
|
||||
auto lift_optional(F&& f)
|
||||
{
|
||||
return [f = forward<F>(f)](
|
||||
auto&&... args) {
|
||||
typedef decay_t<decltype(f(
|
||||
forward<decltype(args)>(args)
|
||||
.value()...))>
|
||||
result_type;
|
||||
if (has_value(args...)) {
|
||||
return optional<result_type>(
|
||||
f(forward<decltype(args)>(
|
||||
args)
|
||||
.value()...));
|
||||
} else {
|
||||
return optional<
|
||||
result_type>();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
`has_value` 比较简单,它可以有一个或多个 `optional` 参数,并在所有参数都有值时返回真,否则返回假。`lift_optional` 稍复杂些,它接受一个函数,返回另外一个函数。在返回的函数里,参数是一个或多个 `optional` 类型,`result_type` 是用参数的值(`value()`)去调用原先函数时的返回值类型,最后返回的则是 `result_type` 的 `optional` 封装。函数内部会检查所有的参数是否都有值(通过调用 `has_value`):有值时会去拿参数的值去调用原先的函数,否则返回一个空的 `optional` 对象。
|
||||
|
||||
这个函数能把一个原本要求参数全部有效的函数抬升(lift)成一个接受和返回 `optional` 参数的函数,并且,只在参数全部有效时去调用原来的函数。这是一种非常函数式的编程方式。使用上面函数的示例代码如下:
|
||||
|
||||
```
|
||||
#include <iostream>
|
||||
#include <functional>
|
||||
#include <optional>
|
||||
#include <type_traits>
|
||||
#include <utility>
|
||||
|
||||
using namespace std;
|
||||
|
||||
// 需包含 lift_optional 的定义
|
||||
|
||||
constexpr int increase(int n)
|
||||
{
|
||||
return n + 1;
|
||||
}
|
||||
|
||||
// 标准库没有提供 optional 的输出
|
||||
ostream&
|
||||
operator<<(ostream& os,
|
||||
optional<int>(x))
|
||||
{
|
||||
if (x) {
|
||||
os << '(' << *x << ')';
|
||||
} else {
|
||||
os << "(Nothing)";
|
||||
}
|
||||
return os;
|
||||
}
|
||||
|
||||
int main()
|
||||
{
|
||||
auto inc_opt =
|
||||
lift_optional(increase);
|
||||
auto plus_opt =
|
||||
lift_optional(plus<int>());
|
||||
cout << inc_opt(optional<int>())
|
||||
<< endl;
|
||||
cout << inc_opt(make_optional(41))
|
||||
<< endl;
|
||||
cout << plus_opt(
|
||||
make_optional(41),
|
||||
optional<int>())
|
||||
<< endl;
|
||||
cout << plus_opt(
|
||||
make_optional(41),
|
||||
make_optional(1))
|
||||
<< endl;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
输出结果是:
|
||||
|
||||
>
|
||||
<p>`(Nothing)`<br>
|
||||
`(42)`<br>
|
||||
`(Nothing)`<br>
|
||||
`(42)`</p>
|
||||
|
||||
|
||||
## variant
|
||||
|
||||
`optional` 是一个非常简单而又好用的模板,很多情况下,使用它就足够解决问题了。在某种意义上,可以把它看作是允许有两种数值的对象:要么是你想放进去的对象,要么是 `nullopt`(再次提醒,联想 `nullptr`)。如果我们希望除了我们想放进去的对象,还可以是 `nullopt` 之外的对象怎么办呢(比如,某种出错的状态)?又比如,如果我希望有三种或更多不同的类型呢?这种情况下,`variant` [4] 可能就是一个合适的解决方案。
|
||||
|
||||
在没有 `variant` 类型之前,你要达到类似的目的,恐怕会使用一种叫做带标签的联合(tagged union)的数据结构。比如,下面就是一个可能的数据结构定义:
|
||||
|
||||
```
|
||||
struct FloatIntChar {
|
||||
enum {
|
||||
Float,
|
||||
Int,
|
||||
Char
|
||||
} type;
|
||||
union {
|
||||
float float_value;
|
||||
int int_value;
|
||||
char char_value;
|
||||
};
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
这个数据结构的最大问题,就是它实际上有很多复杂情况需要特殊处理。对于我们上面例子里的 POD 类型,这么写就可以了(但我们仍需小心保证我们设置的 `type` 和实际使用的类型一致)。如果我们把其中一个类型换成非 POD 类型,就会有复杂问题出现。比如,下面的代码是不能工作的:
|
||||
|
||||
```
|
||||
struct StringIntChar {
|
||||
enum {
|
||||
String,
|
||||
Int,
|
||||
Char
|
||||
} type;
|
||||
union {
|
||||
string string_value;
|
||||
int int_value;
|
||||
char char_value;
|
||||
};
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
编译器会很合理地看到在 union 里使用 `string` 类型会带来构造和析构上的问题,所以会拒绝工作。要让这个代码工作,我们得手工加上析构函数,并且,在析构函数里得小心地判断存储的是什么数值,来决定是否应该析构(否则,默认不调用任何 union 里的析构函数,从而可能导致资源泄漏):
|
||||
|
||||
```
|
||||
~StringIntChar()
|
||||
{
|
||||
if (type == String) {
|
||||
string_value.~string();
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这样,我们才能安全地使用它(还是很麻烦):
|
||||
|
||||
```
|
||||
StringIntChar obj{
|
||||
.type = StringIntChar::String,
|
||||
.string_value = "Hello world"};
|
||||
cout << obj.string_value << endl;
|
||||
|
||||
```
|
||||
|
||||
这里用到了按成员初始化的语法,把类型设置成了字符串,同时设置了字符串的值。不用说,这是件麻烦、容易出错的事情。同时,细查之后我发现,这个语法虽然在 C99 里有,但在 C++ 里要在 C++20 才会被标准化,因此实际是有兼容性问题的——老版本的 MSVC,或最新版本的 MSVC 在没有开启 C++20 支持时,就不支持这个语法。
|
||||
|
||||
所以,目前的主流建议是,应该避免使用“裸” union 了。替换方式,就是这一节要说的 `variant`。上面的例子,如果用 `variant` 的话,会非常的干净利落:
|
||||
|
||||
```
|
||||
variant<string, int, char> obj{
|
||||
"Hello world"};
|
||||
cout << get<string>(obj) << endl;
|
||||
|
||||
```
|
||||
|
||||
可以注意到我上面构造时使用的是 `const char*`,但构造函数仍然能够正确地选择 `string` 类型,这是因为标准要求实现在没有一个完全匹配的类型的情况下,会选择成员类型中能够以传入的类型来构造的那个类型进行初始化(有且只有一个时)。`string` 类存在形式为 `string(const char*)` 的构造函数(不精确地说),所以上面的构造能够正确进行。
|
||||
|
||||
跟 `tuple` 相似,`variant` 上可以使用 `get` 函数模板,其模板参数可以是代表序号的数字,也可以是类型。如果编译时可以确定序号或类型不合法,我们在编译时就会出错。如果序号或类型合法,但运行时发现 `variant` 里存储的并不是该类对象,我们则会得到一个异常 `bad_variant_access`。
|
||||
|
||||
`variant` 上还有一个重要的成员函数是 `index`,通过它我们能获得当前的数值的序号。就我们上面的例子而言,`obj.index()` 即为 `1`。正常情况下,`variant` 里总有一个有效的数值(缺省为第一个类型的默认构造结果),但如果 `emplace` 等修改操作中发生了异常,`variant` 里也可能没有任何有效数值,此时 `index()` 将会得到 `variant_npos`。
|
||||
|
||||
从基本概念来讲,`variant` 就是一个安全的 union,相当简单,我就不多做其他介绍了。你可以自己看文档来了解进一步的信息。其中比较有趣的一个非成员函数是 `visit` [5],文档里展示了一个非常简洁的、可根据当前包含的变量类型进行函数分发的方法。
|
||||
|
||||
**平台细节:**在老于 Mojave 的 macOS 上编译含有 `optional` 或 `variant` 的代码,需要在文件开头加上:
|
||||
|
||||
```
|
||||
#if defined(__clang__) && defined(__APPLE__)
|
||||
#include <__config>
|
||||
#undef _LIBCPP_AVAILABILITY_BAD_OPTIONAL_ACCESS
|
||||
#undef _LIBCPP_AVAILABILITY_BAD_VARIANT_ACCESS
|
||||
#define _LIBCPP_AVAILABILITY_BAD_OPTIONAL_ACCESS
|
||||
#define _LIBCPP_AVAILABILITY_BAD_VARIANT_ACCESS
|
||||
#endif
|
||||
|
||||
```
|
||||
|
||||
原因是苹果在头文件里把 `optional` 和 `variant` 在早期版本的 macOS 上禁掉了,而上面的代码去掉了这几个宏里对使用 `bad_optional_access` 和 `bad_variant_access` 的平台限制。我真看不出使用这两个头文件跟 macOS 的版本有啥关系。😞
|
||||
|
||||
## expected
|
||||
|
||||
和前面介绍的两个模板不同,`expected` 不是 C++ 标准里的类型。但概念上这三者有相关性,因此我们也放在一起讲一下。
|
||||
|
||||
我前面已经提到,`optional` 可以作为一种代替异常的方式:在原本该抛异常的地方,我们可以改而返回一个空的 `optional` 对象。当然,此时我们就只知道没有返回一个合法的对象,而不知道为什么没有返回合法对象了。我们可以考虑改用一个 `variant`,但我们此时需要给错误类型一个独特的类型才行,因为这是 `variant` 模板的要求。比如:
|
||||
|
||||
```
|
||||
enum class error_code {
|
||||
success,
|
||||
operation_failure,
|
||||
object_not_found,
|
||||
…
|
||||
};
|
||||
|
||||
variant<Obj, error_code>
|
||||
get_object(…);
|
||||
|
||||
```
|
||||
|
||||
这当然是一种可行的错误处理方式:我们可以判断返回值的 `index()`,来决定是否发生了错误。但这种方式不那么直截了当,也要求实现对允许的错误类型作出规定。Andrei Alexandrescu 在 2012 年首先提出的 Expected 模板 [6],提供了另外一种错误处理方式。他的方法的要点在于,把完整的异常信息放在返回值,并在必要的时候,可以“重放”出来,或者手工检查是不是某种类型的异常。
|
||||
|
||||
他的概念并没有被广泛推广,最主要的原因可能是性能。异常最被人诟病的地方是性能,而他的方式对性能完全没有帮助。不过,后面的类似模板都汲取了他的部分思想,至少会用一种显式的方式来明确说明当前是异常情况还是正常情况。在目前的 expected 的标准提案 [7] 里,用法有点是 `optional` 和 `variant` 的某种混合:模板的声明形式像 `variant`,使用正常返回值像 `optional`。
|
||||
|
||||
下面的代码展示了一个 expected 实现 [8] 的基本用法。
|
||||
|
||||
```
|
||||
#include <climits>
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
#include <tl/expected.hpp>
|
||||
|
||||
using namespace std;
|
||||
using tl::expected;
|
||||
using tl::unexpected;
|
||||
|
||||
// 返回 expected 的安全除法
|
||||
expected<int, string>
|
||||
safe_divide(int i, int j)
|
||||
{
|
||||
if (j == 0)
|
||||
return unexpected(
|
||||
"divide by zero"s);
|
||||
if (i == INT_MIN && j == -1)
|
||||
return unexpected(
|
||||
"integer divide overflows"s);
|
||||
if (i % j != 0)
|
||||
return unexpected(
|
||||
"not integer division"s);
|
||||
else
|
||||
return i / j;
|
||||
}
|
||||
|
||||
// 一个测试函数
|
||||
expected<int, string>
|
||||
caller(int i, int j, int k)
|
||||
{
|
||||
auto q = safe_divide(j, k);
|
||||
if (q)
|
||||
return i + *q;
|
||||
else
|
||||
return q;
|
||||
}
|
||||
|
||||
// 支持 expected 的输出函数
|
||||
template <typename T, typename E>
|
||||
ostream& operator<<(
|
||||
ostream& os,
|
||||
const expected<T, E>& exp)
|
||||
{
|
||||
if (exp) {
|
||||
os << exp.value();
|
||||
} else {
|
||||
os << "unexpected: "
|
||||
<< exp.error();
|
||||
}
|
||||
return os;
|
||||
}
|
||||
|
||||
// 调试使用的检查宏
|
||||
#define CHECK(expr) \
|
||||
{ \
|
||||
auto result = (expr); \
|
||||
cout << result; \
|
||||
if (result == \
|
||||
unexpected( \
|
||||
"divide by zero"s)) { \
|
||||
cout \
|
||||
<< ": Are you serious?"; \
|
||||
} else if (result == 42) { \
|
||||
cout << ": Ha, I got you!"; \
|
||||
} \
|
||||
cout << endl; \
|
||||
}
|
||||
|
||||
int main()
|
||||
{
|
||||
CHECK(caller(2, 1, 0));
|
||||
CHECK(caller(37, 20, 7));
|
||||
CHECK(caller(39, 21, 7));
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
输出是:
|
||||
|
||||
>
|
||||
<p>`unexpected: divide by zero: Are you serious?`<br>
|
||||
`unexpected: not integer division`<br>
|
||||
`42: Ha, I got you!`</p>
|
||||
|
||||
|
||||
一个 `expected<T, E>` 差不多可以看作是 `T` 和 `unexpected<E>` 的 `variant`。在学过上面的 `variant` 之后,我们应该很容易看明白上面的程序了。下面是几个需要注意一下的地方:
|
||||
|
||||
- 如果一个函数要正常返回数据,代码无需任何特殊写法;如果它要表示出现了异常,则可以返回一个 `unexpected` 对象。
|
||||
- 这个返回值可以用来和一个正常值或 unexpected 对象比较,可以在布尔值上下文里检查是否有正常值,也可以用 `*` 运算符来取得其中的正常值——与 `optional` 类似,在没有正常值的情况下使用 `*` 是未定义行为。
|
||||
- 可以用 `value` 成员函数来取得其中的正常值,或使用 `error` 成员函数来取得其中的错误值——与 `variant` 类似,在 `expected` 中没有对应的值时产生异常 `bad_expected_access`。
|
||||
- 返回错误跟抛出异常比较相似,但检查是否发生错误的代码还是要比异常处理啰嗦。
|
||||
|
||||
## Herbception
|
||||
|
||||
上面的用法初看还行,但真正用起来,你会发现仍然没有使用异常方便。这只是为了解决异常在错误处理性能问题上的无奈之举。大部分试图替换 C++ 异常的方法都是牺牲编程方便性,来换取性能。只有 Herb Sutter 提出了一个基本兼容当前 C++ 异常处理方式的错误处理方式 [9],被戏称为 Herbception。
|
||||
|
||||
上面使用 expected 的示例代码,如果改用 Herbception 的话,可以大致如下改造(示意,尚无法编译):
|
||||
|
||||
```
|
||||
int safe_divide(int i, int j) throws
|
||||
{
|
||||
if (j == 0)
|
||||
throw arithmetic_errc::
|
||||
divide_by_zero;
|
||||
if (i == INT_MIN && j == -1)
|
||||
throw arithmetic_errc::
|
||||
integer_divide_overflows;
|
||||
if (i % j != 0)
|
||||
throw arithmetic_errc::
|
||||
not_integer_division;
|
||||
else
|
||||
return i / j;
|
||||
}
|
||||
|
||||
int caller(int i, int j,
|
||||
int k) throws
|
||||
{
|
||||
return i + safe_divide(j, k);
|
||||
}
|
||||
|
||||
#define CHECK(expr) \
|
||||
try { \
|
||||
int result = (expr); \
|
||||
cout << result; \
|
||||
if (result == 42) { \
|
||||
cout << ": Ha, I got you!"; \
|
||||
} \
|
||||
} \
|
||||
catch (error e) { \
|
||||
if (e == arithmetic_errc:: \
|
||||
divide_by_zero) { \
|
||||
cout \
|
||||
<< "Are you serious? "; \
|
||||
} \
|
||||
cout << "An error occurred"; \
|
||||
} \
|
||||
cout << endl
|
||||
|
||||
int main()
|
||||
{
|
||||
CHECK(caller(2, 1, 0));
|
||||
CHECK(caller(37, 20, 7));
|
||||
CHECK(caller(39, 21, 7));
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们可以看到,上面的代码和普通使用异常的代码非常相似,区别有以下几点:
|
||||
|
||||
- 函数需要使用 `throws`(注意不是 `throw`)进行声明。
|
||||
- 抛出异常的语法和一般异常语法相同,但抛出的是一个 `std::error` 值 [10]。
|
||||
- 捕捉异常时不需要使用引用(因为 `std::error` 是个“小”对象),且使用一般的比较操作来检查异常“类型”,不再使用开销大的 RTTI。
|
||||
|
||||
虽然语法上基本是使用异常的样子,但 Herb 的方案却没有异常的不确定开销,性能和使用 expected 相仿。他牺牲了异常类型的丰富,但从实际编程经验来看,越是体现出异常优越性的地方——异常处理点和异常发生点距离较远的时候——越不需要异常有丰富的类型。因此,总体上看,这是一个非常吸引人的方案。不过,由于提案时间较晚,争议颇多,这个方案要进入标准至少要 C++23 了。我们目前稍稍了解一下就行。
|
||||
|
||||
更多技术细节,请查看参考资料。
|
||||
|
||||
## 内容小结
|
||||
|
||||
本讲我们讨论了两个 C++ 标准库的模板 `optional` 和 `variant`,然后讨论了两个标准提案 expected 和 Herbception。这些结构都可以使用在错误处理过程中——前三者当前可用,但和异常相比有不同的取舍;Herbception 当前还不可用,但有希望在错误处理上达到最佳的权衡点。
|
||||
|
||||
## 课后思考
|
||||
|
||||
错误处理是一个非常复杂的问题,在 C++ 诞生之后这么多年仍然没有该如何处理的定论。如何对易用性和性能进行取舍,一直是一个有矛盾的老大难问题。你的实际项目中是如何选择的?你觉得应该如何选择?
|
||||
|
||||
欢迎留言和我分享你的看法。
|
||||
|
||||
## 参考资料
|
||||
|
||||
[1] Wikipedia, “Null object pattern”. [https://en.wikipedia.org/wiki/Null_object_pattern](https://en.wikipedia.org/wiki/Null_object_pattern)
|
||||
|
||||
[2] cppreference.com, “std::optional”. [https://en.cppreference.com/w/cpp/utility/optional](https://en.cppreference.com/w/cpp/utility/optional)
|
||||
|
||||
[2a] cppreference.com, “std::optional”. [https://zh.cppreference.com/w/cpp/utility/optional](https://zh.cppreference.com/w/cpp/utility/optional)
|
||||
|
||||
[3] Chase Geigle, cpptoml. [https://github.com/skystrife/cpptoml](https://github.com/skystrife/cpptoml)
|
||||
|
||||
[4] cppreference.com, “std::optional”. [https://en.cppreference.com/w/cpp/utility/variant](https://en.cppreference.com/w/cpp/utility/variant)
|
||||
|
||||
[4a] cppreference.com, “std::optional”. [https://zh.cppreference.com/w/cpp/utility/variant](https://zh.cppreference.com/w/cpp/utility/variant)
|
||||
|
||||
[5] cppreference.com, “std::visit”. [https://en.cppreference.com/w/cpp/utility/variant/visit](https://en.cppreference.com/w/cpp/utility/variant/visit)
|
||||
|
||||
[5a] cppreference.com, “std::visit”. [https://zh.cppreference.com/w/cpp/utility/variant/visit](https://zh.cppreference.com/w/cpp/utility/variant/visit)
|
||||
|
||||
[6] Andrei Alexandrescu, “Systematic error handling in C++”. [https://channel9.msdn.com/Shows/Going+Deep/C-and-Beyond-2012-Andrei-Alexandrescu-Systematic-Error-Handling-in-C](https://channel9.msdn.com/Shows/Going+Deep/C-and-Beyond-2012-Andrei-Alexandrescu-Systematic-Error-Handling-in-C)
|
||||
|
||||
[7] Vicente J. Botet Escribá and JF Bastien, “Utility class to represent expected object”. [http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2017/p0323r3.pdf](http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2017/p0323r3.pdf)
|
||||
|
||||
[8] Simon Brand, expected. [https://github.com/TartanLlama/expected](https://github.com/TartanLlama/expected)
|
||||
|
||||
[9] Herb Sutter, “P0709R0: Zero-overhead deterministic exceptions: Throwing values”. [http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2018/p0709r0.pdf](http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2018/p0709r0.pdf)
|
||||
|
||||
[10] Niall Douglas, “P1028R0: SG14 `status_code` and standard `error object` for P0709 Zero-overhead deterministic exceptions”. [http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2018/p1028r0.pdf](http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2018/p1028r0.pdf)
|
||||
385
极客时间专栏/现代C++实战30讲/实战篇/23 | 数字计算:介绍线性代数和数值计算库.md
Normal file
385
极客时间专栏/现代C++实战30讲/实战篇/23 | 数字计算:介绍线性代数和数值计算库.md
Normal file
@@ -0,0 +1,385 @@
|
||||
<audio id="audio" title="23 | 数字计算:介绍线性代数和数值计算库" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/8b/b1/8bcc899b1dfec29c3d6b90f47a522db1.mp3"></audio>
|
||||
|
||||
你好,我是吴咏炜。
|
||||
|
||||
科学计算在今天已经完全可以使用 C++ 了。我不是从事科学计算这一领域的工作的,不过,在工作中也多多少少接触到了一些计算相关的库。今天,我就给你介绍几个有用的计算库。
|
||||
|
||||
## Armadillo
|
||||
|
||||
说到计算,你可能首先会想到矩阵、矢量这些东西吧?这些计算,确实就是科学计算中的常见内容了。这些领域的标准,即是一些 Fortran 库定下的,如:
|
||||
|
||||
- BLAS [1]
|
||||
- LAPACK [2]
|
||||
- ARPACK [3]
|
||||
|
||||
它们的实现倒不一定用 Fortran,尤其是 BLAS:
|
||||
|
||||
- OpenBLAS [4] 是用汇编和 C 语言写的
|
||||
- Intel MKL [5] 有针对 Intel 的特定 CPU 指令集进行优化的汇编代码
|
||||
- Mir GLAS [6] 是用 D 语言写的
|
||||
|
||||
不管实现的方法是哪一种,暴露出来的函数名字是这个样子的:
|
||||
|
||||
- `ddot`
|
||||
- `dgemv`
|
||||
- `dsyrk`
|
||||
- `sgemm`
|
||||
- ……
|
||||
|
||||
这个接口的唯一好处,应该就是,它是跨语言并且跨实现的😅。所以,使用这些函数时,你可以切换不同的实现,而不需要更改代码。唯一需要修改的,通常就是链接库的名字或位置而已。
|
||||
|
||||
假设我们需要做一个简单的矩阵运算,对一个矢量进行旋转:
|
||||
|
||||
$$<br>
|
||||
\begin{aligned}<br>
|
||||
\mathbf{P} &= \begin{bmatrix} 1 \\\ 0 \end{bmatrix}\\\<br>
|
||||
\mathbf{R} &= \begin{bmatrix}<br>
|
||||
\cos(\theta) & -\sin(\theta) \\\<br>
|
||||
\sin(\theta) & \cos(\theta)\end{bmatrix}\\\<br>
|
||||
\mathbf{P^\prime} &= \mathbf{R} \cdot \mathbf{P}<br>
|
||||
\end{aligned}<br>
|
||||
$$
|
||||
|
||||
这么一个简单的操作,用纯 C 接口的 BLAS 来表达,有点痛苦:你需要使用的大概是 `dgemv_` 函数,而这个函数需要 11 个参数!我查阅了一下资料之后,也就放弃了给你展示一下如何调用 `dgemv_` 的企图,我们还是老老实实地看一下在现代 C++ 里的写法吧:
|
||||
|
||||
```
|
||||
#include <armadillo>
|
||||
#include <cmath>
|
||||
#include <iostream>
|
||||
|
||||
using namespace std;
|
||||
|
||||
int main()
|
||||
{
|
||||
// 代表位置的向量
|
||||
arma::vec pos{1.0, 0.0};
|
||||
|
||||
// 旋转矩阵
|
||||
auto& pi = arma::datum::pi;
|
||||
double angle = pi / 2;
|
||||
arma::mat rot = {
|
||||
{cos(angle), -sin(angle)},
|
||||
{sin(angle), cos(angle)}};
|
||||
|
||||
cout << "Current position:\n"
|
||||
<< pos;
|
||||
cout << "Rotating "
|
||||
<< angle * 180 / pi
|
||||
<< " deg\n";
|
||||
|
||||
arma::vec new_pos = rot * pos;
|
||||
cout << "New position:\n"
|
||||
<< new_pos;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这就是使用 Armadillo [7] 库来实现矢量旋转的代码。这个代码,基本就是上面的数学公式的一一对应了。代码相当直白,我只需要稍稍说明一下:
|
||||
|
||||
- 所有的 Armadillo 的类型和函数都定义在 `arma` 名空间下。
|
||||
- Armadillo 在 `arma::datum` 下定义了包括 pi 和 e 在内的一些数学常量。
|
||||
- `vec` 是矢量类型,`mat` 是矩阵类型,这两个类型实际上是 `Col<double>` 和 `Mat<double>` 的缩写别名。
|
||||
- Armadillo 支持使用 C++11 的列表初始化语法来初始化对象。
|
||||
- Armadillo 支持使用流来输出对象。
|
||||
|
||||
上面代码的输出为:
|
||||
|
||||
>
|
||||
<p>`Current position:`<br>
|
||||
`1.0000`<br>
|
||||
`0`<br>
|
||||
`Rotating 90 deg`<br>
|
||||
`New position:`<br>
|
||||
`6.1232e-17`<br>
|
||||
`1.0000e+00`</p>
|
||||
|
||||
|
||||
输出里面的 `6.1232e-17` 是浮点数表示不精确的后果,把它理解成 0 就对了。
|
||||
|
||||
我们上面已经提到了 `vec` 实际上是 `Col<double>`,双精度浮点数类型的列矢量。自然,Armadillo 也有行矢量 `rowvec`(即 `Row<double>`),也可以使用其他的数字类型,如 `int`、 `float` 和 `complex<float>`。此外,除了大小不确定的线性代数对象之外,Armadillo 也提供了固定大小的子类型,如 `vec::fixed<2>` 和 `mat::fixed<2, 2>`;为方便使用,还提供了不少别名,如 `imat22` 代表 `Mat<int>::fixed<2, 2>` 等。固定大小的对象不需要动态内存分配,使用上有一定的性能优势。
|
||||
|
||||
Armadillo 是一个非常复杂的库,它的头文件数量超过了 500 个。我们今天不可能、也不必要描述它的所有功能,只能稍稍部分列举一下:
|
||||
|
||||
- 除了目前提到的列矢量、行矢量和矩阵外,Armadillo 也支持三维的数据立方体,`Cube` 模板。
|
||||
- Armadillo 支持稀疏矩阵,`SpMat` 模板。
|
||||
- 除了数学上的加、减、乘运算,Armadillo 支持按元素的乘法、除法、相等、不等、小于比较等(使用 `%`、`/`、`==`、`!=`、`<` 等)运算,结果的大小跟参数相同,每个元素是相应运算的结果。某些运算符可能不太直观,尤其是 `%`(不是取模)和 `==`(返回不是单个布尔值,而是矩阵)。
|
||||
- Armadillo 支持对非固定大小的矢量、矩阵和立方体,改变其大小(`.reshape()` 和 `resize()`)。
|
||||
- Armadillo 可以方便地按行(`.col()`)、列(`.row()`)、对角线(`.diag()`)读写矩阵的内容,包括用一个矢量去改写矩阵的对角线。
|
||||
- Armadillo 可以方便地对矩阵进行转置(`.t()`)、求反(`.inv()`)。
|
||||
- Armadillo 可以对矩阵进行特征分解(`eigen_sym()`、`eigen_gen()` 等)。
|
||||
- Armadillo 支持傅立叶变换(`fft()`、`fft2()` 等)。
|
||||
- Armadillo 支持常见的统计计算,如平均值、中位值、标准偏差等(`mean()`、`median()`、`stddev()` 等)。
|
||||
- Armadillo 支持多项式方程求根(`roots`)。
|
||||
- Armadillo 支持 k‐平均聚类(**k**-means clustering)算法(`kmeans`)。
|
||||
- 等等。
|
||||
|
||||
如果你需要用到这些功能,你可以自己去查看一下具体的细节,我们这儿只提几个与编程有关的细节。
|
||||
|
||||
### 对象的输出
|
||||
|
||||
我们上面已经展示了直接把对象输出到一个流。我们的写法是:
|
||||
|
||||
```
|
||||
cout << "Current position:\n"
|
||||
<< pos;
|
||||
|
||||
```
|
||||
|
||||
实际上基本等价于调用 `print` 成员函数:
|
||||
|
||||
```
|
||||
pos.print("Current position:");
|
||||
|
||||
```
|
||||
|
||||
这个写法可能会更简单些。此外,在这两种情况,输出的格式都是 Armadillo 自动控制的。如果你希望自己控制的话,可以使用 `raw_print` 成员函数。比如,对于上面代码里对 `new_pos` 的输出,我们可以写成(需要包含 <iomanip>):
|
||||
|
||||
```
|
||||
cout << fixed << setw(9)
|
||||
<< setprecision(4);
|
||||
new_pos.raw_print(
|
||||
cout, "New position:");
|
||||
|
||||
```
|
||||
|
||||
这种情况下,你可以有效地对格式、宽度和精度进行设置,能得到:
|
||||
|
||||
>
|
||||
<p>`New position:`<br>
|
||||
`0.0000`<br>
|
||||
`1.0000`</p>
|
||||
|
||||
|
||||
记得我们说过 `vec` 是 `Col<double>` 的别名,因此输出是多行的。我们要输出成单行的话,转置(transpose)一下就可以了:
|
||||
|
||||
```
|
||||
cout << fixed << setw(9)
|
||||
<< setprecision(4);
|
||||
new_pos.t().raw_print(
|
||||
cout, "New position:");
|
||||
|
||||
```
|
||||
|
||||
输出为:
|
||||
|
||||
>
|
||||
<p>`New position:`<br>
|
||||
`0.0000 1.0000`</p>
|
||||
|
||||
|
||||
### 表达式模板
|
||||
|
||||
如果你奇怪前面 `dgemv_` 为什么有 11 个参数,这里有个我没有提的细节是,它执行的实际上是个复合操作:
|
||||
|
||||
$$<br>
|
||||
\mathbf{y} \gets \alpha\mathbf{A}\cdot\mathbf{x} + \beta\mathbf{y}<br>
|
||||
$$
|
||||
|
||||
如果你只是简单地做乘法的话,就相当于 $\alpha$ 为 1、$\beta$ 为 0 的特殊情况。那么问题来了,如果你真的写了类似于上面这样的公式的话,编译器和线性代数库能不能转成合适的调用、而没有额外的开销呢?
|
||||
|
||||
答案是,至少在某些情况下是可以的。秘诀就是表达式模板(expression template)[8]。
|
||||
|
||||
那什么是表达式模板呢?我们先回过去看我上面的例子。有没有注意到我写的是:
|
||||
|
||||
```
|
||||
arma::vec new_pos = rot * pos;
|
||||
|
||||
```
|
||||
|
||||
而没有使用 `auto` 来声明?
|
||||
|
||||
其中部分的原因是,`rot * pos` 的类型并不是 `vec`,而是:
|
||||
|
||||
```
|
||||
const Glue<Mat<double>, Col<double>, glue_times>
|
||||
|
||||
```
|
||||
|
||||
换句话说,结果是一个表达式,而并没有实际进行计算。如果我用 `auto` 的话,行为上似乎一切都正常,但我每次输出这个结果时,都会重新进行一次矩阵的乘法!而我用 `arma::vec` 接收的话,构造时就直接进行了计算,存储了表达式的结果。
|
||||
|
||||
上面的简单例子不能实际触发对 `dgemv_` 的调用,我用下面的代码实际验证出了表达式模板产生的优化(`fill::randu` 表示对矢量和矩阵的内容进行随机填充):
|
||||
|
||||
```
|
||||
#include <armadillo>
|
||||
#include <iostream>
|
||||
|
||||
using namespace std;
|
||||
using namespace arma;
|
||||
|
||||
int main()
|
||||
{
|
||||
vec x(8, fill::randu);
|
||||
mat r(8, 8, fill::randu);
|
||||
vec result = 2.5 * r * x;
|
||||
cout << result;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
赋值语句右边的类型是:
|
||||
|
||||
```
|
||||
const Glue<eOp<Mat<double>,
|
||||
eop_scalar_times>,
|
||||
Col<double>, glue_times>
|
||||
|
||||
```
|
||||
|
||||
当使用这个表达式构造 `vec` 时,就会实际发生对 `dgemv_` 的调用。我也确实跟踪到了,在将要调用 `dgemv_` 时,标量值 2.5 确实在参数 `alpha` 指向的位置上(这个接口的参数都是指针)。
|
||||
|
||||
从上面的描述可以看到,表达式模板是把双刃剑:既可以提高代码的性能,又能增加代码被误用的可能性。在可能用到表达式模板的地方,你需要注意这些问题。
|
||||
|
||||
### 平台细节
|
||||
|
||||
Armadillo 的文档里说明了如何从源代码进行安装,但在 Linux 和 macOS 下通过包管理器安装可能是更快的方式。在 CentOS 下可使用 `sudo yum install armadillo-devel`,在 macOS 下可使用 `brew install armadillo`。使用包管理器一般也会同时安装常见的依赖软件,如 ARPACK 和 OpenBLAS。
|
||||
|
||||
在 Windows 上,Armadillo 的安装包里自带了一个基本版本的 64 位 BLAS 和 LAPACK 库。如果需要更高性能或 32 位版本的话,就需要自己另外去安装了。除非你只是做一些非常简单的线性代数计算(就像我今天的例子),那直接告诉 Armadillo 不要使用第三方库也行。
|
||||
|
||||
>
|
||||
`cl /EHsc /DARMA_DONT_USE_BLAS /DARMA_DONT_USE_LAPACK …`
|
||||
|
||||
|
||||
## Boost.Multiprecision
|
||||
|
||||
众所周知,C 和 C++(甚至推而广之到大部分的常用编程语言)里的数值类型是有精度限制的。比如,上一讲的代码里我们就用到了 `INT_MIN`,最小的整数。很多情况下,使用目前这些类型是够用的(最高一般是 64 位整数和 80 位浮点数)。但也有很多情况,这些标准的类型远远不能满足需要。这时你就需要一个高精度的数值类型了。
|
||||
|
||||
有一次我需要找一个高精度整数类型和计算库,最后找到的就是 Boost.Multiprecision [9]。它基本满足我的需求,以及一般意义上对库的期望:
|
||||
|
||||
- 正确实现我需要的功能
|
||||
- 接口符合直觉、易用
|
||||
- 有良好的性能
|
||||
|
||||
正确实现功能这点我就不多讲了。这是一个基本出发点,没有太多可讨论的地方。在我上次的需求里,对性能其实也没有很高的要求。让我对 Boost.Multiprecision 满意的主要原因,就是它的接口了。
|
||||
|
||||
### 接口易用性
|
||||
|
||||
我在[[第 12 讲]](https://time.geekbang.org/column/article/179363) 提到了 CLN。它对我来讲就是个反面教材。它的整数类型不仅不提供 `%` 运算符,居然还不提供 `/` 运算符!它强迫用户在下面两个方案中做出选择:
|
||||
|
||||
- 使用 `truncate2` 函数,得到一个商数和余数
|
||||
- 使用 `exquo` 函数,当且仅当可以整除的时候
|
||||
|
||||
不管作者的设计原则是什么,这简直就是易用性方面的灾难了——不仅这些函数要查文档才能知晓,而且有的地方我真的只需要简单的除法呀……
|
||||
|
||||
哦,对了,它在 Windows 编译还很不方便,而我那时用的正是 Windows。
|
||||
|
||||
Boost.Multiprecision 的情况则恰恰相反,让我当即大为满意:
|
||||
|
||||
- 使用基本的 `cpp_int` 对象不需要预先编译库,只需要 Boost 的头文件和一个好的编译器。
|
||||
- 常用运算符 `+`、`-`、`*`、`/`、`%` 一个不缺,全部都有。
|
||||
- 可以自然地通过整数和字符串来进行构造。
|
||||
- 提供了用户自定义字面量来高效地进行初始化。
|
||||
- 在使用 IO 流时,输入输出既可以使用十进制,也可以通过 `hex` 来切换到十六进制。
|
||||
|
||||
下面的代码展示了它的基本功能:
|
||||
|
||||
```
|
||||
#include <iomanip>
|
||||
#include <iostream>
|
||||
#include <boost/multiprecision/cpp_int.hpp>
|
||||
|
||||
using namespace std;
|
||||
|
||||
int main()
|
||||
{
|
||||
using namespace boost::
|
||||
multiprecision::literals;
|
||||
using boost::multiprecision::
|
||||
cpp_int;
|
||||
|
||||
cpp_int a =
|
||||
0x123456789abcdef0_cppi;
|
||||
cpp_int b = 16;
|
||||
cpp_int c{"0400"};
|
||||
cpp_int result = a * b / c;
|
||||
cout << hex << result << endl;
|
||||
cout << dec << result << endl;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
输出是:
|
||||
|
||||
>
|
||||
<p>`123456789abcdef`<br>
|
||||
`81985529216486895`</p>
|
||||
|
||||
|
||||
我们可以看到,`cpp_int` 可以通过自定义字面量(后缀 `_cppi`;只能十六进制)来初始化,可以通过一个普通整数来初始化,也可以通过字符串来初始化(并可以使用 `0x` 和 `0` 前缀来选择十六进制和八进制)。拿它可以正常地进行加减乘除操作,也可以通过 IO 流来输入输出。
|
||||
|
||||
### 性能
|
||||
|
||||
Boost.Multiprecision 使用了表达式模板和 C++11 的移动来避免不必要的拷贝。后者当然是件好事,而前者曾经坑了我一下——我第一次使用 Boost.Multiprecision 时非常困惑为什么我使用 `half(n - 1)` 调用下面的简单函数居然会编译不过:
|
||||
|
||||
```
|
||||
template <typename N>
|
||||
inline N half(N n)
|
||||
{
|
||||
return n / 2;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我的意图当然是 `N` 应当被推导为 `cpp_int`,`half` 的结果也是 `cpp_int`。可实际上,`n - 1` 的结果跟上面的 Armadillo 展示的情况类似,是另外一个单独的类型。我需要把 `half(n - 1)` 改写成 `half(N(n - 1))` 才能得到期望的结果。
|
||||
|
||||
我做的计算挺简单,并不觉得表达式模板对我的计算有啥帮助,所以我最后是禁用了表达式模板:
|
||||
|
||||
```
|
||||
typedef boost::multiprecision::
|
||||
number<
|
||||
boost::multiprecision::
|
||||
cpp_int_backend<>,
|
||||
boost::multiprecision::et_off>
|
||||
int_type;
|
||||
|
||||
```
|
||||
|
||||
类似于 Armadillo 可以换不同的 BLAS 和 LAPACK 实现,Boost.Multiprecision 也可以改换不同的后端。比如,如果我们打算使用 GMP [10] 的话,我们需要包含利用 GMP 的头文件,并把上面的 `int_type` 的定义修正一下:
|
||||
|
||||
```
|
||||
#include <boost/multiprecision/gmp.hpp>
|
||||
|
||||
typedef boost::multiprecision::
|
||||
number<
|
||||
boost::multiprecision::gmp_int,
|
||||
boost::multiprecision::et_off>
|
||||
int_type;
|
||||
|
||||
```
|
||||
|
||||
注意,我并不是推荐你换用 GMP。如果你真的对性能非常渴求的话,应当进行测试来选择合适的后端。否则缺省的后端易用性最好——比如,使用 GMP 后端就不能使用自定义字面量了。
|
||||
|
||||
我当时寻找高精度算术库是为了做 RSA 加解密。计算本身不复杂,属于编程几小时、运行几毫秒的情况。如果你有兴趣的话,可以看一下我那时的挑选过程和最终代码 [11]。
|
||||
|
||||
Boost 里好东西很多,远远不止这一样。下一讲我们就来专门聊聊 Boost。
|
||||
|
||||
## 内容小结
|
||||
|
||||
本讲我们讨论了两个进行计算的模板库,Armadillo 和 Boost.Multiprecision,并讨论了它们用到的表达式模板技巧和相关的计算库,如 BLAS、LAPACK 和 GMP。可以看到,使用 C++ 你可以站到巨人肩上,轻松写出高性能的计算代码。
|
||||
|
||||
## 课后思考
|
||||
|
||||
性能和易用性往往是有矛盾的。你对性能和易用性有什么样的偏好呢?欢迎留言与我分享。
|
||||
|
||||
## 参考资料
|
||||
|
||||
[1] Wikipedia, “Basic Linear Algebra Subprograms”. [https://en.wikipedia.org/wiki/Basic_Linear_Algebra_Subprograms](https://en.wikipedia.org/wiki/Basic_Linear_Algebra_Subprograms)
|
||||
|
||||
[2] Wikipedia, “LAPACK”. [https://en.wikipedia.org/wiki/LAPACK](https://en.wikipedia.org/wiki/LAPACK)
|
||||
|
||||
[3] Wikipedia, “ARPACK”. [https://en.wikipedia.org/wiki/ARPACK](https://en.wikipedia.org/wiki/ARPACK)
|
||||
|
||||
[4] Zhang Xianyi et al., OpenBLAS. [https://github.com/xianyi/OpenBLAS](https://github.com/xianyi/OpenBLAS)
|
||||
|
||||
[5] Intel, Math Kernel Library. [https://software.intel.com/mkl](https://software.intel.com/mkl)
|
||||
|
||||
[6] Ilya Yaroshenko, mir-glas. [https://github.com/libmir/mir-glas](https://github.com/libmir/mir-glas)
|
||||
|
||||
[7] Conrad Sanderson and Ryan Curtin, “Armadillo: C++ library for linear algebra & scientific computing”. [http://arma.sourceforge.net/](http://arma.sourceforge.net/)
|
||||
|
||||
[8] Wikipedia, “Expression templates”. [https://en.wikipedia.org/wiki/Expression_templates](https://en.wikipedia.org/wiki/Expression_templates)
|
||||
|
||||
[9] John Maddock, Boost.Multiprecision. [https://www.boost.org/doc/libs/release/libs/multiprecision/doc/html/index.html](https://www.boost.org/doc/libs/release/libs/multiprecision/doc/html/index.html)
|
||||
|
||||
[10] The GNU MP bignum library. [https://gmplib.org/](https://gmplib.org/)
|
||||
|
||||
[11] 吴咏炜, “Choosing a multi-precision library for C++—a critique”. [https://yongweiwu.wordpress.com/2016/06/04/choosing-a-multi-precision-library-for-c-a-critique/](https://yongweiwu.wordpress.com/2016/06/04/choosing-a-multi-precision-library-for-c-a-critique/)
|
||||
609
极客时间专栏/现代C++实战30讲/实战篇/24 | Boost:你需要的“瑞士军刀”.md
Normal file
609
极客时间专栏/现代C++实战30讲/实战篇/24 | Boost:你需要的“瑞士军刀”.md
Normal file
@@ -0,0 +1,609 @@
|
||||
<audio id="audio" title="24 | Boost:你需要的“瑞士军刀”" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f1/95/f14698612ac9a15d134cee6431ce9b95.mp3"></audio>
|
||||
|
||||
你好,我是吴咏炜。
|
||||
|
||||
我们已经零零碎碎提到过几次 Boost 了。作为 C++ 世界里标准库之外最知名的开放源码程序库,我们值得专门用一讲来讨论一下 Boost。
|
||||
|
||||
## Boost 概览
|
||||
|
||||
Boost 的网站把 Boost 描述成为经过同行评审的、可移植的 C++ 源码库(peer-reviewed portable C++ source libraries)[1]。换句话说,它跟很多个人开源库不一样的地方在于,它的代码是经过评审的。事实上,Boost 项目的背后有很多 C++ 专家,比如发起人之一的 Dave Abarahams 是 C++ 标准委员会的成员,也是《C++ 模板元编程》一书 [2] 的作者。这也就使得 Boost 有了很不一样的特殊地位:它既是 C++ 标准库的灵感来源之一,也是 C++ 标准库的试验田。下面这些 C++ 标准库就源自 Boost:
|
||||
|
||||
- 智能指针
|
||||
- thread
|
||||
- regex
|
||||
- random
|
||||
- array
|
||||
- bind
|
||||
- tuple
|
||||
- optional
|
||||
- variant
|
||||
- any
|
||||
- string_view
|
||||
- filesystem
|
||||
- 等等
|
||||
|
||||
当然,将来还会有新的库从 Boost 进入 C++ 标准,如网络库的标准化就是基于 Boost.Asio 进行的。因此,即使相关的功能没有被标准化,我们也可能可以从 Boost 里看到某个功能可能会被标准化的样子——当然,最终标准化之后的样子还是经常有所变化的。
|
||||
|
||||
我们也可以在我们的编译器落后于标准、不能提供标准库的某个功能时使用 Boost 里的替代品。比如,我之前提到过老版本的 macOS 上苹果的编译器不支持 optional 和 variant。除了我描述的不正规做法,改用 Boost 也是方法之一。比如,对于 variant,所需的改动只是:
|
||||
|
||||
- 把包含 <variant> 改成包含 <boost/variant.hpp>
|
||||
- 把代码中的 `std::variant` 改成 `boost::variant`
|
||||
|
||||
这样,就基本大功告成了。
|
||||
|
||||
作为一个准标准的库,很多环境里缺省会提供 Boost。这种情况下,在程序里使用 Boost 不会额外增加编译或运行时的依赖,减少了可能的麻烦。如果我需要某个功能,在标准库里没有,在 Boost 里有,我会很乐意直接使用 Boost 里的方案,而非另外去查找。如果我要使用非 Boost 的第三方库的话,那一般要么是 Boost 里没有,要么就是那个库比 Boost 里的要好用很多了。
|
||||
|
||||
鉴于 Boost 是一个库集合,当前版本(1.72)有 160 个独立库,即使写本书也不可能完整地讨论所有的库。这一讲里,我们也就管中窥豹式地浏览几个 Boost 库。具体你需要什么,还是得你自己回头去细细品味。
|
||||
|
||||
### Boost 的安装
|
||||
|
||||
在主要的开发平台上,现在你都可以直接安装 Boost,而不需要自己从源代码编译了:
|
||||
|
||||
- 在 Windows 下使用 MSVC,我们可以使用 NuGet 安装(按需逐个安装)
|
||||
- 在 Linux 下,我们可以使用系统的包管理器(如 apt 和 yum)安装(按需逐个安装,或一次性安装所有的开发需要的包)
|
||||
- 在 macOS 下,我们可以使用 Homebrew 安装(一次性安装完整的 Boost)
|
||||
|
||||
如果你在某个平台上使用非缺省的编译器,如在 Windows 上或 macOS 上使用 GCC,一般就需要自己编译了,具体步骤请参见 Boost 的文档。不过,很多 Boost 库是完全不需要编译的,只需要把头文件加到编译器能找到的路径里就可以——如我们上一讲讨论的 Boost.Multiprecision 就是这样。我们讨论 Boost 库的时候,也会提一下使用这个库是否需要链接某个 Boost 库——需要的话,也就意味着需要编译和安装这个 Boost 库。
|
||||
|
||||
## Boost.TypeIndex
|
||||
|
||||
TypeIndex 是一个很轻量级的库,它不需要链接,解决的也是使用模板时的一个常见问题,如何精确地知道一个表达式或变量的类型。我们还是看一个例子:
|
||||
|
||||
```
|
||||
#include <iostream>
|
||||
#include <typeinfo>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
#include <boost/type_index.hpp>
|
||||
|
||||
using namespace std;
|
||||
using boost::typeindex::type_id;
|
||||
using boost::typeindex::
|
||||
type_id_with_cvr;
|
||||
|
||||
int main()
|
||||
{
|
||||
vector<int> v;
|
||||
auto it = v.cbegin();
|
||||
|
||||
cout << "*** Using typeid\n";
|
||||
cout << typeid(const int).name()
|
||||
<< endl;
|
||||
cout << typeid(v).name() << endl;
|
||||
cout << typeid(it).name() << endl;
|
||||
|
||||
cout << "*** Using type_id\n";
|
||||
cout << type_id<const int>() << endl;
|
||||
cout << type_id<decltype(v)>()
|
||||
<< endl;
|
||||
cout << type_id<decltype(it)>()
|
||||
<< endl;
|
||||
|
||||
cout << "*** Using "
|
||||
"type_id_with_cvr\n";
|
||||
cout
|
||||
<< type_id_with_cvr<const int>()
|
||||
<< endl;
|
||||
cout << type_id_with_cvr<decltype(
|
||||
(v))>()
|
||||
<< endl;
|
||||
cout << type_id_with_cvr<decltype(
|
||||
move((v)))>()
|
||||
<< endl;
|
||||
cout << type_id_with_cvr<decltype(
|
||||
(it))>()
|
||||
<< endl;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
上面的代码里,展示了标准的 `typeid` 和 Boost 的 `type_id` 和 `type_id_with_cvr` 的使用。它们的区别是:
|
||||
|
||||
- `typeid` 是标准 C++ 的关键字,可以应用到变量或类型上,返回一个 `std::type_info`。我们可以用它的 `name` 成员函数把结果转换成一个字符串,但标准不保证这个字符串的可读性和唯一性。
|
||||
- `type_id` 是 Boost 提供的函数模板,必须提供类型作为模板参数——所以对于表达式和变量我们需要使用 `decltype`。结果可以直接输出到 IO 流上。
|
||||
- `type_id_with_cvr` 和 `type_id` 相似,但它获得的结果会包含 const/volatile 状态及引用类型。
|
||||
|
||||
上面程序在 MSVC 下的输出为:
|
||||
|
||||
>
|
||||
<p>`*** Using typeid`<br>
|
||||
`int`<br>
|
||||
`class std::vector<int,class std::allocator<int> >`<br>
|
||||
`class std::_Vector_const_iterator<class std::_Vector_val<struct std::_Simple_types<int> > >`<br>
|
||||
`*** Using type_id`<br>
|
||||
`int`<br>
|
||||
`class std::vector<int,class std::allocator<int> >`<br>
|
||||
`class std::_Vector_const_iterator<class std::_Vector_val<struct std::_Simple_types<int> > >`<br>
|
||||
`*** Using type_id_with_cvr`<br>
|
||||
`int const`<br>
|
||||
`class std::vector<int,class std::allocator<int> > &`<br>
|
||||
`class std::vector<int,class std::allocator<int> > &&`<br>
|
||||
`class std::_Vector_const_iterator<class std::_Vector_val<struct std::_Simple_types<int> > > &`</p>
|
||||
|
||||
|
||||
在 GCC 下的输出为:
|
||||
|
||||
>
|
||||
<p>`*** Using typeid`<br>
|
||||
`i`<br>
|
||||
`St6vectorIiSaIiEE`<br>
|
||||
`N9__gnu_cxx17__normal_iteratorIPKiSt6vectorIiSaIiEEEE`<br>
|
||||
`*** Using type_id`<br>
|
||||
`int`<br>
|
||||
`std::vector<int, std::allocator<int> >`<br>
|
||||
`__gnu_cxx::__normal_iterator<int const*, std::vector<int, std::allocator<int> > >`<br>
|
||||
`*** Using type_id_with_cvr`<br>
|
||||
`int const`<br>
|
||||
`std::vector<int, std::allocator<int> >&`<br>
|
||||
`std::vector<int, std::allocator<int> >&&`<br>
|
||||
`__gnu_cxx::__normal_iterator<int const*, std::vector<int, std::allocator<int> > >&`</p>
|
||||
|
||||
|
||||
我们可以看到 MSVC 下 `typeid` 直接输出了比较友好的类型名称,但 GCC 下没有。此外,我们可以注意到:
|
||||
|
||||
- `typeid` 的输出忽略了 const 修饰,也不能输出变量的引用类型。
|
||||
- `type_id` 保证可以输出友好的类型名称,输出时也不需要调用成员函数,但例子里它忽略了 `int` 的 const 修饰,也和 `typeid` 一样不能输出表达式的引用类型。
|
||||
- `type_id_with_cvr` 可以输出 const/volatile 状态和引用类型,注意这种情况下模板参数必须包含引用类型,所以我用了 `decltype((v))` 这种写法,而不是 `decltype(v)`。如果你忘了这两者的区别,请复习一下[[第 8 讲]](https://time.geekbang.org/column/article/176850) 的 `decltype`。
|
||||
|
||||
显然,除非你正在使用 MSVC,否则调试期 `typeid` 的用法完全应该用 Boost 的 `type_id` 来替代。另外,如果你的开发环境要求禁用 RTTI(运行时类型识别),那 `typeid` 在 Clang 和 GCC 下根本不能使用,而使用 Boost.TypeIndex 库仍然没有问题。
|
||||
|
||||
当然,上面说的前提都是你在调试中试图获得变量的类型,而不是要获得一个多态对象的运行时类型。后者还是离不开 RTTI 的——虽然你也可以用一些其他方式来模拟 RTTI,但我个人觉得一般的项目不太有必要这样做。下面的代码展示了 `typeid` 和 `type_id` 在获取对象类型上的差异:
|
||||
|
||||
```
|
||||
#include <iostream>
|
||||
#include <typeinfo>
|
||||
#include <boost/type_index.hpp>
|
||||
|
||||
using namespace std;
|
||||
using boost::typeindex::type_id;
|
||||
|
||||
class shape {
|
||||
public:
|
||||
virtual ~shape() {}
|
||||
};
|
||||
|
||||
class circle : public shape {};
|
||||
|
||||
#define CHECK_TYPEID(object, type) \
|
||||
cout << "typeid(" #object << ")" \
|
||||
<< (typeid(object) == \
|
||||
typeid(type) \
|
||||
? " is " \
|
||||
: " is NOT ") \
|
||||
<< #type << endl
|
||||
|
||||
#define CHECK_TYPE_ID(object, \
|
||||
type) \
|
||||
cout << "type_id(" #object \
|
||||
<< ")" \
|
||||
<< (type_id<decltype( \
|
||||
object)>() == \
|
||||
type_id<type>() \
|
||||
? " is " \
|
||||
: " is NOT ") \
|
||||
<< #type << endl
|
||||
|
||||
int main()
|
||||
{
|
||||
shape* ptr = new circle();
|
||||
CHECK_TYPEID(*ptr, shape);
|
||||
CHECK_TYPEID(*ptr, circle);
|
||||
CHECK_TYPE_ID(*ptr, shape);
|
||||
CHECK_TYPE_ID(*ptr, circle);
|
||||
delete ptr;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
输出为:
|
||||
|
||||
>
|
||||
<p>`typeid(*ptr) is NOT shape`<br>
|
||||
`typeid(*ptr) is circle`<br>
|
||||
`type_id(*ptr) is shape`<br>
|
||||
`type_id(*ptr) is NOT circle`</p>
|
||||
|
||||
|
||||
## Boost.Core
|
||||
|
||||
Core 里面提供了一些通用的工具,这些工具常常被 Boost 的其他库用到,而我们也可以使用,不需要链接任何库。在这些工具里,有些已经(可能经过一些变化后)进入了 C++ 标准,如:
|
||||
|
||||
- `addressof`,在即使用户定义了 `operator&` 时也能获得对象的地址
|
||||
- `enable_if`,这个我们已经深入讨论过了([[第 14 讲]](https://time.geekbang.org/column/article/181636))
|
||||
- `is_same`,判断两个类型是否相同,C++11 开始在 <type_traits> 中定义
|
||||
- `ref`,和标准库的相同,我们在[[第 19 讲]](https://time.geekbang.org/column/article/186689) 讨论线程时用过
|
||||
|
||||
我们在剩下的里面来挑几个讲讲。
|
||||
|
||||
### boost::core::demangle
|
||||
|
||||
`boost::core::demangle` 能够用来把 `typeid` 返回的内部名称“反粉碎”(demangle)成可读的形式,看代码和输出应该就非常清楚了:
|
||||
|
||||
```
|
||||
#include <iostream>
|
||||
#include <typeinfo>
|
||||
#include <vector>
|
||||
#include <boost/core/demangle.hpp>
|
||||
|
||||
using namespace std;
|
||||
using boost::core::demangle;
|
||||
|
||||
int main()
|
||||
{
|
||||
vector<int> v;
|
||||
auto it = v.cbegin();
|
||||
|
||||
cout << "*** Using typeid\n";
|
||||
cout << typeid(const int).name()
|
||||
<< endl;
|
||||
cout << typeid(v).name() << endl;
|
||||
cout << typeid(it).name() << endl;
|
||||
|
||||
cout << "*** Demangled\n";
|
||||
cout << demangle(typeid(const int)
|
||||
.name())
|
||||
<< endl;
|
||||
cout << demangle(typeid(v).name())
|
||||
<< endl;
|
||||
cout << demangle(
|
||||
typeid(it).name())
|
||||
<< endl;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
GCC 下的输出为:
|
||||
|
||||
>
|
||||
<p>`*** Using typeid`<br>
|
||||
`i`<br>
|
||||
`St6vectorIiSaIiEE`<br>
|
||||
`N9__gnu_cxx17__normal_iteratorIPKiSt6vectorIiSaIiEEEE`<br>
|
||||
`*** Demangled`<br>
|
||||
`int`<br>
|
||||
`std::vector<int, std::allocator<int> >`<br>
|
||||
`__gnu_cxx::__normal_iterator<int const*, std::vector<int, std::allocator<int> > >`</p>
|
||||
|
||||
|
||||
如果你不使用 RTTI 的话,那直接使用 TypeIndex 应该就可以。如果你需要使用 RTTI、又不是(只)使用 MSVC 的话,`demangle` 就会给你不少帮助。
|
||||
|
||||
### boost::noncopyable
|
||||
|
||||
`boost::noncopyable` 提供了一种非常简单也很直白的把类声明成不可拷贝的方式。比如,我们[[第 1 讲]](https://time.geekbang.org/column/article/169225) 里的 `shape_wrapper`,用下面的写法就明确表示了它不允许被拷贝:
|
||||
|
||||
```
|
||||
#include <boost/core/noncopyable.hpp>
|
||||
|
||||
class shape_wrapper
|
||||
: private boost::noncopyable {
|
||||
…
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
你当然也可以自己把拷贝构造和拷贝赋值函数声明成 `= delete`,不过,上面的写法是不是可读性更佳?
|
||||
|
||||
### boost::swap
|
||||
|
||||
你有没有印象在通用的代码如何对一个不知道类型的对象执行交换操作?不记得的话,标准做法是这样的:
|
||||
|
||||
```
|
||||
{
|
||||
using std::swap;
|
||||
swap(lhs, rhs);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
即,我们需要(在某个小作用域里)引入 `std::swap`,然后让编译器在“看得到” `std::swap` 的情况下去编译 `swap` 指令。根据 ADL,如果在被交换的对象所属类型的名空间下有 `swap` 函数,那个函数会被优先使用,否则,编译器会选择通用的 `std::swap`。
|
||||
|
||||
似乎有点小啰嗦。使用 Boost 的话,你可以一行搞定:
|
||||
|
||||
```
|
||||
boost::swap(lhs, rhs);
|
||||
|
||||
```
|
||||
|
||||
当然,你需要包含头文件 <boost/core/swap.hpp>。
|
||||
|
||||
## Boost.Conversion
|
||||
|
||||
Conversion 同样是一个不需要链接的轻量级的库。它解决了标准 C++ 里的另一个问题,标准类型之间的转换不够方便。在 C++11 之前,这个问题尤为严重。在 C++11 里,标准引入了一系列的函数,已经可以满足常用类型之间的转换。但使用 Boost.Conversion 里的 `lexical_cast` 更不需要去查阅方法名称或动脑子去努力记忆。
|
||||
|
||||
下面是一个例子:
|
||||
|
||||
```
|
||||
#include <iostream>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <boost/lexical_cast.hpp>
|
||||
|
||||
using namespace std;
|
||||
using boost::bad_lexical_cast;
|
||||
using boost::lexical_cast;
|
||||
|
||||
int main()
|
||||
{
|
||||
// 整数到字符串的转换
|
||||
int d = 42;
|
||||
auto d_str =
|
||||
lexical_cast<string>(d);
|
||||
cout << d_str << endl;
|
||||
|
||||
// 字符串到浮点数的转换
|
||||
auto f =
|
||||
lexical_cast<float>(d_str) /
|
||||
4.0;
|
||||
cout << f << endl;
|
||||
|
||||
// 测试 lexical_cast 的转换异常
|
||||
try {
|
||||
int t = lexical_cast<int>("x");
|
||||
cout << t << endl;
|
||||
}
|
||||
catch (bad_lexical_cast& e) {
|
||||
cout << e.what() << endl;
|
||||
}
|
||||
|
||||
// 测试标准库 stoi 的转换异常
|
||||
try {
|
||||
int t = std::stoi("x");
|
||||
cout << t << endl;
|
||||
}
|
||||
catch (invalid_argument& e) {
|
||||
cout << e.what() << endl;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
GCC 下的输出为:
|
||||
|
||||
>
|
||||
<p>`42`<br>
|
||||
`10.5`<br>
|
||||
`bad lexical cast: source type value could not be interpreted as target`<br>
|
||||
`stoi`</p>
|
||||
|
||||
|
||||
我觉得 GCC 里 `stoi` 的异常输出有点太言简意赅了……而 `lexical_cast` 的异常输出在不同的平台上有很好的一致性。
|
||||
|
||||
## Boost.ScopeExit
|
||||
|
||||
我们说过 RAII 是推荐的 C++ 里管理资源的方式。不过,作为 C++ 程序员,跟 C 函数打交道也很正常。每次都写个新的 RAII 封装也有点浪费。Boost 里提供了一个简单的封装,你可以从下面的示例代码里看到它是如何使用的:
|
||||
|
||||
```
|
||||
#include <stdio.h>
|
||||
#include <boost/scope_exit.hpp>
|
||||
|
||||
void test()
|
||||
{
|
||||
FILE* fp = fopen("test.cpp", "r");
|
||||
if (fp == NULL) {
|
||||
perror("Cannot open file");
|
||||
}
|
||||
BOOST_SCOPE_EXIT(&fp) {
|
||||
if (fp) {
|
||||
fclose(fp);
|
||||
puts("File is closed");
|
||||
}
|
||||
} BOOST_SCOPE_EXIT_END
|
||||
puts("Faking an exception");
|
||||
throw 42;
|
||||
}
|
||||
|
||||
int main()
|
||||
{
|
||||
try {
|
||||
test();
|
||||
}
|
||||
catch (int) {
|
||||
puts("Exception received");
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
唯一需要说明的可能就是 `BOOST_SCOPE_EXIT` 里的那个 `&` 符号了——把它理解成 lambda 表达式的按引用捕获就对了(虽然 `BOOST_SCOPE_EXIT` 可以支持 C++98 的代码)。如果不需要捕获任何变量,`BOOST_SCOPE_EXIT` 的参数必须填为 `void`。
|
||||
|
||||
输出为(假设 test.cpp 存在):
|
||||
|
||||
>
|
||||
<p>`Faking an exception`<br>
|
||||
`File is closed`<br>
|
||||
`Exception received`</p>
|
||||
|
||||
|
||||
使用这个库也只需要头文件。注意实现类似的功能在 C++11 里相当容易,但由于 ScopeExit 可以支持 C++98 的代码,因而它的实现还是相当复杂的。
|
||||
|
||||
## Boost.Program_options
|
||||
|
||||
传统上 C 代码里处理命令行参数会使用 `getopt`。我也用过,比如在下面的代码中:
|
||||
|
||||
[https://github.com/adah1972/breaktext/blob/master/breaktext.c](https://github.com/adah1972/breaktext/blob/master/breaktext.c)
|
||||
|
||||
这种方式有不少缺陷:
|
||||
|
||||
- 一个选项通常要在三个地方重复:说明文本里,`getopt` 的参数里,以及对 `getopt` 的返回结果进行处理时。不知道你觉得怎样,我反正发生过改了一处、漏改其他的错误。
|
||||
- 对选项的附加参数需要手工写代码处理,因而常常不够严格(C 的类型转换不够方便,尤其是检查错误)。
|
||||
|
||||
Program_options 正是解决这个问题的。这个代码有点老了,不过还挺实用;懒得去找特别的处理库时,至少这个伸手可用。使用这个库需要链接 boost_program_options 库。
|
||||
|
||||
下面的代码展示了代替上面的 `getopt` 用法的代码:
|
||||
|
||||
```
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
#include <stdlib.h>
|
||||
#include <boost/program_options.hpp>
|
||||
|
||||
namespace po = boost::program_options;
|
||||
using std::cout;
|
||||
using std::endl;
|
||||
using std::string;
|
||||
|
||||
string locale;
|
||||
string lang;
|
||||
int width = 72;
|
||||
bool keep_indent = false;
|
||||
bool verbose = false;
|
||||
|
||||
int main(int argc, char* argv[])
|
||||
{
|
||||
po::options_description desc(
|
||||
"Usage: breaktext [OPTION]... "
|
||||
"<Input File> [Output File]\n"
|
||||
"\n"
|
||||
"Available options");
|
||||
desc.add_options()
|
||||
("locale,L",
|
||||
po::value<string>(&locale),
|
||||
"Locale of the console (system locale by default)")
|
||||
("lang,l",
|
||||
po::value<string>(&lang),
|
||||
"Language of input (asssume no language by default)")
|
||||
("width,w",
|
||||
po::value<int>(&width),
|
||||
"Width of output text (72 by default)")
|
||||
("help,h", "Show this help message and exit")
|
||||
(",i",
|
||||
po::bool_switch(&keep_indent),
|
||||
"Keep space indentation")
|
||||
(",v",
|
||||
po::bool_switch(&verbose),
|
||||
"Be verbose");
|
||||
|
||||
po::variables_map vm;
|
||||
try {
|
||||
po::store(
|
||||
po::parse_command_line(
|
||||
argc, argv, desc),
|
||||
vm);
|
||||
}
|
||||
catch (po::error& e) {
|
||||
cout << e.what() << endl;
|
||||
exit(1);
|
||||
}
|
||||
vm.notify();
|
||||
|
||||
if (vm.count("help")) {
|
||||
cout << desc << "\n";
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
略加说明一下:
|
||||
|
||||
- `options_description` 是基本的选项描述对象的类型,构造时我们给出对选项的基本描述。
|
||||
- `options_description` 对象的 `add_options` 成员函数会返回一个函数对象,然后我们直接用括号就可以添加一系列的选项。
|
||||
- 每个选项初始化时可以有两个或三个参数,第一项是选项的形式,使用长短选项用逗号隔开的字符串(可以只提供一种),最后一项是选项的文字描述,中间如果还有一项的话,就是选项的值描述。
|
||||
- 选项的值描述可以用 `value`、`bool_switch` 等方法,参数是输出变量的指针。
|
||||
- `variables_map`,变量映射表,用来存储对命令行的扫描结果;它继承了标准的 `std::map`。
|
||||
- `notify` 成员函数用来把变量映射表的内容实际传送到选项值描述里提供的那些变量里去。
|
||||
- `count` 成员函数继承自 `std::map`,只能得到 0 或 1 的结果。
|
||||
|
||||
这样,我们的程序就能处理上面的那些选项了。如果运行时在命令行加上 `-h` 或 `--help` 选项,程序就会输出跟原来类似的帮助输出——额外的好处是选项的描述信息较长时还能自动帮你折行,不需要手工排版了。建议你自己尝试一下,提供各种正确或错误的选项,来检查一下运行的结果。
|
||||
|
||||
当然现在有些更新的选项处理库,但它们应该都和 Program_options 更接近,而不是和 `getopt` 更接近。如果你感觉 Program_options 功能不足了,换一个其他库不会是件麻烦事。
|
||||
|
||||
## Boost.Hana
|
||||
|
||||
Boost 里自然也有模板元编程相关的东西。但我不打算介绍 MPL、Fusion 和 Phoenix 那些,因为有些技巧,在 C++11 和 Lambda 表达式到来之后,已经略显得有点过时了。Hana 则不同,它是一个使用了 C++11/14 实现技巧和惯用法的新库,也和一般的模板库一样,只要有头文件就能使用。
|
||||
|
||||
Hana 里定义了一整套供**编译期**使用的数据类型和函数。我们现在看一下它提供的部分类型:
|
||||
|
||||
- `type`:把类型转化成对象(我们在[[第 13 讲]](https://time.geekbang.org/column/article/181608) 曾经示例过相反的动作,把数值转化成对象),来方便后续处理。
|
||||
- `integral_constant`:跟 `std::integral_constant` 相似,但定义了更多的运算符和语法糖。特别的,你可以用字面量来生成一个 `long long` 类型的 `integral_constant`,如 `1_c`。
|
||||
- `string`:一个编译期使用的字符串类型。
|
||||
- `tuple`:跟 `std::tuple` 类似,意图是当作编译期的 `vector` 来使用。
|
||||
- `map`:编译期使用的关联数组。
|
||||
- `set`:编译期使用的集合。
|
||||
|
||||
Hana 里的算法的名称跟标准库的类似,我就不一一列举了。下面的例子展示了一个基本用法:
|
||||
|
||||
```
|
||||
#include <boost/hana.hpp>
|
||||
namespace hana = boost::hana;
|
||||
|
||||
class shape {};
|
||||
class circle {};
|
||||
class triangle {};
|
||||
|
||||
int main()
|
||||
{
|
||||
using namespace hana::literals;
|
||||
|
||||
constexpr auto tup =
|
||||
hana::make_tuple(
|
||||
hana::type_c<shape*>,
|
||||
hana::type_c<circle>,
|
||||
hana::type_c<triangle>);
|
||||
|
||||
constexpr auto no_pointers =
|
||||
hana::remove_if(
|
||||
tup, [](auto a) {
|
||||
return hana::traits::
|
||||
is_pointer(a);
|
||||
});
|
||||
|
||||
static_assert(
|
||||
no_pointers ==
|
||||
hana::make_tuple(
|
||||
hana::type_c<circle>,
|
||||
hana::type_c<triangle>));
|
||||
static_assert(
|
||||
hana::reverse(no_pointers) ==
|
||||
hana::make_tuple(
|
||||
hana::type_c<triangle>,
|
||||
hana::type_c<circle>));
|
||||
static_assert(
|
||||
tup[1_c] == hana::type_c<circle>);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这个程序可以编译,但没有任何运行输出。在这个程序里,我们做了下面这几件事:
|
||||
|
||||
- 使用 `type_c` 把类型转化成 `type` 对象,并构造了类型对象的 `tuple`
|
||||
- 使用 `remove_if` 算法移除了 `tup` 中的指针类型
|
||||
- 使用静态断言确认了结果是我们想要的
|
||||
- 使用静态断言确认了可以用 `reverse` 把 `tup` 反转一下
|
||||
- 使用静态断言确认了可以用方括号运算符来获取 `tup` 中的某一项
|
||||
|
||||
可以看到,Hana 本质上以类似普通的运行期编程的写法,来做编译期的计算。上面展示的只是一些最基本的用法,而 Hana 的文档里展示了很多有趣的用法。尤其值得一看的是,文档中展示了如何利用 Hana 提供的机制,来自己定义 `switch_`、`case_`、`default_`,使得下面的代码可以通过编译:
|
||||
|
||||
```
|
||||
boost::any a = 'x';
|
||||
std::string r =
|
||||
switch_(a)(
|
||||
case_<int>([](auto i) {
|
||||
return "int: "s +
|
||||
std::to_string(i);
|
||||
}),
|
||||
case_<char>([](auto c) {
|
||||
return "char: "s +
|
||||
std::string{c};
|
||||
}),
|
||||
default_(
|
||||
[] { return "unknown"s; }));
|
||||
assert(r == "char: x"s);
|
||||
|
||||
```
|
||||
|
||||
我个人认为很有意思。
|
||||
|
||||
## 内容小结
|
||||
|
||||
本讲我们对 Boost 的意义做了概要介绍,并蜻蜓点水地简单描述了若干 Boost 库的功能。如果你想进一步了解 Boost 的细节的话,就得自行查看文档了。
|
||||
|
||||
## 课后思考
|
||||
|
||||
请你考虑一下,我今天描述的 Boost 库里的功能是如何实现的。然后自己去看一下源代码(开源真是件大好事!),检查一下跟自己想象的是不是有出入。
|
||||
|
||||
## 参考资料
|
||||
|
||||
[1] Boost C++ Libraries. [https://www.boost.org/](https://www.boost.org/)
|
||||
|
||||
[2] David Abarahams and Aleksey Gurtovoy, **C++ Template Metaprogramming**. Addison-Wesley, 2004. 有中文版(荣耀译,机械工业出版社,2010 年)
|
||||
351
极客时间专栏/现代C++实战30讲/实战篇/25 | 两个单元测试库:C++里如何进行单元测试?.md
Normal file
351
极客时间专栏/现代C++实战30讲/实战篇/25 | 两个单元测试库:C++里如何进行单元测试?.md
Normal file
@@ -0,0 +1,351 @@
|
||||
<audio id="audio" title="25 | 两个单元测试库:C++里如何进行单元测试?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/49/95/49b641c0bc262c646457a7e02ce89495.mp3"></audio>
|
||||
|
||||
你好,我是吴咏炜。
|
||||
|
||||
单元测试已经越来越成为程序员工作密不可分的一部分了。在 C++ 里,我们当然也是可以很方便地进行单元测试的。今天,我就来介绍两个单元测试库:一个是 Boost.Test [1],一个是 Catch2 [2]。
|
||||
|
||||
## Boost.Test
|
||||
|
||||
单元测试库有很多,我选择 Boost 的原因我在上一讲已经说过:“如果我需要某个功能,在标准库里没有,在 Boost 里有,我会很乐意直接使用 Boost 里的方案,而非另外去查找。”再说,Boost.Test 提供的功能还挺齐全的,我需要的都有了。作为开胃小菜,我们先看一个单元测试的小例子:
|
||||
|
||||
```
|
||||
#define BOOST_TEST_MAIN
|
||||
#include <boost/test/unit_test.hpp>
|
||||
#include <stdexcept>
|
||||
|
||||
void test(int n)
|
||||
{
|
||||
if (n == 42) {
|
||||
return;
|
||||
}
|
||||
throw std::runtime_error(
|
||||
"Not the answer");
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(my_test)
|
||||
{
|
||||
BOOST_TEST_MESSAGE("Testing");
|
||||
BOOST_TEST(1 + 1 == 2);
|
||||
BOOST_CHECK_THROW(
|
||||
test(41), std::runtime_error);
|
||||
BOOST_CHECK_NO_THROW(test(42));
|
||||
|
||||
int expected = 5;
|
||||
BOOST_TEST(2 + 2 == expected);
|
||||
BOOST_CHECK(2 + 2 == expected);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(null_test)
|
||||
{
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们从代码里可以看到:
|
||||
|
||||
- 我们在包含单元测试的头文件之前定义了 `BOOST_TEST_MAIN`。如果编译时用到了多个源文件,只有一个应该定义该宏。多文件测试的时候,我一般会考虑把这个定义这个宏加包含放在一个单独的文件里(只有两行)。
|
||||
- 我们用 `BOOST_AUTO_TEST_CASE` 来定义一个测试用例。一个测试用例里应当有多个测试语句(如 `BOOST_CHECK`)。
|
||||
- 我们用 `BOOST_CHECK` 或 `BOOST_TEST` 来检查一个应当成立的布尔表达式(区别下面会讲)。
|
||||
- 我们用 `BOOST_CHECK_THROW` 来检查一个应当抛出异常的语句。
|
||||
- 我们用 `BOOST_CHECK_NO_THROW` 来检查一个不应当抛出异常的语句。
|
||||
|
||||
如[[第 21 讲]](https://time.geekbang.org/column/article/187980) 所述,我们可以用下面的命令行来进行编译:
|
||||
|
||||
- MSVC:`cl /DBOOST_TEST_DYN_LINK /EHsc /MD test.cpp`
|
||||
- GCC:`g++ -DBOOST_TEST_DYN_LINK test.cpp -lboost_unit_test_framework`
|
||||
- Clang:`clang++ -DBOOST_TEST_DYN_LINK test.cpp -lboost_unit_test_framework`
|
||||
|
||||
运行结果如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2e/fc/2e71b25e154d6609bd5cd3f4bf4911fc.png" alt="">
|
||||
|
||||
我们现在能看到 `BOOST_CHECK` 和 `BOOST_TEST` 的区别了。后者是一个较新加入 Boost.Test 的宏,能利用模板技巧来输出表达式的具体内容。但在某些情况下,`BOOST_TEST` 试图输出表达式的内容会导致编译出错,这时可以改用更简单的 `BOOST_CHECK`。
|
||||
|
||||
不管是 `BOOST_CHECK` 还是 `BOOST_TEST`,在测试失败时,执行仍然会继续。在某些情况下,一个测试失败后继续执行后面的测试已经没有意义,这时,我们就可以考虑使用 `BOOST_REQUIRE` 或 `BOOST_TEST_REQUIRE`——表达式一旦失败,整个测试用例会停止执行(但其他测试用例仍会正常执行)。
|
||||
|
||||
缺省情况下单元测试的输出只包含错误信息和结果摘要,但输出的详细程度是可以通过命令行选项来进行控制的。如果我们在运行测试程序时加上命令行参数 `--log_level=all`(或 `-l all`),我们就可以得到下面这样更详尽的输出:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4e/73/4ead9a603e0f5c4703637c905a2faf73.png" alt="">
|
||||
|
||||
我们现在额外可以看到:
|
||||
|
||||
- 在进入、退出测试模块和用例时的提示
|
||||
- `BOOST_TEST_MESSAGE` 的输出
|
||||
- 正常通过的测试的输出
|
||||
- 用例里无测试断言的警告
|
||||
|
||||
使用 Windows 的同学如果运行了测试程序的话,多半会惊恐地发现终端上的文字颜色已经发生了变化。这似乎是 Boost.Test 在 Windows 上特有的一个问题:建议你把单元测试的色彩显示关掉。你可以在系统高级设置里添加下面这个环境变量,也可以直接在命令行上输入:
|
||||
|
||||
```
|
||||
set BOOST_TEST_COLOR_OUTPUT=0
|
||||
|
||||
```
|
||||
|
||||
下面我们看一个更真实的例子。
|
||||
|
||||
假设我们有一个 `split` 函数,定义如下:
|
||||
|
||||
```
|
||||
template <typename String,
|
||||
typename Delimiter>
|
||||
class split_view {
|
||||
public:
|
||||
typedef
|
||||
typename String::value_type
|
||||
char_type;
|
||||
class iterator { … };
|
||||
|
||||
split_view(const String& str,
|
||||
Delimiter delimiter);
|
||||
iterator begin() const;
|
||||
iterator end() const;
|
||||
vector<basic_string<char_type>>
|
||||
to_vector() const;
|
||||
vector<basic_string_view<char_type>>
|
||||
to_vector_sv() const;
|
||||
};
|
||||
|
||||
template <typename String,
|
||||
typename Delimiter>
|
||||
split_view<String, Delimiter>
|
||||
split(const String& str,
|
||||
Delimiter delimiter);
|
||||
|
||||
```
|
||||
|
||||
这个函数的意图是把类似于字符串的类型(`string` 或 `string_view`)分割开,并允许对分割的结果进行遍历。为了方便使用,结果也可以直接转化成字符串的数组(`to_vector`)或字符串视图的数组(`to_vector_sv`)。我们不用关心这个函数是如何实现的,我们就需要测试一下,该如何写呢?
|
||||
|
||||
首先,当然是写出一个测试用例的框架,把试验的待分割字符串写进去:
|
||||
|
||||
```
|
||||
BOOST_AUTO_TEST_CASE(split_test)
|
||||
{
|
||||
string_view str{
|
||||
"&grant_type=client_credential"
|
||||
"&appid="
|
||||
"&secret=APPSECRET"};
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
最简单直白的测试,显然就是用 `to_vector` 或 `to_vector_sv` 来查看结果是否匹配了。这个非常容易加进去:
|
||||
|
||||
```
|
||||
vector<string>
|
||||
split_result_expected{
|
||||
"",
|
||||
"grant_type=client_"
|
||||
"credential",
|
||||
"appid=",
|
||||
"secret=APPSECRET"};
|
||||
auto result = split(str, '&');
|
||||
auto result_s =
|
||||
result.to_vector();
|
||||
BOOST_TEST(result_s ==
|
||||
split_result_expected);
|
||||
|
||||
```
|
||||
|
||||
如果 `to_vector` 实现正确的话,我们现在运行程序就能在终端输出上看到:
|
||||
|
||||
>
|
||||
`*** No errors detected`
|
||||
|
||||
|
||||
下面,我们进一步检查 `to_vector` 和 `to_vector_sv` 的结果是否一致:
|
||||
|
||||
```
|
||||
auto result_sv =
|
||||
result.to_vector_sv();
|
||||
BOOST_TEST_REQUIRE(
|
||||
result_s.size() ==
|
||||
result_sv.size());
|
||||
{
|
||||
auto it = result_sv.begin();
|
||||
for (auto& s : result_s) {
|
||||
BOOST_TEST(s == *it);
|
||||
++it;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
最后我们再测试可以遍历 `result`,并且结果和之前的相同:
|
||||
|
||||
```
|
||||
size_t i = 0;
|
||||
auto it = result.begin();
|
||||
auto end = result.end();
|
||||
for (; it != end &&
|
||||
i < result_s.size();
|
||||
++it) {
|
||||
BOOST_TEST(*it == result_s[i]);
|
||||
++i;
|
||||
}
|
||||
BOOST_CHECK(it == end);
|
||||
|
||||
```
|
||||
|
||||
而这,差不多就接近我实际的 `split` 测试代码了。完整代码可参见:
|
||||
|
||||
[https://github.com/adah1972/nvwa/blob/master/test/boosttest_split.cpp](https://github.com/adah1972/nvwa/blob/master/test/boosttest_split.cpp)
|
||||
|
||||
Boost.Test 产生的可执行代码支持很多命令行参数,可以用 `--help` 命令行选项来查看。常用的有:
|
||||
|
||||
- `build_info` 可用来展示构建信息
|
||||
- `color_output` 可用来打开或关闭输出中的色彩
|
||||
- `log_format` 可用来指定日志输出的格式,包括纯文本、XML、JUnit 等
|
||||
- `log_level` 可指定日志输出的级别,有 all、test_suite、error、fatal_error、nothing 等一共 11 个级别
|
||||
- `run_test` 可选择只运行指定的测试用例
|
||||
- `show_progress` 可在测试时显示进度,在测试数量较大时比较有用(见下图)
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c2/e5/c21168d96cd55836575a7e5b44e3a7e5.png" alt="">
|
||||
|
||||
我这儿只是个简单的介绍。完整的 Boost.Test 的功能介绍还是请你自行参看文档。
|
||||
|
||||
## Catch2
|
||||
|
||||
说完了 Boost.Test,我们再来看一下另外一个单元测试库,Catch2。仍然是和上一讲里说的一样,我要选择 Boost 之外的库,一定有一个比较强的理由。Catch2 有着它自己独有的优点:
|
||||
|
||||
- 只需要单个头文件即可使用,不需要安装和链接,简单方便
|
||||
- 可选使用 BDD(Behavior-Driven Development)风格的分节形式
|
||||
- 测试失败可选直接进入调试器(Windows 和 macOS 上)
|
||||
|
||||
我们拿前面 Boost.Test 的示例直接改造一下:
|
||||
|
||||
```
|
||||
#define CATCH_CONFIG_MAIN
|
||||
#include "catch.hpp"
|
||||
#include <stdexcept>
|
||||
|
||||
void test(int n)
|
||||
{
|
||||
if (n == 42) {
|
||||
return;
|
||||
}
|
||||
throw std::runtime_error(
|
||||
"Not the answer");
|
||||
}
|
||||
|
||||
TEST_CASE("My first test", "[my]")
|
||||
{
|
||||
INFO("Testing");
|
||||
CHECK(1 + 1 == 2);
|
||||
CHECK_THROWS_AS(
|
||||
test(41), std::runtime_error);
|
||||
CHECK_NOTHROW(test(42));
|
||||
|
||||
int expected = 5;
|
||||
CHECK(2 + 2 == expected);
|
||||
}
|
||||
|
||||
TEST_CASE("A null test", "[null]")
|
||||
{
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
可以看到,两者之间的相似性非常多,基本只是宏的名称变了一下。唯一值得一提的,是测试用例的参数:第一项是名字,第二项是标签,可以一个或多个。你除了可以直接在命令行上写测试的名字(不需要选项)来选择运行哪个测试外,也可以写测试的标签来选择运行哪些测试。
|
||||
|
||||
这是它在 Windows 下用 MSVC 编译的输出:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6f/c7/6faf4113a57d9c4b65d203de52c8bfc7.png" alt="">
|
||||
|
||||
终端的色彩不会被搞乱。缺省的输出清晰程度相当不错。至少在 Windows 下,它看起来可能是个比 Boost.Test 更好的选择。但反过来,在浅色的终端里,Catch2 的色彩不太友好。Boost.Test 在 Linux 和 macOS 下则不管终端的色彩设定,都有比较友好的输出。
|
||||
|
||||
和 Boost.Test 类似,Catch2 的测试结果输出格式也是可以修改的。默认格式是纯文本,但你可以通过使用 `-r junit` 来设成跟 JUnit 兼容的格式,或使用 `-r xml` 输出成 Catch2 自己的 XML 格式。这方面,它比 Boost.Test 明显易用的一个地方是格式参数大小写不敏感,而在 Boost.Test 里你必须用全大写的形式,如 `-f JUNIT`,麻烦!
|
||||
|
||||
下面我们通过另外一个例子来展示一下所谓的 BDD [3] 风格的测试。
|
||||
|
||||
BDD 风格的测试一般采用这样的结构:
|
||||
|
||||
- Scenario:场景,我要做某某事
|
||||
- Given:给定,已有的条件
|
||||
- When:当,某个事件发生时
|
||||
- Then:那样,就应该发生什么
|
||||
|
||||
如果我们要测试一个容器,那代码就应该是这个样子的:
|
||||
|
||||
```
|
||||
SCENARIO("Int container can be accessed and modified",
|
||||
"[container]")
|
||||
{
|
||||
GIVEN("A container with initialized items")
|
||||
{
|
||||
IntContainer c{1, 2, 3, 4, 5};
|
||||
REQUIRE(c.size() == 5);
|
||||
|
||||
WHEN("I access existing items")
|
||||
{
|
||||
THEN("The items can be retrieved intact")
|
||||
{
|
||||
CHECK(c[0] == 1);
|
||||
CHECK(c[1] == 2);
|
||||
CHECK(c[2] == 3);
|
||||
CHECK(c[3] == 4);
|
||||
CHECK(c[4] == 5);
|
||||
}
|
||||
}
|
||||
|
||||
WHEN("I modify items")
|
||||
{
|
||||
c[1] = -2;
|
||||
c[3] = -4;
|
||||
|
||||
THEN("Only modified items are changed")
|
||||
{
|
||||
CHECK(c[0] == 1);
|
||||
CHECK(c[1] == -2);
|
||||
CHECK(c[2] == 3);
|
||||
CHECK(c[3] == -4);
|
||||
CHECK(c[4] == 5);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
你可以在程序前面加上类型定义来测试你自己的容器类或标准容器(如 `vector<int>`)。这是一种非常直观的写测试的方式。正常情况下,你当然应该看到:
|
||||
|
||||
>
|
||||
`All tests passed (12 assertions in 1 test case)`
|
||||
|
||||
|
||||
如果你没有留意到的话,在 GIVEN 里 WHEN 之前的代码是在每次 WHEN 之前都会执行一遍的。这也是 BDD 方式的一个非常方便的地方。
|
||||
|
||||
如果测试失败,我们就能看到类似下面这样的信息输出了(我存心制造了一个错误):
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/fe/8b/fe7e7efbbc6e69418b378ea27701998b.png" alt="">
|
||||
|
||||
如果没有失败的情况下,想看到具体的测试内容,可以传递参数 `--success`(或 `-s`)。
|
||||
|
||||
如果你发现 Catch2 的编译速度有点慢的话,那我得告诉你,那是非常正常的。在你沮丧之前,我还应该马上告诉你,这在实际项目中完全不是一个问题。因为慢的原因通常主要是构建 Catch2 的主程序部分,而这部份在项目中只需要做一次,以后不会再有变动。你需要的是分离下面这部分代码在主程序里:
|
||||
|
||||
```
|
||||
#define CATCH_CONFIG_MAIN
|
||||
#include "catch.hpp"
|
||||
|
||||
```
|
||||
|
||||
只要这两行,来单独编译 Catch2 的主程序部分。你的实际测试代码里,则不要再定义 `CATCH_CONFIG_MAIN` 了。你会发现,这样一分离后,编译速度会大大加快。事实上,如果 Catch2 的主程序部分不需要编译的话,Catch2 的测试用例的编译速度在我的机器上比 Boost.Test 的还要快。
|
||||
|
||||
我觉得 Catch2 是一个很现代、很好用的测试框架。它的宏更简单,一个 `CHECK` 可以替代 Boost.Test 中的 `BOOST_TEST` 和 `BOOST_CHECK`,也没有 `BOOST_TEST` 在某些情况下不能用、必须换用 `BOOST_CHECK` 的问题。对于一个新项目,使用 Catch2 应该是件更简单、更容易上手的事——尤其如果你在 Windows 上开发的话。
|
||||
|
||||
目前,在 GitHub 上,Catch2 的收藏数超过一万,复刻(fork)数达到一千七,也已经足以证明它的流行程度。
|
||||
|
||||
## 内容小结
|
||||
|
||||
今天我们介绍了两个单元测试库,Boost.Test 和 Catch2。整体上来看,这两个都是很优秀的单元测试框架,可以满足日常开发的需要。
|
||||
|
||||
## 课后思考
|
||||
|
||||
请你自己试验一下本讲中的例子,来制造一些成功和失败的情况。使用一下,才能更容易确定哪一个更适合你的需求。
|
||||
|
||||
## 参考资料
|
||||
|
||||
[1] Gennadiy Rozental and Raffi Enficiaud, Boost.Test. [https://www.boost.org/doc/libs/release/libs/test/doc/html/index.html](https://www.boost.org/doc/libs/release/libs/test/doc/html/index.html)
|
||||
|
||||
[2] Two Blue Cubes Ltd., Catch2. [https://github.com/catchorg/Catch2](https://github.com/catchorg/Catch2)
|
||||
|
||||
[3] Wikipedia, “Behavior-driven development”. [https://en.wikipedia.org/wiki/Behavior-driven_development](https://en.wikipedia.org/wiki/Behavior-driven_development)
|
||||
513
极客时间专栏/现代C++实战30讲/实战篇/26 | Easylogging++和spdlog:两个好用的日志库.md
Normal file
513
极客时间专栏/现代C++实战30讲/实战篇/26 | Easylogging++和spdlog:两个好用的日志库.md
Normal file
@@ -0,0 +1,513 @@
|
||||
<audio id="audio" title="26 | Easylogging++和spdlog:两个好用的日志库" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/74/bd/74f7118683d10140fec8c96f72f851bd.mp3"></audio>
|
||||
|
||||
你好,我是吴咏炜。
|
||||
|
||||
上一讲正文我介绍了两个可以在 C++ 中进行单元测试的库。今天,类似的,我介绍两个实用的日志库,分别是 Easylogging++ [1] 和 spdlog [2]。
|
||||
|
||||
## Easylogging++
|
||||
|
||||
事实上,我本来想只介绍 Easylogging++ 的。但在检查其 GitHub 页面时,我发现了一个问题:它在 2019 年基本没有更新,且目前上报的问题也没有人处理。这是个潜在问题,除非你觉得这个库好到愿意自己动手修问题(话说回来,这个库还是不错的,我在这个项目贡献了 8 个被合并的 pull request)。不管怎样,原先说了要介绍这个库,所以我也还是介绍一下。
|
||||
|
||||
### 概述
|
||||
|
||||
Easylogging++ 一共只有两个文件,一个是头文件,一个是普通 C++ 源文件。事实上,它的一个较早版本只有一个文件。正如 Catch2 里一旦定义了 `CATCH_CONFIG_MAIN` 编译速度会大大减慢一样,把什么东西都放一起最终证明对编译速度还是相当不利的,因此,有人提交了一个补丁,把代码拆成了两个文件。使用 Easylogging++ 也只需要这两个文件——除此之外,就只有对标准和系统头文件的依赖了。
|
||||
|
||||
要使用 Easylogging++,推荐直接把这两个文件放到你的项目里。Easylogging++ 有很多的配置项会影响编译结果,我们先大致查看一下常用的可配置项:
|
||||
|
||||
- `ELPP_UNICODE`:启用 Unicode 支持,为在 Windows 上输出混合语言所必需
|
||||
- `ELPP_THREAD_SAFE`:启用多线程支持
|
||||
- `ELPP_DISABLE_LOGS`:全局禁用日志输出
|
||||
- `ELPP_DEFAULT_LOG_FILE`:定义缺省日志文件名称
|
||||
- `ELPP_NO_DEFAULT_LOG_FILE`:不使用缺省的日志输出文件
|
||||
- `ELPP_UTC_DATETIME`:在日志里使用协调世界时而非本地时间
|
||||
- `ELPP_FEATURE_PERFORMANCE_TRACKING`:开启性能跟踪功能
|
||||
- `ELPP_FEATURE_CRASH_LOG`:启用 GCC 专有的崩溃日志功能
|
||||
- `ELPP_SYSLOG`:允许使用系统日志(Unix 世界的 syslog)来记录日志
|
||||
- `ELPP_STL_LOGGING`:允许在日志里输出常用的标准容器对象(`std::vector` 等)
|
||||
- `ELPP_QT_LOGGING`:允许在日志里输出 Qt 的核心对象(`QVector` 等)
|
||||
- `ELPP_BOOST_LOGGING`:允许在日志里输出某些 Boost 的容器(`boost::container::vector` 等)
|
||||
- `ELPP_WXWIDGETS_LOGGING`:允许在日志里输出某些 wxWidgets 的模板对象(`wxVector` 等)
|
||||
|
||||
可以看到,Easylogging++ 的功能还是很丰富很全面的。
|
||||
|
||||
### 开始使用 Easylogging++
|
||||
|
||||
虽说 Easylogging++ 的功能非常多,但开始使用它毫不困难。我们从一个简单的例子开始看一下:
|
||||
|
||||
```
|
||||
#include "easylogging++.h"
|
||||
INITIALIZE_EASYLOGGINGPP
|
||||
|
||||
int main()
|
||||
{
|
||||
LOG(INFO) << "My first info log";
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
编译链接的时候要把 easylogging++.cc 放进去。比如,使用 GCC 的话,命令行会像:
|
||||
|
||||
>
|
||||
`g++ -std=c++17 test.cpp easylogging++.cc`
|
||||
|
||||
|
||||
运行生成的可执行程序,你就可以看到结果输出到终端和 myeasylog.log 文件里,包含了日期、时间、级别、日志名称和日志信息,形如:
|
||||
|
||||
>
|
||||
`2020-01-25 20:47:50,990 INFO [default] My first info log`
|
||||
|
||||
|
||||
如果你对上面用到的宏感到好奇的话, `INITIALIZE_EASYLOGGINGPP` 展开后(可以用编译器的 `-E` 参数查看宏展开后的结果)是定义了 Easylogging++ 使用到的全局对象,而 `LOG(INFO)` 则是 Info 级别的日志记录器,同时传递了文件名、行号、函数名等日志需要的信息。
|
||||
|
||||
### 使用 Unicode
|
||||
|
||||
如果你在 Windows 上,那有一个复杂性就是是否使用“Unicode”的问题([[第 11 讲]](https://time.geekbang.org/column/article/179357) 中讨论了)。就我们日志输出而言,启用 Unicode 支持的好处是:
|
||||
|
||||
- 可以使用宽字符来输出
|
||||
- 日志文件的格式是 UTF-8,而不是传统的字符集,只能支持一种文字
|
||||
|
||||
要启用 Unicode 支持,你需要定义宏 `ELPP_UNICODE`,并确保程序中有对标准输出进行区域或格式设置(如[[第 11 讲]](https://time.geekbang.org/column/article/179357) 中所述,需要进行设置才能输出含非 ASCII 字符的宽字符串)。下面的程序给出了一个简单的示例:
|
||||
|
||||
```
|
||||
#ifdef _WIN32
|
||||
#include <fcntl.h>
|
||||
#include <io.h>
|
||||
#else
|
||||
#include <locale>
|
||||
#endif
|
||||
#include "easylogging++.h"
|
||||
INITIALIZE_EASYLOGGINGPP
|
||||
|
||||
int main()
|
||||
{
|
||||
#ifdef _WIN32
|
||||
_setmode(_fileno(stdout),
|
||||
_O_WTEXT);
|
||||
#else
|
||||
using namespace std;
|
||||
locale::global(locale(""));
|
||||
wcout.imbue(locale());
|
||||
#endif
|
||||
|
||||
LOG(INFO) << L"测试 test";
|
||||
LOG(INFO)
|
||||
<< "Narrow ASCII always OK";
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
编译使用的命令行是:
|
||||
|
||||
>
|
||||
`cl /EHsc /DELPP_UNICODE test.cpp easylogging++.cc`
|
||||
|
||||
|
||||
### 改变输出文件名
|
||||
|
||||
Easylogging++ 的缺省输出日志名为 myeasylog.log,这在大部分情况下都是不适用的。我们可以直接在命令行上使用宏定义来修改(当然,稍大点的项目就应该放在项目的编译配置文件里了,如 Makefile)。比如,要把输出文件名改成 test.log,我们只需要在命令行上加入下面的选项就可以:
|
||||
|
||||
>
|
||||
`-DELPP_DEFAULT_LOG_FILE=\"test.log\"`
|
||||
|
||||
|
||||
### 使用配置文件设置日志选项
|
||||
|
||||
不过,对于日志文件名称这样的设置,使用配置文件是一个更好的办法。Easylogging++ 库自己支持配置文件,我也推荐使用一个专门的配置文件,并让 Easylogging++ 自己来加载配置文件。我自己使用的配置文件是这个样子的:
|
||||
|
||||
```
|
||||
* GLOBAL:
|
||||
FORMAT = "%datetime{%Y-%M-%d %H:%m:%s.%g} %levshort %msg"
|
||||
FILENAME = "test.log"
|
||||
ENABLED = true
|
||||
TO_FILE = true ## 输出到文件
|
||||
TO_STANDARD_OUTPUT = true ## 输出到标准输出
|
||||
SUBSECOND_PRECISION = 6 ## 秒后面保留 6 位
|
||||
MAX_LOG_FILE_SIZE = 2097152 ## 最大日志文件大小设为 2MB
|
||||
LOG_FLUSH_THRESHOLD = 10 ## 写 10 条日志刷新一次缓存
|
||||
* DEBUG:
|
||||
FORMAT = "%datetime{%Y-%M-%d %H:%m:%s.%g} %levshort [%fbase:%line] %msg"
|
||||
TO_FILE = true
|
||||
TO_STANDARD_OUTPUT = false ## 调试日志不输出到标准输出
|
||||
|
||||
```
|
||||
|
||||
这个配置文件里有两节:第一节是全局(global)配置,配置了适用于所有级别的日志选项;第二节是专门用于调试(debug)级别的配置(你当然也可以自己配置 fatal、error、warning 等其他级别)。
|
||||
|
||||
假设这个配置文件的名字是 log.conf,我们在代码中可以这样使用:
|
||||
|
||||
```
|
||||
#include "easylogging++.h"
|
||||
INITIALIZE_EASYLOGGINGPP
|
||||
|
||||
int main()
|
||||
{
|
||||
el::Configurations conf{
|
||||
"log.conf"};
|
||||
el::Loggers::
|
||||
reconfigureAllLoggers(conf);
|
||||
LOG(DEBUG) << "A debug message";
|
||||
LOG(INFO) << "An info message";
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
注意编译命令行上应当加上 `-DELPP_NO_DEFAULT_LOG_FILE`,否则 Easylogging++ 仍然会生成缺省的日志文件。
|
||||
|
||||
运行生成的可执行程序,我们会在终端上看到一条信息,但在日志文件里则可以看到两条信息。如下所示:
|
||||
|
||||
>
|
||||
<p>`2020-01-26 12:54:58.986739 D [test.cpp:11] A debug message`<br>
|
||||
`2020-01-26 12:54:58.987444 I An info message`</p>
|
||||
|
||||
|
||||
我们也可以明确看到我们在配置文件中定义的日志格式生效了,包括:
|
||||
|
||||
- 日期时间的格式使用“.”分隔秒的整数和小数部分,并且小数部分使用 6 位
|
||||
- 日志级别使用单个大写字母
|
||||
- 对于普通的日志,后面直接跟日志的信息;对于调试日志,则会输出文件名和行号
|
||||
|
||||
我们现在只需要修改配置文件,就能调整日志格式、决定输出和不输出哪些日志了。此外,我也推荐在编译时定义宏 `ELPP_DEBUG_ASSERT_FAILURE`,这样能在找不到配置文件时直接终止程序,而不是继续往下执行、在终端上以缺省的方式输出日志了。
|
||||
|
||||
### 性能跟踪
|
||||
|
||||
Easylogging++ 可以用来在日志中记录程序执行的性能数据。这个功能还是很方便的。下面的代码展示了用于性能跟踪的三个宏的用法:
|
||||
|
||||
```
|
||||
#include <chrono>
|
||||
#include <thread>
|
||||
#include "easylogging++.h"
|
||||
INITIALIZE_EASYLOGGINGPP
|
||||
|
||||
void foo()
|
||||
{
|
||||
TIMED_FUNC(timer);
|
||||
LOG(WARNING) << "A warning message";
|
||||
}
|
||||
|
||||
void bar()
|
||||
{
|
||||
using namespace std::literals;
|
||||
TIMED_SCOPE(timer1, "void bar()");
|
||||
foo();
|
||||
foo();
|
||||
TIMED_BLOCK(timer2, "a block") {
|
||||
foo();
|
||||
std::this_thread::sleep_for(100us);
|
||||
}
|
||||
}
|
||||
|
||||
int main()
|
||||
{
|
||||
el::Configurations conf{
|
||||
"log.conf"};
|
||||
el::Loggers::
|
||||
reconfigureAllLoggers(conf);
|
||||
bar();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
简单说明一下:
|
||||
|
||||
- `TIMED_FUNC` 接受一个参数,是用于性能跟踪的对象的名字。它能自动产生函数的名称。示例中的 `TIMED_FUNC` 和 `TIMED_SCOPE` 的作用是完全相同的。
|
||||
- `TIMED_SCOPE` 接受两个参数,分别是用于性能跟踪的对象的名字,以及用于记录的名字。如果你不喜欢 `TIMED_FUNC` 生成的函数名字,可以用 `TIMED_SCOPE` 来代替。
|
||||
- `TIMED_BLOCK` 用于对下面的代码块进行性能跟踪,参数形式和 `TIMED_SCOPE` 相同。
|
||||
|
||||
在编译含有上面三个宏的代码时,需要定义宏 `ELPP_FEATURE_PERFORMANCE_TRACKING`。你一般也应该定义 `ELPP_PERFORMANCE_MICROSECONDS`,来获取微秒级的精度。下面是定义了上面两个宏编译的程序的某次执行的结果:
|
||||
|
||||
>
|
||||
<p>`2020-01-26 15:00:11.99736 W A warning message`<br>
|
||||
`2020-01-26 15:00:11.99748 I Executed [void foo()] in [110 us]`<br>
|
||||
`2020-01-26 15:00:11.99749 W A warning message`<br>
|
||||
`2020-01-26 15:00:11.99750 I Executed [void foo()] in [5 us]`<br>
|
||||
`2020-01-26 15:00:11.99750 W A warning message`<br>
|
||||
`2020-01-26 15:00:11.99751 I Executed [void foo()] in [4 us]`<br>
|
||||
`2020-01-26 15:00:11.99774 I Executed [a block] in [232 us]`<br>
|
||||
`2020-01-26 15:00:11.99776 I Executed [void bar()] in [398 us]`</p>
|
||||
|
||||
|
||||
不过需要注意,由于 Easylogging++ 本身有一定开销,且开销有一定的不确定性,这种方式只适合颗粒度要求比较粗的性能跟踪。
|
||||
|
||||
性能跟踪产生的日志级别固定为 Info。性能跟踪本身可以在配置文件里的 GLOBAL 节下用 `PERFORMANCE_TRACKING = false` 来关闭。当然,关闭所有 Info 级别的输出也能达到关闭性能跟踪的效果。
|
||||
|
||||
### 记录崩溃日志
|
||||
|
||||
在 GCC 和 Clang 下,通过定义宏 `ELPP_FEATURE_CRASH_LOG` 我们可以启用崩溃日志。此时,当程序崩溃时,Easylogging++ 会自动在日志中记录程序的调用栈信息。通过记录下的信息,再利用 `addr2line` 这样的工具,我们就能知道是程序的哪一行引发了崩溃。下面的代码可以演示这一行为:
|
||||
|
||||
```
|
||||
#include "easylogging++.h"
|
||||
INITIALIZE_EASYLOGGINGPP
|
||||
|
||||
void boom()
|
||||
{
|
||||
char* ptr = nullptr;
|
||||
*ptr = '\0';
|
||||
}
|
||||
|
||||
int main()
|
||||
{
|
||||
el::Configurations conf{
|
||||
"log.conf"};
|
||||
el::Loggers::
|
||||
reconfigureAllLoggers(conf);
|
||||
boom();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
你可以自己尝试编译运行一下,就会在终端和日志文件中看到崩溃的信息了。
|
||||
|
||||
使用 macOS 的需要特别注意一下:由于缺省方式产生的可执行文件是位置独立的,系统每次加载程序会在不同的地址,导致无法通过地址定位到程序行。在编译命令行尾部加上 `-Wl,-no_pie` 可以解决这个问题。
|
||||
|
||||
### 其他
|
||||
|
||||
Easylogging++ 还有很多其他功能,我就不再一一讲解了。有些你简单试一下就可以用起来的。对于 `ELPP_STL_LOGGING`,你也可以在包含 easylogging++.h 之前包含我的 output_container.h,可以达到类似的效果。
|
||||
|
||||
此外,Easylogging++ 的 samples 目录下有不少例子,可以用作参考。比如常见的日志文件切换功能,在 Easylogging++ 里实现是需要稍微写一点代码的:Easylogging++ 会在文件满的时候调用你之前注册的回调函数,而你需要在回调函数里对老的日志文件进行重命名、备份之类的工作,samples/STL/roll-out.cpp 则提供了最简单的实现参考。
|
||||
|
||||
注意我使用的都是全局的日志记录器,但 Easylogging++ 允许你使用多个不同的日志记录器,用于(比如)不同的模块或功能。你如果需要这样的功能的话,也请你自行查阅文档了。
|
||||
|
||||
## spdlog
|
||||
|
||||
跟 Easylogging++ 比起来,spdlog 要新得多了:前者是 2012 年开始的项目,而后者是 2014 年开始的。我在 2016 年末开始在项目中使用 Easylogging++ 时,Easylogging++ 的版本是 9.85 左右,而 spdlog 大概是 0.11,成熟度和热度都不那么高。
|
||||
|
||||
整体上,spdlog 也确实感觉要新很多。项目自己提到的功能点是:
|
||||
|
||||
- 非常快(性能是其主要目标)
|
||||
- 只需要头文件即可使用
|
||||
- 没有其他依赖
|
||||
- 跨平台
|
||||
- 有单线程和多线程的日志记录器
|
||||
- 日志文件旋转切换
|
||||
- 每日日志文件
|
||||
- 终端日志输出
|
||||
- 可选异步日志
|
||||
- 多个日志级别
|
||||
- 通过用户自定义式样来定制输出格式
|
||||
|
||||
### 开始使用 spdlog
|
||||
|
||||
跟 Easylogging++ 的例子相对应,我们以最简单的日志输出开头:
|
||||
|
||||
```
|
||||
#include "spdlog/spdlog.h"
|
||||
|
||||
int main()
|
||||
{
|
||||
spdlog::info("My first info log");
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
代码里看不到的是,输出结果中的“info”字样是彩色的,方便快速识别日志的级别。这个功能在 Windows、Linux 和 macOS 上都能正常工作,对用户还是相当友好的。不过,和 Easylogging++ 缺省就会输出到文件中不同,spdlog 缺省只是输出到终端而已。
|
||||
|
||||
你也许从代码中已经注意到,spdlog 不是使用 IO 流风格的输出了。它采用跟 Python 里的 `str.format` 一样的方式,使用大括号——可选使用序号和格式化要求——来对参数进行格式化。下面是一个很简单的例子:
|
||||
|
||||
```
|
||||
spdlog::warn(
|
||||
"Message with arg {}", 42);
|
||||
spdlog::error(
|
||||
"{0:d}, {0:x}, {0:o}, {0:b}",
|
||||
42);
|
||||
|
||||
```
|
||||
|
||||
输出会像下面这样:
|
||||
|
||||
>
|
||||
<p>`[2020-01-26 17:20:08.355] [warning] Message with arg 42`<br>
|
||||
`[2020-01-26 17:20:08.355] [error] 42, 2a, 52, 101010`</p>
|
||||
|
||||
|
||||
事实上,这就是 C++20 的 `format` 的风格了——spdlog 就是使用了一个 `format` 的库实现 fmt [3]。
|
||||
|
||||
### 设置输出文件
|
||||
|
||||
在 spdlog 里,要输出文件得打开专门的文件日志记录器,下面的例子展示了最简单的用法:
|
||||
|
||||
```
|
||||
#include "spdlog/spdlog.h"
|
||||
#include "spdlog/sinks/basic_file_sink.h"
|
||||
|
||||
int main()
|
||||
{
|
||||
auto file_logger =
|
||||
spdlog::basic_logger_mt(
|
||||
"basic_logger",
|
||||
"test.log");
|
||||
spdlog::set_default_logger(
|
||||
file_logger);
|
||||
spdlog::info("Into file: {1} {0}",
|
||||
"world", "hello");
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
执行之后,终端上没有任何输出,但 test.log 文件里就会增加如下的内容:
|
||||
|
||||
>
|
||||
`[2020-01-26 17:47:37.864] [basic_logger] [info] Into file: hello world`
|
||||
|
||||
|
||||
估计你立即会想问,那我想同时输出到终端和文件,该怎么办呢?
|
||||
|
||||
答案是你可以设立一个日志记录器,让它有两个(或更多个)日志槽(sink)即可。示例代码如下:
|
||||
|
||||
```
|
||||
#include <memory>
|
||||
#include "spdlog/spdlog.h"
|
||||
#include "spdlog/sinks/basic_file_sink.h"
|
||||
#include "spdlog/sinks/stdout_color_sinks.h"
|
||||
|
||||
using namespace std;
|
||||
using namespace spdlog::sinks;
|
||||
|
||||
void set_multi_sink()
|
||||
{
|
||||
auto console_sink = make_shared<
|
||||
stdout_color_sink_mt>();
|
||||
console_sink->set_level(
|
||||
spdlog::level::warn);
|
||||
console_sink->set_pattern(
|
||||
"%H:%M:%S.%e %^%L%$ %v");
|
||||
|
||||
auto file_sink =
|
||||
make_shared<basic_file_sink_mt>(
|
||||
"test.log");
|
||||
file_sink->set_level(
|
||||
spdlog::level::trace);
|
||||
file_sink->set_pattern(
|
||||
"%Y-%m-%d %H:%M:%S.%f %L %v");
|
||||
|
||||
auto logger =
|
||||
shared_ptr<spdlog::logger>(
|
||||
new spdlog::logger(
|
||||
"multi_sink",
|
||||
{console_sink, file_sink}));
|
||||
logger->set_level(
|
||||
spdlog::level::debug);
|
||||
spdlog::set_default_logger(
|
||||
logger);
|
||||
}
|
||||
|
||||
int main()
|
||||
{
|
||||
set_multi_sink();
|
||||
spdlog::warn(
|
||||
"this should appear in both "
|
||||
"console and file");
|
||||
spdlog::info(
|
||||
"this message should not "
|
||||
"appear in the console, only "
|
||||
"in the file");
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
大致说明一下:
|
||||
|
||||
- `console_sink` 是一个指向 `stdout_color_sink_mt` 的智能指针,我们设定让它只显示警告级别及以上的日志信息,并把输出式样调整成带毫秒的时间、有颜色的短级别以及信息本身。
|
||||
- `file_sink` 是一个指向 `basic_file_sink_mt` 的智能指针,我们设定让它显示跟踪级别及以上(也就是所有级别了)的日志信息,并把输出式样调整成带微秒的日期时间、短级别以及信息本身。
|
||||
- 然后我们创建了日志记录器,让它具有上面的两个日志槽。注意这儿的两个细节:1. 这儿的接口普遍使用 `shared_ptr`;2. 由于 `make_shared` 在处理 `initializer_list` 上的缺陷,对 `spdlog::logger` 的构造只能直接调用 `shared_ptr` 的构造函数,而不能使用 `make_shared`,否则编译会出错。
|
||||
- 最后我们调用了 `spdlog::set_default_logger` 把缺省的日志记录器设置成刚创建的对象。这样,之后的日志缺省就会记录到这个新的日志记录器了(我们当然也可以手工调用这个日志记录器的 `critical`、`error`、`warn` 等日志记录方法)。
|
||||
|
||||
在某次运行之后,我的终端上出现了:
|
||||
|
||||
>
|
||||
`20:44:45.086 W this should appear in both console and file`
|
||||
|
||||
|
||||
而 test.log 文件中则增加了:
|
||||
|
||||
>
|
||||
<p>`2020-01-26 20:44:45.086524 W this should appear in both console and file`<br>
|
||||
`2020-01-26 20:44:45.087174 I this message should not appear in the console, only in the file`</p>
|
||||
|
||||
|
||||
跟 Easylogging++ 相比,我们现在看到了 spdlog 也有复杂的一面。两者在输出式样的灵活性上也有不同的选择:Easylogging++ 对不同级别的日志可采用不同的式样,而 spdlog 对不同的日志槽可采用不同的式样。
|
||||
|
||||
### 日志文件切换
|
||||
|
||||
在 Easylogging++ 里实现日志文件切换是需要写代码的,而且完善的多文件切换代码需要写上几十行代码才能实现。这项工作在 spdlog 则是超级简单的,因为 spdlog 直接提供了一个实现该功能的日志槽。把上面的例子改造成带日志文件切换我们只需要修改两处:
|
||||
|
||||
```
|
||||
#include "spdlog/sinks/rotating_file_sink.h"
|
||||
// 替换 basic_file_sink.h
|
||||
…
|
||||
auto file_sink = make_shared<
|
||||
rotating_file_sink_mt>(
|
||||
"test.log", 1048576 * 5, 3);
|
||||
// 替换 basic_file_sink_mt,文件大
|
||||
// 小为 5MB,一共保留 3 个日志文件
|
||||
|
||||
```
|
||||
|
||||
这就非常简单好用了。
|
||||
|
||||
### 适配用户定义的流输出
|
||||
|
||||
虽然 spdlog 缺省不支持容器的输出,但是,它是可以和用户提供的流 `<<` 运算符协同工作的。如果我们要输出普通容器的话,我们只需要在代码开头加入:
|
||||
|
||||
```
|
||||
#include "output_container.h"
|
||||
#include "spdlog/fmt/ostr.h"
|
||||
|
||||
```
|
||||
|
||||
前一行包含了我们用于容器输出的代码,后一行包含了 spdlog 使用 ostream 来输出对象的能力。注意此处包含的顺序是重要的:spdlog 必须能看到用户的 `<<` 的定义。在有了这两行之后,我们就可以像下面这样写代码了:
|
||||
|
||||
```
|
||||
vector<int> v;
|
||||
// …
|
||||
spdlog::info(
|
||||
"Content of vector: {}", v);
|
||||
|
||||
```
|
||||
|
||||
### 只用头文件吗?
|
||||
|
||||
使用 spdlog 可以使用只用头文件的方式,也可以使用预编译的方式。只用头文件的编译速度较慢:我的机器上使用预编译方式构建第一个例子需要一秒多,而只用头文件的方式需要五秒多(Clang 的情况;GCC 耗时要更长)。因此正式使用的话,我还是推荐你使用预编译、安装的方式。
|
||||
|
||||
在安装了库后,编译时需额外定义一个宏,在命令行上要添加库名。以 GCC 为例,命令行会像下面这个样子:
|
||||
|
||||
>
|
||||
`g++ -std=c++17 -DSPDLOG_COMPILED_LIB test.cpp -lspdlog`
|
||||
|
||||
|
||||
### 其他
|
||||
|
||||
刚才介绍的还只是 spdlog 的部分功能。你如果对使用这个库感兴趣的话,应该查阅文档来获得进一步的信息。我这儿觉得下面这些功能点值得提一下:
|
||||
|
||||
- 可以使用多个不同的日志记录器,用于不同的模块或功能。
|
||||
- 可以使用异步日志,减少记日志时阻塞的可能性。
|
||||
- 通过 `spdlog::to_hex` 可以方便地在日志里输出二进制信息。
|
||||
- 可用的日志槽还有 syslog、systemd、Android、Windows 调试输出等;扩展新的日志槽较为容易。
|
||||
|
||||
## 内容小结
|
||||
|
||||
今天我们介绍了两个不同的日志库,Easylogging++ 和 spdlog。它们在功能和实现方式上有很大的不同,建议你根据自己的实际需要来进行选择。
|
||||
|
||||
我目前对新项目的推荐是优先选择 spdlog:仅在你需要某个 Easylogging++ 提供、而 spdlog 不提供的功能时才选择 Easylogging++。
|
||||
|
||||
当然,C++ 的日志库远远不止这两个:我挑选的是我觉得比较好的和有实际使用经验的。其他可选择的日志库至少还有 Boost.Log [4]、g3log [5]、NanoLog [6] 等(Log for C++ 接口有着 Java 式的啰嗦,且感觉有点“年久失修”,我明确不推荐)。在严肃的项目里,选择哪个日志库是值得认真比较和评估一下的。
|
||||
|
||||
## 课后思考
|
||||
|
||||
请对比一下 Easylogging++ 和 spdlog,考虑以下两个问题:
|
||||
|
||||
1. Easylogging++ 更多地使用了编译时的行为定制,而 spdlog 主要通过面向对象的方式在运行时修改日志的行为。你觉得哪种更好?为什么?
|
||||
1. Easylogging++ 使用了 IO 流的方式,而 spdlog 使用了 `std::format` 的方式。你更喜欢哪种?为什么?
|
||||
|
||||
## 参考资料
|
||||
|
||||
[1] Amrayn Web Services, easyloggingpp. [https://github.com/amrayn/easyloggingpp](https://github.com/amrayn/easyloggingpp)
|
||||
|
||||
[2] Gabi Melman, spdlog. [https://github.com/gabime/spdlog](https://github.com/gabime/spdlog)
|
||||
|
||||
[3] Victor Zverovich, fmt. [https://github.com/fmtlib/fmt](https://github.com/fmtlib/fmt)
|
||||
|
||||
[4] Andrey Semashev, Boost.Log v2. [https://www.boost.org/doc/libs/release/libs/log/doc/html/index.html](https://www.boost.org/doc/libs/release/libs/log/doc/html/index.html)
|
||||
|
||||
[5] Kjell Hedström, g3log. [https://github.com/KjellKod/g3log](https://github.com/KjellKod/g3log)
|
||||
|
||||
[6] Stanford University, NanoLog. [https://github.com/PlatformLab/NanoLog](https://github.com/PlatformLab/NanoLog)
|
||||
680
极客时间专栏/现代C++实战30讲/实战篇/27 | C++ REST SDK:使用现代C++开发网络应用.md
Normal file
680
极客时间专栏/现代C++实战30讲/实战篇/27 | C++ REST SDK:使用现代C++开发网络应用.md
Normal file
@@ -0,0 +1,680 @@
|
||||
<audio id="audio" title="27 | C++ REST SDK:使用现代C++开发网络应用" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/44/78/44d5594c569500dc14cd9b55554a8078.mp3"></audio>
|
||||
|
||||
你好,我是吴咏炜。
|
||||
|
||||
在实战篇,我们最后要讲解的一个库是 C++ REST SDK(也写作 cpprestsdk)[1],一个支持 HTTP 协议 [2]、主要用于 RESTful [3] 接口开发的 C++ 库。
|
||||
|
||||
## 初识 C++ REST SDK
|
||||
|
||||
向你提一个问题,你认为用多少行代码可以写出一个类似于 curl [4] 的 HTTP 客户端?
|
||||
|
||||
使用 C++ REST SDK 的话,答案是,只需要五十多行有效代码(即使是适配到我们目前的窄小的手机屏幕上)。请看:
|
||||
|
||||
```
|
||||
#include <iostream>
|
||||
#ifdef _WIN32
|
||||
#include <fcntl.h>
|
||||
#include <io.h>
|
||||
#endif
|
||||
#include <cpprest/http_client.h>
|
||||
|
||||
using namespace utility;
|
||||
using namespace web::http;
|
||||
using namespace web::http::client;
|
||||
using std::cerr;
|
||||
using std::endl;
|
||||
|
||||
#ifdef _WIN32
|
||||
#define tcout std::wcout
|
||||
#else
|
||||
#define tcout std::cout
|
||||
#endif
|
||||
|
||||
auto get_headers(http_response resp)
|
||||
{
|
||||
auto headers = resp.to_string();
|
||||
auto end =
|
||||
headers.find(U("\r\n\r\n"));
|
||||
if (end != string_t::npos) {
|
||||
headers.resize(end + 4);
|
||||
};
|
||||
return headers;
|
||||
}
|
||||
|
||||
auto get_request(string_t uri)
|
||||
{
|
||||
http_client client{uri};
|
||||
// 用 GET 方式发起一个客户端请求
|
||||
auto request =
|
||||
client.request(methods::GET)
|
||||
.then([](http_response resp) {
|
||||
if (resp.status_code() !=
|
||||
status_codes::OK) {
|
||||
// 不 OK,显示当前响应信息
|
||||
auto headers =
|
||||
get_headers(resp);
|
||||
tcout << headers;
|
||||
}
|
||||
// 进一步取出完整响应
|
||||
return resp
|
||||
.extract_string();
|
||||
})
|
||||
.then([](string_t str) {
|
||||
// 输出到终端
|
||||
tcout << str;
|
||||
});
|
||||
return request;
|
||||
}
|
||||
|
||||
#ifdef _WIN32
|
||||
int wmain(int argc, wchar_t* argv[])
|
||||
#else
|
||||
int main(int argc, char* argv[])
|
||||
#endif
|
||||
{
|
||||
#ifdef _WIN32
|
||||
_setmode(_fileno(stdout),
|
||||
_O_WTEXT);
|
||||
#endif
|
||||
|
||||
if (argc != 2) {
|
||||
cerr << "A URL is needed\n";
|
||||
return 1;
|
||||
}
|
||||
|
||||
// 等待请求及其关联处理全部完成
|
||||
try {
|
||||
auto request =
|
||||
get_request(argv[1]);
|
||||
request.wait();
|
||||
}
|
||||
// 处理请求过程中产生的异常
|
||||
catch (const std::exception& e) {
|
||||
cerr << "Error exception: "
|
||||
<< e.what() << endl;
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这个代码有点复杂,需要讲解一下:
|
||||
|
||||
- 第 14–18 行,我们根据平台来定义 `tcout`,确保多语言的文字能够正确输出。
|
||||
- 第 20–29 行,我们定义了 `get_headers`,来从 `http_response` 中取出头部的字符串表示。
|
||||
- 第 36 行,构造了一个客户端请求,并使用 `then` 方法串联了两个下一步的动作。`http_client::request` 的返回值是 `pplx::task<http_response>`。`then` 是 `pplx::task` 类模板的成员函数,参数是能接受其类型参数对象的函数对象。除了最后一个 `then` 块,其他每个 `then` 里都应该返回一个 `pplx::task`,而 `task` 的内部类型就是下一个 `then` 块里函数对象接受的参数的类型。
|
||||
- 第 37 行开始,是第一段异步处理代码。参数类型是 `http_response`——因为`http_client::request` 的返回值是 `pplx::task<http_response>`。代码中判断如果响应的 HTTP 状态码不是 200 OK,就会显示响应头来帮助调试。然后,进一步取出所有的响应内容(可能需要进一步的异步处理,等待后续的 HTTP 响应到达)。
|
||||
- 第 49 行开始,是第二段异步处理代码。参数类型是 `string_t`——因为上一段 `then` 块的返回值是 `pplx::task<string_t>`。代码中就是简单地把需要输出的内容输出到终端。
|
||||
- 第 56–60 行,我们根据平台来定义合适的程序入口,确保命令行参数的正确处理。
|
||||
- 第 62–65 行,在 Windows 上我们把标准输出设置成宽字符模式,来确保宽字符(串)能正确输出(参考[[第 11 讲]](https://time.geekbang.org/column/article/179357) )。注意 `string_t` 在 Windows 上是 `wstring`,在其他平台上是 `string`。
|
||||
- 第 72–83 行,如注释所言,产生 HTTP 请求、等待 HTTP 请求完成,并处理相关的异常。
|
||||
|
||||
整体而言,这个代码还是很简单的,虽然这种代码风格,对于之前没有接触过这种函数式编程风格的人来讲会有点奇怪——这被称作持续传递风格(continuation-passing style),显式地把上一段处理的结果传递到下一个函数中。这个代码已经处理了 Windows 环境和 Unix 环境的差异,底下是相当复杂的。
|
||||
|
||||
另外提醒一下,在 Windows 上如果你把源代码存成 UTF-8 的话,需要确保文件以 BOM 字符打头。Windows 的编辑器通常缺省就会做到;在 Vim 里,可以通过 `set bomb` 命令做到这一点。
|
||||
|
||||
## 安装和编译
|
||||
|
||||
上面的代码本身虽然简单,但要把它编译成可执行文件比我们之前讲的代码都要复杂——C++ REST SDK 有外部依赖,在 Windows 上和 Unix 上还不太一样。它的编译和安装也略复杂,如果你没有这方面的经验的话,建议尽量使用平台推荐的二进制包的安装方式。
|
||||
|
||||
由于其依赖较多,使用它的编译命令行也较为复杂。正式项目中绝对是需要使用项目管理软件的(如 cmake)。此处,我给出手工编译的典型命令行,仅供你尝试编译上面的例子作参考。
|
||||
|
||||
Windows MSVC:
|
||||
|
||||
>
|
||||
`cl /EHsc /std:c++17 test.cpp cpprest.lib zlib.lib libeay32.lib ssleay32.lib winhttp.lib httpapi.lib bcrypt.lib crypt32.lib advapi32.lib gdi32.lib user32.lib`
|
||||
|
||||
|
||||
Linux GCC:
|
||||
|
||||
>
|
||||
`g++ -std=c++17 -pthread test.cpp -lcpprest -lcrypto -lssl -lboost_thread -lboost_chrono -lboost_system`
|
||||
|
||||
|
||||
macOS Clang:
|
||||
|
||||
>
|
||||
`clang++ -std=c++17 test.cpp -lcpprest -lcrypto -lssl -lboost_thread-mt -lboost_chrono-mt`
|
||||
|
||||
|
||||
## 概述
|
||||
|
||||
有了初步印象之后,现在我们可以回过头看看 C++ REST SDK 到底是什么了。它是一套用来开发 HTTP 客户端和服务器的现代异步 C++ 代码库,支持以下特性(随平台不同会有所区别):
|
||||
|
||||
- HTTP 客户端
|
||||
- HTTP 服务器
|
||||
- 任务
|
||||
- JSON
|
||||
- URI
|
||||
- 异步流
|
||||
- WebSocket 客户端
|
||||
- OAuth 客户端
|
||||
|
||||
上面的例子里用到了 HTTP 客户端、任务和 URI(实际上是由 `string_t` 隐式构造了 `uri`),我们下面再介绍一下异步流、JSON 和 HTTP 服务器。
|
||||
|
||||
## 异步流
|
||||
|
||||
C++ REST SDK 里实现了一套异步流,能够实现对文件的异步读写。下面的例子展示了我们如何把网络请求的响应异步地存储到文件 results.html 中:
|
||||
|
||||
```
|
||||
#include <iostream>
|
||||
#include <utility>
|
||||
#ifdef _WIN32
|
||||
#include <fcntl.h>
|
||||
#include <io.h>
|
||||
#endif
|
||||
#include <stddef.h>
|
||||
#include <cpprest/http_client.h>
|
||||
#include <cpprest/filestream.h>
|
||||
|
||||
using namespace utility;
|
||||
using namespace web::http;
|
||||
using namespace web::http::client;
|
||||
using namespace concurrency::streams;
|
||||
using std::cerr;
|
||||
using std::endl;
|
||||
|
||||
#ifdef _WIN32
|
||||
#define tcout std::wcout
|
||||
#else
|
||||
#define tcout std::cout
|
||||
#endif
|
||||
|
||||
auto get_headers(http_response resp)
|
||||
{
|
||||
auto headers = resp.to_string();
|
||||
auto end =
|
||||
headers.find(U("\r\n\r\n"));
|
||||
if (end != string_t::npos) {
|
||||
headers.resize(end + 4);
|
||||
};
|
||||
return headers;
|
||||
}
|
||||
|
||||
auto get_request(string_t uri)
|
||||
{
|
||||
http_client client{uri};
|
||||
// 用 GET 方式发起一个客户端请求
|
||||
auto request =
|
||||
client.request(methods::GET)
|
||||
.then([](http_response resp) {
|
||||
if (resp.status_code() ==
|
||||
status_codes::OK) {
|
||||
// 正常的话
|
||||
tcout << U("Saving...\n");
|
||||
ostream fs;
|
||||
fstream::open_ostream(
|
||||
U("results.html"),
|
||||
std::ios_base::out |
|
||||
std::ios_base::trunc)
|
||||
.then(
|
||||
[&fs,
|
||||
resp](ostream os) {
|
||||
fs = os;
|
||||
// 读取网页内容到流
|
||||
return resp.body()
|
||||
.read_to_end(
|
||||
fs.streambuf());
|
||||
})
|
||||
.then(
|
||||
[&fs](size_t size) {
|
||||
// 然后关闭流
|
||||
fs.close();
|
||||
tcout
|
||||
<< size
|
||||
<< U(" bytes "
|
||||
"saved\n");
|
||||
})
|
||||
.wait();
|
||||
} else {
|
||||
// 否则显示当前响应信息
|
||||
auto headers =
|
||||
get_headers(resp);
|
||||
tcout << headers;
|
||||
tcout
|
||||
<< resp.extract_string()
|
||||
.get();
|
||||
}
|
||||
});
|
||||
return request;
|
||||
}
|
||||
|
||||
#ifdef _WIN32
|
||||
int wmain(int argc, wchar_t* argv[])
|
||||
#else
|
||||
int main(int argc, char* argv[])
|
||||
#endif
|
||||
{
|
||||
#ifdef _WIN32
|
||||
_setmode(_fileno(stdout),
|
||||
_O_WTEXT);
|
||||
#endif
|
||||
|
||||
if (argc != 2) {
|
||||
cerr << "A URL is needed\n";
|
||||
return 1;
|
||||
}
|
||||
|
||||
// 等待请求及其关联处理全部完成
|
||||
try {
|
||||
auto request =
|
||||
get_request(argv[1]);
|
||||
request.wait();
|
||||
}
|
||||
// 处理请求过程中产生的异常
|
||||
catch (const std::exception& e) {
|
||||
cerr << "Error exception: "
|
||||
<< e.what() << endl;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
跟上一个例子比,我们去掉了原先的第二段处理统一输出的异步处理代码,但加入了一段嵌套的异步代码。有几个地方需要注意一下:
|
||||
|
||||
- C++ REST SDK 的对象基本都是基于 `shared_ptr` 用引用计数实现的,因而可以轻松大胆地进行复制。
|
||||
- 虽然 `string_t` 在 Windows 上是 `wstring`,但文件流无论在哪个平台上都是以 UTF-8 的方式写入,符合目前的主流处理方式(`wofstream` 的行为跟平台和环境相关)。
|
||||
- `extract_string` 的结果这次没有传递到下一段,而是直接用 `get` 获得了最终结果(类似于[[第 19 讲]](https://time.geekbang.org/column/article/186689) 中的 `future`)。
|
||||
|
||||
这个例子的代码是基于 [cpprestsdk 官方的例子](https://github.com/Microsoft/cpprestsdk/wiki/Getting-Started-Tutorial)改编的。但我做的下面这些更动值得提一下:
|
||||
|
||||
- 去除了不必要的 `shared_ptr` 的使用。
|
||||
- `fstream::open_ostream` 缺省的文件打开方式是 `std::ios_base::out`,官方例子没有用 `std::ios_base::trunc`,导致不能清除文件中的原有内容。此处 C++ REST SDK 的 `file_stream` 行为跟标准 C++ 的 `ofstream` 是不一样的:后者缺省打开方式也是 `std::ios_base::out`,但此时文件内容**会**被自动清除。
|
||||
- 沿用我的前一个例子,先进行请求再打开文件流,而不是先打开文件流再发送网络请求,符合实际流程。
|
||||
- 这样做的一个结果就是 `then` 不完全是顺序的了,有嵌套,增加了复杂度,但展示了实际可能的情况。
|
||||
|
||||
## JSON 支持
|
||||
|
||||
在基于网页的开发中,JSON [5] 早已取代 XML 成了最主流的数据交换方式。REST 接口本身就是基于 JSON 的,自然,C++ REST SDK 需要对 JSON 有很好的支持。
|
||||
|
||||
JSON 本身可以在网上找到很多介绍的文章,我这儿就不多讲了。有几个 C++ 相关的关键点需要提一下:
|
||||
|
||||
- JSON 的基本类型是空值类型、布尔类型、数字类型和字符串类型。其中空值类型和数字类型在 C++ 里是没有直接对应物的。数字类型在 C++ 里可能映射到 `double`,也可能是 `int32_t` 或 `int64_t`。
|
||||
- JSON 的复合类型是数组(array)和对象(object)。JSON 数组像 C++ 的 `vector`,但每个成员的类型可以是任意 JSON 类型,而不像 `vector` 通常是同质的——所有成员属于同一类型。JSON 对象像 C++ 的 `map`,键类型为 JSON 字符串,值类型则为任意 JSON 类型。JSON 标准不要求对象的各项之间有顺序,不过,从实际项目的角度,我个人觉得保持顺序还是非常有用的。
|
||||
|
||||
如果你去搜索“c++ json”的话,还是可以找到一些不同的 JSON 实现的。功能最完整、名声最响的目前似乎是 nlohmann/json [6],而腾讯释出的 RapidJSON [7] 则以性能闻名 [8]。需要注意一下各个实现之间的区别:
|
||||
|
||||
- nlohmann/json 不支持对 JSON 的对象(object)保持赋值顺序;RapidJSON 保持赋值顺序;C++ REST SDK 可选保持赋值顺序(通过 `web::json::keep_object_element_order` 和 `web::json::value::object` 的参数)。
|
||||
- nlohmann/json 支持最友好的初始化语法,可以使用初始化列表和 JSON 字面量;C++ REST SDK 只能逐项初始化,并且一般应显式调用 `web::json::value` 的构造函数(接受布尔类型和字符串类型的构造函数有 `explicit` 标注);RapidJSON 介于中间,不支持初始化列表和字面量,但赋值可以直接进行。
|
||||
- nlohmann/json 和 C++ REST SDK 支持直接在用方括号 `[]` 访问不存在的 JSON 数组(array)成员时改变数组的大小;RapidJSON 的接口不支持这种用法,要向 JSON 数组里添加成员要麻烦得多。
|
||||
- 作为性能的代价,RapidJSON 里在初始化字符串值时,只会传递指针值;用户需要保证字符串在 JSON 值使用过程中的有效性。要复制字符串的话,接口要麻烦得多。
|
||||
- RapidJSON 的 JSON 对象没有 `begin` 和 `end` 方法,因而无法使用标准的基于范围的 for 循环。总体而言,RapidJSON 的接口显得最特别、不通用。
|
||||
|
||||
如果你使用 C++ REST SDK 的其他功能,你当然也没有什么选择;否则,你可以考虑一下其他的 JSON 实现。下面,我们就只讨论 C++ REST SDK 里的 JSON 了。
|
||||
|
||||
在 C++ REST SDK 里,核心的类型是 `web::json::value`,这就对应到我前面说的“任意 JSON 类型”了。还是拿例子说话(改编自 RapidJSON 的例子):
|
||||
|
||||
```
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <assert.h>
|
||||
#ifdef _WIN32
|
||||
#include <fcntl.h>
|
||||
#include <io.h>
|
||||
#endif
|
||||
#include <cpprest/json.h>
|
||||
|
||||
using namespace std;
|
||||
using namespace utility;
|
||||
using namespace web;
|
||||
|
||||
#ifdef _WIN32
|
||||
#define tcout std::wcout
|
||||
#else
|
||||
#define tcout std::cout
|
||||
#endif
|
||||
|
||||
int main()
|
||||
{
|
||||
#ifdef _WIN32
|
||||
_setmode(_fileno(stdout),
|
||||
_O_WTEXT);
|
||||
#endif
|
||||
|
||||
// 测试的 JSON 字符串
|
||||
string_t json_str = U(R"(
|
||||
{
|
||||
"s": "你好,世界",
|
||||
"t": true,
|
||||
"f": false,
|
||||
"n": null,
|
||||
"i": 123,
|
||||
"d": 3.1416,
|
||||
"a": [1, 2, 3]
|
||||
})");
|
||||
tcout << "Original JSON:"
|
||||
<< json_str << endl;
|
||||
|
||||
// 保持元素顺序并分析 JSON 字符串
|
||||
json::keep_object_element_order(
|
||||
true);
|
||||
auto document =
|
||||
json::value::parse(json_str);
|
||||
|
||||
// 遍历对象成员并输出类型
|
||||
static const char* type_names[] =
|
||||
{
|
||||
"Number", "Boolean", "String",
|
||||
"Object", "Array", "Null",
|
||||
};
|
||||
for (auto&& value :
|
||||
document.as_object()) {
|
||||
tcout << "Type of member "
|
||||
<< value.first << " is "
|
||||
<< type_names[value.second
|
||||
.type()]
|
||||
<< endl;
|
||||
}
|
||||
|
||||
// 检查 document 是对象
|
||||
assert(document.is_object());
|
||||
|
||||
// 检查 document["s"] 是字符串
|
||||
assert(document.has_field(U("s")));
|
||||
assert(
|
||||
document[U("s")].is_string());
|
||||
tcout << "s = "
|
||||
<< document[U("s")] << endl;
|
||||
|
||||
// 检查 document["t"] 是字符串
|
||||
assert(
|
||||
document[U("t")].is_boolean());
|
||||
tcout
|
||||
<< "t = "
|
||||
<< (document[U("t")].as_bool()
|
||||
? "true"
|
||||
: "false")
|
||||
<< endl;
|
||||
|
||||
// 检查 document["f"] 是字符串
|
||||
assert(
|
||||
document[U("f")].is_boolean());
|
||||
tcout
|
||||
<< "f = "
|
||||
<< (document[U("f")].as_bool()
|
||||
? "true"
|
||||
: "false")
|
||||
<< endl;
|
||||
|
||||
// 检查 document["f"] 是空值
|
||||
tcout
|
||||
<< "n = "
|
||||
<< (document[U("n")].is_null()
|
||||
? "null"
|
||||
: "?")
|
||||
<< endl;
|
||||
|
||||
// 检查 document["i"] 是整数
|
||||
assert(
|
||||
document[U("i")].is_number());
|
||||
assert(
|
||||
document[U("i")].is_integer());
|
||||
tcout << "i = "
|
||||
<< document[U("i")] << endl;
|
||||
|
||||
// 检查 document["d"] 是浮点数
|
||||
assert(
|
||||
document[U("d")].is_number());
|
||||
assert(
|
||||
document[U("d")].is_double());
|
||||
tcout << "d = "
|
||||
<< document[U("d")] << endl;
|
||||
|
||||
{
|
||||
// 检查 document["a"] 是数组
|
||||
auto& a = document[U("a")];
|
||||
assert(a.is_array());
|
||||
|
||||
// 测试读取数组元素并转换成整数
|
||||
int y = a[0].as_integer();
|
||||
(void)y;
|
||||
|
||||
// 遍历数组成员并输出
|
||||
tcout << "a = ";
|
||||
for (auto&& value :
|
||||
a.as_array()) {
|
||||
tcout << value << ' ';
|
||||
}
|
||||
tcout << endl;
|
||||
}
|
||||
|
||||
// 修改 document["i"] 为长整数
|
||||
{
|
||||
uint64_t bignum = 65000;
|
||||
bignum *= bignum;
|
||||
bignum *= bignum;
|
||||
document[U("i")] = bignum;
|
||||
|
||||
assert(!document[U("i")]
|
||||
.as_number()
|
||||
.is_int32());
|
||||
assert(document[U("i")]
|
||||
.as_number()
|
||||
.to_uint64() ==
|
||||
bignum);
|
||||
tcout << "i is changed to "
|
||||
<< document[U("i")]
|
||||
<< endl;
|
||||
}
|
||||
|
||||
// 在数组里添加数值
|
||||
{
|
||||
auto& a = document[U("a")];
|
||||
a[3] = 4;
|
||||
a[4] = 5;
|
||||
tcout << "a is changed to "
|
||||
<< document[U("a")]
|
||||
<< endl;
|
||||
}
|
||||
|
||||
// 在 JSON 文档里添加布尔值:等号
|
||||
// 右侧 json::value 不能省
|
||||
document[U("b")] =
|
||||
json::value(true);
|
||||
|
||||
// 构造新对象,保持多个值的顺序
|
||||
auto temp =
|
||||
json::value::object(true);
|
||||
// 在新对象里添加字符串:等号右侧
|
||||
// json::value 不能省
|
||||
temp[U("from")] =
|
||||
json::value(U("rapidjson"));
|
||||
temp[U("changed for")] =
|
||||
json::value(U("geekbang"));
|
||||
|
||||
// 把对象赋到文档里;json::value
|
||||
// 内部使用 unique_ptr,因而使用
|
||||
// move 可以减少拷贝
|
||||
document[U("adapted")] =
|
||||
std::move(temp);
|
||||
|
||||
// 完整输出目前的 JSON 对象
|
||||
tcout << document << endl;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
例子里我加了不少注释,应当可以帮助你看清 JSON 对象的基本用法了。唯一遗憾的是宏 `U`(类似于[[第 11 讲]](https://time.geekbang.org/column/article/179357) 里提到过的 `_T`)的使用有点碍眼:要确保代码在 Windows 下和 Unix 下都能工作,目前这还是必要的。
|
||||
|
||||
建议你测试一下这个例子。查看一下结果。
|
||||
|
||||
C++ REST SDK 里的 `http_request` 和 `http_response` 都对 JSON 有原生支持,如可以使用 `extract_json` 成员函数来异步提取 HTTP 请求或响应体中的 JSON 内容。
|
||||
|
||||
## HTTP 服务器
|
||||
|
||||
前面我们提到了如何使用 C++ REST SDK 来快速搭建一个 HTTP 客户端。同样,我们也可以使用 C++ REST SDK 来快速搭建一个 HTTP 服务器。在三种主流的操作系统上,C++ REST SDK 的 `http_listener` 会通过调用 Boost.Asio [9] 和操作系统的底层接口(IOCP、epoll 或 kqueue)来完成功能,向使用者隐藏这些细节、提供一个简单的编程接口。
|
||||
|
||||
我们将搭建一个最小的 REST 服务器,只能处理一个 sayHi 请求。客户端应当向服务器发送一个 HTTP 请求,URI 是:
|
||||
|
||||
>
|
||||
`/sayHi?name=…`
|
||||
|
||||
|
||||
“…”部分代表一个名字,而服务器应当返回一个 JSON 的回复,形如:
|
||||
|
||||
```
|
||||
{"msg": "Hi, …!"}
|
||||
|
||||
```
|
||||
|
||||
这个服务器的有效代码行同样只有六十多行,如下所示:
|
||||
|
||||
```
|
||||
#include <exception>
|
||||
#include <iostream>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#ifdef _WIN32
|
||||
#include <fcntl.h>
|
||||
#include <io.h>
|
||||
#endif
|
||||
#include <cpprest/http_listener.h>
|
||||
#include <cpprest/json.h>
|
||||
|
||||
using namespace std;
|
||||
using namespace utility;
|
||||
using namespace web;
|
||||
using namespace web::http;
|
||||
using namespace web::http::
|
||||
experimental::listener;
|
||||
|
||||
#ifdef _WIN32
|
||||
#define tcout std::wcout
|
||||
#else
|
||||
#define tcout std::cout
|
||||
#endif
|
||||
|
||||
void handle_get(http_request req)
|
||||
{
|
||||
auto& uri = req.request_uri();
|
||||
|
||||
if (uri.path() != U("/sayHi")) {
|
||||
req.reply(
|
||||
status_codes::NotFound);
|
||||
return;
|
||||
}
|
||||
|
||||
tcout << uri::decode(uri.query())
|
||||
<< endl;
|
||||
|
||||
auto query =
|
||||
uri::split_query(uri.query());
|
||||
auto it = query.find(U("name"));
|
||||
if (it == query.end()) {
|
||||
req.reply(
|
||||
status_codes::BadRequest,
|
||||
U("Missing query info"));
|
||||
return;
|
||||
}
|
||||
|
||||
auto answer =
|
||||
json::value::object(true);
|
||||
answer[U("msg")] = json::value(
|
||||
string_t(U("Hi, ")) +
|
||||
uri::decode(it->second) +
|
||||
U("!"));
|
||||
|
||||
req.reply(status_codes::OK,
|
||||
answer);
|
||||
}
|
||||
|
||||
int main()
|
||||
{
|
||||
#ifdef _WIN32
|
||||
_setmode(_fileno(stdout),
|
||||
_O_WTEXT);
|
||||
#endif
|
||||
|
||||
http_listener listener(
|
||||
U("http://127.0.0.1:8008/"));
|
||||
listener.support(methods::GET,
|
||||
handle_get);
|
||||
|
||||
try {
|
||||
listener.open().wait();
|
||||
|
||||
tcout << "Listening. Press "
|
||||
"ENTER to exit.\n";
|
||||
string line;
|
||||
getline(cin, line);
|
||||
|
||||
listener.close().wait();
|
||||
}
|
||||
catch (const exception& e) {
|
||||
cerr << e.what() << endl;
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如果你熟悉 HTTP 协议的话,上面的代码应当是相当直白的。只有少数几个细节我需要说明一下:
|
||||
|
||||
- 我们调用 `http_request::reply` 的第二个参数是 `json::value` 类型,这会让 HTTP 的内容类型(Content-Type)自动置成“application/json”。
|
||||
- `http_request::request_uri` 函数返回的是 `uri` 的引用,因此我用 `auto&` 来接收。`uri::split_query` 函数返回的是一个普通的 `std::map`,因此我用 `auto` 来接收。
|
||||
- `http_listener::open` 和 `http_listener::close` 返回的是 `pplx::task<void>`;当这个任务完成时(`wait` 调用返回),表示 HTTP 监听器上的对应操作(打开或关闭)真正完成了。
|
||||
|
||||
运行程序,然后在另外一个终端里使用我们的第一个例子生成的可执行文件(或 curl):
|
||||
|
||||
>
|
||||
`curl "http://127.0.0.1:8008/sayHi?name=Peter"`
|
||||
|
||||
|
||||
我们就应该会得到正确的结果:
|
||||
|
||||
>
|
||||
`{"msg":"Hi, Peter!"}`
|
||||
|
||||
|
||||
你也可以尝试把路径和参数写错,查看一下程序对出错的处理。
|
||||
|
||||
## 关于线程的细节
|
||||
|
||||
C++ REST SDK 使用异步的编程模式,使得写不阻塞的代码变得相当容易。不过,底层它是使用一个线程池来实现的——在 C++20 的协程能被使用之前,并没有什么更理想的跨平台方式可用。
|
||||
|
||||
C++ REST SDK 缺省会开启 40 个线程。在目前的实现里,如果这些线程全部被用完了,会导致系统整体阻塞。反过来,如果你只是用 C++ REST SDK 的 HTTP 客户端,你就不需要这么多线程。这个线程数量目前在代码里是可以控制的。比如,下面的代码会把线程池的大小设为 10:
|
||||
|
||||
```
|
||||
#include <pplx/threadpool.h>
|
||||
…
|
||||
crossplat::threadpool::
|
||||
initialize_with_threads(10);
|
||||
|
||||
```
|
||||
|
||||
如果你使用 C++ REST SDK 开发一个服务器,则不仅应当增加线程池的大小,还应当对并发数量进行统计,在并发数接近线程数时主动拒绝新的连接——一般可返回 `status_codes::ServiceUnavailable`——以免造成整个系统的阻塞。
|
||||
|
||||
## 内容小结
|
||||
|
||||
今天我们对 C++ REST SDK 的主要功能作了一下概要的讲解和演示,让你了解了它的主要功能和这种异步的编程方式。还有很多功能没有讲,但你应该可以通过查文档了解如何使用了。
|
||||
|
||||
这只能算是我们旅程中的一站——因为随着 C++20 的到来,我相信一定会有更多好用的网络开发库出现的。
|
||||
|
||||
## 课后思考
|
||||
|
||||
作为实战篇的最后一讲,内容还是略有点复杂的。如果你一下子消化不了,可以复习前面的相关内容。
|
||||
|
||||
如果对这讲的内容本身没有问题,则可以考虑一下,你觉得 C++ REST SDK 的接口好用吗?如果好用,原因是什么?如果不好用,你有什么样的改进意见?
|
||||
|
||||
## 参考资料
|
||||
|
||||
[1] Microsoft, cpprestsdk. [https://github.com/microsoft/cpprestsdk](https://github.com/microsoft/cpprestsdk)
|
||||
|
||||
[2] Wikipedia, “Hypertext Transfer Protocol”. [https://en.wikipedia.org/wiki/Hypertext_Transfer_Protocol](https://en.wikipedia.org/wiki/Hypertext_Transfer_Protocol)
|
||||
|
||||
[2a] 维基百科, “超文本传输协议”. [https://zh.m.wikipedia.org/zh-hans/超文本传输协议](https://zh.m.wikipedia.org/zh-hans/%E8%B6%85%E6%96%87%E6%9C%AC%E4%BC%A0%E8%BE%93%E5%8D%8F%E8%AE%AE)
|
||||
|
||||
[3] RESTful. [https://restfulapi.net/](https://restfulapi.net/)
|
||||
|
||||
[4] curl. [https://curl.haxx.se/](https://curl.haxx.se/)
|
||||
|
||||
[5] JSON. [https://www.json.org/](https://www.json.org/)
|
||||
|
||||
[6] Niels Lohmann, json. [https://github.com/nlohmann/json](https://github.com/nlohmann/json)
|
||||
|
||||
[7] Tencent, rapidjson. [https://github.com/Tencent/rapidjson](https://github.com/Tencent/rapidjson)
|
||||
|
||||
[8] Milo Yip, nativejson-benchmark. [https://github.com/miloyip/nativejson-benchmark](https://github.com/miloyip/nativejson-benchmark)
|
||||
|
||||
[9] Christopher Kohlhoff, Boost.Asio. [https://www.boost.org/doc/libs/release/doc/html/boost_asio.html](https://www.boost.org/doc/libs/release/doc/html/boost_asio.html)
|
||||
Reference in New Issue
Block a user