mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-10-21 01:13:45 +08:00
mod
This commit is contained in:
208
极客时间专栏/人人都能学会的编程入门课/综合项目篇/26 | 牛刀小试(上):实现测试框架前的基础准备.md
Normal file
208
极客时间专栏/人人都能学会的编程入门课/综合项目篇/26 | 牛刀小试(上):实现测试框架前的基础准备.md
Normal file
@@ -0,0 +1,208 @@
|
||||
<audio id="audio" title="26 | 牛刀小试(上):实现测试框架前的基础准备" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/89/c5/89f76ce0f62371a258b4252941de72c5.mp3"></audio>
|
||||
|
||||
你好,我是胡光。经历了千难万险,我们终于来到了这次编程探险旅程的最后一个阶段“综合项目篇”。
|
||||
|
||||
还记得开篇的时候,我跟你讲过的,要带你看看这片编程森林中最有趣的地方,不知道你感受到这份乐趣了吗?接下来的最后几节课,是我为你精心准备的,作为你编程入门学习之行的完成礼物,同时,也是对整个专栏学习内容的一个总结升华。
|
||||
|
||||
对于本章的学习,你需要综合运用“语言基础篇”“编码能力训练篇”以及“算法数据结构篇”中所学知识,完成两个小项目,一个是“个人测试框架开发”,另一个是“自制简易计算器”。我希望通过这两个项目,打通你编程学习的“任督二脉”,让你在后续的其他编程知识学习中,可以游刃有余,乐在其中。
|
||||
|
||||
## 软件开发流程
|
||||
|
||||
在开始做项目之前呢,让我们先来了解下一般的项目开发流程。
|
||||
|
||||
拿传统的软件工程开发流程来说,一个软件的项目开发流程,包括:需求分析、概要设计、详细设计、编码、测试、软件交付、验收和维护。虽然包括了8个阶段,可总的来说,你可以把它总结为软件开发的前中后三个部分。
|
||||
|
||||
软件开发的前期,由产品经理、项目经理跟进需求,做需求分析,然后是概要设计,出一份系统的详细设计。这是项目的头部阶段,主要是从理论上验证需求可行,并且最终产出一份切实可行的软件系统的详细设计。就像我在咱们每节课中,给你留思考题,此时我的角色就是项目经理和产品经理,我首先需要分析给你留的思考题是否可做,以及跟你说明白,大致怎么做。
|
||||
|
||||
软件开发的中期,是由技术人员负责,主要是根据系统的详细设计,进行编码和测试,把图纸上的系统实现出来。你也可以认为这是一个施工的过程。这个过程,类比到咱们的思考题上,就是你根据我的提示,具体完成每一道思考题的程序,并且对完成的程序做简单测试,以保证程序的正确性。
|
||||
|
||||
软件开发的后期,由项目经理带领技术支持人员做软件交付、验收及后期维护的相关工作。这一部分,你可以理解为,是你交作业的过程,客户就是老师,你的作业要是不合格,老师就会给你退回去并提出修改建议。
|
||||
|
||||
从这个过程中,你可以看到作为技术人员,与我们相关的,就是软件开发的中期阶段,也就是编码和测试。
|
||||
|
||||
在之前的学习中,我们把学习任务主要放在了编码阶段,一直没有提测试阶段的事情,下面我们就来说说测试阶段主要做的事情。
|
||||
|
||||
## 黑盒测试与白盒测试
|
||||
|
||||
关于测试阶段的测试方法,可以大致分为五种:白盒测试,黑盒测试,灰盒测试,静态测试与动态测试。
|
||||
|
||||
看着这些测试方法的名称,你可能有点儿懵,其实你完全没有必要掌握全部的测试方法,只需要了解其中的黑盒测试和白盒测试就行,知道了这两种测试方法,就可以满足你对项目开发流程的概念认知。
|
||||
|
||||
我们先来说说白盒测试与黑盒测试都是什么意思。首先名字里面的“盒”,其实指的就是项目中的系统,你可以理解成为是我们写的程序,它也是被测试的对象。至于这个白与黑呢,意思就是在测试过程中,是否关注代码实现逻辑。
|
||||
|
||||
白盒测试,就是关注代码实现逻辑,从而产生的测试行为。这种行为就像你把一个盒子打开,仔细检查其内部有无错误一样。
|
||||
|
||||
而不关注代码实现逻辑,而产生的测试行为,就是黑盒测试。在这个测试过程中,我们只关注系统的输出是否满足我们的要求。
|
||||
|
||||
下面我来举几个具体的例子,你来分析一下哪一种是白盒测试,哪一种是黑盒测试。
|
||||
|
||||
场景一:你写完了一个程序,运行以后输入数据,测试程序的输出结果是否符合预期。
|
||||
|
||||
场景二:当你发现,程序的结果不符合预期的时候,开始检查你程序的代码逻辑,并且针对于每一个函数功能做测试。
|
||||
|
||||
在场景一中,虽然程序是你写的,可你在做测试的时候,并没有关心程序内部的代码逻辑,而是关注整体程序的功能是否符合预期,所以这个属于黑盒测试。
|
||||
|
||||
而在场景二中,你在测试程序的时候,关注到了程序内部的代码逻辑是否正确,并且针对代码中的函数,开始做针对性测试。由于这个测试过程关注到了代码本身的逻辑,而不单单是程序功能本身,所以,这属于白盒测试。
|
||||
|
||||
通过这两个日常写程序的场景,你会发现,其实测试行为对于我们来说并不陌生,测试就是为了保证程序功能的正确性的,而所谓的黑盒测试或者白盒测试,并没有优劣之分。在实际工作中,我们也会经常综合运用这两种测试,来查找程序中存在的潜在问题。
|
||||
|
||||
总的来说,想要保证程序的正确性,必然会涉及到运用相关的测试方法。所以请你记住,测试并不是测试人员的专属,很多时候也是开发人员需要掌握的技能。
|
||||
|
||||
对于开发人员来说,最基本的测试方法,就是针对自己程序中的每一个功能模块,编写对应的单元测试,而单元测试中的单元,是程序中最小的测试单位。例如 C 语言中,一个单元就是一个函数,C++中的一个单元,就是一个类。
|
||||
|
||||
所以,我们说到 C 语言的单元测试,指的是对每一个函数,编写一段测试程序。而可以辅助开发人员编写这些单元测试的程序,我们叫做“单元测试框架”,也可以简称为“测试框架”。
|
||||
|
||||
## 初识:Google 测试框架
|
||||
|
||||
好了,关于软件开发流程的基本情况呢,我讲完了,下面我们正式说说测试框架。
|
||||
|
||||
咱们要完成的第一个项目,就是编写一个简易的 C 语言测试框架。不过在写这个C语言测试框架之前,咱们先看看之前的大厂或大牛们是怎么做的,这样,我们在写自己的测试框架的时候,也能有所借鉴。
|
||||
|
||||
今天,我要带你认识的是一个由 Google 开发的单元测试框架 Google Test,我们一般称它为 gtest。
|
||||
|
||||
关于如何安装 Google 测试框架呢,由于大家的编程环境不同,所以,你可以按照网上其他资料中给出的安装教程,结合自己的编程环境,准备好 gtest 的相关环境。
|
||||
|
||||
gtest 是一个 C++ 的单元测试框架,如果你不方便准备 gtest 相关环境,也不碍事儿,你可以看我下面的讲解。我的环境中,已经安装好了 gtest 的相关模块,为了简单说明 gtest 的使用与运行效果,给你准备了如下代码:
|
||||
|
||||
```
|
||||
#include <stdio.h>
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
// 判断一个数字 x 是否是素数
|
||||
int is_prime(int x) {
|
||||
for (int i = 2; i * i < x; i++) {
|
||||
if (x % i == 0) return 0;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
// 第一个测试用例
|
||||
TEST(test1, test_is_prime) {
|
||||
EXPECT_EQ(is_prime(3), 1);
|
||||
EXPECT_EQ(is_prime(5), 1);
|
||||
EXPECT_EQ(is_prime(7), 1);
|
||||
}
|
||||
|
||||
// 第二个测试用例
|
||||
TEST(test2, test_is_prime) {
|
||||
EXPECT_EQ(is_prime(4), 0);
|
||||
EXPECT_EQ(is_prime(0), 0);
|
||||
EXPECT_EQ(is_prime(1), 0);
|
||||
}
|
||||
|
||||
int main() {
|
||||
return RUN_ALL_TESTS();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
就是一份简单的 gtest 的使用代码。代码中,包含了 gtest/gtest.h 头文件以后,程序就具备了一些魔法效果,下面让我来给你具体讲一讲。
|
||||
|
||||
从程序的结构上来说,先是一个 is_prime 函数,接下来是两段以 TEST 作为开头的代码(这两段代码的作用,咱们稍后做介绍),最后是一个主函数。主函数中原本 return 0 的位置,替换成了 return RUN_ALL_TESTS(),也就是说,主函数中只执行了一个 RUN_ALL_TESTS 函数,而这个 RUN_ALL_TESTS 函数有什么作用呢?咱们来看一下程序的具体输出:
|
||||
|
||||
```
|
||||
[==========] Running 2 tests from 1 test suite.
|
||||
[----------] Global test environment set-up.
|
||||
[----------] 2 tests from test_is_prime
|
||||
[ RUN ] test_is_prime.test1
|
||||
[ OK ] test_is_prime.test1 (1 ms)
|
||||
[ RUN ] test_is_prime.test2
|
||||
gtest_test.cpp:25: Failure
|
||||
Expected equality of these values:
|
||||
is_prime(4)
|
||||
Which is: 1
|
||||
0
|
||||
gtest_test.cpp:26: Failure
|
||||
Expected equality of these values:
|
||||
is_prime(0)
|
||||
Which is: 1
|
||||
0
|
||||
gtest_test.cpp:27: Failure
|
||||
Expected equality of these values:
|
||||
is_prime(1)
|
||||
Which is: 1
|
||||
0
|
||||
[ FAILED ] test_is_prime.test2 (0 ms)
|
||||
[----------] 2 tests from test_is_prime (1 ms total)
|
||||
|
||||
[----------] Global test environment tear-down
|
||||
[==========] 2 tests from 1 test suite ran. (1 ms total)
|
||||
[ PASSED ] 1 test.
|
||||
[ FAILED ] 1 test, listed below:
|
||||
[ FAILED ] test_is_prime.test2
|
||||
|
||||
1 FAILED TEST
|
||||
|
||||
```
|
||||
|
||||
由于咱们的环境有所不同,所以你环境中的输出内容,可能和我这个输出结果略有差别,不过,这不影响我们接下来的讨论。
|
||||
|
||||
先看输出内容的第 4 行和第 6 行,意思是说,执行测试用例 test_is_prime.test1 和 test_is_prime.test2,这不就是上面两个以 TEST 开头的两段代码相关的输出内容么?
|
||||
|
||||
接下来从第 7 行到第 21 行是一段报错信息,意思就是说 is_prime(4),is_prime(0) 与 is_prime(1) 函数返回值错误,也就意味着 is_prime 函数实现有错误,这段错误所涉及的信息,在源代码中的第二个测试用例中有涉及。
|
||||
|
||||
以上就是我们对 gtest 单元测试框架的一个感性认识,从这些感性认知,我们逐渐走向理性层面,逐步展开属于我们自己的思考。
|
||||
|
||||
## 对于 gtest 的三个思考
|
||||
|
||||
面对刚才的演示代码和输出内容,你可能会产生如下三个问题:
|
||||
|
||||
1. 源代码中的 EXPECT_EQ 是做什么的?
|
||||
1. 以 TEST 开头的代码段,和我们学习的函数很不一样,那它究竟是什么?
|
||||
1. 主函数中只调用了 RUN_ALL_TESTS 函数,为什么好像是执行了程序中所有的 TEST 代码段?这个功能是怎么实现的?
|
||||
|
||||
第一个问题不难,查看相关 gtest 的文档资料,你就可以知道,EXPECT_EQ 是 gtest 里面自带的宏,主要作用是判断传入的两部分的值是否相等。如果不相等,就会产生类似于输出内容中第 7 行到第 21 行的输出内容。
|
||||
|
||||
第二个问题,以 TEST 开头的这段代码,明显不符合我们对 C 语言的语法认知,我们确实没有见过不用规定返回值类型,也不用规定参数类型的函数定义方式。关于 TEST 究竟是个什么的问题,更加合理的猜测,就是 TEST 实际上是一个宏。
|
||||
|
||||
我们来回顾一下宏的作用,宏就是做简单的替换。正是因为TEST(test_is_prime, test1) 这段代码实际上是一个宏,所以展开以后,和后面的大括号中的内容一起组成了一段合法的代码内容,这样理解,原本的代码内容也就解释得通了。
|
||||
|
||||
而 TEST 宏展开以后会被替换成什么内容呢?关于这个问题,我留下充足的时间请你去思考,同时,包括第三个问题的答案,都留作今天的思考题。我希望你认真想一想,并将你的思考结果写在留言区,我们一起讨论。
|
||||
|
||||
下面这段代码,就是作为我们后续的项目测试代码,对于这份源代码,我们不会对它做任何的改动,所以我建议你把代码内容保存下来,以备咱们后面课程练习使用。
|
||||
|
||||
```
|
||||
#include <stdio.h>
|
||||
#include "geek_test.h" // 替换掉原 gtest/gtest.h 头文件
|
||||
|
||||
// 判断一个数字 x 是否是素数
|
||||
int is_prime(int x) {
|
||||
for (int i = 2; i * i < x; i++) {
|
||||
if (x % i == 0) return 0;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
// 第一个测试用例
|
||||
TEST(test1, test_is_prime) {
|
||||
EXPECT_EQ(is_prime(3), 1);
|
||||
EXPECT_EQ(is_prime(5), 1);
|
||||
EXPECT_EQ(is_prime(7), 1);
|
||||
}
|
||||
|
||||
// 第二个测试用例
|
||||
TEST(test2, test_is_prime) {
|
||||
EXPECT_EQ(is_prime(4), 0);
|
||||
EXPECT_EQ(is_prime(0), 0);
|
||||
EXPECT_EQ(is_prime(1), 0);
|
||||
}
|
||||
|
||||
int main() {
|
||||
return RUN_ALL_TESTS();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们后续的目标,就是开发一个自己的测试框架,替换掉上述代码中的 gtest/gtest.h 头文件,使得程序具有和之前类似的功能和输出内容。整个过程,我们不会改动上述代码中的内容,这一点,请你一定要牢记。
|
||||
|
||||
从这一课开始,我希望你能脱离我的指导和提示,可以独立完成咱们每节课的任务,这也是为了检验你这段时间以来的学习成果。当然,如果你实在想不出答案,也可以回来看看我的做法,以供参考。
|
||||
|
||||
## 课程小结
|
||||
|
||||
最后呢,我来给你做一下今天的课程小结:
|
||||
|
||||
1. 测试行为,不是测试工程师的专属,你应该把它作为一个开发工程师的习惯。
|
||||
1. 单元测试属于白盒测试范畴,Google 的 gtest 就是一种辅助我们编写单元测试的框架。
|
||||
1. gtest 中的 TEST 本质上是一个宏,而这个宏应该展开成怎样的代码内容,还需要你认真思考,这个思考过程对你来说是很有价值的。
|
||||
|
||||
好了,今天就到这里了,我是胡光,我们下期见。
|
262
极客时间专栏/人人都能学会的编程入门课/综合项目篇/27 | 牛刀小试(下):实现一个自己的测试框架.md
Normal file
262
极客时间专栏/人人都能学会的编程入门课/综合项目篇/27 | 牛刀小试(下):实现一个自己的测试框架.md
Normal file
@@ -0,0 +1,262 @@
|
||||
<audio id="audio" title="27 | 牛刀小试(下):实现一个自己的测试框架" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/32/d4/32c5b35ec37018515fb39da02689cfd4.mp3"></audio>
|
||||
|
||||
你好,我是胡光,欢迎回来,今天呢,我们继续学习测试框架的相关内容。
|
||||
|
||||
上节课中,我们讲到了软件开发一般分为前中后三个部分,提到作为技术人员的我们,一般主要负责在软件开发中期的编码与测试阶段。还有,我还讲到我们一般会综合运用白盒测试与黑盒测试这两种方法来进行程序测试。
|
||||
|
||||
更主要的是,我们还介绍了 Google 的单元测试框架 gtest,并对测试框架代码进行了一番解读。其中提到代码中的 TEST 是一个宏,那它展开后被替换的内容是什么呢?还有, RUN_ALL_TESTS 函数是如何依次执行程序中所有的测试用例函数的?
|
||||
|
||||
今天呢,我们就一个一个地来解决这些问题,并最终实现一个咱们自己的测试框架。
|
||||
|
||||
## 初步实现TEST 宏
|
||||
|
||||
今天我们实现的所有代码呢,都会写在一个名字为 geek_test.h 的头文件中。当然我们也知道,将声明和定义写在一起,在大型工程中是会出现严重的编译错误,在实际的工程开发中,我们并不会这么做。
|
||||
|
||||
今天把声明和定义写在一起,只是为了课程内容的讲解需要,而你也完全没有必要担心,这不会影响你对主要内容的学习。
|
||||
|
||||
我们先回到上节课中的源代码:
|
||||
|
||||
```
|
||||
#include <stdio.h>
|
||||
#include "geek_test.h" // 替换掉原 gtest/gtest.h 头文件
|
||||
|
||||
// 判断一个数字 x 是否是素数
|
||||
int is_prime(int x) {
|
||||
for (int i = 2; i * i < x; i++) {
|
||||
if (x % i == 0) return 0;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
// 第一个测试用例
|
||||
TEST(test1, test_is_prime) {
|
||||
EXPECT_EQ(is_prime(3), 1);
|
||||
EXPECT_EQ(is_prime(5), 1);
|
||||
EXPECT_EQ(is_prime(7), 1);
|
||||
}
|
||||
|
||||
// 第二个测试用例
|
||||
TEST(test2, test_is_prime) {
|
||||
EXPECT_EQ(is_prime(4), 0);
|
||||
EXPECT_EQ(is_prime(0), 0);
|
||||
EXPECT_EQ(is_prime(1), 0);
|
||||
}
|
||||
|
||||
int main() {
|
||||
return RUN_ALL_TESTS();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们的目的,是在不改变这份源代码的前提下,通过在 geek_test.h 中添加一些源码,使得这份代码的运行效果,能够类似于 gtest 的运行效果。
|
||||
|
||||
想要完成这个目标,我们就要先来思考 TEST 宏这里的内容,请你仔细观察这段由 TEST 宏定义的测试用例的相关代码:
|
||||
|
||||
```
|
||||
TEST(test1, test_is_prime) {
|
||||
EXPECT_EQ(is_prime(3), 1);
|
||||
EXPECT_EQ(is_prime(5), 1);
|
||||
EXPECT_EQ(is_prime(7), 1);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
TEST(test1, test_is_prime) 这部分应该是在调用 TEST 宏,而这部分被预处理器展开以后的内容,只有和后面大括号里的代码组合在一起,才是一段合法的 C语言代码,也只有这样,这份代码才能通过编译。
|
||||
|
||||
既然如此,我们就不难想到,TEST 宏展开以后,它应该是一个函数定义的头部,后面大括号里的代码,就是这个展开以后的函数头部的函数体部分,这样一切就都说得通了。
|
||||
|
||||
在实现 TEST 宏之前,我们还需要想清楚一个问题:由于程序中可以定义多个 TEST 测试用例,如果每一个 TEST 宏展开都是一个函数头部的话,那这个展开的函数的名字是什么呢?如果每一个 TEST 宏展开的函数名字都一样,那程序一定无法通过编译,编译器会报与函数名重复相关的错误,所以, TEST 宏是如何确定展开函数的名字呢?
|
||||
|
||||
不知道你有没有注意到,TEST 宏需要传入两个参数,这两个参数在输出信息中与测试用例的名字有关。那我们就该想到,可以使用这两个参数拼接出一个函数名,只要 TEST 传入的这两个参数不一样,那扩展出来的函数名就不同。最后,我们就可以初步得到如下的 TEST 宏的一个实现:
|
||||
|
||||
```
|
||||
#define TEST(test_name, func_name) \
|
||||
void test_name##_##func_name()
|
||||
|
||||
```
|
||||
|
||||
如代码所示的 TEST 宏实现,我们将 TEST 宏的两个参数内容使用## 连接在一起,中间用一个额外的下划线连接,组成一个函数名字,这个函数的返回值类型是 void,无传入参数。
|
||||
|
||||
根据这个实现,预处理器会将源代码中两处 TEST 宏的内容,替换成如下代码所示内容:
|
||||
|
||||
```
|
||||
void test1_test_is_prime() {
|
||||
EXPECT_EQ(is_prime(3), 1);
|
||||
EXPECT_EQ(is_prime(5), 1);
|
||||
EXPECT_EQ(is_prime(7), 1);
|
||||
}
|
||||
|
||||
void test2_test_is_prime() {
|
||||
EXPECT_EQ(is_prime(4), 0);
|
||||
EXPECT_EQ(is_prime(0), 0);
|
||||
EXPECT_EQ(is_prime(1), 0);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这样,也就把原来看似不合理的 TEST 宏,转换成了合法的 C 语言代码了。
|
||||
|
||||
## `__attribute__`:让其它函数先于主函数执行
|
||||
|
||||
在继续讲测试框架的设计之前,我们来补充一个知识点。
|
||||
|
||||
之前,我们所学习到的程序执行过程,既是从主函数开始,也是从主函数结束。也就是说,在常规的程序执行过程中,其它函数都是在主函数执行之后,才被直接或者间接调用执行。接下来,我就要给你讲一种能够让函数在主函数执行之前就执行的编程技巧。
|
||||
|
||||
首先,我们先来看如下代码:
|
||||
|
||||
```
|
||||
#include <stdio.h>
|
||||
|
||||
void pre_output() {
|
||||
printf("hello geek!\n");
|
||||
return ;
|
||||
}
|
||||
|
||||
int main() {
|
||||
printf("hello main!");
|
||||
return 0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
代码运行以后,会输出一行字符串 “hello main!”。
|
||||
|
||||
接下来呢,我们对上述代码稍微修改,在 pre_output 函数前面加上`__attribute__`((constructor)) 。这样,pre_output 函数就会先于主函数执行,代码如下:
|
||||
|
||||
```
|
||||
#include <stdio.h>
|
||||
|
||||
__attribute__((constructor))
|
||||
void pre_output() {
|
||||
printf("hello geek!\n");
|
||||
return ;
|
||||
}
|
||||
|
||||
int main() {
|
||||
printf("hello main!\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如上代码执行以后,程序会输出两行内容,第1行是 pre_output 函数输出的内容 “hello geek!”,第2行才是主函数的执行输出内容 “hello main!”。
|
||||
|
||||
从输出内容可以看出,加了`__attribute__`((constructor)) 以后,pre_output 函数会先于 main 主函数执行,这种有趣的特性,在接下来的操作中我们还会用得上,你要理解并记住。
|
||||
|
||||
其实 `__attribute__` 的作用还很多,你可以上网搜搜,会让你的程序性质变得特别有意思。
|
||||
|
||||
## RUN_ALL_TESTS 函数设计
|
||||
|
||||
好了,准备工作都做完了,接下来让我们来思考一下 RUN_ALL_TESTS 函数要完成的事情,以及完成这些事情所需要的条件。
|
||||
|
||||
从主函数中调用 RUN_ALL_TESTS 函数的方式来看,RUN_ALL_TESTS 函数应该是一个返回值为整型的函数。这样,我们可以得到这样的函数声明形式:
|
||||
|
||||
```
|
||||
int RUN_ALL_TESTS();
|
||||
|
||||
```
|
||||
|
||||
从测试框架的执行输出结果中看,RUN_ALL_TESTS 函数可以依次性地执行每一个 TEST 宏扩展出来的测试用例函数,这是怎么做到的呢?
|
||||
|
||||
我们可以这样认为:在主函数执行 RUN_ALL_TESTS 函数之前,有一些函数过程,就已经把测试用例函数的相关信息,记录在了一个 RUN_ALL_TESTS 函数可以访问到的地方,等到 RUN_ALL_TESTS 函数执行的时候,就可以根据这些记录的信息,依次性地执行这些测试用例函数。整个过程,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f6/46/f6f8dae2ee9c7d9b21b0c581e64b6346.jpg" alt="" title="图1: RUN_ALL_TESTS 执行流程"><br>
|
||||
图中红色部分,就是我们推测的,某些完成测试用例函数信息注册的函数,它们先于主函数执行,将测试用例的信息,写入到一个公共存储区中。
|
||||
|
||||
接下来,我们需要考虑的就是这些注册函数,究竟将什么信息存储到了公共存储区中,才能使得 RUN_ALL_TESTS 函数可以调用到这些测试用例?你自己也可以想想是什么。答案就是这个信息是测试用例函数的函数地址,因为只有把函数地址存储到这个存储区中,才能保证RUN_ALL_TESTS 函数可以调用它们。所以,这片公共存储区,就应该是一个函数指针数组。
|
||||
|
||||
那如何解决注册函数问题呢?最简单直接的设计方法,就是每多一个由 TEST 宏定义的测试用例,就配套一个注册函数,所以这个注册函数的逻辑,可以设计在 TEST 宏展开的内容中。这就需要我们对 TEST 宏进行重新设计,这里我一会儿再给你进行说明。
|
||||
|
||||
我们先来完成 RUN_ALL_TESTS 函数从存储区中,读取并执行测试用例的过程:
|
||||
|
||||
```
|
||||
typedef void (*test_function_t)();
|
||||
|
||||
test_function_t test_function_arr[100];
|
||||
int test_function_cnt = 0;
|
||||
|
||||
int RUN_ALL_TESTS() {
|
||||
for (int i = 0; i < test_function_cnt; i++) {
|
||||
printf("RUN TEST : %d\n", i + 1);
|
||||
test_function_arr[i]();
|
||||
printf("RUN TEST DONE\n\n");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
代码中用到了函数指针相关的技巧,其中 test_function_t 是我们定义的函数指针类型,这种函数指针类型的变量,可以用来指向返回值是 void,传入参数为空的函数。
|
||||
|
||||
之后,定义了一个有100 位的函数指针数组 test_function_arr,数组中的每个位置,都可以存储一个函数地址,数组中元素数量,记录在整型变量 test_function_cnt 中。这样,RUN_ALL_TESTS 函数中的逻辑就很简单了,就是依次遍历函数指针数组中的每个函数,然后依次执行这些函数,这些函数每一个都是一个测试用例。
|
||||
|
||||
## 重新设计:TEST 宏
|
||||
|
||||
根据前面的分析,TEST 扩展出来的内容,不仅要有测试用例的函数头部,还需要有先于主函数执行的注册函数,主要用于注册 TEST 扩展出来的测试用例函数。由此,我们可以得出如下示例代码:
|
||||
|
||||
```
|
||||
#define TEST(test_name, func_name) \
|
||||
void test_name##_##func_name(); \
|
||||
__attribute__((constructor)) \
|
||||
void register_##test_name##_##func_name() { \
|
||||
test_function_arr[test_function_cnt] = test_name##_##func_name; \
|
||||
test_function_cnt++; \
|
||||
} \
|
||||
void test_name##_##func_name()
|
||||
|
||||
```
|
||||
|
||||
这个新设计的 TEST 宏,除了末尾保留了原 TEST 宏内容以外,在扩展的测试用例函数头部添加了一段扩展内容,这段新添加的扩展内容,会扩展出来一个函数声明,以及一个以 register 开头的会在主函数执行之前执行的注册函数;注册函数内部的逻辑很简单,就是将测试函数的函数地址,存储在函数指针数组 test_function_arr 中,这部分区域中的数据,后续会被 RUN_ALL_TESTS 函数使用。
|
||||
|
||||
如果以如上 TEST 宏作为实现,原程序中的两个测试用例代码,会被展开成如下样子:
|
||||
|
||||
```
|
||||
void test1_test_is_prime();
|
||||
|
||||
__attribute__((constructor))
|
||||
void register_test1_test_is_prime() {
|
||||
test_function_arr[test_function_cnt] = test1_test_is_prime;
|
||||
test_function_cnt++;
|
||||
}
|
||||
|
||||
void test1_test_is_prime() {
|
||||
EXPECT_EQ(is_prime(3), 1);
|
||||
EXPECT_EQ(is_prime(5), 1);
|
||||
EXPECT_EQ(is_prime(7), 1);
|
||||
}
|
||||
|
||||
void test2_test_is_prime();
|
||||
|
||||
__attribute__((constructor))
|
||||
void register_test2_test_is_prime() {
|
||||
test_function_arr[test_function_cnt] = test2_test_is_prime;
|
||||
test_function_cnt++;
|
||||
}
|
||||
|
||||
void test2_test_is_prime() {
|
||||
EXPECT_EQ(is_prime(4), 0);
|
||||
EXPECT_EQ(is_prime(0), 0);
|
||||
EXPECT_EQ(is_prime(1), 0);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这个展开内容,是我给你做完代码格式整理以后的样子,实际展开结果会比这个格式乱一点儿,不过代码逻辑都一样。从展开内容中你可以看到,在展开代码的第 4 行和第 18 行分别就是两个测试用例函数的注册函数。
|
||||
|
||||
至此,我们就算是初步完成了测试框架中关键的两个部分的设计:一个是 TEST 宏,另外一个就是 RUN_ALL_TESTS 函数。它们同时也是串起测试框架流程最重要的两部分。
|
||||
|
||||
关于 EXPECT_EQ 是如何实现的,我就留作思考题吧,也希望你认真想一想,把你的答案写在留言区中,我们一起讨论。这个实现答案肯定不唯一,你只需要尽量做到最好即可。
|
||||
|
||||
## 课程小结
|
||||
|
||||
最后,我来给你做一下今天的课程小结:
|
||||
|
||||
1.`__attribute__`((constructor)) 可以修饰函数,使修饰的函数先于主函数执行。<br>
|
||||
2. RUN_ALL_TESTS 之所以可以获得程序中所有测试用例的函数信息,是因为有一批注册函数,将测试用例函数记录下来了。<br>
|
||||
3. 通过测试框架这个项目,我们再一次看到,宏可以将原本看似不合理的代码,变得合理。
|
||||
|
||||
通过这两次课程,我希望你意识到,我们不是在阅读已有的测试框架的源码,而是在根据已有的测试框架,脑补其内部实现过程。
|
||||
|
||||
其实,脑补这个能力,往往是项目开发中的重要能力之一。例如,根据产品需要的外在功能描述,脑补后续的开发细节;根据竞品可见的功能表现,脑补其背后的技术细节。能够脑补一个产品的实现细节,可以让我们逐渐掌握,认清相关技术边界的能力,这个能力可以让我们不盲目崇拜某个公司,也不会随意轻视某个产品。
|
||||
|
||||
好了,今天就到这里了。下期我将带你继续完善测试框架的相关功能,我是胡光,我们下期见。
|
232
极客时间专栏/人人都能学会的编程入门课/综合项目篇/28 | 尝试升级(上):完善测试框架的功能与提示.md
Normal file
232
极客时间专栏/人人都能学会的编程入门课/综合项目篇/28 | 尝试升级(上):完善测试框架的功能与提示.md
Normal file
@@ -0,0 +1,232 @@
|
||||
<audio id="audio" title="28 | 尝试升级(上):完善测试框架的功能与提示" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/95/88/9529b82cef4f4e3277335f6016bec788.mp3"></audio>
|
||||
|
||||
你好,我是胡光,欢迎回来。
|
||||
|
||||
在上一节课中呢,我们学习了测试框架的主要功能流程,完成了最重要的 RUN_ALL_TESTS 函数的功能逻辑。并且在这个学习期间,我们还使用了注册函数的技巧,就是让一些函数先于主函数执行,将测试用例函数信息记录在一个函数指针数组中,为后续的 RUN_ALL_TESTS 函数功能的执行作铺垫。
|
||||
|
||||
可你有没有发现,我们上节课程所完成的代码,只能让我们的测试框架在整体流程功能上跑通,然而程序的输出内容却不如 gtest 丰富。
|
||||
|
||||
今天,我们的主要任务,就是参考 gtest 的输出,逐步完善我们自己测试框架的相关信息输出方面的细节,从而让输出内容更加符合我们想要的信息。来,让我们一起开始吧。
|
||||
|
||||
## 温故知新,gtest 的输出结果
|
||||
|
||||
我们先来回顾一下 gtest 的输出结果,gtest 的输出内容大体可以分成三个部分。
|
||||
|
||||
第一部分,一套单元测试的相关信息:
|
||||
|
||||
```
|
||||
[==========] Running 2 tests from 1 test suite.
|
||||
[----------] Global test environment set-up.
|
||||
[----------] 2 tests from test_is_prime
|
||||
|
||||
```
|
||||
|
||||
这段信息说明这套单元测试中,包含了 2 个测试用例。
|
||||
|
||||
第二部分,是每个单元测试运行信息的输出:
|
||||
|
||||
```
|
||||
[ RUN ] test_is_prime.test1
|
||||
[ OK ] test_is_prime.test1 (1 ms)
|
||||
[ RUN ] test_is_prime.test2
|
||||
gtest_test.cpp:25: Failure
|
||||
Expected equality of these values:
|
||||
is_prime(4)
|
||||
Which is: 1
|
||||
0
|
||||
gtest_test.cpp:26: Failure
|
||||
Expected equality of these values:
|
||||
is_prime(0)
|
||||
Which is: 1
|
||||
0
|
||||
gtest_test.cpp:27: Failure
|
||||
Expected equality of these values:
|
||||
is_prime(1)
|
||||
Which is: 1
|
||||
0
|
||||
[ FAILED ] test_is_prime.test2 (0 ms)
|
||||
|
||||
```
|
||||
|
||||
如上所示,第一个单元测试 test_is_prime.test1 运行结果正确,所用时间是 1ms;第二个单元测试 test_is_prime.test2 中,有三个判等 EXPECT 断言的结果是错误的,也就是 is_prime 函数的返回值,和测试用例中期望的返回值不符,这说明 is_prime 函数存在 Bug。
|
||||
|
||||
第三部分,就是这套单元测试的总结信息,以及整个程序单元测试结果的汇总信息。这段信息,有兴趣的小伙伴可以自己理解着看一下,由于不是咱们今天课程的重点,就不展开介绍了。
|
||||
|
||||
```
|
||||
[----------] 2 tests from test_is_prime (1 ms total)
|
||||
|
||||
[----------] Global test environment tear-down
|
||||
[==========] 2 tests from 1 test suite ran. (1 ms total)
|
||||
[ PASSED ] 1 test.
|
||||
[ FAILED ] 1 test, listed below:
|
||||
[ FAILED ] test_is_prime.test2
|
||||
|
||||
1 FAILED
|
||||
|
||||
```
|
||||
|
||||
好了,关于gtest 的输出内容,我大致说清楚了。
|
||||
|
||||
今天呢,我们先忽略 gtest 输出内容的第一部分和第三部分,主要关注 gtest 输出内容的第二部分,也就是每个单元测试运行信息的输出部分。通过第二部分的输出内容,你能想出我们应该从哪些方面来完善测试框架?
|
||||
|
||||
这里呢,我给出我的想法:通过观察第二部分的输出,我们基本要从三个方面完善测试框架的输出信息。
|
||||
|
||||
1. 在每个测试用例运行之前,要先行输出相关测试用例的名字;
|
||||
1. 每个测试用例运行结束以后,要输出测试用例的运行时间与运行结果(OK 或者 FAILED);
|
||||
1. 若测试用例中的 EXPECT 断言出错,需要输出错误提示信息。
|
||||
|
||||
好了,优化的方向找到了,那么接下来,我们就开始测试框架改装行动吧!
|
||||
|
||||
## 测试用例的名字输出
|
||||
|
||||
首先是如何输出测试用例的名字。我们先回忆一下上节课设计的注册函数,如下所示:
|
||||
|
||||
```
|
||||
#define TEST(test_name, func_name) \
|
||||
void test_name##_##func_name(); \
|
||||
__attribute__((constructor)) \
|
||||
void register_##test_name##_##func_name() { \
|
||||
test_function_arr[test_function_cnt] = test_name##_##func_name; \
|
||||
test_function_cnt++; \
|
||||
} \
|
||||
void test_name##_##func_name()
|
||||
|
||||
```
|
||||
|
||||
注册函数是随着 TEST 展开的,从展开的代码逻辑中可以看到,它只是将测试用例的函数地址记录在了函数指针数组中。要想 RUN_ALL_TESTS 函数后续能够输出测试用例的函数名称的话,我们只需要修改注册函数的功能逻辑即可,也就是让注册函数在记录函数信息的时候,增加记录对应测试用例的名称。
|
||||
|
||||
而这个名称信息,应该记录在哪里呢?有两种代码实现方式:
|
||||
|
||||
1. 另外开辟一个记录测试用例名称的字符串数组;
|
||||
1. 修改 test_function_arr 数组中的元素类型,将新增的测试用例名称以及函数地址信息打包成一个数据元素。
|
||||
|
||||
显然,相较于第一种实现方式,第二种代码实现方式会使程序具有更好的封装特性。我们采用之前在“语言基础篇“中学习的结构体相关知识,就可以完成这种多种数据类型打包成一种新的数据类型的功能需求。
|
||||
|
||||
下面就是我们将函数指针信息和测试用例名称信息,封装成的一个新的结构体类型:
|
||||
|
||||
```
|
||||
struct test_function_info_t {
|
||||
test_function_t func; // 测试用例函数指针,指向测试用例函数
|
||||
const char *name; // 指向测试用例名称
|
||||
} test_function_arr[100];
|
||||
int test_function_cnt = 0;
|
||||
|
||||
```
|
||||
|
||||
如代码所示,我们定义了一种新的数据类型,叫做 test_function_info_t。这种结构体类型包含了指向测试用例的函数指针 func 字段, 与指向测试用例名称的字符串指针 name 字段,并且我们将这种结构体类型,作为 test_function_arr 数组新的元素类型。
|
||||
|
||||
既然测试用例信息的存储区 test_function_arr 的数据类型发生了改变,那么负责存储信息的注册函数,与使用信息的 RUN_ALL_TESTS 函数的相关逻辑都需要作出改变。
|
||||
|
||||
首先,我们来看注册函数的改变。想要修改注册函数的逻辑,就是修改 TEST 宏,从功能上来说,注册函数中需要额外记录一个测试用例名称信息,示例代码如下:
|
||||
|
||||
```
|
||||
#define TEST(test_name, func_name) \
|
||||
void test_name##_##func_name(); \
|
||||
__attribute__((constructor)) \
|
||||
void register_##test_name##_##func_name() { \
|
||||
test_function_arr[test_function_cnt].func = test_name##_##func_name; \
|
||||
test_function_arr[test_function_cnt].name = #func_name "." #test_name; \
|
||||
test_function_cnt++; \
|
||||
} \
|
||||
void test_name##_##func_name()
|
||||
|
||||
```
|
||||
|
||||
代码中主要是增加了第 6 行的逻辑,这一行的代码将 TEST 宏参数的两部分,拼成一个字符串,中间用点 (.) 连接,例如 TEST(test1, test_is_prime) 宏调用中,拼凑的字符串就是 test_is_prime.test1,和 gtest 中的输出的测试用例名称信息格式是一致的。
|
||||
|
||||
改完了注册函数的逻辑以后,最后调整一下 RUN_ALL_TESTS 中使用 test_function_arr 数组的逻辑代码即可:
|
||||
|
||||
```
|
||||
int RUN_ALL_TESTS() {
|
||||
for (int i = 0; i < test_function_cnt; i++) {
|
||||
printf("[ RUN ] %s\n", test_function_arr[i].name);
|
||||
test_function_arr[i].func();
|
||||
printf("RUN TEST DONE\n\n");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
代码中的第 3 行,是仿照 gtest 的输出格式进行调整的,在输出测试用例名称之前,先输出一段包含 RUN 英文的标志信息。
|
||||
|
||||
至此,我们就完成了输出测试用例名字的框架功能改造。
|
||||
|
||||
## 输出测试用例的运行结果信息
|
||||
|
||||
接下来,就让我们进行第二个功能改造:输出测试用例的运行结果信息。
|
||||
|
||||
以下是我们示例代码中的 2 个测试用例,在 gtest 框架下的运行结果信息输出:
|
||||
|
||||
```
|
||||
[ OK ] test_is_prime.test1 (1 ms)
|
||||
[ FAILED ] test_is_prime.test2 (0 ms)
|
||||
|
||||
```
|
||||
|
||||
根据输出的信息,我们可知 gtest 会统计每个测试用例运行的时间,并以毫秒为计量单位,输出此时间信息。不仅如此,gtest 还会输出与测试用例是否正确相关的信息,如果测试用例运行正确,就会输出一行包含 OK 的标志信息,否则就输出一行包含 FAILED 的标志信息。
|
||||
|
||||
根据我们自己测试框架的设计,这行信息只有可能是在 RUN_ALL_TESTS 函数的 for 循环中,执行完每一个测试用例函数以后输出的信息。
|
||||
|
||||
由此,我们面临的是两个需要解决的问题:
|
||||
|
||||
1. 如何统计函数过程的运行时间?
|
||||
1. 如何确定获得每一个测试用例函数的测试结果是否正确?
|
||||
|
||||
说到如何统计函数过程的运行时间,我这里就需要介绍两个新的知识点,一个是函数 clock() ,另 一个是宏 CLOCKS_PER_SEC。下面我会对它们详细讲解。
|
||||
|
||||
我们先说函数 clock() 。它的返回值代表了:从运行程序开始,到调用 clock() 函数时,经过的 CPU 时钟计时单元。并且,这个 clock() 函数的返回值,实际上反映的是我们程序的运行时间。那这个 CPU 时钟计时单元究竟是什么呢?你可以把 1 个 CPU 时钟计时单元,简单的理解成是一个单位时间长度,只不过这个单位时间长度,不是我们常说的 1 秒钟。
|
||||
|
||||
接下来,我们再说说宏 CLOCKS_PER_SEC 。它实际上是一个整型值,代表多少个 CPU 时钟计时单元是 1 秒。这个值在不同环境中会有所不同,在早年我的 Windows 电脑上,这个值是1000,也就是1000 个 CPU 时钟计时单位等于 1 秒。而现在我的 Mac 电脑上,这个值是 1000000,也就是 1000000 个 CPU 时钟计时单位等于 1 秒钟。显然,这个数字越大,统计粒度就越精细。
|
||||
|
||||
有了上面这两个工具,我们就可以轻松地统计一个函数的运行时间。在函数运行之前,记录一个 clock() 值,函数运行结束以后,再记录一个 clock() 值,用两个记录值的差值除以 CLOCKS_PER_SEC ,得到的就是以秒为单位的函数运行时间,再乘以 1000,即为毫秒单位。
|
||||
|
||||
这样呢,我们就解决了刚刚提的第一个问题:统计函数过程的运行时间。
|
||||
|
||||
至于如何获得每一个测试用例的测试结果,我们可以采用一个简单的解决办法,那就是记录一个全局变量,代表测试用例结果正确与否。当测试用例中的 EXPECT_EQ 断言发生错误时,就修改这个全局变量的值,这样我们的 RUN_ALL_TESTS 函数,就可以在测试用例函数执行结束以后,得知执行过程是否有错。
|
||||
|
||||
综合以上所有信息,我们可以重新设计 RUN_ALL_TESTS 函数如下:
|
||||
|
||||
```
|
||||
int test_run_flag;
|
||||
#define EXPECT_EQ(a, b) test_run_flag &= ((a) == (b))
|
||||
|
||||
int RUN_ALL_TESTS() {
|
||||
for (int i = 0; i < test_function_cnt; i++) {
|
||||
printf("[ RUN ] %s\n", test_function_arr[i].name);
|
||||
test_run_flag = 1;
|
||||
long long t1 = clock();
|
||||
test_function_arr[i].func();
|
||||
long long t2 = clock();
|
||||
if (test_run_flag) {
|
||||
printf("[ OK ] ");
|
||||
} else {
|
||||
printf("[ FAILED ] ");
|
||||
}
|
||||
printf("%s", test_function_arr[i].name);
|
||||
printf(" (%.0lf ms)\n\n", 1.0 * (t2 - t1) / CLOCKS_PER_SEC * 1000);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
代码中的第 8 行是在测试用例运行之前,记录一个开始时间值 t1;代码中的第 10 行是在测试用例函数执行完后,记录一个结束时间值 t2;在代码的第 17 行,根据 t1 、t2 以及 CLOCKS_PER_SEC 的值,计算得到测试用例函数实际运行的时间,并输出得到的结果。
|
||||
|
||||
这段代码中增加了一个全局变量“test_run_flag”,这个变量每次在测试用例执行之前,都会被初始化为 1,当测试用例结束执行以后,RUN_ALL_TESTS 函数中,根据 test_run_flag 变量的值,选择输出 OK 或者 FAILED 的标志信息。同时,我们可以看到,test_run_flag 变量的值只有在 EXPECT_EQ 断言中,才可能被修改。
|
||||
|
||||
## EXPECT_EQ 断言的实现
|
||||
|
||||
最后呢,我们还剩下一个 EXPECT_EQ 断言的实现,这个就给你留作思考题,请你基于我上述所讲的内容,试试自己实现这个带错误提示输出的 EXPECT_EQ 断言吧。也欢迎你把你的答案写在留言区,我们一起讨论。
|
||||
|
||||
## 课程小结
|
||||
|
||||
通过今天的课程呢,我希望你认识到**工程开发中的一个基本原则:功能迭代,数据先行。也就是说,无论我们做什么样的功能开发,首先要考虑的是与数据相关的部分。**更细致的解释,就是你考虑某种功能的实现,要明白这个功能都依赖于哪些数据信息,这些信息在哪里存储,在哪里修改,在哪里读取使用。把数据相关部分设计明白了,你的功能开发也就基本实现了一半了。
|
||||
|
||||
就像我们今天改造的第一个功能,输出测试用例的名字。
|
||||
|
||||
首先,我们考虑如何存储名字信息,最先被修改的就是 test_function_arr 数组的数据类型,我们改造了数据存储的结构。然后,我们修改了注册函数的相关功能逻辑,也就是解决了数据的写入与修改过程。最后,我们修改 RUN_ALL_TESTS 中的输出逻辑,也就是解决了数据在哪里读取和使用的事情。
|
||||
|
||||
至此,我已经向你演示了基本的功能迭代开发过程。接下来你可以自己试着,给输出的内容加上点儿颜色,以便更清晰地展示测试过程中的测试信息。除此之外呢,你也可以开动你的创造力,给测试框架加些令人惊喜的功能。
|
||||
|
||||
好了,今天就到这里了,我是胡光,我们下节课见。
|
181
极客时间专栏/人人都能学会的编程入门课/综合项目篇/29 | 尝试升级(下):“链表”知识在测试框架中的应用.md
Normal file
181
极客时间专栏/人人都能学会的编程入门课/综合项目篇/29 | 尝试升级(下):“链表”知识在测试框架中的应用.md
Normal file
@@ -0,0 +1,181 @@
|
||||
<audio id="audio" title="29 | 尝试升级(下):“链表”知识在测试框架中的应用" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d8/19/d8884c609452ebba9c7d1f894d8af719.mp3"></audio>
|
||||
|
||||
你好,我是胡光,欢迎回来。
|
||||
|
||||
上节课中,我们通过参考 gtest 的输出,完善了我们自己的测试框架的输出信息,也就是添加了测试用例的名称、运行结果以及运行时间。并且,我提到了在一般情况下,项目中的功能开发原则:**功能迭代,数据先行**。就是要开发新的功能之前,我们应该先考虑清楚实现这部分功能相关的数据,在系统中的存储与使用的情况,只有这样,才能更好地完成功能的实现与迭代优化。
|
||||
|
||||
今天迎来我们整个测试框架项目的最后一节课。这节课的目的,一是对前几节课内容进行一个总结,二是向你说明我们现在开发的测试框架代码,其实还有很多优化的空间。至于这个优化空间是什么呢?这次我将带着你结合之前学习的“链表”知识,对测试框架进行一个具体的优化改进。
|
||||
|
||||
关于测试框架的优化,是一个不断学习的过程。在这个过程中,你深刻体会到“知不足,然后能自反也”这句话的含义。就是在优化代码的过程中,你会发现自己的不足,然后努力提高自己的能力去弥补不足;当你提升了自己之后,你又会看到自己在其他方面的不足,进而继续提高自己。
|
||||
|
||||
好了,废话不多说,我们正式开始今天的学习。
|
||||
|
||||
## 揭晓答案:EXPECT_EQ 宏究竟是如何实现的
|
||||
|
||||
在对测试框架进行优化之前呢,我先来回答一下,可能困扰了你两节课的一个问题:就是 EXPECT_EQ 宏究竟是如何实现的?这个问题的答案呢,我给出一个可行的实现,仅供参考。
|
||||
|
||||
首先,EXPECT_EQ(a, b) 在a,b 两部分值相等的时候,不会产生额外的输出信息,而当 a,b 两部分不相等的时候,就会输出相应的提示信息。如下所示:
|
||||
|
||||
```
|
||||
gtest_test.cpp:25: Failure
|
||||
Expected equality of these values:
|
||||
is_prime(4)
|
||||
Which is: 1
|
||||
0
|
||||
|
||||
```
|
||||
|
||||
这段输出信息,对应的是源代码中的 “EXPECT_EQ(is_prime(4), 0); ”的输出。如你所见,第 1 行的输出内容包含了源文件名(gtest_test.cpp),EXPECT_EQ 宏所在的代码位置(25),以及一个提示结果(Failure)。
|
||||
|
||||
接下来的信息,你自己就可以看懂了,就是关于 EXPECT_EQ 传入两部分的值。对于函数调用部分,EXPECE_EQ 会输出这个函数的调用形式及返回值信息,也就是输出中的 “is_prime(4)”“Which is: 1” 这段内容。而对于数值信息,只会输出数值信息本身,也就是输出信息中第 5 行的那个 0。
|
||||
|
||||
实际上,要想在宏中实现类似于这种根据传入参数类型,选择输出形式的功能,对于现在的你来说可能有点困难。所以,我们可以重新设计一种输出形式,只要能够清晰地展示错误信息就可以。
|
||||
|
||||
重新设计的输出提示,如下所示:
|
||||
|
||||
```
|
||||
gtest_test.cpp:25: Failure
|
||||
Expected (is_prime(4) == 0):
|
||||
Which is: (1 == 0)
|
||||
|
||||
```
|
||||
|
||||
修改完以后的输出信息,你可以看到,第 2 行就是传入 EXPECT_EQ 宏两部分的比较,第 3 行是这两部分实际输出值的比较。
|
||||
|
||||
重新设计了输出信息以后,就可以来看看 EXPECT_EQ 宏的实现了:
|
||||
|
||||
```
|
||||
#define EXPECT(a, b, comp) { \
|
||||
__typeof(a) val_a = (a), val_b = (b); \
|
||||
if (!(val_a comp val_b)) { \
|
||||
printf("%s:%d: Failure\n", __FILE__, __LINE__); \
|
||||
printf("Expected (%s %s %s):\n", #a, #comp, #b); \
|
||||
printf(" Which is: (%d %s %d)\n", val_a, #comp, val_b); \
|
||||
test_run_flag = 0; \
|
||||
} \
|
||||
}
|
||||
#define EXPECT_EQ(a, b) EXPECT(a, b, ==)
|
||||
#define EXPECT_LT(a, b) EXPECT(a, b, <)
|
||||
#define EXPECT_GT(a, b) EXPECT(a, b, >)
|
||||
#define EXPECT_NE(a, b) EXPECT(a, b, !=)
|
||||
|
||||
```
|
||||
|
||||
在这段实现中,你会发现,我们不仅实现了 EXPECT_EQ,还额外实现了EXPECT_LT、EXPECT_GT、EXPECT_NE 等用于比较的宏。其中,LT 是英文 little 的缩写,是判断小于关系的;GT 是 great 的缩写,是判断大于关系的;NE 是 not equal 的缩写,是判断不等于关系的。而这些所有的宏,都是基于 EXPECT 宏实现的。
|
||||
|
||||
我们将用于比较的运算符,当作参数传递给 EXPECT 宏。有了 EXPECT 宏以后,你就可以参考代码中的第10~13行的内容,轻松地扩展出用于小于等于或者大于等于的宏了。由于 EXPECT 宏的实现,全都是我们之前学习过的知识点,所以在这里我就不再赘述了,你可以自行阅读文稿中的代码。
|
||||
|
||||
## 用链表存储测试用例
|
||||
|
||||
看完了 EXPECT 宏的参考实现以后,整个测试框架的基础功能,就算是彻底搭建完成了。
|
||||
|
||||
接下来,我们再重新审视下面这段函数指针数组 test_function_arr 的代码设计,来思考一下这个测试框架中还有没有可以优化的地方。
|
||||
|
||||
```
|
||||
struct test_function_info_t {
|
||||
test_function_t func; // 测试用例函数指针,指向测试用例函数
|
||||
const char *name; // 指向测试用例名称
|
||||
} test_function_arr[100];
|
||||
int test_function_cnt = 0;
|
||||
|
||||
```
|
||||
|
||||
这段代码中,我们使用了数组来定义存储测试函数信息的存储区,这个数组的大小有 100 位,也就是说,最多可以存储 100 个测试用例函数信息。
|
||||
|
||||
那我们来思考一个问题:要是有程序中定义了 1000 个测试用例,怎么办呢?毕竟,对于中型项目开发来说,定义 1000 个测试用例,可不是什么难事儿。这个时候,你可能会说,那简单啊,数组大小设置成 10000 不就行了。
|
||||
|
||||
但是你要明白,这种设计尽管简单粗暴且有效,可它一点儿程序设计的美感都没有。什么意思呢?就是当我们为测试用例准备了10000个数组空间的时候,可能在真正的开发过程中,只定义了 2 个测试用例,这就会浪费掉 9998 个数组空间。
|
||||
|
||||
更形象地描述这种行为的话,这种设计方式很像计划经济,计划多少用多少。同时,它的弊端也很明显,一旦计划不好,要不是造成空间浪费,要不就是资源紧张。
|
||||
|
||||
所以,我们应该尝试着从“计划经济”向“市场经济”转变一下,可不可以转变成想用多少就生产多少。那应该怎么做呢?
|
||||
|
||||
我们知道,在程序中数组的空间大小,是需要提前计划出来的。但是有一种结构的空间,是可以动态增加或减少的,那就是我们之前讲过的“**链表**”结构。你想一下,如果我们把一个一个的测试函数信息,封装成一个一个的链表节点,每当增加一个测试用例的时候,就相当于向整个链表中插入一个新的节点。此时,用链表实现的存储测试函数信息的结构,它所占空间大小就和实际测试用例的数量成正比了。这就是我说的用多少,就生产多少。
|
||||
|
||||
下面,我们就来说说具体怎么做。
|
||||
|
||||
第一步,我们需要改变 test_function_info_t 的结构定义,也就是把原先存储测试用例函数信息的结构体类型,改装成链表结构。最简单的方法,就是在结构体的定义中,增加一个指针字段,指向下一个 test_function_info_t 类型的数据,代码如下所示:
|
||||
|
||||
```
|
||||
struct test_function_info_t {
|
||||
test_function_t func; // 测试用例函数指针,指向测试用例函数
|
||||
const char *name; // 指向测试用例名称
|
||||
struct test_function_info_t *next;
|
||||
};
|
||||
struct test_function_info_t head, *tail = &head;
|
||||
|
||||
```
|
||||
|
||||
可以看到,我们给 test_function_info_t 结构体类型增加了一个链表中的 next 字段,除此之外,我们还定义了一个虚拟头节点 head 和一个指针变量 tail。这里你需要注意,head 是虚拟头节点,后续我们会向 head 所指向链表中插入链表节点,tail 指针则指向了整个链表的最后一个节点的地址。
|
||||
|
||||
第二步,在准备好了数据存储结构以后,需要改写的就是函数注册的逻辑了。在改写 TEST 宏中的注册函数逻辑之前呢,我们先准备一个工具函数 add_test_function,这个工具函数的作用,就是根据传入的参数,新建一个链表节点,并且插入到整个链表的末尾:
|
||||
|
||||
```
|
||||
void add_test_function(const char *name, test_function_t func) {
|
||||
struct test_function_info_t *node;
|
||||
node = (struct test_function_info_t *)malloc(sizeof(struct test_function_info_t));
|
||||
node->func = func;
|
||||
node->name = name;
|
||||
node->next = NULL;
|
||||
tail->next = node;
|
||||
tail = node;
|
||||
return ;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
好了, add_test_function 工具函数准备好之后,我们正式来改写 TEST 宏中注册函数的逻辑。其实难度也不大,也就是要求注册函数调用 add_test_function 函数,并且传入相关的测试用例的函数信息即可:
|
||||
|
||||
```
|
||||
#define TEST(test_name, func_name) \
|
||||
void test_name##_##func_name(); \
|
||||
__attribute__((constructor)) \
|
||||
void register_##test_name##_##func_name() { \
|
||||
add_test_function(#func_name "." #test_name, \
|
||||
test_name##_##func_name); \
|
||||
} \
|
||||
void test_name##_##func_name()
|
||||
|
||||
```
|
||||
|
||||
最后一步,处理完了数据写入的过程以后,来让我们修改一下使用这份数据的代码逻辑,那就是 RUN_ALL_TESTS 函数中的相关逻辑。之前,RUN_ALL_TESTS 函数中,循环遍历数组中的每一个测试用例,并且执行相关的测试用例函数,对这一部分,修改成针对于链表结构的遍历方式即可,代码如下所示:
|
||||
|
||||
```
|
||||
int RUN_ALL_TESTS() {
|
||||
struct test_function_info_t *p = head.next;
|
||||
for (; p; p = p->next) {
|
||||
printf("[ RUN ] %s\n", p->name);
|
||||
test_run_flag = 1;
|
||||
long long t1 = clock();
|
||||
p->func();
|
||||
long long t2 = clock();
|
||||
if (test_run_flag) {
|
||||
printf("[ OK ] ");
|
||||
} else {
|
||||
printf("[ FAILED ] ");
|
||||
}
|
||||
printf("%s", p->name);
|
||||
printf(" (%.0lf ms)\n\n", 1.0 * (t2 - t1) / CLOCKS_PER_SEC * 1000);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这样,我们就彻底完成了测试用例函数信息存储部分的“**链表**”改造过程。
|
||||
|
||||
对于上面的这份代码实现,你会发现,链表节点空间是通过 malloc 函数动态申请出来的,可在我们的程序中,并没有对这些空间使用 free 进行释放,如果你想让这个程序对空间的申请与回收做到有始有终,变得更加干净,那应该怎么办呢?
|
||||
|
||||
这里你可以借助 `__attribute__`((destructor)) 的功能,之前我们介绍了一个`__attribute__`((constructor))的作用是让函数先于主函数执行,而destructor 就是使函数在主函数结束以后才执行的函数特性设置。有了这个特性设置,你可以实现一个函数,专门用来销毁测试函数链表所占存储空间,这样在逻辑上,你的程序会变得更完美。当然,你即使不这么做,也不会影响到原有的程序功能的正确性。
|
||||
|
||||
## 项目小结
|
||||
|
||||
至此,我们关于测试框架开发的内容,就算是告一段落了。
|
||||
|
||||
从26讲到29讲,我们经历了一个项目从 0 到 1 的过程,继而又完成了项目从 1 到 1.5 的升级。所谓从 0 到 1 就是项目从最初的想法变成代码的过程,从 1 到 1.5 就是我们对于代码的优化过程。这是一个追求极致、不断优化项目的过程。
|
||||
|
||||
关于测试框架开发的讲解内容虽然结束了,但我希望这几节课可以成为你优化这份代码的一个起点。日后,你可以选择增加额外的功能,修改实现的架构,甚至是使用不同的语言重新进行实现,哪怕是一个小小的改动,都是值得称赞的。
|
||||
|
||||
在下节课,我将指导你完成一个简易的计算器程序,也算是给我们这个课程一个圆满的结束。
|
||||
|
||||
好了,今天就到这里了,我是胡光,我们下一节课见。
|
245
极客时间专栏/人人都能学会的编程入门课/综合项目篇/30 | 毕业设计:实现你自己的计算器程序.md
Normal file
245
极客时间专栏/人人都能学会的编程入门课/综合项目篇/30 | 毕业设计:实现你自己的计算器程序.md
Normal file
@@ -0,0 +1,245 @@
|
||||
<audio id="audio" title="30 | 毕业设计:实现你自己的计算器程序" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/00/a6/008aa6c1e5051ff40c4838a7ed358fa6.mp3"></audio>
|
||||
|
||||
你好,我是胡光,欢迎来到“综合项目篇”的最后一节课。
|
||||
|
||||
这节课,我将带你完成一个富有挑战性的任务,就是一起开发一门“编程语言”。哈哈……我说开发一门编程语言当然是和你开玩笑,不过我们可以开发编程语言中的一小部分,那就是定义变量和基本的表达式运算功能。
|
||||
|
||||
三个月的时间,我们一起用 C 语言写了这么久的代码,我相信只要你坚持学习,不断拓展自己的编程能力,终有一天你可以开发出一门自己的编程语言。今天,我就带你打个头阵,从计算器程序开始。
|
||||
|
||||
## 计算器程序的功能设计
|
||||
|
||||
我们将要实现的计算器程序也算是开发一个小项目,那么开发项目的第一步,就是对我们实现的功能进行设计,一般计算器功能如下:
|
||||
|
||||
1. 第一次出现的变量赋值语句,即为变量定义;
|
||||
1. 计算表达式的值。
|
||||
|
||||
这两个功能,看似简单,可实际要考虑的还很多,例如:变量是否有作用域的限制啊,合法变量名的规则,表达式中支持的运算符种类啊,每一种运算符的优先级,等等。这些需要考虑的细节,每一个都会给我们的项目增加一点点难度。
|
||||
|
||||
为了把难度控制在一个可以实现的范围,我们对计算器功能做进一步的细致描述,同时也是降低项目实现难度,重新修订的功能定义如下:
|
||||
|
||||
1. 第一次出现的变量赋值语句,即为变量定义;
|
||||
1. 计算表达式的值;
|
||||
1. 没有作用域的概念,所有变量都是全局变量;
|
||||
1. 变量名只允许26个小写的英文字母,也就是说,程序中最多有26个变量;
|
||||
1. 表达式只支持四则混合运算+、-、*、/ 以及 ();
|
||||
1. 表达式中参与运算的值均为正整数,除法规则参考 C 语言整形之间的除法规则;
|
||||
1. 变量赋值语句和表达式语句,均各占一行。
|
||||
|
||||
这里,我给你看一份符合上述规则的输入数据:
|
||||
|
||||
```
|
||||
a = 3
|
||||
b = a * 3 + 5
|
||||
(a + 4) * (b + 5)
|
||||
|
||||
```
|
||||
|
||||
可以看到,第 1 行输入,定义了变量 a,同时给 a 变量赋值为 3;第 2 行,定义了变量 b,同时给 b 变量赋值为 a * 3 + 5 的值,也就是 14;第 3 行,是一行表达式,计算的是 (a + 4) * (b + 5) 的值,最后的结果应该等于 7 * 19 = 133。
|
||||
|
||||
针对这份输入数据,我们的计算器程序分别输出每行表达式对应的值,也就是:
|
||||
|
||||
```
|
||||
3
|
||||
14
|
||||
133
|
||||
|
||||
```
|
||||
|
||||
清楚了计算器程序的功能以后,下面我就给你讲讲如何完成这个程序。
|
||||
|
||||
## 二叉树的遍历
|
||||
|
||||
首先,你需要掌握二叉树的三种遍历方式,这是我们后续解决表达式求值问题的思维利器。在讲遍历方式之前,先来简单的看一下二叉树的基本结构。
|
||||
|
||||
**二叉树,就是每个节点下面最多有两个子节点的结构**。如下图所示,就是一个二叉树结构:<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/a1/18/a14b8cd2a3867a15b7765c82a7e30618.jpg" alt="" title="图1:二叉树结构示意图"><br>
|
||||
我们把其中的 A 节点叫做“根节点”,B 和 C 是 A 节点的两个“子节点”,同理,E 和 F 是 C 节点的两个子节点,D 是 B 节点的子节点。如果更细致地划分,以 B 为根节点的子树,处于 A 节点的左侧,所以称为 A 节点的左子树,C 称为 A 节点的右子树。反过来,我们把 A 节点称为 B 和 C 节点的父节点,同时它也是 D、E、F 节点的祖先节点。以上就是二叉树中的一些基本概念了。
|
||||
|
||||
认识了二叉树的基本概念以后,我们接下来就来看看二叉树的三种遍历方式:前序遍历、中序遍历与后序遍历。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/e0/88/e00a304c35b4ec6afb19440877af4388.jpg" alt="" title="图2:二叉树的遍历方式"><br>
|
||||
从图中可见,每一种遍历的方式,都是采用递归的定义方式。而所谓的前、中、后序遍历,其实说的是根节点的位置:根节点在左右子树遍历之前,那就是前序遍历;夹在左右子树中间,就是中序遍历;位于左右子树遍历之后,那就是后序遍历。
|
||||
|
||||
如果我们将图 1 中的二叉树结构,分别按照三种方式进行遍历,会得到如下所示的遍历结果:
|
||||
|
||||
```
|
||||
前序遍历:A B D C E F
|
||||
中序遍历:D B A E C F
|
||||
后序遍历:D B E F C A
|
||||
|
||||
```
|
||||
|
||||
这里你一定要注意,**在写某一种遍历结果的时候,一定是按照递归展开的方式**。例如,在中序遍历中,我们是将根节点左子树所形成的中序遍历结果(D B),放在根节点 A 的左侧,然后是根节点 A,接着是根节点右子树的中序遍历结果(E C F)。所以最后,整棵树的中序遍历结果就是 D B A E C F。
|
||||
|
||||
## 思维利器:表达式树
|
||||
|
||||
介绍完了二叉树的基本概念及三种遍历方式后,我们接下来就要赋予这个二叉树结构一些实际的意义,让它能够帮助我们理解表达式求值的过程。
|
||||
|
||||
其实,**任何一个四则混合运算的表达式,都能转换成相对应的二叉树,而原表达式的值,等于对应二叉树的后序遍历结果**。例如,下图就是一个加法表达式和它所对应的表达式树:<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/3f/d3/3f3f949bd3ba8ef47f07d959459160d3.jpg" alt="" title="图3:表达式树示意图(一)"><br>
|
||||
我们看到,在表达式树中,根节点就是运算符+(加号),加号的左子树是数字3,右子树是数字 5。根据刚刚所说的对应规则,在表达式树上,按照后序遍历的顺序,得到的就是表达式的值。图3中的表达式树,首先遍历得到左子树的数字3,再遍历得到右子树的数字 5,最后遍历到根节点的运算符+(加号),就将左右子树的值做加法,得到原表达式的结果 8。
|
||||
|
||||
下面,我们来看一个稍微复杂一点儿的表达式,以及它所对应的表达式树。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/3d/fa/3d99dddcc2089643a6c49ea4427632fa.jpg" alt="" title="图4:表达式树示意图(二)"><br>
|
||||
从图中可见,原表达式是(3 + 5) * (6 - 2),而其对应的表达式树中,已经没有了括号的影子。那括号的影响在表达式树上怎么体现呢?其实括号对表达式的影响,已经被表达式树转换成了等价的树形结构关系。这一点怎么理解呢,听我慢慢给你解释。
|
||||
|
||||
这里有个关键词,就是“顺序”。我们知道,表达式是按照计算顺序,得到计算结果的。表达式树,按照的是后序遍历方式,这本身也是规定了一种计算“顺序”。根据后序遍历规则,我们可以知道,表达式树的根节点所代表的运算,是原表达式中最后一个执行的运算。
|
||||
|
||||
我们回到示意图中具体示例来分析,图中表达式树的计算顺序应该是这样的:首先计算左子树所代表的 3 + 5 表达式的值,再计算右子树代表的 6 - 2表达式的值,最后根据根节点的乘法运算,计算得到左右子树的乘积值。
|
||||
|
||||
如此你会发现,表达式树的这种计算顺序,与原表达式添加了括号以后的计算顺序等价。
|
||||
|
||||
综上所述,我们可知,表达式树中越靠近根节点的运算符,优先级越低,而根节点代表了原表达式中,优先级最低的那个运算符。表达式中原有的括号,其实就是用来控制运算符之间的计算顺序的,这种计算顺序,对应的就是表达式树中的父子节点关系,这就是我们刚刚所说的,**原表达式中的括号,被转换成了等价的树形结构关系的含义**。
|
||||
|
||||
理解了表达式树以后,对于我们计算表达式的值,究竟有何作用呢?难道是在程序中,将读入的表达式字符串,转换成程序内的一棵表达式树结构么?
|
||||
|
||||
不知道你还记不记得,之前我们在讲链表结构的时候,提到链表不仅仅是一种程序中的结构,更重要的是它所体现出来的“链表思维”。其实今天我们学习的表达式树结构同样如此,我们不需要在程序中真正地建立一棵表达式树,而是利用表达式树去理解表达式计算的过程。
|
||||
|
||||
下面我们就来具体看看,如何利用这种思维,解决表达式计算问题。
|
||||
|
||||
我们知道,任何一个表达式,都对应一个等价的表达式树。而这个表达式树的根节点所对应的,就是原表达式中最后一个被计算的运算符。如果我们可以找到这个运算符在原表达式中的位置,那么这个运算符所的左边部分,对应的就是表达式树根节点的左子树,运算符的右边部分,对应的就是表达式树根节点的右子树。
|
||||
|
||||
我们用 String 代表原表达式字符串,op 代表整个表达式中最后一个被计算的运算符,L_String 是 op 运算符左边的字符串,R_String 就是右边的字符串。
|
||||
|
||||
假设,我们有一个函数 get_val(String),可以得到 String 所代表的表达式的值。那么关于 get_val(String),我们就可以得到如下递推关系:
|
||||
|
||||
```
|
||||
get_val(String) = get_val(L_String) op get_val(R_String)
|
||||
|
||||
```
|
||||
|
||||
也就是当前表达式的值,等于左边表达式的值与右边表达式的值之间的运算结果。
|
||||
|
||||
这里我给你举个具体的例子,还是拿前面的那个乘法表达式来看:
|
||||
|
||||
```
|
||||
get_val("(3+5)*(6-2)") = get_val("(3+5)") * get_val("(6-2)")
|
||||
|
||||
```
|
||||
|
||||
如果我们能确定,表达式字符串中最后一个被计算的运算符的位置,我们就可以把原表达式字符串分成两部分,进行递归求解。所以,**找到最后一个被计算的运算符的位置,才是我们完成程序的关键**。
|
||||
|
||||
到了这里,关于如何利用表达式树来解决表达式计算问题,我们就解释完了。
|
||||
|
||||
最后,我们就来看一下,确定运算符计算顺序的处理技巧。
|
||||
|
||||
## 确定运算符顺序的技巧
|
||||
|
||||
怎么确定表达式中每一个运算符的计算顺序呢?其实我们可以通过给每个运算符赋予一个权重,权重越高,代表计算优先级越高。下面我就来说说这个权重是怎么设置。
|
||||
|
||||
根据四则混合运算的基础规则,我们可以给 +、-、`*`、/ 运算符设定一个基础权重,例如,+、- 权重是1,`*`、/ 权重是 2。
|
||||
|
||||
那括号呢?我们可以对括号里面的所有运算符,额外加上一个很大的权重。假设,运算符外有1 层括号,就额外增加权重 100。如果一个运算符被套在了两层括号里面,那它的权重就应该被额外加上 200。
|
||||
|
||||
按照这个规则,请你计算下面这个表达式中,每个运算符的权重:
|
||||
|
||||
```
|
||||
((3 + 5) * 6) - 7 * 9 + 4
|
||||
|
||||
```
|
||||
|
||||
很简单,从左到右,运算符号依次是+、`*`、-、`*`、+,它们的运算符权重分别是 201、102、1、2、1。根据权重可知,最后一个被计算的运算符,应该是末尾权重为 1 的运算符,也就对应了表达式中最后一个+(加号)。根据这个加号所在位置,我们可以把表达式分成左右两部分,进行递归求解。
|
||||
|
||||
在实际编码过程中,我们可以记录一个值 temp,代表由括号产生的额外权重,当碰到左括号的时候,我们就给 temp 加上100,碰到右括号的时候,temp 就相应的减去 100。对于计算正常的 +、-、*、/ 运算符权重的时候,其权重值应该等于基础权重加上 temp 这个额外权重。
|
||||
|
||||
好了,整个程序的核心思路,我已经提供给你了,希望你能通过自己的思考,试着做出来。当然,如果你实在想不出来,可以参考文末我给出的参考代码。
|
||||
|
||||
至于如何定义变量,你可以先实现一个没有变量的表达式求值的程序,然后再将定义变量,作为一个新功能,加入到你的程序中。还记得我们之前讲的系统功能迭代过程吧?我们说:功能迭代,数据先行。对于定义变量这个功能的迭代,我们的实现过程也不例外,先思考清楚变量的值“如何存储,如何使用”,把这两个问题想明白了,功能也就开发完一半儿了。
|
||||
|
||||
## 课程小结
|
||||
|
||||
最后,我们做一下课程小结。通过今天的课程,我希望你知道:
|
||||
|
||||
1. 二叉树的三种遍历方式:前序遍历、中序遍历与后序遍历,它们主要是依据根节点的位置划分出来的。
|
||||
1. 我们掌握了表达式与其对应的表达式树的对应关系。
|
||||
1. 表达式树的后续遍历结果,就等于原表达式的值。这种特性,给我们设计表达式求值程序,提供了思维方面的指导。
|
||||
|
||||
好了,今天的课程就到这里结束了。真的想跟你再说一次“我们下期见“,可送君千里,终有一别。初航我带你,远航靠自己,我是海贼胡船长,我们江湖再见。
|
||||
|
||||
>
|
||||
参考代码
|
||||
|
||||
|
||||
```
|
||||
/*************************************************************************
|
||||
> File Name: calc.cpp
|
||||
> Author: hug
|
||||
> Created Time: 五 3/27 22:13:04 2020
|
||||
************************************************************************/
|
||||
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#define INF 0x3f3f3f3f
|
||||
|
||||
/*
|
||||
* 计算表达式 str 从 l 到 r 位置的值
|
||||
* */
|
||||
int calc(const char *str, int l, int r) {
|
||||
/*
|
||||
* pos : 根节点运算符的位置,初始化为 -1
|
||||
* priority : 根节点运算符的权重
|
||||
* temp : 由括号产生的额外权重
|
||||
* */
|
||||
int pos = -1, priority = INF - 1, temp = 0;
|
||||
for (int i = l; i <= r; i++) {
|
||||
int cur_priority = INF;
|
||||
switch (str[i]) {
|
||||
case '(': temp += 100; break;
|
||||
case ')': temp -= 100; break;
|
||||
case '+':
|
||||
case '-': cur_priority = 1 + temp;
|
||||
case '*':
|
||||
case '/': cur_priority = 2 + temp;
|
||||
default: break;
|
||||
}
|
||||
/*
|
||||
* cur_priority : 当前运算符的优先级
|
||||
* 更新区间内最低优先级的运算符的位置
|
||||
* */
|
||||
if (cur_priority <= priority) {
|
||||
pos = i;
|
||||
priority = cur_priority;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* 如果 pos == -1,说明这一段表达式中没有运算符
|
||||
* 说明,这一段表达式中只有数字,也就是递归到了树的叶子结点
|
||||
* */
|
||||
if (pos == -1) {
|
||||
int num = 0;
|
||||
for (int i = l; i <= r; i++) {
|
||||
if (str[i] < '0' || str[i] >= '9') continue;
|
||||
num = num * 10 + (str[i] - '0');
|
||||
}
|
||||
return num;
|
||||
}
|
||||
|
||||
/*
|
||||
* 递归计算得到运算符左边及右边表达式的值
|
||||
* 再根据当前运算符,得到当前表达式的值
|
||||
* */
|
||||
int a = calc(str, l, pos - 1);
|
||||
int b = calc(str, pos + 1, r);
|
||||
switch (str[pos]) {
|
||||
case '+': return a + b;
|
||||
case '-': return a - b;
|
||||
case '*': return a * b;
|
||||
case '/': return a / b;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int get_val(const char *str) {
|
||||
return calc(str, 0, strlen(str) - 1);
|
||||
}
|
||||
|
||||
int main() {
|
||||
char str[1000];
|
||||
while (scanf("%[^\n]", str) != EOF) {
|
||||
getchar();
|
||||
printf("%s = %d\n", str, get_val(str));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
```
|
Reference in New Issue
Block a user