TDD培訓回演:四則運算(總結)

前言


上週末參加了一次代碼培訓,首次接觸了TDD(Test -Driven Development)開發方式。總的來說,能夠接受一種新的編程思想,收穫不小。本來是打算當天回家就做下培訓內容的總結,結果回家有了其他活動,然後拖到這兩天。先是抽時間把培訓內容代碼的演練自己搞了一遍,主要是回憶整個開發的過程,然後在這裏我再記錄下,分享TDD開發的魅力。

關於TDD介紹的文章網上很多,想了解下的同學可以參考下這篇博文《淺談測試驅動開發(TDD)》。

TDD開發實例

    培訓實例是實現一個普通的四則運算,以下開始整個過程。


準備工作:環境VC6,新建工程,新建expr.cpp / test.cpp文件。


三步軍規(重點)
  1. 編譯通過,測試不過;(主要是準備測試用例,測試用例的構建是在準確分析了需求之上的,一句話就是將需求細化,明確至最小)
  2. 快速實現,運行通過;(在步驟一基礎上,快速實現功能代碼,使所有測試用例均能運行通過)
  3. 消除重複,重構優化,領域規則的抽象;(對代碼進行重構,消除重複,甚至進行領域規則的抽象。這個真的很難,往往在實現前兩步驟時,消除冗餘還好些,至於後面的抽象完全有想象,沒有方向。然後,我反正是第一次在寫代碼時候聽說領域規則這麼個詞,表示不那麼明白,後來自己理解就是提取出某一類相似的有規律的內容...)
    三步軍規這是培訓教練們反覆強調的TDD開發步驟,雖然略有循規蹈矩的嫌疑,但對於我們新人來說,TDD開發方式的入門門檻很高,沒有一定的積累和經驗,我感覺學不來。所以按流程走夯實基礎不失爲一種好的入門方法。

1. 編譯通過,測試不過


    測試驅動開發,自然就應該首先編寫測試用例。測試用例的編寫其實是一個很難的部分,當然,因爲是培訓實例,需求都很明確化了,所以這點只能自己感受吧。下面直接給出test.cpp中的測試用例代碼,需要說明的是,以下的測試用例都不是一蹴而就寫好的,而是隨着課程的深入一步一步添加的,這裏僅給出最後的總體用例,用例的添加過程在後面的代碼演進中體現吧。
/* 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...
}
/* expr.cpp */
int expr(const char *str)
{
	/* Nothing... */

	return 0;
}

2. 快速實現,重構抽象

    之所以將軍規中的二、三步合在一塊,是因爲培訓中都是完成一類測試用例功能,然後對比,重構、抽象,這都是自然一體的,沒有明顯地分割。

=====(加減法)==============================華麗的分割線=====================================================

    先從解析表達式的數字開始,即通過用例:assert(expr("1") == 1)...,然後實現加減法,中間的步驟都用註釋,代碼如下。
/* 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;
	*/
}
    (當時的內心獨白:代碼培訓課就培訓這玩意...後來事實是我錯了...)

    OK,先優化這段代碼吧,覺得不爽的地方:數字太多,表達式重複。解決它,直接看代碼:
(注意代碼變化的次序!)
/* 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;
}
    到這裏就實現了簡單表達式的連續加減法了,但似乎離目標還差很遠。。不急,先提取出來加減法的東西,代碼變化如下:
/* 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);
}
=====(乘除法)==============================華麗的分割線=====================================================

    到這裏,開始進行乘除法的測試用例。這裏先提供int expr_muldiv(const char *)接口,用來代替expr(const char *)的功能。通過加減法的分析,如果不計算加減,只做乘除,即通過用例:assert(expr("4*5") == 20)... 基本可以用一模一樣的代碼,無非就是add_sub中的條件不一樣,所以代碼如下,很容易理解:
/* 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);
}
=====(混合運算)==============================華麗的分割線=====================================================

    通過對比上面的代碼,單加減,單乘除的代碼是一致的,那如果混合運算還是不行。。。先不要想太多,直接一步一步來,開始再跑混合運算用例。上面的代碼的套路就是解析數字,然後判斷運算符,最後進行計算。現在直接觀察測例assert(expr("2+4*2") == 10)...先算單乘4*2=8,然後算單加2+8=10;最後一步本質還是加法,所以看add_sub()修改後的代碼:
/* 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;
}
    再一次對比上面兩段代碼的變化,以及mul_div()的實現,可以看出結論,完全可以通過mul_div()代替parse_num(),所以變化代碼如下:
/* 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);
}
*/
    到這一步,除了帶括號的表達式,四則運算都能實現了,再繼續對比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()判斷加減運算符。
    所以,我們可以看出乘除運算符比加減運算符優先級更高(這結論牛!)。。。所以,我們可以這麼抽象,代碼如下:
