Wednesday, 13 May 2015

Learning Python (3) - Bowling Scores Kata

The Bowling Scores Kata is widely discussed. This version is after the description in Emily Bache's book. I note that the Ron Jeffries' description doesn't have the special symbol for spare, which simplifies things. Essentially the goal is to produce a score for a line of frames in 10 pin bowling. Not roll by roll, just the end score. It is just complex enough to allow a couple of different approaches, but still compact enough to solve quickly. It's an exercise I've given programming students, and done myself in Java a couple of times before. This got it onto my list of katas, to look at how my approaches to the two languages differ.

I've taken a couple of different approaches in the Java version to compare them. In Python my instinct was to take a functional approach: consume a frame, produce a score and the unconsumed frames. Consuming a frame is easy to build up test by test, to move from adding scores to handling the more complex features of spares and strikes. Once a frame can be handled, a whole game is easy.

So, tests first, in the order written.

import unittest
import bowlinggame

class Test(unittest.TestCase):

  def testScoreNumberTurn(self):
    (score, remainder) = bowlinggame.processTurn("12")
    self.assertEqual(3, score)
    self.assertEqual(0, len(remainder))

  def testScoreNoHit(self):
    (score, remainder) = bowlinggame.processTurn("--")
    self.assertEqual(0, score)
    self.assertEqual(0, len(remainder))
  #I'm sufficiently confident of the way scoreNormalRoll is being used not to test "1-" and "-1" etc
  def testScoreNumberTurnNotLast(self):
    (score, remainder) = bowlinggame.processTurn("1234")
    self.assertEqual(3, score)
    self.assertEqual(2, len(remainder))

  def testScoreSpareTurnNotLast(self):
    (score, remainder) = bowlinggame.processTurn("1/34")
    self.assertEqual(13, score)
    self.assertEqual(2, len(remainder))

  def testScoreStrikeFollowedByTwoBalls(self):
    (score, remainder) = bowlinggame.processTurn("X12")
    self.assertEqual(13, score)
    self.assertEqual(2, len(remainder))

  def testScoreStrikeFollowedBySpare(self):
    (score, remainder) = bowlinggame.processTurn("X1/")
    self.assertEqual(20, score)
    self.assertEqual(2, len(remainder))
  def testScoreStrikeFollowedByNumbers(self):
    (score, remainder) = bowlinggame.processTurn("X12")
    self.assertEqual(13, score)
    self.assertEqual(2, len(remainder))

  def testScoreStrikeFollowedByStrikeNumbers(self):
    (score, remainder) = bowlinggame.processTurn("XX23")
    self.assertEqual(22, score)
    self.assertEqual(3, len(remainder))
  def testScoreStrikeFollowedByStrikes(self):
    (score, remainder) = bowlinggame.processTurn("XXXX")
    self.assertEqual(30, score)
    self.assertEqual(3, len(remainder))

  def testSimpleGame(self):
    score = bowlinggame.processGame("12345123451234512345")
    self.assertEqual(60, score)

  def testPerfectGame(self):
    score = bowlinggame.processGame("XXXXXXXXXXXX")
    self.assertEqual(300, score)

  def testHeartbreakGame(self):
    score = bowlinggame.processGame("9-9-9-9-9-9-9-9-9-9-")
    self.assertEqual(90, score)

  def testAllSparesGame(self):
    score = bowlinggame.processGame("5/5/5/5/5/5/5/5/5/5/5")
    self.assertEqual(150, score)

if __name__ == "__main__":
    #import sys;sys.argv = ['', 'Test.testName']

And the code. The various methods were added to as the tests were produced, with a couple of refactoring passes.
Bowling Game Kata
Created on 11 May 2015
@author: Dan Chalmers 

def processGame(line):
  score = 0
  for _ in range(0,10):
    (turnScore, line) = processTurn(line)
    score += turnScore
  return score  
def processTurn(throws): 
  if throws[1] == '/':
    return (10+scoreExtraBalls(1, throws[2:]), throws[2:])
  elif throws[0] == 'X':
    return (10+scoreExtraBalls(2, throws[1:]), throws[1:])
    return (scoreNormalRoll(throws[0]) + scoreNormalRoll(throws[1]), throws[2:])

# Strikes and spares get 2 or 1 extra balls, with no further extras, that just score      
def scoreExtraBalls(extras, throws):
  if extras == 0:
    return 0
  # A spare extra ball will give 10 overall, needs some special code. 
  # But a strike in the extras works as a number!
  elif extras == 2 and throws[1] == '/':
      return 10
    return scoreNormalRoll(throws[0]) + scoreExtraBalls(extras-1, throws[1:])
def scoreNormalRoll(throw):
  if throw == '-':
    return 0
  elif throw == 'X':
    return 10
    return int(throw)

A version which pre-processes spares to 10-previous (and X to 10 and - to 0) would be almost as fiddly as this, even if the core algorithm is more compact. In this version the connection between the special outcomes and the behaviour is arguably clearer.

I've written using the "consume a turn" approach in Java too, and being able to return a pair rather than making a class to generate Frame objects instantly makes this neater. The Python version has fewer named functions, and these functions are marginally more complex - but, I think, readable entities.

I think that this Kata fits some of Python's strengths neatly: a functional approach without needing to store state, and processing a stream of values (my Phone Numbers solution also played to this).

No comments:

Post a Comment