前言
上周末参加了一次代码培训,首次接触了TDD(Test -Driven Development)开发方式。总的来说,能够接受一种新的编程思想,收获不小。本来是打算当天回家就做下培训内容的总结,结果回家有了其他活动,然后拖到这两天。先是抽时间把培训内容代码的演练自己搞了一遍,主要是回忆整个开发的过程,然后在这里我再记录下,分享TDD开发的魅力。
关于TDD介绍的文章网上很多,想了解下的同学可以参考下这篇博文《浅谈测试驱动开发(TDD)》。
TDD开发实例
培训实例是实现一个普通的四则运算,以下开始整个过程。
准备工作:环境VC6,新建工程,新建expr.cpp / test.cpp文件。
三步军规:(重点)
- 编译通过,测试不过;(主要是准备测试用例,测试用例的构建是在准确分析了需求之上的,一句话就是将需求细化,明确至最小)
- 快速实现,运行通过;(在步骤一基础上,快速实现功能代码,使所有测试用例均能运行通过)
- 消除重复,重构优化,领域规则的抽象;(对代码进行重构,消除重复,甚至进行领域规则的抽象。这个真的很难,往往在实现前两步骤时,消除冗余还好些,至于后面的抽象完全有想象,没有方向。然后,我反正是第一次在写代码时候听说领域规则这么个词,表示不那么明白,后来自己理解就是提取出某一类相似的有规律的内容...)
三步军规这是培训教练们反复强调的TDD开发步骤,虽然略有循规蹈矩的嫌疑,但对于我们新人来说,TDD开发方式的入门门槛很高,没有一定的积累和经验,我感觉学不来。所以按流程走夯实基础不失为一种好的入门方法。
1. 编译通过,测试不过
测试驱动开发,自然就应该首先编写测试用例。测试用例的编写其实是一个很难的部分,当然,因为是培训实例,需求都很明确化了,所以这点只能自己感受吧。下面直接给出test.cpp中的测试用例代码,需要说明的是,以下的测试用例都不是一蹴而就写好的,而是随着课程的深入一步一步添加的,这里仅给出最后的总体用例,用例的添加过程在后面的代码演进中体现吧。
<span style="font-size:18px;">/* test.cpp */ #include <assert.h> extern int expr(const char *); /* 演练过程中主要用到的代替接口 */ // extern int expr_muldiv(const char *); // extern int expr_bracket_addsub(const char *); // extern int expr_bracket_muldiv(const char *); void main() { // test_pares_num assert(expr("1") == 1); assert(expr("2") == 2); // test_addsub assert(expr("1+2") == 3); assert(expr("2+3+4") == 9); assert(expr("2+3-4") == 1); // test_muldiv assert(expr("1") == 1); // assert(expr_muldiv("1") == 1); assert(expr("4*5") == 20); assert(expr("4*5*6") == 120); assert(expr("4*5/2") == 10); // test_mix assert(expr("2+4*2") == 10); assert(expr("2+4/2") == 4); assert(expr("1+2+3*4") == 15); assert(expr("2*3+4/2") == 8); // test_mix_bracket assert(expr("(1)") == 1); // assert(expr_bracket_addsub("1") == 1); or expr_bracket_muldiv()... assert(expr("(1+1)") == 2); assert(expr("(2+(3-4))") == 1); assert(expr("(2*(5+1)") == 12); assert(expr("(2+3)-(4-2)") == 3); assert(expr("(2+3)*(4/2)") == 10); // test_pow... }</span>
<span style="font-size:18px;">/* expr.cpp */ int expr(const char *str) { /* Nothing... */ return 0; }</span>
2. 快速实现,重构抽象
=====(加减法)==============================华丽的分割线=====================================================
先从解析表达式的数字开始,即通过用例:assert(expr("1") == 1)...,然后实现加减法,中间的步骤都用注释,代码如下。
<span style="font-size:18px;">/* expr.cpp */ int expr(const char *str) { /* 代码1 (表示expr.cpp中重要代码变化的次序) */ // return str[0] - '0'; assert(expr("1") == 1) /* 代码2 */ /* assert(expr("1+2") == 3) int i = 0; int Result = str[0] - '0'; char Opt = str[1]; int Right = str[2] - '0'; Result = Result + Right; return Result; */ }</span>(当时的内心独白:代码培训课就培训这玩意...后来事实是我错了...)
(注意代码变化的次序!)
<span style="font-size:18px;">/* expr.cpp */ /* 构造出这个结构,str意义不变,pos代表上面字符串数组的下标位置,这个不难理解 */ typedef struct { const char *str; int pos; } context; int parse_opt(context &ctx) { return ctx.str[ctx.pos++]; } int parse_num(context &ctx) { return ctx.str[ctx.pos++] - '0'; } int expr(const char *str) { /* 代码3 */ /* 这是优化前面两个用例之后的代码,然后继续开始跑连加的用例, assert(expr("2+3+4") == 9)...,实现代码4 context ctx = {str,0}; int Result = parse_num(ctx); char Opt = parse_opt(ctx); int Right = parse_num(ctx); Result = Result + Right; return Result; */ /* 代码4 */ /* context ctx = {str,0}; int Result = parse_num(ctx); while (ctx.str[ctx.pos] == '+') // 考虑到'-'与'+'同优先级,连减的测试用例通过过程一致,在这里加条件,实现代码5 { char Opt = parse_opt(ctx); int Right = parse_num(ctx); Result = Result + Right; } return Result; */ /* 代码5 */ /* 考虑到'-'与'+'同优先级,连减的测试用例通过过程一致,看实现'+' 与 '-'代码,实现代码6 context ctx = {str,0}; int Result = parse_num(ctx); while (ctx.str[ctx.pos] == '+' || ctx.str[ctx.pos] == '+') // 条件太长,继续优化,看下面代码 { char Opt = parse_opt(ctx); int Right = parse_num(ctx); // Result = Result + Right; 这里Opt可以为'+'、‘-’,所以这个表达式不合适,继续提取 Result = calc(Result,Opt,Right); // calc()代码往下看 } return Result; */ /* 代码6 */ context ctx = {str,0}; int Result = parse_num(ctx); while (is_add_or_sub(ctx)) // is_add_or_sub()代码往下看 { char Opt = parse_opt(ctx); int Right = parse_num(ctx); Result = calc(Result,Right); } return Result; } bool is_add_or_sub(context &ctx) { return ctx.str[ctx.pos] == '+' || ctx.str[ctx.pos] == '-'; } int calc(int left,char opt,int right) { int Result = 0; if (opt == '+') { Result = left + right; } else if (opt == '-') { Result = left - right; } else if (opt == '*') { Result = left * right; } else if (opt == '/') { Result = left / right; } return Result; }</span>到这里就实现了简单表达式的连续加减法了,但似乎离目标还差很远。。不急,先提取出来加减法的东西,代码变化如下:
<span style="font-size:18px;">/* expr.cpp */ int add_sub(context &ctx) { int Result = parse_num(ctx); while (is_add_or_sub(ctx)) { char Opt = parse_opt(ctx); int Right = parse_num(ctx); Result = calc(Result,Right); } return Result; } int expr(const char *str) { /* 代码7 */ context ctx = {str,0}; return add_sub(ctx); }</span>
=====(乘除法)==============================华丽的分割线=====================================================
<span style="font-size:18px;">/* expr.cpp */ bool is_mul_or_div(context &ctx) { return ctx.str[ctx.pos] == '*' || ctx.str[ctx.pos] == '/'; } int mul_div(context &ctx) { int Result = parse_num(ctx); while (is_mul_or_div(ctx)) { char Opt = parse_opt(ctx); int Right = parse_num(ctx); Result = calc(Result,Right); } return Result; } int expr_muldiv(const char *str) { /* 代码8 */ context ctx = {str,0}; return mul_div(ctx); }</span>=====(混合运算)==============================华丽的分割线=====================================================
通过对比上面的代码,单加减,单乘除的代码是一致的,那如果混合运算还是不行。。。先不要想太多,直接一步一步来,开始再跑混合运算用例。上面的代码的套路就是解析数字,然后判断运算符,最后进行计算。现在直接观察测例assert(expr("2+4*2") == 10)...先算单乘4*2=8,然后算单加2+8=10;最后一步本质还是加法,所以看add_sub()修改后的代码:
<span style="font-size:18px;">/* expr.cpp */ int add_sub(context &ctx) { int Result = parse_num(ctx); while (is_add_or_sub(ctx)) { char Opt = parse_opt(ctx); int Right = mul_div(ctx); // 这一步解析的数字Right应该是8,这个8怎么得来的?通过单乘 mul_div()得来。 Result = calc(Result,Right); } return Result; } // 同理,如果是测例assert(expr("2*4+2") == 10)...即最后是计算单加8+2=10;所以,代码应该是: /* expr.cpp */ int add_sub(context &ctx) { int Result = mul_div(ctx); // 这一步解析的数字Result应该是8,这个8怎么得来的?通过单乘 mul_div()得来。 while (is_add_or_sub(ctx)) { char Opt = parse_opt(ctx); int Right = parse_num(ctx); Result = calc(Result,Right); } return Result; }</span>再一次对比上面两段代码的变化,以及mul_div()的实现,可以看出结论,完全可以通过mul_div()代替parse_num(),所以变化代码如下:
<span style="font-size:18px;">/* expr.cpp */ int add_sub(context &ctx) { /* 代码9 */ int Result = mul_div(ctx); while (is_add_or_sub(ctx)) { char Opt = parse_opt(ctx); int Right = mul_div(ctx); Result = calc(Result,Right); } return Result; } int mul_div(context &ctx) { int Result = parse_num(ctx); while (is_add_or_sub(ctx)) { char Opt = parse_opt(ctx); int Right = parse_num(ctx); Result = calc(Result,Right); } return Result; } int expr(const char *str) { context ctx = {str,0}; return add_sub(ctx); } /* 到这里,可以看到expr_muldiv功能完全多余,add_sub就已经提供了加减乘除四则运算了。 int expr_muldiv(const char *str) { context ctx = {str,0}; return mul_div(ctx); } */</span>到这一步,除了带括号的表达式,四则运算都能实现了,再继续对比add_sub、mul_div,简直就是惊人的相似。行吧,不用说需要消除重复,那就开始重构吧。
观察add_sub()、mul_div()可以发现,除了开头解析数字和while循环条件外,其他并无不同。再分析:
当运行add_sub()时,最开始优先是解析数字,后来因为混合运算,变成了接卸乘除运算的结果,即parse_num(ctx) --> mul_div(ctx)。当运行mul_div()时,依然保持优先解析数字;
再看判断条件,add_sub中的第一轮while循环应该是Result = mul_div(ctx)这行代码mul_div(ctx)中的while(),该while条件判断乘除运算符,返回乘除计算结果后再进入外面while()判断加减运算符。
所以,我们可以看出乘除运算符比加减运算符优先级更高(这结论牛!)。。。所以,我们可以这么抽象,代码如下:
<span style="font-size:18px;">/* expr.cpp */ /* 代码10 */ typedef int (*HIGH_OPER)(context &ctx); // HIGH_OPER函数指针表示优先运算 typedef bool (*IS_LOW_OPT)(context &ctx); // 判断优先级低的运算符 int oper(context &ctx,HIGH_OPER pHighOper,IS_LOW_OPT pIsLowOpt) { int Result = pHighOper(ctx);; while (pIsLowOpt(ctx)) { char Opt = parse_opt(ctx); int Right = pHighOper(ctx); Result = calc(Result,Right); } return Result; } int mul_div(context &ctx) { return oper(ctx,parse_num,is_mul_or_div); } int add_sub(context &ctx) { return oper(ctx,mul_div,is_add_or_sub); }</span>=====(带括号混合运算)==============================华丽的分割线=====================================================
OK,以上已经有点框架的样子了。下面可以开始进行带括号的表达式运算了。继续运行测试用例assert(expr("(1)") == 1)...进行有括号运算的最原始代码:
<span style="font-size:18px;">/* expr.cpp */ /* 代码11 */ void parse_left_bracket(context &ctx) { if (ctx.str[ctx.pos] == '(') { ctx.pos++; } } void parse_right_bracket(context &ctx) { if (ctx.str[ctx.pos] == ')') { ctx.pos++; } } /* 带括号的加减 */ int expr_bracket_addsub(const char *str) { context ctx = {str,0}; // assert(expr("(1)") == 1); assert(expr("(1+1)") == 1)... int Result = 0; parse_left_bracket(ctx); Result = parse_num(ctx); parse_right_bracket(ctx); while (is_add_or_sub(ctx)) { char Opt = parse_opt(ctx); parse_left_bracket(ctx); int Right = parse_num(ctx); parse_right_bracket(ctx); Result = calc(Result,Right); } return Result; } /* 带括号的乘除 */ int expr_bracket_muldiv(const char *str) { context ctx = {str,0}; // assert(expr("(1)") == 1); assert(expr("(1*2)") == 1)... int Result = 0; parse_left_bracket(ctx); Result = parse_num(ctx); parse_right_bracket(ctx); while (is_mul_or_div(ctx)) { char Opt = parse_opt(ctx); parse_left_bracket(ctx); int Right = parse_num(ctx); parse_right_bracket(ctx); Result = calc(Result,Right); } return Result; }</span>看到代码,似乎懂得了什么吧。带括号的表达式其实依然只是解析数字时有变化。照上面的思路缕一下:
无括号,表达式只有加减运算时,先取数字;表达式含加减乘除运算时,先算乘除;
有括号,只有加减运算时,先去左括号,再取数字,再去右括号...
那么,有括号,加减乘除运算时???
这里可以这么思考,括号中的表达式又是一个新的子表达式,完全可以通过上面的无括号表达式计算方法运算。所以,照这个思路,代码更改如下:
<span style="font-size:18px;">/* expr.cpp */ /* 代码12 */ /* 带括号的加减乘除 */ int expr_bracket_addsub(const char *str) { context ctx = {str,0}; // assert(expr("(1)") == 1); assert(expr("(1+1)") == 1)... int Result = 0; parse_left_bracket(ctx); Result = add_sub(ctx); parse_right_bracket(ctx); while (is_add_or_sub(ctx)) { char Opt = parse_opt(ctx); parse_left_bracket(ctx); int Right = add_sub(ctx); parse_right_bracket(ctx); Result = calc(Result,Right); } return Result; }</span>再仔细观察上面的代码,去左括号,算子表达式的值,去右括号,这几步动作的实质就是优先获取子表达式的值,按照这个思路,重构优化代码:
<span style="font-size:18px;">/* expr.cpp */ /* 代码13 */ int parse_bracket_num(context &ctx) { int Result = 0; if (ctx.str[ctx.pos] == '(') { ctx.pos++; Result = add_sub(ctx); parse_right_bracket(ctx); } else if (ctx.str[ctx.pos] >= '0' && ctx.str[ctx.pos] <= '9') { Result = ctx.str[ctx.pos++] - '0'; } return Result; } int mul_div(context &ctx) { return oper(ctx,parse_bracket_num,is_mul_or_div); } /* 带括号的加减乘除 */ int expr_bracket_addsub(const char *str) { context ctx = {str,0}; // assert(expr("(1)") == 1); assert(expr("(1+1)") == 1)... int Result = parse_bracket_num(ctx); while (is_add_or_sub(ctx)) { char Opt = parse_opt(ctx); int Right = parse_bracket_num(ctx);; Result = calc(Result,Right); } return Result; }</span>到了这一步,真有种“今日劈开旁门,方见明月如洗”的感觉。将parse_num()改成parse_bracket_num(),然后就发现这个expr_bracket_addsub(const char *str)的实质不就是add_sub()么?不相信自己的话,继续跑测试用例,之前的测试用例全部覆盖通过,完全无压力。所以,这个expr_bracket_addsub()又是多余的代码,直接expr()就OK了。
=====(扩展:幂乘运算)==============================华丽的分割线=====================================================
<span style="font-size:18px;">/* expr.cpp */ /* 代码12 */ typedef struct { const char *str; int pos; } context; typedef int (*HIGH_OPER)(context &ctx); typedef bool (*IS_LOW_OPT)(context &ctx); void parse_right_bracket(context &ctx) { if (ctx.str[ctx.pos] == ')') { ctx.pos++; } } int parse_opt(context &ctx) { return ctx.str[ctx.pos++]; } int parse_bracket_num(context &ctx) { int Result = 0; if (ctx.str[ctx.pos] == '(') { ctx.pos++; Result = add_sub(ctx); parse_right_bracket(ctx); } else if (ctx.str[ctx.pos] >= '0' && ctx.str[ctx.pos] <= '9') { Result = ctx.str[ctx.pos++] - '0'; } return Result; } bool is_mul_or_div(context &ctx) { return ctx.str[ctx.pos] == '*' || ctx.str[ctx.pos] == '/'; } bool is_add_or_sub(context &ctx) { return ctx.str[ctx.pos] == '+' || ctx.str[ctx.pos] == '-'; } int calc(int left,int right) { int Result = 0; if (opt == '+') { Result = left + right; } else if (opt == '-') { Result = left - right; } else if (opt == '*') { Result = left * right; } else if (opt == '/') { Result = left / right; } return Result; } int oper(context &ctx,is_add_or_sub); } int expr(const char *str) { context ctx = {str,0}; return add_sub(ctx); }</span>通过领域规则抽象出的接口oper()的参数,我们可以看出,ctx是计算表达式环境,高优先计算函数,低优先运算符判断。既然是通用领域规则的抽象,那如果我现在再加入更高优先级的运算,能不能扩展这个运算功能。嗯,以幂乘为例。
幂乘比乘除优先级更大,运算符的优先级更高,所以,我们扩展并修改下这么一段代码:
<span style="font-size:18px;">/* expr.cpp */ /* 代码13 */ bool is_pow(context &ctx) { return ctx.str[ctx.pos] == '^' ; } int pow(context &ctx) { return oper(ctx,is_pow); } int mul_div(context &ctx) { return oper(ctx,pow,is_mul_or_div); } int calc(int left,int right) { int Result = 0; if (opt == '+') { Result = left + right; } else if (opt == '-') { Result = left - right; } else if (opt == '*') { Result = left * right; } else if (opt == '/') { Result = left / right; } if (opt == '^') // 扩展的代码 { Result = left;<span style="white-space:pre"> </span>for (int i = 1; i < right; i++) { Result *= left; } } return Result; }</span>就这么一小段,幂乘功能添加。并且丝毫不用再考虑各种负责的场景。不相信自己的话,编写幂乘测试用例,去跑测试。。
=====(异常处理)==============================华丽的分割线=====================================================
你以为到这就结束了么??No,除了上面代码框架的抽象,这也是我当初内心独白错了的原因。上面我们介绍了正常功能以及扩展功能的实现,说明了所实现代码框架的可读、可扩展。接着,我们开始处理异常情况。
先分析什么是异常,目前那肯定就只能是表达式的异常,那又有多少类异常??当时,教练们让我们自己枚举...然后就可以脑补当时场景了。再然后,听了N种异常后,教练只说了一句淡定的话,你们说了这么多,就是解析表达式的数字、运算符解析不出来么??一语中的!!!
那么为什么解析不出来?就是本应该是数字或者运算符的那一个位置出现了别的字符。OK,按照这个思路,我们修改下解析数字功能函数的功能不就可以了??直接看修改代码:
先分析什么是异常,目前那肯定就只能是表达式的异常,那又有多少类异常??当时,教练们让我们自己枚举...然后就可以脑补当时场景了。再然后,听了N种异常后,教练只说了一句淡定的话,你们说了这么多,就是解析表达式的数字、运算符解析不出来么??一语中的!!!
那么为什么解析不出来?就是本应该是数字或者运算符的那一个位置出现了别的字符。OK,按照这个思路,我们修改下解析数字功能函数的功能不就可以了??直接看修改代码:
<span style="font-size:18px;">/* expr.cpp */ /* 代码14 */ typedef struct { const char *str; int pos; int errno; // 增加错误码标识异常 } context; int parse_bracket_num(context &ctx) { int Result = 0; if (ctx.str[ctx.pos] == '(') { ctx.pos++; Result = add_sub(ctx); parse_right_bracket(ctx); } else if (ctx.str[ctx.pos] >= '0' && ctx.str[ctx.pos] <= '9') { Result = ctx.str[ctx.pos++] - '0'; } else // 解析括号,解析数字都没成功,解析运算符有专门pares_opt。。所以剩下的情况自然异常 { ctx.errno = -1; } return Result; } int expr(const char *str) { context ctx = {str,0}; int Result = add_sub(ctx); if (ctx.errno == -1) // 异常处理 { return -1; } return Result; }</span>上面的修改不解释了。为什么要在结构体增加错误码,异常处理的位置为什么就是这么简单地放在expr()和parse_bracket_num()里,真的就是经验积累了,个人认为。