C++工程实践必备技能
Posted C+G
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了C++工程实践必备技能相关的知识,希望对你有一定的参考价值。
文章目录
单元测试框架
google test是一个C++中常用且历史悠久的测试框架,其他类似且较新的测试框架有 catch2 或 doetest等,这两个测试框架的优势在于引入简单,是完全 head only
的,但是也正是因为 head only
导致编译速度很慢,当然 doctest
还是挺快的,但 catch2
真的编译太慢了。而 googletest
引入就需要我们自行编译了,当然用cmake的话是可以简化这个过程的,gtest
的使用和引入其实也很简单,由于是直接链接编译好的库,所以编译速度是比较快的(最近测试了下,同样以链接库的方式比 doctest
慢一些… )。我现在更推荐使用 doctest 而不是 gtest 了。
本来是想讲 Google test 的使用的(看我开篇就知道),但是使用了 doctest
后,我现在完全放弃了 Googletest,对我而言有以下几点非常好:
- 文件轻量,非常轻量,就几个文件,而且代码量好像就7000行,编译速度奇快,而相对的 Googletest 里包含的东西有点多,比如 gmock,对比起来略显重量级。
- CLion 对 doctest 的支持更好,每次我用 Googletest 的时候,CLion都需要重新我当前使用的测试框架,而使用doctest后,则完全没有这方面困扰,反应奇快,这也是轻量带来的好处。
- api超级友好,用过就真的回不去。虽然断言宏不是很多,但核心观点是它分解了比较表达式,所以会比其他框架用起来方便太多。
- 功能丰富(比如支持对模板进行批量测试),尽管代码轻量,但是功能也毫不含糊,感觉比googletest更好用。
如何引入
正如上述所说,doctest
是head-only的,所以仅仅只需要一个 .h
文件即可,但是我建议不要这样,这样编译速度会慢一些,建议使用编译库再链接的方式,这种方式在cmake里面也很简单,如果不懂cmake,可以看看我这期视频:cmake入门 。
你只需在cmake项目中添加下列代码:
include(FetchContent)
FetchContent_Declare(
doctest
GIT_REPOSITORY https://github.com/doctest/doctest.git
GIT_TAG v2.4.9
GIT_SHALLOW TRUE
)
FetchContent_MakeAvailable(doctest)
target_link_libraries(target doctest_with_main)
这里的仓库链接由于是GitHub上,如果你不会科学上网的话,建议可以去手动下载GitHub上的代码然后 add_subdirectory()
也是一样的。当然也可以把对应的仓库在gitee上创建一个镜像,那么你就可以直接把上面的 GIT_REPOSITORY
换成你镜像的地址了,比如我拉了一个镜像地址如下:https://gitee.com/acking-you/doctest.git 替换即可。
如何使用
开始前,你可以直接去看官方文档,写的也挺详细:官方文档
首先,我们要清楚,一个测试框架,你需要注意的就只有两点:
- 如何组织测试 -> 测试宏
- 如何进行测试断言 -> 断言宏
通过下面这个简单的测试进行一个简单的讲解:
//这个宏如果是通过链接的方式引入库的话千万不要加,如果是通过直接的include头文件引入的则需要加入
//#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
#include "doctest.h"
int factorial(int number) return number <= 1 ? number : factorial(number - 1) * number;
TEST_CASE("testing the factorial function")
CHECK(factorial(1) == 1);
CHECK(factorial(2) == 2);
CHECK(factorial(3) == 6);
CHECK(factorial(10) == 3628800);
上述代码是在测试斐波那契数列的值。
-
通过
TEST_CASE
这个宏来组织一个测试,参数是该测试的名字是一个字符串值,在CLion中会以这个名字来标识这个测试。与googletest
相比,对应的是TEST
宏,但不同的是 googletest 需要传两个参数,两个都不是字符串,而且必须符合C++变量命名的字符规则,所以不能以空格或者其他非字母数字的任何符号放在其中,这点其实很不方便。 -
通过
CHECK
宏来进行断言判断,如果失败了CLion中会有对应的提示。参数是一个判断表达式,不要小看这个宏,它是默认支持几乎所有内置的类型,并且包括stl容器。对应的 googletest 一般使用EXPECT_EQ()
传递两个参数来进行比较,默认不支持const char*
类型,需要使用EXPECT_STREQ
,而 doctest 则不需要有这方面的考虑,只需要关注这个CHECK
宏即可,当然它也有对应的CHECK_XX
宏。
测试相关
经过一个小demo的讲解,那么大家对于测试宏有了一定的了解,下面将继续介绍更多的测试宏。
SUBCASE
这个宏用于在TEST_CASE中继续产生更小的分组,然后你可以安全的捕获到外界的变量来使用。因为每个SUBCASE都是完全独立的重新执行,而不是在同一次执行,比如我将下面的代码块分为1、2、3,那么第一个SUBCASE的顺序将会是 1->2->结束
,第二个SUBCASE的执行顺序将会是 1->3->结束
。如果最外层的代码在 SUBCASE 后面,那么不会被执行,所有的 SUBCASE
执行情况,我们可以看作是从一个树的根节点到子节点的简单遍历,但每次遍历没有前后文关系(也就是每次遍历都是重新执行的)
TEST_CASE("vectors can be sized and resized")
std::vector<int> v(5);
//1
REQUIRE(v.size() == 5);
REQUIRE(v.capacity() >= 5);
SUBCASE("adding to the vector increases it's size")
//2
v.push_back(1);
CHECK(v.size() == 6);
CHECK(v.capacity() >= 6);
SUBCASE("reserving increases just the capacity")
//3
v.reserve(6);
CHECK(v.size() == 5);
CHECK(v.capacity() >= 6);
例如下面这个例子将会输出:
TEST_CASE("lots of nested subcases")
cout << endl << "root" << endl;
SUBCASE("")
cout << "1" << endl;
SUBCASE("") cout << "1.1" << endl;
SUBCASE("")
cout << "2" << endl;
SUBCASE("") cout << "2.1" << endl;
SUBCASE("")
cout << "2.2" << endl;
SUBCASE("")
cout << "2.2.1" << endl;
SUBCASE("") cout << "2.2.1.1" << endl;
SUBCASE("") cout << "2.2.1.2" << endl;
SUBCASE("") cout << "2.3" << endl;
SUBCASE("") cout << "2.4" << endl;
TEST_SUITE
test suite表示测试集,顾名思义,就是可以把 test case 分组。
比如可以这样写:
TEST_SUITE("math")
TEST_CASE("") // part of the math test suite
TEST_CASE("") // part of the math test suite
也可以分开用 TEST_SUITE_BEGIN 和 TEST_SUITE_END 宏来实现:
TEST_SUITE_BEGIN("utils");
TEST_CASE("") // part of the utils test suite
TEST_SUITE_END();
TEST_CASE("") // not part of any test suite
分组后的好处当然是可以直接分组执行了。
TEST_CASE_FIXTURE
这个宏是用来直接测试某个类的方法的,相当于是通过继承的方式创建了一个新的类,所以 protect 修饰的东西都能访问,比如:
class UniqueTestsFixture
private:
static int uniqueID;
protected:
int conn;
public:
UniqueTestsFixture()
: conn(10)
protected:
static int getID()
return ++uniqueID;
;
int UniqueTestsFixture::uniqueID = 0;
TEST_CASE_FIXTURE(UniqueTestsFixture, "test get ID")
REQUIRE(getID() == conn);
TEST_CASE_TEMPLATE
这个宏是用来测试模板的,如果需要测试的模板功能有共通性,只是类型不一致,那么你可以减少重复劳动,直接用这个宏来帮忙实例化再测试。
比如下列代码测试了 std::any
对于接收字符串类型和整数类型的情况测试:
TEST_CASE_TEMPLATE("test std::any as integer", T, char, short, int, long long int)
auto v = T();
std::any var = T();
CHECK(std::any_cast<T>(var)==v);
TEST_CASE_TEMPLATE("test std::any as string", T, const char*, std::string_view, std::string)
T v = "hello world";
std::any var = v;
CHECK(std::any_cast<T>(var)==v);
也可用 TEST_CASE_TEMPLATE_DEFINE
先定义一个模板测试,后面再用 TEST_CASE_TEMPLATE_INVOKE
来决定实例化模板的类型:
TEST_CASE_TEMPLATE_DEFINE("test std::any as integer", T,integer)
auto v = T();
std::any var = T();
CHECK(std::any_cast<T>(var)==v);
TEST_CASE_TEMPLATE_DEFINE("test std::any as string", T,string)
T v = "hello world";
std::any var = v;
CHECK(std::any_cast<T>(var)==v);
TEST_CASE_TEMPLATE_INVOKE(integer, char, short, int, long long int);
TEST_CASE_TEMPLATE_INVOKE(string, const char*, std::string_view, std::string);
断言相关
doctest的断言宏是很有规律的,它的设计我之前也提到过,是一种尽量以表达式的方式去简化对api的记忆,你只需要清楚三个断言的等级即可,当然如果想要直接通过对应的类似于 gtest
的 EXPECT_XXX
之类的api来进行断言,实际上也是有的。
断言宏一共有以下三个等级:
- REQUIRE:这个等级算是最高的,如果断言失败,不仅会标记为测试不通过,而且会强制退出测试(也就是后续的测试将不会再进行)。
- CHECK:如果断言失败,标记为测试不通过,但不会强制退出(也就是后续的测试还是会执行)。
- WARN:如果断言失败,不会标记测试不通过,也不会强制退出,但是会给出对应的提示。
常用断言宏
下面为常见的宏使用,使用这些宏比直接使用表达式的编译速度要快一点。
<LEVEL>
表示 REQUIRE、CHECK、WARN 三个等级。
<LEVEL>_EQ(left, right)
- same as<LEVEL>(left == right)
<LEVEL>_NE(left, right)
- same as<LEVEL>(left != right)
<LEVEL>_GT(left, right)
- same as<LEVEL>(left > right)
<LEVEL>_LT(left, right)
- same as<LEVEL>(left < right)
<LEVEL>_GE(left, right)
- same as<LEVEL>(left >= right)
<LEVEL>_LE(left, right)
- same as<LEVEL>(left <= right)
<LEVEL>_UNARY(expr)
- same as<LEVEL>(expr)
<LEVEL>_UNARY_FALSE(expr)
- same as<LEVEL>_FALSE(expr)
小提示:在引入头文件之前定义
DOCTEST_CONFIG_SUPER_FAST_ASSERTS
这个宏,也可以提升编译速度。
<LEVEL>_MESSAGE
:这个宏用于在错误的适合你可以设置对应的提示信息。
同样,你可以为了方便,先通过 INFO
宏来进行提示消息的预设,然后只要出现测试失败,都会提示这个预设的消息。
CHECK_MESSAGE(2==1,"not valid");
比如上面的代码可以用 INFO
宏,写成下面这样:
INFO("not valid")
CHECK(2==1);
常用工具函数
doctest::Contains()
用于判断字符串是包含这其中的字符。
比如下面这个例子:
REQUIRE("foobar" == doctest::Contains("foo"));
doctest::Approx()
用于更精确的比较浮点数。
比如下面这个例子:
REQUIRE(22.0/7 == doctest::Approx(3.141).epsilon(0.01)); // allow for a 1% error
benchmark框架
关于benchmark,我建议使用 nanobench
,同样也是因为引入简单轻量,使用简单且 head only
。
官方文档如下:https://nanobench.ankerl.com/tutorial.html#usage
如何引入
其实官方文档已经介绍了如何引入,它也是推荐使用下面的方式进行引入:
cmake_minimum_required(VERSION 3.14)
set(CMAKE_CXX_STANDARD 17)
project(
CMakeNanobenchExample
VERSION 1.0
LANGUAGES CXX)
include(FetchContent)
FetchContent_Declare(
nanobench
GIT_REPOSITORY https://github.com/martinus/nanobench.git
GIT_TAG v4.1.0
GIT_SHALLOW TRUE)
FetchContent_MakeAvailable(nanobench)
add_executable(MyExample my_example.cpp)
target_link_libraries(MyExample PRIVATE nanobench)
如何使用
使用非常简单,不依赖于宏,而是使用对应的类的成员函数。
比如:
#include <nanobench.h>
#include <atomic>
int main()
int y = 0;
std::atomic<int> x(0);
ankerl::nanobench::Bench().run("compare_exchange_strong", [&]
x.compare_exchange_strong(y, 0);
);
输出如下:
可以看得出来,上述的输出结果其实可以直接copy到markdown中,会被渲染为表格。
- ns/op:每个bench内容需要经历的时间(ns为单位)。
- op/s:每秒可以执行多少次操作。
- err%:运行多次测试的波动情况(误差)。
- ins/op:每次操作需要多少条指令。
- cyc/op:每次操作需要多少次时钟周期。
- bra/op:每次操作有多少次分支预判。
- miss%:分支预判的miss率。
- total:本次消耗的总时间。
- benchmark:对应的名字。
对于不同的机器上述的指标支持程度略有不同,官方的描述为:
CPU statistics like instructions, cycles, branches, branch misses are only available on Linux, through perf events. On some systems you might need to change permissions through
perf_event_paranoid
or use ACL.
防止被优化
如下示例:
#include <nanobench.h>
#include <thirdparty/doctest/doctest.h>
TEST_CASE("tutorial_fast_v1")
uint64_t x = 1;
ankerl::nanobench::Bench().run("++x", [&]()
++x;
);
可能无法输出结果,因为x被优化了,所以可以改为下面这样:
#include <nanobench.h>
#include <doctest/doctest.h>
TEST_CASE("tutorial_fast_v2")
uint64_t x = 1;
ankerl::nanobench::Bench().run("++x", [&]()
ankerl::nanobench::doNotOptimizeAway(x += 1);
);
优化不稳定
有些时候输出结果会提示你测试不稳定,你可以按照提示增加 minEpochIterations
。
比如:
#include <nanobench.h>
#include <doctest/doctest.h>
#include <random>
TEST_CASE("tutorial_fluctuating_v1")
std::random_device dev;
std::mt19937_64 rng(dev());
ankerl::nanobench::Bench().run("random fluctuations", [&]
// each run, perform a random number of rng calls
auto iterations = rng() & UINT64_C(0xff);
for (uint64_t i = 0; i < iterations; ++i)
ankerl::nanobench::doNotOptimizeAway(rng());
);
输出如下:
我们按照提示修改代码如下:
#include <nanobench.h>
#include <doctest/doctest.h>
#include <random>
TEST_CASE("tutorial_fluctuating_v2")
std::random_device dev;
std::mt19937_64 rng(dev());
ankerl::nanobench::Bench().minEpochIterations(5000).run(
"random fluctuations", [&]
// each run, perform a random number of rng calls
auto iterations = rng() & UINT64_C(0xff);
for (uint64_t i = 0; i < iterations; ++i)
ankerl::nanobench::doNotOptimizeAway(rng());
);
结果果然稳定了。
比较测试结果
有时候我们需要对很多测试结果进行比较,在 nanobench
中,很容易做到,只要共用同一个 Bench
对象即可,在开始的时候调用对应的方法。
比如官方给出了一个对比不同随机数生成器的性能的例子:完整代码:example_random_number_generators.cpp
private:
static constexpr uint64_t rotl(uint64_t x, unsigned k) noexcept
return (x << k) | (x >> (64U - k));
uint64_t stateA;
uint64_t stateB;
;
namespace
// Benchmarks how fast we can get 64bit random values from Rng.
template <typename Rng>
void bench(ankerl::nanobench::Bench* bench, char const* name)
std::random_device dev;
Rng rng(dev());
bench->run(name, [&]()
auto r = std::uniform_int_distribution<uint64_t>(rng);
ankerl::nanobench::doNotOptimizeAway(r);
);
// namespace
TEST_CASE("example_random_number_generators")
// perform a few warmup calls, and since the runtime is not always stable
// for each generator, increase the number of epochs to get more accurate
// numbers.
ankerl::nanobench::Bench b;
b.title("Random Number Generators")
.unit("uint64_t")
.warmup(100)
.relative(true);
b.performanceCounters(true);
// sets the first one as the baseline
bench<std::default_random_engine>(&b, "std::default_random_engine");
bench<std::mt19937>(&b, "std::mt19937");
bench<std::mt19937_64>(&b, "std::mt19937_64");
bench<std::ranlux24_base>(&b, "std::ranlux24_base")以上是关于C++工程实践必备技能的主要内容,如果未能解决你的问题,请参考以下文章