鱼和熊掌兼得:C++代码在编译时完成白盒测试
摘要:如果能够让代码在编译的时候,自动完成白盒测试,这不是天方夜谭。
白盒测试也叫开发者测试,是对特定代码函数或模块所进行的功能测试。当前主流的白盒测试方法是:先针对仿真或者生产环境编译出可执行文件,然后运行得到测试结果。这种方法有3个问题:
- 可能需要专门针对白盒测试额外做一次构建。这是因为仿真环境和实际运行环境可能是不同的硬件平台,而且白盒测试需要额外链接一些库(比如GTest),构建方式和发布版本不一样。这一方面让构建需要加入额外动作,另一方面也不容易保证两套构建工程的一致性,难以确保开发人员每次发布软件前都通过了白盒测试。
- 为了运行白盒测试,必须要搭建运行环境。有些执行机环境资源不太容易获得(比如嵌入式单板),这就给开发人员随时随地开展测试带来了障碍。
- 当代码发生修改时,需要人为判断执行哪一部分白盒测试用例。当依赖关系复杂时,这种影响关系分析并不容易。
如果能够让代码在编译的时候,自动完成白盒测试,则上面3个问题将都不存在。当测试用例没有通过时,我们希望编译失败。这看起来像是天方夜谭,但随着C++语言的编译期计算功能越来越成熟,对于相当一部分代码来说它已不再是幻想。
一个简单的例子
C++11开始提供了强大的编译期计算工具:constexpr。在后续的C++14/17/20等版本中,constexpr的功能被不断的扩展,被称为“病毒式扩张”的C++特性[1]。这里先看一个获取字符串长度的constexpr函数(本文中代码都在C++17环境下编译运行):
template<typename T, auto E = '\0'>
constexpr size_t StrLen(const T& str) noexcept
{
size_t i = 0;
while (str[i] != E) {
++i;
}
return i;
}
这个函数和C库函数strlen的主要区别有两点:一是它泛化了char类型为模板参数;二是它可以在编译期计算。要注意的是,constexpr函数也可以在运行期作为正常函数调用。
想要测试StrLen,最直接的办法是用constexpr常量和static_assert:
constexpr const char* g_str = "abc";
static_assert(StrLen(g_str) == 3);
这样当然行得通,但是这会污染全局名字空间,而且如果函数功能是对入参做修改(不要惊讶,constexpr函数真的可以修改入参,而且是在编译期),传入constexpr类型的入参是行不通的。所以好一点的做法是写成测试函数:
constexpr bool TestStrLen() noexcept
{
char testStr[] = "abc"; // 并不需要为constexpr
assert(StrLen(testStr) == 3); // 不能用static_assert
testStr[2] = '\0';
assert(StrLen(testStr) == 2);
return true;
} // 为了强制TestStrLen在编译期执行,必须有这行
constexpr bool DUMB = TestStrLen();
注意在测试代码中,不需要传给被测函数constexpr入参,只要整个过程可以在编译期计算就行了。因此TestStrLen里面可以修改局部变量并检查结果。另外由于StrLen返回的结果并不是constexpr常量,因此检查输出时也不能用static_assert。C++17保证了当assert中的条件为true时,它可以在编译期执行[2],所以assert调用不会影响编译期计算。
编译期测试的好处
除了本文开头所说的3个问题外,编译期测试还有其他的好处。比如,我们修改一下刚才的测试代码:
constexpr bool TestStrLen() noexcept
{
char testStr[] = {'a', 'b', 'c'}; // 少了结束符
assert(StrLen(testStr) == 3); // 内部数组越界
return true;
} constexpr bool DUMB = TestStrLen();
这段代码编译时,会产生以下错误:
D:\Work\Source_Codes\MyProgram\VSCode\main.cpp:45:33: in 'constexpr' expansion of 'TestStrLen()'
D:\Work\Source_Codes\MyProgram\VSCode\main.cpp:41:5: in 'constexpr' expansion of 'StrLen<char [3]>(((const char (&)[3])(& testStr)))'
D:\Work\Source_Codes\MyProgram\VSCode\main.cpp:45:34: error: array subscript value '3' is outside the bounds of array type 'char [3]'
constexpr bool DUMB = TestStrLen();
^
可以看到,如果白盒测试触发了数组越界,将会使编译报错。我们再来尝试一个空指针:
constexpr bool TestStrLen() noexcept
{
char* testStr = nullptr;
assert(StrLen(testStr) == 0);
return true;
} constexpr bool DUMB = TestStrLen();
这时编译器会报错:
D:\Work\Source_Codes\MyProgram\VSCode\main.cpp:45:33: in 'constexpr' expansion of 'TestStrLen()'
D:\Work\Source_Codes\MyProgram\VSCode\main.cpp:41:5: in 'constexpr' expansion of 'StrLen<char*>(((char* const&)(& testStr)))'
D:\Work\Source_Codes\MyProgram\VSCode\main.cpp:45:34: error: dereferencing a null pointer
constexpr bool DUMB = TestStrLen();
^
可以看到,编译期测试能有效的发现数组越界、空指针等问题。这是因为编译期计算并没有将代码翻译成机器指令运行,而是由编译器根据C++标准推导表达式结果。任何的未定义行为都会导致编译错误。
如果使用通常的测试方法,则需要使用一些编译手段或者消毒器等技术来探测这些未定义行为,还不一定能保证探测到。而且相关问题定位起来也会困难得多。
需要注意的是,编译期测试并不是形式化验证,测试通过并不表示未定义行为一定不存在。只有用例设计的输入组合能够触发未定义行为时,才会产生编译错误。
编译期测试框架
上面的测试代码有个易用性问题:当assert失败导致测试不通过时,错误信息不太友好:
In file included from D:/mingw64/lib/gcc/x86_64-w64-mingw32/8.1.0/include/c++/cassert:44,
from D:\Work\Source_Codes\MyProgram\VSCode\main.cpp:19:
D:\Work\Source_Codes\MyProgram\VSCode\main.cpp:45:33: in 'constexpr' expansion of 'TestStrLen()'
D:\Work\Source_Codes\MyProgram\VSCode\main.cpp:41:5: error: call to non-'constexpr' function 'void _assert(const char*, const char*, unsigned int)'
assert(StrLen(testStr) == 2);
^~~~~~
这个错误信息能看得人一头雾水。其原因是assert在条件为false时,将变身为非constexpr函数,导致编译器认为不满足constexpr求值条件。
这当然不是我们想要的。我们希望测试失败时要提示具体的用例,最好能具体到哪一行校验失败。
想要达成这个效果,需要一些技巧。一般的C++编译器会在类模板的错误信息中打印出模板参数。利用这个特点,我们可以把测试失败的行号作为类模板参数,并强制该模板实例化。
#define ASSERT_RETURN(exp) \
if (!(exp)) { \
return __LINE__; \
} constexpr uint32_t TestStrLen() noexcept
{
const char* testStr = "abc";
ASSERT_RETURN(StrLen(testStr) == 2); // 失败时返回行号
return 0;
} template<std::uint32_t L>
class TestFailedAtLine {
static_assert(L == 0);
}; // 模板显式实例化,强制运行测试用例函数
template class TestFailedAtLine<TestStrLen()>;
当ASSERT_RETURN校验失败时,编译提示信息会是这样: D:\Work\Source_Codes\MyProgram\VSCode\main.cpp: In instantiation of 'class TestFailedAtLine<46>':
D:\Work\Source_Codes\MyProgram\VSCode\main.cpp:56:16: required from here
D:\Work\Source_Codes\MyProgram\VSCode\main.cpp:52:21: error: static assertion failed
static_assert(L == 0);
~~^~~~
这里TestFailedAtLine<46>告诉了我们第46行的ASSERT_RETURN失败了。这样定位问题就方便多了。
但如果测试用例有很多个,希望分多个函数写,还是有些麻烦——因为必须给每个函数配一个模板类(TestFailedAtLine)。如果加用例的时候忘记了写这个模板类,就会导致用例不会被执行。
一个易用的框架,应该尽可能做到让用户添加功能时只改一个地方。想要做到这点并不容易,因为constexpr函数必须要完整定义以后才能被调用。但是利用lambda可以达到效果,其原理是:设计一个函数接受多个lambda对象,并且依次执行这些lambda对象。每一个lambda对象都作为一个测试用例。
// 仅用于中止递归
constexpr uint32_t TestExcute() noexcept
{
return 0;
} // 执行用例的函数,每一个参数都是待执行的测试用例
template<typename T, typename... F>
constexpr uint32_t TestExcute(T func, F... funcs) noexcept
{
auto ret = func();
if (ret != 0) {
return ret;
}
return TestExcute(funcs...);
} #define ASSERT_RETURN(exp) \
if (!(exp)) { \
return __LINE__; \
} // 上面的代码可以放到公共头文件中,被测试用例cpp文件包含 // 下面的代码可放到测试cpp文件中,在链接时可以跳过该cpp
// 测试用例集,每个用例都是一个lambda对象
constexpr std::uint32_t FAILED_LINE = TestExcute( // 常规测试
[]() -> std::uint32_t {
const char* testStr = "abc";
ASSERT_RETURN(StrLen(testStr) == 3);
return 0;
}, // 边界测试,输入空字符串
[]() -> std::uint32_t {
ASSERT_RETURN(StrLen("") == 0);
return 0;
}, // 扩展测试,元素为uint16_t类型,以0xFFFF结束
[]() -> std::uint32_t {
array<uint16_t, 4> a{10, 20, 30, 0xFFFF};
ASSERT_RETURN((StrLen<decltype(a), 0xFFFF>(a) == 3));
return 0;
} // 还可以加入更多测试用例……
); template<std::uint32_t L>
class TestFailedAtLine {
static_assert(L == 0);
}; // 模板显式实例化,强制运行测试用例函数
template class TestFailedAtLine<FAILED_LINE>;
在这个测试框架中,想添加或者删除测试用例,只要在TestExcute函数调用里增删lambda函数就可以了,其他的地方都不用改。每个新增的测试用例(lambda对象)都会确保被执行到。
测试框架利用了lambda对象的两个特性:构造函数和operator()成员函数可以隐式的作为constexpr函数。前者确保lambda对象可以作为constexpr入参传给TestExcute,后者确保编译期可以调用lambda对象。这两个特性需要C++17才能完整支持。
如注释所述,TestExcute和其后的代码可以单独放到一个cpp文件中,并且不参与链接。但是该文件编译失败时,仍然会中止构建过程,达到测试防护效果。其实即使把所有代码都放到发布版本软件里去也没有问题,TestFailedAtLine类型定义不会占用二进制空间,而constexpr的函数和常量因为没有被使用也会被编译器优化掉。
我们的测试框架看起来有模有样了,下面来看一个更复杂些的例子。
更复杂的例子——切割字符串
下面的代码以空格为分隔符来切割传入的字符串,每次可获取一个单词。很多人喜欢把这种功能设计为传入string并返回vector<string>,但这在C++中是非常低效的做法。本文的代码使用string_view,不仅不会产生拷贝字符串和内存分配开销,还让代码功能可以在编译期进行测试。
class Splitter {
public:
explicit constexpr Splitter(string_view whole) noexcept : whole(whole) {} constexpr string_view NextWord() noexcept
{
if (wordEnd == string_view::npos) {
return "";
}
wordBegin = whole.find_first_not_of(' ', wordEnd);
if (wordBegin == string_view::npos) {
return "";
}
wordEnd = whole.find(' ', wordBegin);
if (wordEnd == string_view::npos) {
return whole.substr(wordBegin);
}
return whole.substr(wordBegin, wordEnd - wordBegin);
} private:
string_view whole;
size_t wordBegin{0};
size_t wordEnd{0};
};
需要说明的是,string_view的拷贝代价很小(内部只保存指针),因此作为函数参数时没有必要传引用。另外string_view所代表的字符串不可被修改,因此也没有必要加const。此外还要注意string_view的结尾并不一定有'\0'结束符,因此它可以用于指向字符串中间的某一段内容,但是切勿将data()返回的指针当做C字符串使用。
对代码写编译期测试用例如下:
// 下面的代码可放到测试cpp文件中,在链接时可以跳过该cpp
// 测试用例集,每个用例都是一个lambda对象
constexpr std::uint32_t FAILED_LINE = TestExcute( // 边界条件,空字符串
[]() -> std::uint32_t {
Splitter words("");
ASSERT_RETURN(words.NextWord() == ""sv);
return 0;
}, // 边界条件,只有空格
[]() -> std::uint32_t {
Splitter words(" ");
ASSERT_RETURN(words.NextWord() == ""sv);
return 0;
}, // 只有一个单词
[]() -> std::uint32_t {
Splitter words("abc");
ASSERT_RETURN(words.NextWord() == "abc"sv);
ASSERT_RETURN(words.NextWord() == ""sv);
return 0;
}, // 多个单词,单空格分割
[]() -> std::uint32_t {
Splitter words("C++ compile time computation");
ASSERT_RETURN(words.NextWord() == "C++"sv);
ASSERT_RETURN(words.NextWord() == "compile"sv);
ASSERT_RETURN(words.NextWord() == "time"sv);
ASSERT_RETURN(words.NextWord() == "computation"sv);
ASSERT_RETURN(words.NextWord() == ""sv);
return 0;
}, // 多个单词,含多个连续空格,且首尾有空格
[]() -> std::uint32_t {
Splitter words(" 0 598 3426 ");
ASSERT_RETURN(words.NextWord() == "0"sv);
ASSERT_RETURN(words.NextWord() == "598"sv);
ASSERT_RETURN(words.NextWord() == "3426"sv);
ASSERT_RETURN(words.NextWord() == ""sv);
return 0;
}
);
可以看到,编译期的测试用例可以覆盖相当全面的场景,对于代码质量保障有很大的好处。
如果后续Splitter类的代码(或者其依赖的下层代码)修改了,在增量编译时,编译期会自动识别是否需要重新“测试”,确保不会放过修改引入的错误。
编译期测试的当前限制和应用前景
编译期测试的限制就是C++编译期计算的限制,主要为只能对constexpr接口进行测试。在C++17中,仍然有很多库函数不支持constexpr,如大多数泛型算法、需要动态分配内存的所有容器(如std::vector、std::string)等等。这导致当前编译期计算只能用于很小部分的底层函数。
但是,随着C++后续版本的到来,编译期计算的允许范围会越来越大。刚刚发布的C++20版本已经将大多数的泛型算法改为了constexpr函数,并且还允许operator new、虚函数、std::vector和std::string在编译期计算[3],这会使得相当大一部分的软件模块以后能够在编译期进行测试。
说不定,未来C++代码的测试方法会因此发生革命。
尾注
[1] 称为“病毒式扩张”是因为constexpr函数要求其调用其他的函数也都是constexpr函数。因此当越来越多的底层函数定义为constexpr时,上层函数也越来越多的被标记为constexpr。这个过程在标准库的代码中正在快速的进行。
[2] https://en.cppreference.com/w/cpp/error/assert
[3] 在这个页面中可以看到当前各编译器对C++20的支持进展。GCC的最新版本已经能支持虚函数、泛型算法在编译期的计算了。可惜的是目前还没有编译器支持std::vector和std::string的编译期计算。
本文分享自华为云社区《让C++代码在编译时完成白盒测试》,原文作者:飞得乐 。
鱼和熊掌兼得:C++代码在编译时完成白盒测试的更多相关文章
- AlloyTouch 0.2.0发布--鱼和熊掌兼得
原文链接:https://github.com/AlloyTeam/AlloyTouch/wiki/AlloyTouch-0.2.0 背景 公司师姐昨日在KM发了篇长文,主要结论RAF+transfo ...
- SaaS服务和个性化需求,就不能鱼和熊掌兼得吗?
随时随地.轻松高效,移动工作让人类的自由度最大化.但企业的移动化过程却不轻松:要综合考虑销售.产品.客服.市场销售.人力资源等错综复杂的流程和需求,以及原有IT系统.数据信息的对接. 千企千面,很难有 ...
- Linux Kernel 代码艺术——编译时断言
本系列文章主要写我在阅读Linux内核过程中,关注的比较难以理解但又设计巧妙的代码片段(不关注OS的各个模块的设计思想,此部分我准备写在“深入理解Linux Kernel” 系列文章中),一来通过内核 ...
- Linux Kernel 代码艺术——编译时断言【转】
转自:http://www.cnblogs.com/hazir/p/static_assert_macro.html 本系列文章主要写我在阅读Linux内核过程中,关注的比较难以理解但又设计巧妙的代码 ...
- 预编译加速编译(precompiled_header),指定临时文件生成目录,使项目文件夹更干净(MOC_DIR,RCC_DIR, UI_DIR, OBJECTS_DIR),#pragma execution_character_set("UTF-8")"这个命令是在编译时产生作用的,而不是运行时
预编译加速编译 QT也可以像VS那样使用预编译头文件来加速编译器的编译速度.首先在.pro文件中加入: CONFIG += precompiled_header 然后定义需要预编译的头文件: PREC ...
- 事物的隔离级别与并发完美体现了cap理论(确保数据完整、安全、一致性,在此基础上实现高性能访问(鱼和熊掌不可兼得)
事物的隔离级别与并发完美体现了cap理论(确保数据完整.安全.一致性,在此基础上实现高性能访问(鱼和熊掌不可兼得)
- 鱼和熊掌可兼得?一文看懂又拍云 SCDN
转眼已是 9102 年,参与工作多年的二狗子凭借他聪明的脑瓜和孜孜不倦的钻研精神,成为了某中型企业的资深网站管理员.不同于一般的"网管",二狗子自然是业内最优秀的那一类. 但是,最 ...
- c++的性能, c#的产能?!鱼和熊掌可以兼得,.NET NATIVE初窥
对于微软开发者来说,每次BUILD大会都是值得期待的.这次也是惊喜满满,除了大众瞩目的WP8.1的发布还有一项会令开发者兴奋的技术出现:.NET NATIVE.下面就来详细了解一下其为何物. [小九的 ...
- apt 根据注解,编译时生成代码
apt: @Retention后面的值,设置的为CLASS,说明就是编译时动态处理的.一般这类注解会在编译的时候,根据注解标识,动态生成一些类或者生成一些xml都可以,在运行时期,这类注解是没有的~~ ...
- java如何在eclipse编译时自动生成代码
用eclipse写java代码,自动编译时,如何能够触发一个动作,这个动作是生成本项目的代码,并且编译完成后,自动生成的代码也编译好了, java编辑器中就可以做到对新生成的代码的自动提示? 不生成代 ...
随机推荐
- 记Halo1.5版本迁移Halo2.10.0版本
原文地址: 记Halo1.5版本迁移Halo2.10.0版本 - Stars-One的杂货小窝 上一篇Window10安装linux子系统及子系统安装1Panel面板 - Stars-One的杂货小窝 ...
- 手撕Vue-界面驱动数据更新
经过上一篇文章,已经将数据驱动界面改变的过程实现了,本章节将实现界面驱动数据更新的过程. 界面驱动数据更新的过程,主要是通过 v-model 指令实现的, 只有 v-model 指令才能实现界面驱动数 ...
- 打造美团外卖新体验,HarmonyOS SDK持续赋能开发者共赢鸿蒙生态
从今年8月起,所有升级到HarmonyOS 4的手机用户在美团外卖下单后,可通过屏幕上的一个"小窗口",随时追踪到"出餐.取餐.送达"等订单状态.这个能让用户实 ...
- 一篇文章带你掌握测试基础语言——Python
一篇文章带你掌握测试基础语言--Python 本篇文章针对将Python作为第二语言的用户观看(已有Java或C基础的用户) 因为之前学习过Java语言,所以本篇文章主要针对Python的特征和一些基 ...
- Net 高级调试之六:对象检查之值类型、应用类型、数组和异常的转储
一.简介 今天是<Net 高级调试>的第六篇文章.记得我刚接触 Net 框架的时候,还是挺有信心的,对所谓的值类型和引用类型也能说出自己的见解,毕竟,自己一直在努力.当然这些见解都是书本上 ...
- 在keil MDK中定义非初始化(noini)变量
具体 可以参考ARM官方资料:ARM: Uninialized Variables Get Initialized 这里是对上述资料的总结, 该方法已在项目中得到验证. 方法: 分散加载文件如下: 定 ...
- DOT 学习笔记
开始大恶补图论了. 说句闲话,\(\text{ODT}\) 和 \(\text{DOT}\). \(\text{DOT}\),全称「树上启发式合并(\(\text{dsu on tree}\))」,乍 ...
- 栈与队列应用:逆波兰计算器(逆波兰表达式;后缀表达式)把运算符放到运算量后边 && 中缀表达式转化为后缀表达式
1 //1.实现对逆波兰输入的表达式进行计算如(2-1)*(2+3)= 5 就输入2 1 - 2 3 + * //先把2 1 压栈 遇到-弹栈 再把2 3压进去 遇到+弹栈 最后遇到*弹栈 2 //2 ...
- WPF --- 如何以Binding方式隐藏DataGrid列
引言 如题,如何以Binding的方式动态隐藏DataGrid列? 预想方案 像这样: 先在ViewModel创建数据源 People 和控制列隐藏的 IsVisibility,这里直接以 MainW ...
- .NET周刊【11月第3期 2023-11-19】
国内文章 .NET8.0 AOT 经验分享 FreeSql/FreeRedis/FreeScheduler 均已通过测试 https://www.cnblogs.com/FreeSql/p/17836 ...