调试和测试在软件开发过程中扮演着极其重要的角色。测试有助于我们在调试和修复问题时发现问题。然而,如果我们在实施阶段遵循某些规则,许多潜在的缺陷是可以避免的。此外,由于测试过程非常昂贵,如果我们能够在需要人工测试之前使用某些工具自动分析软件,那就太好了。此外,我们应该在什么时候、如何以及测试什么软件也很重要。
在本章中,我们将涵盖以下主题:
- 了解问题的根本原因
- 调试 C++ 程序
- 理解静态和动态分析
- 探索单元测试、TDD 和 BDD
在本章中,我们将学习如何分析软件缺陷,如何使用 GNU 调试器 ( GDB )工具调试程序,以及如何使用工具自动分析软件。我们还将学习单元测试、测试驱动开发 ( TDD )、行为驱动开发 ( BDD )的概念,以及如何在软件工程开发过程中练习使用它们。
本章的代码可以在本书的 GitHub 资源库中找到:https://github.com/PacktPublishing/Expert-CPP。
在医学上,一个好的医生需要理解治疗症状和治愈疾病之间的区别。比如给手臂骨折的病人吃止痛药,只会带走症状;手术可能是帮助骨骼逐渐愈合的正确方法。
根本原因分析 ( RCA )是一个系统化的过程,用于确定问题的根本原因。借助相关的适当工具,它试图使用一组特定的步骤来确定问题的主要原因。通过这样做,我们可以确定以下内容:
- 发生了什么事?
- 怎么发生的?
- 为什么会这样?
- 将采用什么适当的方法来预防或减少它,使其不再发生?
RCA 假设一个地方的动作触发另一个地方的另一个动作,以此类推。通过追溯行动链的开始,我们可以发现问题的根源,以及它是如何发展成我们的症状的。啊哈!这正是我们应该遵循的修复或减少软件缺陷的过程。在下面的小节中,我们将了解基本的 RCA 步骤,如何应用 RCA 过程来检测软件缺陷,以及 C++ 开发人员应该遵循哪些特定规则来防止软件中出现此类缺陷。
通常,RCA 流程包含以下五个步骤:
-
定义问题:在这个阶段,我们可能会找到以下问题的答案:发生了什么?问题的症状是什么?问题发生在什么环境或条件下?
-
收集数据:做一个原因因素图,需要收集足够的数据。这一步可能既昂贵又耗时。
-
制作因果因子图:因果因子图提供了一个可视化的结构,我们可以用它来组织和分析收集到的数据。因果因素图只不过是一个带有逻辑测试的序列图,用来解释导致症状发生的事件。该图表流程应推动数据收集流程,直到调查人员对图表的完整性感到满意。
-
识别根本原因:通过检查因果因素图,我们可以制作一个称为根本原因图的决策图,以识别根本原因。
-
建议并实施解决方案:一旦确定了根本原因或多种原因,以下问题的答案可以帮助我们找到解决方案:我们可以做些什么来防止问题再次发生?解决方案将如何实施?谁来负责?实施该解决方案的成本或风险是什么?
RCA 树形图是软件工程行业中最流行的因素图之一。以下是它的示例结构:
假设我们有一个问题有 A 、 B 和 C 症状。症状 A 可由事件 A1 或 A2 引起,症状 B 可由事件 B1 和 B2 或 B3 和 B4 引起,症状 C 由事件 C1 和 C2 引起。经过数据收集,我们发现症状 A 和 C 从未出现过,我们只有症状 B 。进一步分析可知,问题发生时并未涉及事件 B1 、 B2 ,因此我们可以认定该问题发生的根本原因是因为事件 B3 或 B4 。
如果软件有缺陷,而不是仅仅在故障点修复它,我们应该对它应用 RCA,并调查问题的原始根本原因。然后,问题的根本原因可以追溯到需求、设计、实现、验证和/或测试计划和输入数据。当找到问题的根源并加以解决后,软件的质量就可以得到提高,因此维护费用就会大大降低。
我们刚刚学习了如何找到问题的根源,但是要记住最好的防守就是好的进攻。那么,与其分析和解决一个问题,不如我们能阻止它的发生呢?
从成本的角度来看,IBM 的一项研究表明,假设需求和设计的总体成本为 1X,那么实现和编码过程将花费 5X,单元和集成测试将花费大约 10X,全面客户 beta 测试的成本将花费大约~15X,在产品发布后修复 bug 的成本占据大约 30X!因此,最小化代码缺陷是降低生产成本的最有效方法之一。
虽然找到软件缺陷根本原因的通用方法非常重要,但是如果我们能够在实现阶段防止一些缺陷,那就更好了。为此,我们需要有良好的编码行为,这意味着必须遵循某些规则。这些规则可以分为低级和高级。低级规则可能包括以下项目:
- 未初始化的变量
- 整数除法
- 误用
=
代替==
- 可能将有符号变量赋给无符号变量
- 在
switch
语句中缺少break
- 复合表达式或函数调用中的副作用
说到高级规则,我们有以下相关主题:
-
接口
-
资源管理
-
内存管理
-
并发
B.Stroustrup 和 H. Sutter 在他们的实时文档 *C++ 核心指南(0.8 版)*中建议遵循这些规则,其中强调了静态类型安全和资源安全。他们还强调了范围检查的可能性,以避免取消 null-ptr 的引用、悬空指针和异常的系统使用。如果开发人员遵循这样的规则,这将导致他/她的代码是静态类型安全的,没有任何资源泄漏。此外,它不仅会捕获更多的编程逻辑错误,而且还会运行得更快。
由于页面限制,我们将在本小节中只看几个例子。如果你想看更多的例子,请去https://isocpp.github.io/CppCoreGuidelines。
未初始化的变量是程序员最常犯的错误之一。当我们声明一个变量时,一定量的连续内存将被分配给它。如果没有初始化,它仍然有一些价值,但是没有确定性的方法来预测它。因此,当我们执行程序时,会出现不可预测的行为:
//ch13_rca_uninit_variable.cpp
#include <iostream>
int main()
{
int32_t x;
// ... //do something else but not assign value to x
if (x>0) {
std::cout << "do A, x=" << x << std::endl;
}
else {
std::cout << "do B, x=" << x << std::endl;
}
return 0;
}
在前面的代码中,当x
被声明时,操作系统将分配 4 个字节的未使用内存给它,这意味着x
的值是驻留在该内存中的任何值。每次我们运行这个程序时,x
的地址和值都可能不同。此外,一些编译器,如 Visual Studio,会在调试版本中将x
的值初始化为0
,但在发布版本中保持其未初始化状态。在这种情况下,我们在调试版本和发布版本中有完全不同的输出。
当一个运算符、表达式、语句或函数完成计算时,它可能会被延长,或者可能会持续存在于它的复合中。这种持续存在有一些副作用,可能会导致一些未定义的行为。让我们看看下面的代码来理解这一点:
//ch13_rca_compound.cpp
#include <iostream>
int f(int x, int y)
{
return x*y;
}
int main()
{
int x = 3;
std::cout << f(++ x, x) << std::endl; //bad,f(4,4) or f(4,3)?
}
由于操作数求值顺序的未定义行为,前面代码的结果可能是 16 或 12。
通常情况下,二元运算符(+
、-
、*
、/
、%
、<
、<=
、>
、>=
、==
、!=
、&&
、||
、!
、&
、|
、<<
、>>
、~
、^
、=
、+=
、-=
、*=
、/=
和%=
如果两个操作数属于不同的类型,其中一个将被提升为与另一个相同的类型。粗略地说,6.3.1.1[ISO/IEC 9899:2011]小节给出了三个 C 标准转换规则:
- 当我们混合相同等级的类型时,有符号的类型将被提升为无符号类型。
- 当我们混合不同级别的类型时,如果排名较低的一方的所有值都可以由排名较高的一方表示,排名较低的一方将被提升到排名较高的类型。
- 如果在前面的情况中,排名较低的类型的所有值都不能由排名较高的类型表示,那么将使用排名较高的类型的无符号版本。
现在,让我们来看看传统的有符号整数减去无符号整数的问题:
//ch13_rca_mix_sign_unsigned.cpp
#include <iostream>
using namespace std;
int main()
{
int32_t x = 10;
uint32_t y = 20;
uint32_t z = x - y; //z=(uint32_t)x - y
cout << z << endl; //z=4294967286\.
}
在上例中,有符号的int
将自动转换为无符号的int
,结果为uint32_t z
= -10
。另一方面,由于−10
不能表示为无符号的int
值,其十六进制值0xFFFFFFF6
在二进制补码机上将被解释为UINT_MAX - 9
(即4294967286
)。
以下示例涉及构造函数中类成员的初始化顺序。因为初始化顺序是类成员在类定义中出现的顺序,所以最好将每个成员的声明分成不同的行:
//ch13_rca_order_of_evaluation.cpp
#include <iostream>
using namespace std;
class A {
public:
A(int x) : v2(v1), v1(x) {
};
void print() {
cout << "v1=" << v1 << ",v2=" << v2 << endl;
};
protected:
//bad: the order of the class member is confusing, better
//separate it into two lines for non-ambiguity order declare
int v1, v2;
};
class B {
public:
//good: since the initialization order is: v1 -> v2,
//after this we have: v1==x, v2==x.
B(int x) : v1(x), v2(v1) {};
//wrong: since the initialization order is: v1 -> v2,
//after this we have: v1==uninitialized, v2==x.
B(float x) : v2(x), v1(v2) {};
void print() {
cout << "v1=" << v1 << ", v2=" << v2 << endl;
};
protected:
int v1; //good, here the declaration order is clear
int v2;
};
int main()
{
A a(10);
B b1(10), b2(3.0f);
a.print(); //v1=10,v2=10,v3=10 for both debug and release
b1.print(); //v1=10, v2=10 for both debug and release
b2.print(); //v1=-858993460,v2=3 for debug; v1=0,v2=3 for release.
}
在类A
中,虽然声明顺序是v1 -> v2
,但是将它们放在一行会让其他开发人员感到困惑。在B
类的第一个构造函数中,v1
将被初始化为x
,然后v2
将被初始化为v1
,因为它的声明顺序是v1->v2
。但是在其第二个构造函数中,v1
将首先被初始化为v2
(此时,v2
还没有初始化!),则v2
将由x
初始化。这导致了v1
在调试和发布版本中输出值的不同。
以下示例显示运行时检查(整数类型变量云的位数)可以由编译时检查代替:
//check # of bits for int
//courtesy: https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines
int nBits = 0; // don't: avoidable code
for (int i = 1; i; i <<= 1){
++ nBits;
}
if (nBits < 32){
cerr << "int too small\n";
}
由于int
可以是 16 位,也可以是 32 位,这取决于操作系统,因此本例无法实现其试图实现的目标。我们应该使用int32_t
或者用以下内容替换它:
static_assert(sizeof(int) >= 4); //compile-time check
另一个例子是将n
整数的最大数量读入一维数组:
void read_into(int* p, int n); // a function to read max n integers into *p
...
int v[10];
read_into(v, 100); //bad, off the end, but the compile cannot catch this error.
这可以通过span<int>
来解决:
void read_into( span<int> buf); // read into a range of integers
...
int v[10];
read_into(v); //better, the compiler will figure out the number of elements
这里的一般规则是尽可能在编译时进行分析,而不是推迟到运行时。
内存泄漏意味着分配的动态内存永远无法释放。在 C 语言中,我们使用malloc()
和/或calloc()
来分配内存,使用free()
来释放内存。在 C++ 中,new
运算符和delete
或delete []
运算符用于动态管理内存。虽然借助智能指针和资源获取即初始化 ( RAII )可以降低内存泄漏的风险,但如果我们希望构建高质量的代码,仍然需要遵循一些规则。
首先,最简单的内存管理方式是您自己的代码从不分配的内存。比如只要能写T x;
就不要写T* x = new T();
或者shared_ptr<T> x(new T() );
。
接下来,不要使用自己的代码管理内存,如下所示:
void f_bad(){
T* p = new T() ;
... //do something with p
delete p ; //leak if throw or return before reaching this line
}
相反,尝试使用 RAII,如下所示:
void f_better()
{
std::auto_ptr<T> p(new T()) ; //other smart pointers is ok also
... //do something with p
//will not leak regardless whether this point is reached or not
}
然后用unique_ptr
代替shared_ptr
,除非需要分享其所有权,如下:
void f_bad()
{
shared_ptr<Base> b = make_shared<Derived>();
...
} //b will be destroyed at here
由于b
是本地使用,不需要复制,所以它的refcount
永远是1
。这意味着我们可以用一个unique_ptr
来代替它:
void f_better()
{
unique_ptr<Base> b = make_unique<Derived>();
... //use b locally
} //b will be destroyed at here
最后,即使真的需要自己动态管理内存,如果有std container
库类可用,也不要手动分配内存。
在本节中,我们学习了如何使用 RCA 定位问题,以及如何通过编写最佳实践来防止问题。接下来,我们将学习如何使用调试器工具来控制程序的逐行执行,并在运行期间检查变量和表达式的值。
调试是发现和解决程序问题或缺陷的过程。这可能包括交互式调试、数据/控制流分析以及单元和集成测试。在本节中,我们将只关注交互式调试,这是一个使用断点逐行执行源代码的过程,同时显示正在使用的变量的值及其相应的内存地址。
根据您的开发环境,C++ 社区中有许多可用的工具。以下列表显示了不同平台上最受欢迎的。
- Linux/Unix:
- GDB :免费开源命令行界面 ( CLI )调试器。
- Eclipse :免费开源集成开发环境 ( IDE )。它不仅支持调试,还支持编译、分析和智能编辑。
- Valgrind :另一款开源动态分析工具;它有利于调试内存泄漏和线程错误。
- 亲和:商业图形用户界面 ( 图形用户界面)工具,为 GDB 、 LLDB 和 LLVM 调试器而构建。
- DDD :针对 GDB 、 DBX 、 JDB 、 **XDB、**和 Python 的开源数据显示调试器,它以图形的形式显示数据结构。
- Emacs 模式下的 GDB:一个开源的 GUI 工具,使用 GNU Emacs 在和 GDB 一起调试的时候可以查看和编辑源代码。
- KDevelop :面向 C/C++、Objective-等编程语言的免费开源 IDE 和调试器工具。
- 奈米弗:一款开源工具,在 GNOME 桌面环境下运行良好。
- SlickEdit :调试多线程和多处理器代码的好工具。
- Windows:
- Visual Studio :带 GUI 的商业工具,社区版免费。
- GDB :这个也可以在 Cygwin 或者 MinGW 的帮助下在 Windows 中运行。
- Eclipse :其 C++ 开发工具 ( CDT )可以通过工具链中的 MinGW GCC 编译器安装在 Windows 上。
- 苹果电脑:
- LLDB :这是 macOS 上 Xcode 中的默认调试器,支持桌面和 iOS 设备及其模拟器上的 C/C++ 和 Objective-C。
- GDB :这个 CLI 调试器也用在 macOS 和 iOS 系统上。
- Eclipse :这个使用 GCC 的免费 IDE 适用于 macOS。
由于 GDB 可以在所有平台上运行,我们将在下面的小节中向您展示如何使用 GDB。
GDB 代表 GNU 调试器,它允许开发人员看到另一个程序在执行时内部发生了什么,或者另一个程序在崩溃时正在做什么。GDB 可以做以下四件主要的事情:
- 启动一个程序,并指定任何可能影响其行为的东西。
- 在给定的条件下停止程序。
- 检查程序停止时发生了什么。
- 运行程序时更改变量值。这意味着我们可以尝试一些东西来纠正一个 bug 的影响和/或继续学习另一个 bug 的副作用。
请注意,涉及两个程序或可执行文件:一个是 GDB,而另一个是要调试的程序。由于这两个程序可以在同一台机器上运行,也可以在不同的机器上运行,因此我们可以进行三类调试,如下所示:
- 本机调试:两个程序运行在同一台机器上。
- 远程调试 : GDB 在主机上运行,被调试的程序在远程机器上运行。
- 模拟器调试 : GDB 在主机上运行,被调试的程序在模拟器上运行。
基于撰写本书时的最新版本(GDB v8.3),GDB 支持的语言包括 C、C++、Objective-C、Ada、Assembly、D、Fortran、Go、OpenCL、Modula-2、Pascal 和 Rust。
由于 GDB 是调试行业中最先进的工具,并且非常复杂,具有许多功能,因此不可能在本节中了解其所有功能。相反,我们将通过查看示例来研究最有用的特性。
在练习这些示例之前,我们需要通过运行以下代码来检查gdb
是否已经安装在我们的系统上:
~wus1/chapter-13$ gdb --help
如果显示以下信息,我们将准备开始:
This is the GNU debugger. Usage:
gdb [options] [executable-file [core-file or process-id]]
gdb [options] --args executable-file [inferior-arguments ...]
Selection of debuggee and its files:
--args Arguments after executable-file are passed to inferior
--core=COREFILE Analyze the core dump COREFILE.
--exec=EXECFILE Use EXECFILE as the executable.
...
否则,我们需要安装它。让我们看看如何在不同的操作系统上安装它:
- 对于基于 Debian 的 Linux:
~wus1/chapter-13$ s*udo apt-get install build-essential*
- 对于基于红帽的 Linux:
~wus1/chapter-13$***sudo yum install build-essential***
- 对于 macOS:
~wus1/chapter-13$***brew install gdb***
Windows 用户可以通过 MinGW 发行版安装 GDB。苹果电脑将需要任务化配置。
然后,再次键入gdb --help
检查是否安装成功。
在下面的例子中,我们将学习如何设置断点、继续、单步执行或单步执行函数、打印变量的值,以及如何在gdb
中使用帮助。源代码如下:
//ch13_gdb_1.cpp
#include <iostream>
float multiple(float x, float y);
int main()
{
float x = 10, y = 20;
float z = multiple(x, y);
printf("x=%f, y=%f, x*y = %f\n", x, y, z);
return 0;
}
float multiple(float x, float y)
{
float ret = x + y; //bug, should be: ret = x * y;
return ret;
}
正如我们在第 3 章、*面向对象编程的细节中提到的,*让我们在调试模式下构建这个程序,如下所示:
~wus1/chapter-13$ g++ -g ch13_gdb_1.cpp -o ch13_gdb_1.out
注意,对于 g++,-g
选项意味着调试信息将包含在输出二进制文件中。如果我们运行这个程序,它将显示以下输出:
x=10.000000, y=20.000000, x*y = 30.000000
现在,让我们用gdb
来看看 bug 在哪里。为此,我们需要执行以下命令行:
~wus1/chapter-13$ gdb ch13_gdb_1.out
通过这样做,我们将看到以下输出:
GNU gdb (Ubuntu 8.1-0ubuntu3) 8.1.0.20180409-git
Copyright (C) 2018 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law. Type "show copying"
and "show warranty" for details.
This GDB was configured as "aarch64-linux-gnu".
Type "show configuration" for configuration details.
For bug reporting instructions, please see:
<http://www.gnu.org/software/gdb/bugs/>.
Find the GDB manual and other documentation resources online at:
<http://www.gnu.org/software/gdb/documentation/>.
For help, type "help".
Type "apropos word" to search for commands related to "word"...
Reading symbols from a.out...done.
(gdb)
现在,让我们详细看看各种命令:
break
和run
:如果我们输入b main
或break main
,按进入,在主功能处会插入一个breakpoint
。然后,我们可以键入run
或r
开始调试程序。以下信息将显示在终端窗口中。在这里,我们可以看到我们的第一个breakpoint
在源代码的第六行,并且被调试的程序已经暂停,以便等待新的命令:
(gdb) b main
Breakpoint 1 at 0x8ac: file ch13_gdb_1.cpp, line 6.
(gdb) r
Starting program: /home/nvidia/wus1/Chapter-13/a.out
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/aarch64-linux-gnu/libthread_db.so.1".
Breakpoint 1, main () at ch13_gdb_1.cpp:6
6 float x = 10, y = 20;
next
、print
和quit
: 该n
或**next
命令将转到下一行代码。如果该行调用一个子程序,它不会进入该子程序;相反,它跳过呼叫,将其视为一条源线路。如果我们想显示一个变量的值,我们可以使用p
或print
命令,后跟变量的名称。最后,如果我们想退出gdb
,可以使用q
或quit
命令。以下是运行这些操作后终端窗口的输出:**
(gdb) n
7 float z = multiple(x, y);
(gdb) p z
$1 = 0
(gdb) n
8 printf("x=%f, y=%f, x*y = %f\n", x, y, z);
(gdb) p z
$2 = 30
(gdb) q
A debugging session is active.
Inferior 1 [process 29187] will be killed.
Quit anyway? (y or n) y
~/wus1/Chapter-13$
step
:现在我们来学习一下如何踏入multiple()
功能,找到 bug。为此,我们需要使用b
、r
和n
命令重新开始,首先到达 7 号线。然后,我们可以使用s
或step
命令进入multiple()
功能。接下来,我们使用n
命令到达第 14 行,p
打印ret
变量的值,即 30。在这一点上,我们已经弄清楚了,用ahha the bug is at line 14!:
,而不是x*y
,我们有一个错别字,就是x+y
。以下代码块是这些命令的相应输出:
~/wus1/Chapter-13$gdb ch13_gdb_1.out
...
(gdb) b main
Breakpoint 1 at 0x8ac: file ch13_gdb_1.cpp, line 6.
(gdb) r
The program being debugged has been started already.
Start it from the beginning? (y or n) y
Starting program: /home/nvidia/wus1/Chapter-13/a.out
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/aarch64-linux-gnu/libthread_db.so.1". Breakpoint 1, main () at ch13_gdb_1.cpp:6
6 float x = 10, y = 20;
(gdb) n
7 float z = multiple(x, y);
(gdb) s
multiple (x=10, y=20) at ch13_gdb_1.cpp:14
14 float s = x + y;
(gdb) n
15 return s;
(gdb) p s
$1 = 30
help
:最后,我们来学习一下help
命令,结束这个小例子。当gdb
启动时,我们可以使用help
或h
命令在其命令输入行中获取特定命令的使用信息。例如,下面的“终端”窗口总结了我们到目前为止学到的内容:
(gdb) h b
Set breakpoint at specified location.
break [PROBE_MODIFIER] [LOCATION] [thread THREADNUM] [if CONDITION]
PROBE_MODIFIER shall be present if the command is to be placed in a
probe point. Accepted values are `-probe' (for a generic, automatically
guessed probe type), `-probe-stap' (for a SystemTap probe) or
`-probe-dtrace' (for a DTrace probe).
LOCATION may be a linespec, address, or explicit location as described
below.
....
(gdb) h r
Start debugged program.
You may specify arguments to give it.
Args may include "*", or "[...]"; they are expanded using the
shell that will start the program (specified by the "$SHELL" environment
variable). Input and output redirection with ">", "<", or ">>"
are also allowed.
(gdb) h s
Step program until it reaches a different source line.
Usage: step [N]
Argument N means step N times (or till program stops for another reason).
(gdb) h n
Step program, proceeding through subroutine calls.
Usage: next [N]
Unlike "step", if the current source line calls a subroutine,
this command does not enter the subroutine, but instead steps over
the call, in effect treating it as a single source line.
(gdb) h p
Print value of expression EXP.
Variables accessible are those of the lexical environment of the selected
stack frame, plus all those whose scope is global or an entire file.
(gdb) h h
Print list of commands.
(gdb) h help
Print list of commands.
(gdb) help h
Print list of commands.
(gdb) help help
Print list of commands.
至此,我们已经了解了一些可以用来调试程序的基本命令。这些命令是break
、run
、next
、print
、quit
、step
和help
。我们将在下一小节中学习函数和条件断点、观察点以及continue
和finish
命令。
在本例中,我们将学习如何设置函数断点、条件断点以及使用continue
命令。然后,我们将学习如何完成一个函数调用,而不需要一步一步地执行所有代码行。源代码如下:
//ch13_gdb_2.cpp
#include <iostream>
float dotproduct( const float *x, const float *y, const int n);
int main()
{
float sxx,sxy;
float x[] = {1,2,3,4,5};
float y[] = {0,1,1,1,1};
sxx = dotproduct( x, x, 5);
sxy = dotproduct( x, y, 5);
printf( "dot(x,x) = %f\n", sxx );
printf( "dot(x,y) = %f\n", sxy );
return 0;
}
float dotproduct( const float *x, const float *y, const int n )
{
const float *p = x;
const float *q = x; //bug: replace x by y
float s = 0;
for(int i=0; i<n; ++ i, ++ p, ++ q){
s += (*p) * (*q);
}
return s;
}
同样,在构建并运行ch13_gdb_2.cpp
之后,我们得到以下输出:
~/wus1/Chapter-13$ g++ -g ch13_gdb_2.cpp -o ch13_gdb_2.out
~/wus1/Chapter-13$ ./ch13_gdb_2.out
dot(x,x) = 55.000000
dot(x,y) = 55.000000
既然dot(x,x)
和dot(x,y)
给我们的结果是一样的,那么这里肯定有问题。现在,让我们通过学习如何在dot()
函数中设置断点来调试它:
- 函数断点:要在函数的开头设置断点,我们可以使用
b function_name
命令。像往常一样,我们可以在输入时使用制表符补全。例如,假设我们键入以下内容:
(gdb) b dot<Press TAB Key>
如果我们这样做,下面的命令行将自动弹出:
(gdb) b dotproduct(float const*, float const*, int)
如果它是一个类的成员函数,则应该包含它的类名,如下所示:
(gdb) b MyClass::foo(<Press TAB key>
- 条件断点:设置条件断点有几种方式:
(gdb) b f.cpp:26 if s==0 //set a breakpoint in f.cpp, line 26 if s==0
(gdb) b f.cpp:20 if ((int)strcmp(y, "hello")) == 0
- 列出并删除断点:一旦我们设置了几个断点,我们就可以列出或删除它们,如下所示:
(gdb) i b (gdb) delete breakpoints 1 (gdb) delete breakpoints 2-5
- 移除使 成为断点 无条件:由于每个断点都有一个数字,我们可以从断点处移除一个条件,就像这样:
(gdb) cond 1 //break point 1 is unconditional now
- 观察点:当表达式的值发生变化时,观察点可以停止执行,而不必预测它可能发生在哪里(哪一行)。有三种观察点:
watch
:gdb
将在写入发生时断开rwatch
:gdb
将在读取发生时断开awatch
:gdb
将在写入或读取发生时断开
下面的代码显示了一个这样的例子:
(gdb) watch v //watch the value of variable v
(gdb) watch *(int*)0x12345678 //watch an int value pointed by an address
(gdb) watch a*b + c/d // watch an arbitrarily complex expression
- 继续:当我们在断点处检查完变量值后,我们可以使用
continue
或c
命令继续程序执行,直到调试器遇到断点、信号、错误或正常进程终止。 - 完成:一旦我们进入一个函数,我们可能会想继续执行它,直到它返回到它的调用者行。这可以使用
finish
命令来完成。
现在,让我们将这些命令放在一起调试ch13_gdb_2.cpp
。以下是终端窗口的输出。为了方便起见,我们将其分为三个部分:
//gdb output of example ch13_gdb_2.out -- part 1
~/wus1/Chapter-13$ gdb ch13_gdb_2.out //cmd 1
...
Reading symbols from ch13_gdb_2.out ... done.
(gdb) b dotproduct(float const*, float const*, int) //cmd 2
Breakpoint 1 at 0xa5c: file ch13_gdb_2.cpp, line 20.
(gdb) b ch13_gdb_2.cpp:24 if i==1 //cmd 3
Breakpoint 2 at 0xa84: file ch13_gdb_2.cpp, line 24.
(gdb) i b //cmd 4
Num Type Disp Enb Address What
1 breakpoint keep y 0x0000000000000a5c in dotproduct(float const*, float const*, int) at ch13_gdb_2.cpp:20
2 breakpoint keep y 0x0000000000000a84 in dotproduct(float const*, float const*, int) at ch13_gdb_2.cpp:24
stop only if i==1
(gdb) cond 2 //cmd 5
Breakpoint 2 now unconditional.
(gdb) i b //cmd 6
Num Type Disp Enb Address What
1 breakpoint keep y 0x0000000000000a5c in dotproduct(float const*, float const*, int) at ch13_gdb_2.cpp:20
2 breakpoint keep y 0x0000000000000a84 in dotproduct(float const*, float const*, int) at ch13_gdb_2.cpp:24
在第一部分中,我们有以下六个命令:
cmd 1
:我们从构建的可执行文件的参数ch13_gdb_2.out
开始gdb
。这将向我们简要显示它的版本和文档以及使用信息,然后告诉我们读取符号的过程已经完成,正在等待下一个命令。cmd 2
:我们设置了breakpoint
功能(在dotproduct()
)。cmd 3
:设置条件breakpoint
。cmd 4
:它列出了关于断点的信息,告诉我们有两个断点。cmd 5
:我们把breakpoint 2
设为unconditional
。cmd 6
:我们再次列出断点信息。此时,我们可以看到两个断点。这些分别位于ch13_gdb_2.cp
文件的第 20 行和第 24 行。
接下来,让我们看看第二部分的gdb
输出:
//gdb output of example ch13_gdb_2.out -- part 2
(gdb) r //cmd 7
Starting program: /home/nvidia/wus1/Chapter-13/ch13_gdb_2.out
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/aarch64-linux-gnu/libthread_db.so.1".
Breakpoint 1, dotproduct (x=0x7fffffed68, y=0x7fffffed68, n=5) at ch13_gdb_2.cpp:20
20 const float *p = x;
(gdb) p x //cmd 8
$1 = (const float *) 0x7fffffed68
(gdb) c //cmd 9
Continuing.
Breakpoint 2, dotproduct (x=0x7fffffed68, y=0x7fffffed68, n=5) at ch13_gdb_2.cpp:24
24 s += (*p) * (*q);
(gdb) p i //cmd 10
$2 = 0
(gdb) n //cmd 11
23 for(int i=0; i<n; ++ i, ++ p, ++ q){
(gdb) n //cmd 12
Breakpoint 2, dotproduct (x=0x7fffffed68, y=0x7fffffed68, n=5) at ch13_gdb_2.cpp:24
24 s += (*p) * (*q);
(gdb) p s //cmd 13
$4 = 1
(gdb) watch s //cmd 14
Hardware watchpoint 3: s
第二部分包含以下 cmd:
cmd 7
:通过给出run
命令,程序开始运行,并在第 20 行的第一个断点处停止。cmd 8
:我们打印x
的值,显示其地址。cmd 9
:我们继续节目。一旦继续,它将在第 24 行的第二个断点处停止。cmd 10
:打印i
的数值,为0
。cmd 11-12
:我们使用next
命令两次。此时,s += (*p) * (*q)
语句被执行。cmd 13
:打印s
的数值,为1
。cmd 14
:我们打印s
的值。
最后,第三部分如下:
//gdb output of example ch13_gdb_2.out -- part 3
(gdb) n //cmd 15
Hardware watchpoint 3: s
Old value = 1
New value = 5
dotproduct (x=0x7fffffed68, y=0x7fffffed68, n=5) at ch13_gdb_2.cpp:23
23 for(int i=0; i<n; ++ i, ++ p, ++ q){
(gdb) finish //cmd 16
Run till exit from #0 dotproduct (x=0x7fffffed68, y=0x7fffffed68, n=5) at ch13_gdb_2.cpp:23
Breakpoint 2, dotproduct (x=0x7fffffed68, y=0x7fffffed68, n=5) at ch13_gdb_2.cpp:24
24 s += (*p) * (*q);
(gdb) delete breakpoints 1-3 //cmd 17
(gdb) c //cmd 18
Continuing.
dot(x,x) = 55.000000
dot(x,y) = 55.000000
[Inferior 1 (process 31901) exited normally]
[Inferior 1 (process 31901) exited normally]
(gdb) q //cmd 19
~/wus1/Chapter-13$
在这一部分中,我们有以下命令:
cmd 15
:我们使用next
命令查看如果执行下一次迭代s
的值是多少。显示s
的旧值为1
(s = 11),新值为5
(s=11+2*2)。到目前为止,一切顺利!cmd 16
:使用finish
命令继续运行程序,直到退出该功能。cmd 17
:我们删除断点 1 到 3。cmd 18
:使用continue
命令。cmd 19
:我们退出gdb
回到终端窗口。
当处理长堆栈跟踪或多线程堆栈跟踪时,查看和分析终端窗口的gdb
输出可能会不方便。但是,我们可以先将整个会话或特定输出记录到文本文件中,然后使用其他文本编辑器工具离线浏览。为此,我们需要使用以下命令:
(gdb) set logging on
当我们执行这个命令时,gdb
会将所有终端窗口输出保存到当前运行的gdb
文件夹中名为gdb.txt
的文本文件中。如果我们想停止日志记录,我们可以只键入以下内容:
(gdb) set logging off
GDB 的一大优点是,我们可以根据需要打开和关闭 set log 命令多次,而不用担心被转储的文件名。这是因为所有的输出都连接到gdb.txt
文件中。
这里有一个返回ch13_gdb_2.out
的例子,其中gdb
输出被转储:
~/wus1/Chapter-13$ gdb ch13_gdb_2.out //cmd 1
...
Reading symbols from ch13_gdb_2.out...done.
(gdb) set logging on //cmd 2
Copying output to gdb.txt.
(gdb) b ch13_gdb_2.cpp:24 if i==1 //cmd 3
Breakpoint 1 at 0xa84: file ch13_gdb_2.cpp, line 24.
(gdb) r //cmd 4
...
Breakpoint 1, dotproduct (x=0x7fffffed68, y=0x7fffffed68, n=5) at ch13_gdb_2.cpp:24
24 s += (*p) * (*q);
(gdb) p i //cmd 5
$1 = 1
(gdb) p s //cmd 6
$2 = 1
(gdb) finish //cmd 7
Run till exit from #0 dotproduct (x=0x7fffffed68, y=0x7fffffed68, n=5) at ch13_gdb_2.cpp:24
0x00000055555559e0 in main () at ch13_gdb_2.cpp:11
11 sxx = dotproduct( x, x, 5);
Value returned is $3 = 55
(gdb) delete breakpoints 1 //cmd 8
(gdb) set logging off //cmd 9
Done logging to gdb.txt.
(gdb) c //cmd 10
Continuing.
dot(x,x) = 55.000000
dot(x,y) = 55.000000
[Inferior 1 (process 386) exited normally]
(gdb) q //cmd 11
~/wus1/Chapter-13$ cat gdb.txt //cmd 12
前面代码中使用的命令如下:
-
cmd 1
:启动gdb
。 -
cmd 2
:我们将日志标志设置为开。此时,gdb
表示输出将被复制到gdb.txt
文件中。 -
cmd 3
:设置条件break point
。 -
cmd 4
:我们运行程序,当到达第 24 行的条件breakpoint
时,程序停止。 -
cmd 5
和cmd 6
:我们打印i
和s
的值,接受。 -
cmd 7
:通过执行功能命令的单步执行,显示sxx
为55
(调用sxx=dotproduct( x, x, 5))
后),程序停在sxy *=* dotproduct( x, y, 5).
行 -
cmd 8
:我们删除breakpoint 1
。 -
cmd 9
:我们将日志标志设置为关闭。 -
cmd 10
:一旦给出继续指令,则退出main
功能,gdb
等待新的命令。 -
cmd 11
:我们输入q
退出gdb
。 -
cmd 12
:当它回到终端窗口时,我们通过在操作系统中运行cat
命令来打印记录的gdb.txt
文件的内容。
到目前为止,我们已经学习了足够的 GDB 命令来调试一个程序。正如你可能已经注意到的,这很耗时,因此非常昂贵。有时,由于在错误的地方调试,情况会变得更糟。为了有效地调试,我们需要遵循正确的策略。我们将在下一小节中介绍这一点。
由于调试是软件开发生命周期中成本最高的阶段,因此发现错误并修复它们是不可行的,尤其是对于大型复杂系统。但是,在实际过程中可以使用某些策略,其中一些策略如下:
-
用 printf()或者 std::cout :这是老套的做事方式。通过向终端打印一些信息,我们可以检查变量值,并在何时何地执行各种日志配置文件,以便进一步分析。
-
使用调试器:虽然学习使用 GDB 类型的调试器工具不是一蹴而就的,但是可以节省很多时间。所以,试着一步一步逐渐熟悉它。
-
重现 bug:每当现场报告 bug 时,记录运行环境并输入数据。
-
转储日志文件:应用应该将日志消息转储到文本文件中。当崩溃发生时,我们应该首先检查日志文件,看看是否发生了异常事件。
-
猜一猜:大致猜一个 bug 的位置,然后证明是对是错。
-
分而治之:即使在我们不知道有什么 bug 的最坏情况下,我们仍然可以使用二分搜索法策略来设置断点,然后缩小范围并最终定位它们。
-
简化:始终从最简化的场景开始,逐步增加外设、输入模块等,直到 bug 可以重现。
-
源代码版本控制:如果一个 bug 突然出现在一个发行版上,但是之前运行的很好,那就先做一个源代码树检查。可能有人做了改变!
-
不要放弃:有些 bug 真的很难定位和/或修复,尤其是复杂的多团队参与的系统。暂时把它们放在一边,在回家的路上重新考虑一下——T2 啊哈时刻可能最终会出现。
到目前为止,我们已经了解了使用 RCA 进行宏观级别的问题定位,以及我们可以遵循的防止问题发生的良好编码实践。此外,通过使用最先进的调试器工具,如 GDB,我们可以逐行控制程序的执行,以便我们可以在微观层面分析和修复问题。所有这些活动都是程序员集中和手动的。有什么自动工具可以帮助我们诊断程序的潜在缺陷吗?我们将在下一节中研究静态和动态分析。
在前几节中,我们学习了根本原因分析过程以及如何使用 GDB 来调试缺陷。这一节将讨论如何分析一个程序,有没有执行它。前者称为动态分析,后者称为静态分析。
静态分析评估计算机程序的质量而不执行它。虽然这通常可以通过自动工具和代码审查/检查来检查源代码,但我们在这一部分将只关注自动工具。
自动静态代码分析工具旨在根据一组或多组编码规则或准则来分析一组代码。通常情况下,人们会交替使用静态代码分析*、*静态分析或源代码分析。通过用每个可能的代码执行路径扫描整个代码库,我们可以在测试阶段之前发现许多潜在的错误。但是,它也有几个限制,如下所示:
- 它会产生假阳性和假阴性警报。
- 它只应用扫描算法内部实现的规则,其中一些规则可能会被主观解释。
- 它无法找到运行时环境中引入的漏洞。
- 它可以提供一种虚假的安全感,即一切都在解决之中。
在商业和免费开源类别下,大约有 30 种自动 C/C++ 代码分析工具[9]。这些工具的名称包括 Clang、Clion、CppCheck、Eclipse、Visual Studio 和 GNU g++,仅举几个例子。作为例子,我们想介绍一下**-**Wall
、-Weffcc++
和-Wextra
选项,它们内置在 GNU 编译器 g++ [10]中:
-
-Wall
:这将启用所有施工警告,这对于某些用户来说是有问题的。这些警告很容易避免或修改,即使与宏结合使用也是如此。它还启用了 C ++ 方言选项和 Objective-C/C ++ 方言选项中描述的一些特定于语言的警告。 -
-Wextra
:顾名思义,它检查某些没有被-Wall
检查的额外警告标志。将打印以下任何情况的警告信息:- 指针与整数零和
<
、<=
、>
或>=
操作数进行比较。 - 非枚举数和枚举数出现在条件表达式中。
- 模糊的虚拟基地。
- 订阅一个
register
类型的数组。 - 使用
register
类型变量的地址。 - 派生类的复制构造函数不初始化它的基类。注意(b)-(f)只是 C++ 的。
- 指针与整数零和
-
-Weffc++
:检查是否违反了斯科特·迈耶斯撰写的有效且更有效的 C++ 中建议的一些准则。这些准则包括以下内容:- 为具有动态分配内存的类定义复制构造函数和赋值运算符。
- 在构造函数中,初始化优于赋值。
- 在基类中使析构函数虚拟化。
- 让
=
操作员返回一个参考到*this
。 - 当必须返回对象时,不要试图返回引用。
- 区分递增和递减运算符的前缀和后缀形式。
- 切勿超载
&&
、||
或,
。
为了探索这三个选项,让我们看下面的例子:
//ch13_static_analysis.cpp
#include <iostream>
int *getPointer(void)
{
return 0;
}
int &getVal() {
int x = 5;
return x;
}
int main()
{
int *x = getPointer();
if( x> 0 ){
*x = 5;
}
else{
std::cout << "x is null" << std::endl;
}
int &y = getVal();
std::cout << y << std::endl;
return 0;
}
首先,让我们在没有任何选项的情况下构建它:
g++ -o ch13_static.out ch13_static_analysis.cpp
这可以成功构建,但是如果我们运行它,不出所料,它会崩溃,并显示一条分段故障 ( 核心转储)消息。
接下来,让我们添加-Wall
、-Weffc++
、和**-Wextra
选项并重建它:**
g++ -Wall -o ch13_static.out ch13_static_analysis.cpp
g++ -Weffc++ -o ch13_static.out ch13_static_analysis.cpp
g++ -Wextra -o ch13_static.out ch13_static_analysis.cpp
-Wall
和-Weffc++
都给了我们以下信息:
ch13_static_analysis.cpp: In function ‘int& getVal()’:
ch13_static_analysis.cpp:9:6: warning: reference to local variable ‘x’ returned [-Wreturn-local-addr]
int x = 5;
^
这里,它抱怨在int & getVal()
函数中(在cpp
文件的第 9 行),返回了对局部变量的引用。这是行不通的,因为一旦程序退出功能,x
就是垃圾(x
的寿命只限于功能范围内)。引用死变量没有任何意义。
-Wextra
给了我们以下信息:
ch13_static_analysis.cpp: In function ‘int& getVal()’:
ch13_static_analysis.cpp:9:6: warning: reference to local variable ‘x’ returned [-Wreturn-local-addr]
int x = 5;
^
ch13_static_analysis.cpp: In function ‘int main()’:
ch13_static_analysis.cpp:16:10: warning: ordered comparison of pointer with integer zero [-Wextra]
if( x> 0 ){
^
前面的输出显示*-*Wextra
不仅给出了来自-Wall
的警告,还检查了前面提到的六件事。在这个例子中,它警告我们在代码的第 16 行有一个指针和整数零之间的比较。
现在我们知道了如何在编译时使用静态分析选项,我们将通过执行一个程序来看看动态分析。
动态分析是动态程序分析的简短版本,它通过在真实或虚拟处理器上执行软件程序来分析软件程序的性能。与静态分析类似,动态分析也可以自动或手动完成。例如,单元测试、集成测试、系统测试和验收测试通常是人参与的动态分析过程。另一方面,内存调试、内存泄漏检测和分析工具,如 IBM purify、Valgrind 和 Clang 是自动动态分析工具。在这一小节中,我们将重点介绍自动动态分析工具。
动态分析过程包含准备输入数据、启动测试程序、收集必要的参数和分析其输出等步骤。粗略地说,动态分析工具的机制是它们使用代码插装和/或模拟环境来在被分析的代码执行时对其执行检查。我们可以通过以下方式与程序进行交互:
- 源代码插装:编译前在原始源代码中插入一个特殊的代码段。
- 目标代码插装:一个特殊的二进制代码被直接添加到可执行文件中。
- 编译阶段插装:通过特殊的编译器开关增加一个校验码。
- 它不会改变源代码。相反,它使用特殊的执行阶段库来检测错误。
动态分析有以下优点:
- 没有假阳性或假阴性结果,因为将检测到模型无法预测的错误。
- 它不需要源代码,这意味着专有代码可以由第三方机构测试。
动态分析的缺点如下:
- 它只检测与输入数据相关的路线上的缺陷。可能找不到其他缺陷。
- 它一次只能检查一个执行路径。为了获得完整的图片,我们需要尽可能多地运行测试。这需要大量的计算资源。
- 它无法检查代码的正确性。从错误的操作中得到正确的结果是可能的。
- 在真正的处理器上执行不正确的代码可能会产生意想不到的结果。
现在,让我们使用 Valgrind 来查找以下示例中给出的内存泄漏和越界问题:
//ch13_dynamic_analysis.cpp
#include <iostream>
int main()
{
int n=10;
float *p = (float *)malloc(n * sizeof(float));
for( int i=0; i<n; ++ i){
std::cout << p[i] << std::endl;
}
//free(p); //leak: free() is not called
return 0;
}
要使用 Valgrind 进行动态分析,需要执行以下步骤:
- 首先,我们需要安装
valgrind
。我们可以使用以下命令来实现这一点:
sudo apt install valgrind //for Ubuntu, Debian, etc.
- 一旦安装成功,我们可以通过将可执行文件作为参数以及其他参数传递来运行
valgrind
,如下所示:
valgrind --leak-check=full --show-leak-kinds=all --track-origins=yes \
--verbose --log-file=valgrind-out.txt ./myExeFile myArgumentList
- 接下来,让我们构建这个程序,如下所示:
g++ -o ch13_dyn -std=c++ 11 -Wall ch13_dynamic_analysis.cpp
- 然后,我们运行
valgrind
,像这样:
valgrind --leak-check=full --show-leak-kinds=all --track-origins=yes \
--verbose --log-file=log.txt ./ch13_dyn
最后可以查看log.txt
的内容。粗体和斜体线条表示内存泄漏的位置和大小。通过查看地址(0x4844BFC
)及其对应的功能名称(main()
),我们可以看到这个malloc
在main()
功能中:
... //ignore many lines at begining
by 0x108A47: main (in /home/nvidia/wus1/Chapter-13/ch13_dyn)
==18930== Uninitialised value was created by a heap allocation
==18930== at 0x4844BFC: malloc (in /usr/lib/valgrind/vgpreload_memcheck-arm64-linux.so)
... //ignore many lines in middle
==18930== HEAP SUMMARY:
==18930== in use at exit: 40 bytes in 1 blocks
==18930== total heap usage: 3 allocs, 2 frees, 73,768 bytes allocated
==18930==
==18930== 40 bytes in 1 blocks are definitely lost in loss record 1 of 1
==18930== at 0x4844BFC: malloc (in /usr/lib/valgrind/vgpreload_memcheck-arm64-linux.so)
==18930==
==18930== LEAK SUMMARY:
==18930== definitely lost: 40 bytes in 1 blocks
==18930== indirectly lost: 0 bytes in 0 blocks
==18930== possibly lost: 0 bytes in 0 blocks
==18930== still reachable: 0 bytes in 0 blocks
==18930== suppressed: 0 bytes in 0 blocks
在这里,我们可以看到malloc()
被调用来在地址0x4844BFC
分配一些内存。堆摘要部分表明我们在0x4844BFC
有 40 字节的内存损失。最后,泄漏总结部分显示,肯定有一块 40 字节的内存丢失。通过在log.txt
文件中搜索0x4844BFC
的地址值,我们最终发现在原始代码中没有free(p)
行被调用。取消这条线的注释后,我们重新进行valgrind
分析,因此泄漏问题现在不在报告中。
总之,借助静态和动态分析工具,程序的潜在缺陷可以自动大大减少。然而,为了确保软件的质量,人类必须参与最终的测试和评估。现在,我们将探讨软件工程中的单元测试、测试驱动开发和行为驱动开发概念。
在前一节中,我们学习了自动静态和动态程序分析。这一部分将集中在人参与的(准备测试代码)测试,这是动态分析的另一部分。这些是单元测试、测试驱动开发和行为驱动开发。
单元测试假设如果我们已经有了一个单一的代码单元,那么我们需要编写一个测试驱动程序并准备输入数据来检查它的输出是否正确。之后,我们执行集成测试来一起测试多个单元,然后是验收测试,测试整个应用。由于集成和验收测试比单元测试更难维护,更与项目相关,因此在本书中涵盖它们是非常具有挑战性的。感兴趣的可以去https://www.iso.org/standard/45142.html了解更多。
与单元测试相反,TDD 认为我们应该先有测试代码和数据,开发一些代码并使其快速通过,最后重构直到客户满意。另一方面,BDD 的理念是,我们不应该测试一个程序的实现,而应该测试它期望的行为。为此,BDD 强调,参与软件生产的人员之间也应该建立一个交流平台和语言。
我们将在下面的小节中详细讨论这些方法。
单元是更大或更复杂的应用中的单个组件。通常,一个单元有它自己的用户界面,如一个函数、一个类或整个模块。单元测试是一种软件测试方法,用于确定代码单元的行为是否符合设计要求。单元测试的主要特征如下:
- 它小而简单,编写和运行速度快,因此,它可以在早期开发周期中发现问题,因此可以轻松修复问题。
- 因为它是独立于依赖项的,所以每个测试用例都可以并行运行。
- 单元测试驱动程序帮助我们理解单元接口。
- 当被测试的单元稍后被集成时,它极大地帮助集成和验收测试。
- 它通常由开发人员准备和执行。
虽然我们可以从头开始编写单元测试包,但是社区中已经开发了很多单元测试框架 ( UTFs )。助推。Test、CppUnit、GoogleTest、Unit++、CxxTest 是最受欢迎的。这些 utf 通常提供以下功能:
- 他们只需要最少的工作来建立一个新的测试。
- 它们依赖于标准库并支持跨平台,这意味着它们易于移植和修改。
- 它们支持测试夹具,这允许我们为几个不同的测试重用相同的对象配置。
- 它们能很好地处理异常和崩溃。这意味着 UTF 可以报告异常,但不能报告崩溃。
- 它们有很好的断言功能。每当断言失败时,就应该打印它的源代码位置和变量值。
- 它们支持不同的输出,这些输出可以方便地由人类或其他工具进行分析。
- 它们支持测试套件,每个套件可能包含几个测试用例。
现在,让我们看一下 Boost UTF 的一个例子(从 1.59.0 版开始)。它支持三种不同的用法变体:单头变体、静态库变体和共享库变体。它包括四种类型的测试用例:无参数测试用例、数据驱动测试用例、模板测试用例和参数化测试用例。
它还有七种类型的检查工具:BOOST_TEST()
、BOOST_CHECK()
、BOOST_REQUIRE(
)、BOOST_ERROR()
、BOOST_FAIL()
、BOOST_CHECK_MESSAGE( )
和BOOST_CHECK_EQUAL()
。它支持夹具,并以多种方式控制测试输出。编写测试模块时,我们需要遵循以下步骤:
- 定义我们测试程序的名称。这将用于输出消息。
- 选择一种用法变体:仅标题、与静态链接或作为共享库。
- 选择一个测试用例并将其添加到测试套件中。
- 对测试过的代码进行正确性检查。
- 在每个测试用例之前初始化测试中的代码。
- 自定义报告测试失败的方式。
- 控制构建的测试模块的运行时行为,这也称为运行时配置。
例如,以下示例涵盖了步骤 1-4 。如果您感兴趣,可以在https://www . boost . org/doc/libs/1 _ 70 _ 0/libs/test/doc/html/index . html获得步骤 5-7 的示例:
//ch13_unit_test1.cpp
#define BOOST_TEST_MODULE my_test //item 1, "my_test" is module name
#include <boost/test/included/unit_test.hpp> //item 2, header-only
//declare we begin a test suite and name it "my_suite "
BOOST_AUTO_TEST_SUITE( my_suite )
//item 3, add a test case into test suit, here we choose
// BOOST_AUTO_TEST_CASE and name it "test_case1"
BOOST_AUTO_TEST_CASE(test_case1) {
char x = 'a';
BOOST_TEST(x); //item 4, checks if c is non-zero
BOOST_TEST(x == 'a'); //item 4, checks if c has value 'a'
BOOST_TEST(x == 'b'); //item 4, checks if c has value 'b'
}
//item 3, add the 2nd test case
BOOST_AUTO_TEST_CASE( test_case2 )
{
BOOST_TEST( true );
}
//item 3, add the 3rd test case
BOOST_AUTO_TEST_CASE( test_case3 )
{
BOOST_TEST( false );
}
BOOST_AUTO_TEST_SUITE_END() //declare we end test suite
为此,我们可能需要安装 boost,如下所示:
sudo apt-get install libboost-all-dev
然后,我们可以构建和运行它,如下所示:
~/wus1/Chapter-13$ g++ -g ch13_unit_test1.cpp
~/wus1/Chapter-13$ ./a.out
前面的代码产生以下输出:
Running 3 test cases...
ch13_unit_test1.cpp(13): error: in "my_suite/test_case1": check x == 'b' has failed ['a' != 'b']
ch13_unit_test1.cpp(25): error: in "my_suite/test_case3": check false has failed
*** 2 failures are detected in the test module "my_test"
这里我们可以看到test_case1
和test_case3
有故障。特别是在test_case1
中,x
的值不等于b
,显然虚开的支票无法通过test_case3
中的测试。
如下图所示,TDD 过程从编写失败的测试代码开始,然后添加/修改代码以让测试通过。之后,我们重构测试计划和代码,直到满足所有需求[16,17]。让我们看一下下图:
第一步是写一个失败的测试。TDD 不是先开发代码,而是先开始编写测试代码。因为我们还没有代码,我们知道,如果我们运行测试,它将失败。在这个阶段,定义了测试数据格式和接口,并设想了代码实现细节。
第 2 步的目标是用最少的开发工作让测试尽快通过。我们不想完美地实现所有事情;我们只想让它通过测试。一旦它变成绿色,我们将有一些东西展示给客户,并告诉客户,此时客户可能会在看到初始产品后细化需求。然后,我们进入下一阶段。
第三个阶段是重构。在这个阶段,我们可能会进去,看看,看看我们想改变什么,以及如何改变。
对于传统开发人员来说,TDD 最难的是从编码->测试模式到测试->编码模式的思维转变。为了对测试套件有一个模糊的概念,J. Hartikainen 建议开发人员考虑以下五个步骤[18]来开始:
- 首先决定输入和输出。
- 选择类/函数签名。
- 只决定测试功能的一个微小方面。
- 实施测试。
- 实现代码。
一旦我们完成了这个迭代,我们就可以逐渐重构它,直到实现整体的综合目标。
接下来,我们将通过一个案例研究的实施来演示 TDD 过程。在本研究中,我们将开发一个 Mat 类来执行 2D 矩阵代数,就像我们在 Matlab 中所做的那样。这是一个类模板,可以保存所有数据类型的 m 乘 n 矩阵。矩阵代数包括加、减、乘、除矩阵,还具有元素运算能力。
我们开始吧。
首先,我们只需要以下内容:
- 根据给定的行数和列数创建一个
Mat
对象(默认值应该是 0 乘 0,这是一个空矩阵)。 - 逐行打印其元素。
- 从
rows()
和cols()
得到矩阵大小。
基于这些要求,我们可以使用失败的单元测试代码来提升 UTF,如下所示:
// ch13_tdd_boost_UTF1.cpp
#define BOOST_TEST_MODULE tdd_test
#include <boost/test/included/unit_test.hpp>
#include "ch13_tdd_v1.h"
BOOST_AUTO_TEST_SUITE(tdd_suite) //begin a test suite: "tdd_suite"
BOOST_AUTO_TEST_CASE(test_case1) {
Mat<int> x(2, 3); //create a 2 x 3 int matrix
x.print("int x=");
BOOST_TEST(2 == x.rows());
BOOST_TEST(3 == x.cols());
Mat<float> y; //create a 0 x 0 empty float matrix
y.print("float y=");
BOOST_TEST(0 == y.rows());
BOOST_TEST(0 == y.cols());
Mat<char> z(1,10); //create a 1 x 10 char matrix
z.print("char z=");
BOOST_TEST(1 == z.rows());
BOOST_TEST(10 == z.cols());
}
BOOST_AUTO_TEST_SUITE_END() //end test suite
既然我们的测试代码已经准备好了,我们就可以开发代码了。
实现最小代码段的一种方法是通过前面的测试,如下所示:
//file: ch13_tdd_v1.h
#ifndef __ch13_TDD_V1__
#define __ch13_TDD_V1__
#include <iostream>
#include <assert.h>
template< class T>
class Mat {
public:
Mat(const uint32_t m=0, const uint32_t n=0);
Mat(const Mat<T> &rhs) = delete;
~Mat();
Mat<T>& operator = (const Mat<T> &x) = delete;
uint32_t rows() { return m_rows; }
uint32_t cols() { return m_cols; }
void print(const char* str) const;
private:
void creatBuf();
void deleteBuf();
uint32_t m_rows; //# of rows
uint32_t m_cols; //# of cols
T* m_buf;
};
#include "ch13_tdd_v1.cpp"
#endif
一旦我们有了前面的头文件,我们就可以开发它对应的cpp
文件,如下所示:
//file: ch13_tdd_v1.cpp
#include "ch13_tdd_v1.h"
using namespace std;
template< class T>
Mat<T>::Mat(const uint32_t m, const uint32_t n)
: m_rows(m)
, m_cols(n)
, m_buf(NULL)
{
creatBuf();
}
template< class T>
Mat<T> :: ~Mat()
{
deleteBuf();
}
template< class T>
void Mat<T>::creatBuf()
{
uint32_t sz = m_rows * m_cols;
if (sz > 0) {
if (m_buf) { deleteBuf();}
m_buf = new T[sz];
assert(m_buf);
}
else {
m_buf = NULL;
}
}
template< class T>
void Mat<T>::deleteBuf()
{
if (m_buf) {
delete[] m_buf;
m_buf = NULL;
}
}
template< class T>
void Mat<T> ::print(const char* str) const
{
cout << str << endl;
cout << m_rows << " x " << m_cols << "[" << endl;
const T *p = m_buf;
for (uint32_t i = 0; i<m_rows; i++) {
for (uint32_t j = 0; j < m_cols; j++) {
cout << *p++ << ", ";
}
cout << "\n";
}
cout << "]\n";
}
假设我们使用 g++ 构建并执行它,它支持-std=c++ 11
或更高版本:
~/wus1/Chapter-13$ g++ -g ch13_tdd_boost_UTF1.cpp~/wus1/Chapter-13$ a.out
这将导致以下输出:
Running 1 test case...
int x=2 x 3[
1060438054, 1, 4348032,
0, 4582960, 0,
]
float y=0 x 0[
]
char z=1 x 10[
s,s,s,s,s,s,s,s,s,s,
]
在test_case1
中,我们创建了三个矩阵并测试了rows()
、cols()
和print()
功能。第一个是 2x3 int
型矩阵。由于没有初始化,其元素的值是不可预测的,这就是为什么我们可以从print()
看到这些随机数。我们在这一点上也通过了rows()
和cols()
测试(两个BOOST_TEST() calls
没有错误)。第二种是空浮点型矩阵;它的print()
功能什么都不给,它的cols()
和rows()
都是零。最后,第三个是 1x10 char
类型的未初始化矩阵。同样,这三个函数的所有输出都是预期的。
目前为止,一切顺利——我们通过了测试!但是,在向我们的客户展示了前面的结果之后,他/她可能会要求我们再添加两个接口,如下所示:
- 为所有元素创建一个具有给定初始值的 m×n 矩阵。
- 相加
numel()
返回矩阵的元素总数。 - 添加
empty()
,如果矩阵为零行或零列,则返回真,否则返回假。
一旦我们将第二个测试用例添加到我们的测试套件中,整个重构的测试代码将如下所示:
// ch13_tdd_Boost_UTF2.cpp
#define BOOST_TEST_MODULE tdd_test
#include <boost/test/included/unit_test.hpp>
#include "ch13_tdd_v2.h"
//declare we begin a test suite and name it "tdd_suite"
BOOST_AUTO_TEST_SUITE(tdd_suite)
//add the 1st test case
BOOST_AUTO_TEST_CASE(test_case1) {
Mat<int> x(2, 3);
x.print("int x=");
BOOST_TEST(2 == x.rows());
BOOST_TEST(3 == x.cols());
Mat<float> y;
BOOST_TEST(0 == y.rows());
BOOST_TEST(0 == y.cols());
Mat<char> z(1, 10);
BOOST_TEST(1 == z.rows());
BOOST_TEST(10 == z.cols());
}
//add the 2nd test case
BOOST_AUTO_TEST_CASE(test_case2)
{
Mat<int> x(2, 3, 10);
x.print("int x=");
BOOST_TEST( 6 == x.numel() );
BOOST_TEST( false == x.empty() );
Mat<float> y;
BOOST_TEST( 0 == y.numel() );
BOOST_TEST( x.empty() ); //bug x --> y
}
BOOST_AUTO_TEST_SUITE_END() //declare we end test suite
下一步是修改代码以通过这个新的测试计划。为简洁起见,这里不打印ch13_tdd_v2.h
和ch13_tdd_v2.cpp
文件。你可以从这本书的 GitHub 资源库下载它们。构建并执行ch13_tdd_Boost_UTF2.cpp
后,我们得到如下输出:
Running 2 test cases...
int x=2x3[
1057685542, 1, 1005696,
0, 1240624, 0,
]
int x=2x3[
10, 10, 10,
10, 10, 10,
]
../Chapter-13/ch13_tdd_Boost_UTF2.cpp(34): error: in "tdd_suite/test_case2": che
ck x.empty() has failed [(bool)0 is false]
在第一个输出中,由于我们只是定义了一个 2x3 整数矩阵,并没有在test_case1
中初始化它,所以未定义的行为——即六个随机数——被打印出来。第二个输出来自test_case2
,这里x
的所有六个元素都被初始化为10
。在我们展示并讲述了前面的结果之后,我们的客户可能会要求我们添加其他新功能或修改现有功能。但是,经过几次迭代,最终,我们将到达快乐点并停止分解。
既然我们已经了解了 TDD,我们将讨论 BDD。
软件开发最困难的部分是与业务参与者、开发人员和质量分析团队沟通。由于误解或模糊的需求、技术争论和缓慢的反馈周期,项目很容易超出预算、错过最后期限或完全失败。
(BDD) [20]是一个敏捷开发过程,有一套旨在减少沟通差距/障碍和其他浪费活动的实践。它还鼓励团队成员在生产生命周期中不断与现实世界的例子交流。
BDD 包含两个主要部分:刻意发现和 TDD。为了让不同组织和团队的人理解所开发软件的正确行为,刻意发现阶段引入了示例映射技术,通过具体的示例让不同角色的人进行对话。这些例子将成为自动化测试和系统行为的活文档。在其 TDD 阶段,BDD 指定任何软件单元的测试都应该根据单元的期望行为来指定。
针对不同的平台和编程语言,有几种 BDD 框架工具(JBehave、RBehave、Fitnesse、黄瓜[21],等等)。一般来说,这些框架执行以下步骤:
- 阅读由业务分析师在审慎发现阶段准备的规范格式文档。
- 将文档转换成有意义的子句。每个单独的子句都能够被设置为质量保证的测试用例。开发人员也可以从子句中实现源代码。
- 为每个子句场景自动执行测试。
总之,我们已经了解了关于应用开发管道中应该包括什么、何时、如何以及测试过程的策略。如下图所示,传统的 V 形[2]模型强调需求->设计->编码->测试的模式。TDD 认为开发过程应该由测试驱动,而 BDD 将来自不同背景和角色的人之间的交流添加到 TDD 框架中,并专注于行为测试:
此外,单元测试强调在编码完成时测试单个组件。TDD 更侧重于如何在编写代码之前编写测试,然后通过下一级测试计划添加/修改代码。BDD 鼓励客户、业务分析师、开发人员和质量保证分析师之间的合作。虽然我们可以单独使用每一个,但在这个敏捷软件开发时代,我们真的应该将它们结合起来以获得最佳结果。
在本章中,我们简要介绍了软件开发过程中与测试和调试相关的主题。测试发现问题,根本原因分析有助于在宏观层面上定位问题。然而,良好的编程实践可以在早期阶段防止软件缺陷。此外,被称为 GDB 的命令行界面调试工具可以帮助我们设置断点并逐行执行程序,同时在程序运行时打印变量值。
我们还讨论了自动分析工具和人工参与的测试过程。静态分析在不执行程序的情况下评估程序的性能。另一方面,动态分析工具可以通过执行程序来发现缺陷。最后,我们了解了软件开发管道中应该包含什么、什么时候以及如何包含测试过程的策略。单元测试强调在编码完成时测试单个组件。TDD 更关注如何在开发代码之前编写测试,然后通过下一级测试计划来重申这个过程。BDD 鼓励客户、业务分析师、开发人员和质量保证分析师之间的合作。
在下一章中,我们将学习如何使用 Qt 为运行在 Linux、Windows、iOS 和 Android 系统上的跨平台应用创建图形用户界面 ( GUI )程序。首先,我们将深入研究跨平台图形用户界面编程的基本概念。然后我们将介绍 Qt 及其小部件的概述。最后,通过一个案例,我们将学习如何使用 Qt 设计和实现一个网络应用。
-
J.鲁尼和范登·赫维尔, *初学者根本原因分析*质量进步,2004 年 7 月,第 45-53 页。
-
T.软件问题根源分析方法。修订版,第 73 号,第 81 页,2011 年。
-
K.A. Briski 等 *最小化代码缺陷提高软件质量降低开发成本*IBM Rational Software Analyzer 和 IBM Rational PurifyPlus 软件。
-
https://www . learncpp . com/CPP-programming/八字-c-programming-errors-the-编译器-不会-catch 。
-
B.斯特鲁特普和 h .萨特, C++ 核心指南:https://isocpp.github.io/CppCoreGuidelines。
-
https://www . fayewilliams . com/2014/02/21/debug-for-初学者/ 。
-
https://www.perforce.com/blog/qac/what-static-code-analysis。
-
https://www . embedded . com/static-vs-dynamic-analysis-for-secure-code-development-part-2/。
-
国际标准化组织/国际电工委员会/国际电工委员会 29119-1:2013 软件和系统工程–软件测试 。
-
http://games from inside . com/exploring-the-c-unit-testing-framework-丛林。
-
https://www . boost . org/doc/libs/1 _ 70 _ 0/libs/test/doc/html/index . html。
-
K.Beck, *通过实例进行测试驱动开发,*由 Addison Wesley 出版,ISBN 978-0321146533。
-
H.关于测试优先编程方法的有效性 ,Proc。美国电气和电子工程师协会传输。关于软件工程,31(1)。2005 年 1 月。
-
https://codeutopia . net/blog/2015/03/01/unit-testing-TDD-and-BDD。
-
D.北,介绍 BDD,https://dannorth.net/introducing-bdd/(2006 年 3 月)。
-
D.北,东基奥等。艾尔,“jbehave.org/team-list”,2019 年 5 月。
除此之外,您还可以看看以下来源(这些在本章中没有直接提及):
- B.Stroustrup 和 H. Sutter, C++ 核心指南 : 。
- G.加速。测试:https://www . boost . org/doc/libs/1 _ 70 _ 0/libs/test/doc/html/index . html
- D.北,引入 BDD:https://dannorth.net/introducing-bdd/
- 使用
gdb
功能断点、条件断点和watchpoint
、continue
和finish
命令,调试ch13_gdb_2.cpp
。 - 使用
g++ -c -Wall -Weffc++ -Wextra x.cpp -o x.out
构建cpp files ch13_rca*.cpp
。从他们的警告输出中你看到了什么? - 为什么静态分析会产生假警报,而动态分析不会?
- 下载
ch13_tdd_v2.h/.cpp
并执行下一阶段重构。在这个阶段,我们将添加复制构造函数、赋值操作符和元素操作符,如+
、-
、*
、/
等。更具体地说,我们需要做以下事情:- 将第三个测试用例添加到我们的测试套件中,即
ch13_tdd_Boost_UTF2.cpp
。 - 将这些函数的实现添加到文件中;例如
ch13_tdd_v2.h/.cpp
。 - 运行测试套件来测试它们。********
- 将第三个测试用例添加到我们的测试套件中,即