/* 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);
}
=====(帶括號混合運算)==============================華麗的分割線=====================================================

    OK,以上已經有點框架的樣子了。下面可以開始進行帶括號的表達式運算了。繼續運行測試用例assert(expr("(1)") == 1)...進行有括號運算的最原始代碼:
/* 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;
}
    看到代碼,似乎懂得了什麼吧。帶括號的表達式其實依然只是解析數字時有變化。照上面的思路縷一下:
    1)無括號,表達式只有加減運算時,先取數字;表達式含加減乘除運算時,先算乘除;
    2)有括號,只有加減運算時,先去左括號,再取數字,再去右括號...
    那麼,有括號,加減乘除運算時???

    這裏可以這麼思考,括號中的表達式又是一個新的子表達式,完全可以通過上面的無括號表達式計算方法運算。所以,照這個思路,代碼更改如下:
/* 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;
}
    再仔細觀察上面的代碼,去左括號,算子表達式的值,去右括號,這幾步動作的實質就是優先獲取子表達式的值,按照這個思路,重構優化代碼:
/* 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;
}
    到了這一步,真有種“今日劈開旁門,方見明月如洗”的感覺。將parse_num()改成parse_bracket_num(),然後就發現這個expr_bracket_addsub(const char *str)的實質不就是add_sub()麼?不相信自己的話,繼續跑測試用例,之前的測試用例全部覆蓋通過,完全無壓力。所以,這個expr_bracket_addsub()又是多餘的代碼,直接expr()就OK了。

=====(擴展:冪乘運算)==============================華麗的分割線=====================================================

    到這一步四則運算所有功能全部完成。。但怎麼知道這麼個抽象的代碼框架就是好的呢?先看下走到這一步的完整代碼:
/* 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);
}
    通過領域規則抽象出的接口oper()的參數,我們可以看出,ctx是計算表達式環境,高優先計算函數,低優先運算符判斷。既然是通用領域規則的抽象,那如果我現在再加入更高優先級的運算,能不能擴展這個運算功能。嗯,以冪乘爲例。

    冪乘比乘除優先級更大,運算符的優先級更高,所以,我們擴展並修改下這麼一段代碼:
/* 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;
		for (int i = 1; i < right; i++)
		{
			Result *=  left;
		}
	}

	return Result;
}
    就這麼一小段,冪乘功能添加。並且絲毫不用再考慮各種負責的場景。不相信自己的話,編寫冪乘測試用例,去跑測試。。

=====(異常處理)==============================華麗的分割線=====================================================

    你以爲到這就結束了麼??No,除了上面代碼框架的抽象,這也是我當初內心獨白錯了的原因。上面我們介紹了正常功能以及擴展功能的實現,說明了所實現代碼框架的可讀、可擴展。接着,我們開始處理異常情況。

    先分析什麼是異常,目前那肯定就只能是表達式的異常,那又有多少類異常??當時,教練們讓我們自己枚舉...然後就可以腦補當時場景了。再然後,聽了N種異常後,教練只說了一句淡定的話,你們說了這麼多,就是解析表達式的數字、運算符解析不出來麼??一語中的!!!

    那麼爲什麼解析不出來?就是本應該是數字或者運算符的那一個位置出現了別的字符。OK,按照這個思路,我們修改下解析數字功能函數的功能不就可以了??直接看修改代碼:
/* 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;
}
    上面的修改不解釋了。爲什麼要在結構體增加錯誤碼,異常處理的位置爲什麼就是這麼簡單地放在expr()和parse_bracket_num()裏,真的就是經驗積累了,個人認爲。

總結

    TDD開發優勢很明顯,因爲有測例的保障,快速反饋,問題定位,對寫的代碼更加信心。同時,不足也有很多,一方面TDD的測例編寫需要不斷細化需求;另一方面,重構,領域規則抽象是確實需要有實打實的經驗積累的,並不是分分鐘就能get()√的技能,分分鐘get()√的只能是這種方式,思想。另外,TDD特別適合中小型的互聯網項目,因爲它本身就是伴隨敏捷開發而來一種開發方式。總的來說,TDD開發是一個從量變到質變的過程,隨着測例增多而進行重構、優化,進而領域規則抽象,從而實現理想的代碼。



發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章