Bowling Game是一道经典的编程Kata,而且是Bob大叔推荐的Kata,那么它究竟有什么样的魅力呢?这次的星云道场就和大家一起来说道说道。
题设
保龄球比赛一局有10轮,每轮选手有两次投球机会来击倒全部10个球瓶。如果第一球就击倒全部球瓶,称之为“strike”,用“x”表示,得分为10分再加下两球的倒瓶数;如果第一球未全中则再打一球,当第二球击倒剩余球瓶,称之为“spare”,用“/”表示,得分为10分再加下轮第一球的倒瓶数;如第二球也未击倒所有球瓶,称之为“miss”,用“-”表示,得分就是两次击倒球瓶之和。
当比赛到第10轮时获得三次投球机会,如第一次全中,仍可继续投完剩余两个球;如果补中,则继续投完剩余一球;如果两次投球未击倒全部球瓶,则比赛结束。计算得分最高者则为优胜,计分范例如下:
- X X X X X X X X X X X X (12 投,12 全中) = 10轮 * 30分 = 300分
- 9- 9- 9- 9- 9- 9- 9- 9- 9- 9- (20 投,10 次失误,每次9分) = 10轮 * 9分 = 90分
- 5/ 5/ 5/ 5/ 5/ 5/ 5/ 5/ 5/ 5/5 (21投,10次补中,第一球均为5分,第10轮第三投也为5分) = 10轮 * 15分 = 150分
分析
题设围绕保龄球规则展开,初看不难,但如果深究题设就会马上陷入一些“重要”的细节之中,比如一轮比赛中击中的球瓶数量,这完全就是一个随机值;再比如如何计算本轮比赛得分,似乎也不容易,因为很有可能与后面的投球相关,可后面的投球却也是随机行为。这样的问题一个接着一个,很快就占据了全部大脑细胞。
虽然上面这些问题在完成这道Kata时,可能都是需要考虑的,不过这并不意味着它们就是Bowling Kata的关注焦点,实际上它们确实不是。尝试将上述Bowling需求进行分解,就能得到两个最明显的要求:
- 统计每轮比赛投球击中的球瓶数,也就是说击中的球瓶只是输入值,在合理范围内即可
- 计算一局比赛结束后的得分,因此不必考虑那些还未进行的轮次
因此考虑设计一个具有上述两个功能的类来表达,其中roll方法用于记录每次投球击中的球瓶数量,score方法用于计算总分。
public class BowlingGame {
public void roll(int pins) {...}
public int score() {...}
}
实现
从上述分析可知,Bowling需要记录所有的投球结果才能计算获得分数。考虑几种投球结果的极端情况可得:
- 10轮均为全中,则产生12(10 + 2)个投球结果
- 10轮均为补中,则产生21(10 * 2 + 1)个投球结果
- 10轮均为未全部击中,则产生20(10 * 2)个投球结果
因此简单设计一个满足上述边界情况下的击中球瓶的存储。
public class BowlingGame {
private int rolls[] = new int[21];
private int rollIndex = 0;
public void roll(int pins) {
rolls[rollIndex++] = pins;
}
}
接着就可以采用TDD的方式开始逐个实现各种记分规则了,先来一个最简单的。
@Test
public void should_be_zero_when_no_pins_knocked_down() throws Exception {
// given
for (int i = 0; i < 20; i++) {
game.roll(0);
}
// when
// then
assertThat(game.score(), is(0));
}
这个实现不会有太大的难度,简单快速地实现就可以了,并且该实现对于每轮未能击中所有球瓶的情况都可适用。
public void roll(int pins) {
rolls[rollIndex++] = pins;
}
public int score() {
int score = 0;
for (int r : rolls) {
score += r;
}
return score;
}
在这个基础上试试出现补中的场景,对原有用例简单调整即可获得。
@Test
public void should_score_spare() throws Exception {
// given
game.roll(9);
game.roll(1);
for (int i = 0; i < 18; i++) {
game.roll(4);
}
// when
// then
assertThat(game.score(), is(86));
}
之前的实现无法满足用例是因为补中的场景中,分数的计算不再是两次投球击中的球瓶之和,而是在这个基础上增加下一次投球的击中球瓶数作为奖励,按此思路首先调整原有实现。
public int score() {
int score = 0;
int index = 0;
for (int frame = 0; frame < 10; frame++) {
score += roll[index] + roll[index + 1];
index += 2;
}
return score;
}
在此基础上增加对补中场景的判断以及得分计算方法。
public int score() {
int score = 0;
int index = 0;
for (int frame = 0; frame < 10; frame++) {
if (rolls[index] + rolls[index + 1] == 10) {
score += 10 + rolls[index + 2];
index += 2;
} else {
score += rolls[index] + rolls[index + 1];
index += 2;
}
}
return score;
}
有了前两种场景的实现,实现全中场景就可以以此类推,照例先给出测试用例。
@Test
public void should_score_strike() throws Exception {
// given
rollStrike();
rolls(18, 4);
// when
int score = game.score();
// then
assertThat(score, is(90));
}
public int score() {
int score = 0;
int index = 0;
for (int frame = 0; frame < 10; frame++) {
if (isStrike(index)) {
score += 10 + strikeBonus(index);
index += 1;
}else if (isSpare(index)) {
score += 10 + spareBonus(index);
index += 2;
} else {
score += missScore(index);
index += 2;
}
}
return score;
}
通过上面的几次迭代Bowling要求的功能就实现了,当然这种实现比较粗旷,基本上是遇到一个问题解决一个问题,没有过多的预先设计,而是在代码实现的过程中逐步地完善整个设计。
思考
为何Bob大叔要推荐Bowling,因为它足够简单不需要太多复杂的技术,运用的都是编程语言中最最基本的概念,无论是何种语言的初学者都能胜任,绝对的入门经典;同时Bowling又足够的丰富,因为它的题设是递进式的,非常适合通过TDD迭代地实现,从简入繁逐层推进,另外它还能练习重构,可以比较容易地把clean code中的一些基础原则运用在里面;最后它又是能够启发思考的,当你完成这道Kata之后你就发现对于需求关注点、职责的把握可以避免过早地陷入各种零散又相互交织的细节中,于是如何更好地把握需求、澄清需求(清楚要做什么或不做什么)就是平日工作中的首要任务了。