您的位置:首页 > 其它

TDD培训回演:四则运算(总结)

2014-09-27 15:15 302 查看

前言

上周末参加了一次代码培训,首次接触了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>
(当时的内心独白:代码培训课就培训这玩意...后来事实是我错了...)

OK,先优化这段代码吧,觉得不爽的地方:数字太多,表达式重复。解决它,直接看代码:
(注意代码变化的次序!)
<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, Opt, 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, Opt, Right);
	}

	return Result;
}

int expr(const char *str)
{
	/*  代码7 */
	context ctx = {str, 0};
	
	return add_sub(ctx);
}</span>

=====(乘除法)==============================华丽的分割线=====================================================

到这里,开始进行乘除法的测试用例。这里先提供int expr_muldiv(const char *)接口,用来代替expr(const char *)的功能。通过加减法的分析,如果不计算加减,只做乘除,即通过用例:assert(expr("4*5") == 20)... 基本可以用一模一样的代码,无非就是add_sub中的条件不一样,所以代码如下,很容易理解:

<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, Opt, 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, Opt, 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, Opt, 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, Opt, 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, Opt, 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, Opt, 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, Opt, 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, Opt, 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, Opt, 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, Opt, 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, 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;
}

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, Opt, Right);
	}

	return Result;
}

int mul_div(context &ctx)
{
	return oper(ctx, parse_bracket_num, is_mul_or_div);
}

int add_sub(context &ctx)
{
	return oper(ctx, mul_div, 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, parse_bracket_num, is_pow);
}

int mul_div(context &ctx)
{
	return oper(ctx, pow, is_mul_or_div);
}

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;
	} 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,按照这个思路,我们修改下解析数字功能函数的功能不就可以了??直接看修改代码:

<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()里,真的就是经验积累了,个人认为。

总结

TDD开发优势很明显,因为有测例的保障,快速反馈,问题定位,对写的代码更加信心。同时,不足也有很多,一方面TDD的测例编写需要不断细化需求;另一方面,重构,领域规则抽象是确实需要有实打实的经验积累的,并不是分分钟就能get()√的技能,分分钟get()√的只能是这种方式,思想。另外,TDD特别适合中小型的互联网项目,因为它本身就是伴随敏捷开发而来一种开发方式。总的来说,TDD开发是一个从量变到质变的过程,随着测例增多而进行重构、优化,进而领域规则抽象,从而实现理想的代码。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: