Chapter 9 · pp. 105–110
Case Study: Word Play
This chapter uses word puzzles to practice working with files, strings and loops. You will read a long list of English words from a file and write small functions that test each word for a property, such as having no letter 'e' or reading the same backward. Along the way you learn a new development plan, reduction to a previously solved problem, and some ideas about testing.
Reading word lists
The chapter's data is a plain-text file called words.txt. It holds 113,809 official crosswords, which are words that count as valid in crossword puzzles and other word games. The list comes from the Moby lexicon project, and its first word is 'aa'. Because it is plain text, you can open it in a text editor or read it from Python. (p. 105)
Opening a file. The built-in function open takes the file name and returns a file object. A common name for an input file object is fin.
fin = open('words.txt')
A file object is a value that represents an open file. It also remembers where it currently is in the file. (pp. 105, 109)
Reading one line at a time. The method readline reads up to the next newline and returns it as a string. Because the file object keeps its position, calling readline twice gives two different words: first 'aa\n', then 'aah\n'. The object moves forward after every read. (pp. 105–106)
Reading every line. The same position-tracking is why a file object works in a for loop. Each pass of the loop gives the next line:
fin = open('words.txt')
for line in fin:
word = line.strip()
print(word)
This lets a program handle every word in the list without calling readline by hand. (p. 106)
The newline problem. Every line ends with the newline character \n, so readline returns 'aa\n' and not 'aa'. The string method strip removes this trailing whitespace, so line.strip() gives 'aa'. If you skip strip, a word's length will be one too big, and comparisons with other strings can fail. For example, 'aa\n' is not equal to 'aa'. (pp. 105–106)
The search pattern
Many of the chapter's puzzles (words longer than 20 letters, words with no 'e', words using only certain letters) can be solved with the same pattern: loop over the letters, and stop early when a letter breaks the rule. (p. 107)
def has_no_e(word):
for letter in word:
if letter == 'e':
return False
return True
The loop returns False the moment it sees an 'e'. If the loop ends normally, no 'e' was found, so the function returns True. Notice that True is returned only after the whole loop has finished, because you must check every letter before you can be sure. (p. 107)
Three variations. The functions avoids, uses_only and uses_all have the same shape. They differ in two ways: which string the loop walks through, and whether the test uses in or not in. (p. 107)
- avoids(word, forbidden) loops over word and returns False if a letter is in forbidden. For example, avoids('cat', 'xyz') is True.
- uses_only(word, available) loops over word and returns False if a letter is not in available. So uses_only('hoe', 'acefhlo') is True, but uses_only('hat', 'acefhlo') is False because of 't'.
- uses_all(word, required) loops over required and returns False if a letter is not in word. So uses_all('education', 'aeiou') is True.
The loop in uses_all walks through the required letters because those are the ones that must all be found. (p. 107)
Reduction to a previously solved problem
This is a development plan in which you notice that your new problem is really an old problem in disguise, then reuse the old solution. (p. 107)
The uses_all problem is uses_only with the two arguments swapped. "Does word contain every letter of required?" is the same as "Does required use only letters found in word?" So you can write:
def uses_all(word, required):
return uses_only(required, word)
In the same way, a palindrome is a word that equals its own reverse, so is_palindrome(word) can be written as is_reverse(word, word). Reusing working code saves effort and means fewer new places for bugs to hide. (pp. 107, 109)
Looping with indices
A for loop is fine when you only need the characters. When you must compare neighbors or work from both ends, an index variable is easier. (p. 108)
Abecedarian words. A word is abecedarian if its letters appear in alphabetical order (double letters are fine). With a while loop:
def is_abecedarian(word):
i = 0
while i < len(word)-1:
if word[i+1] < word[i]:
return False
i = i+1
return True
The index i starts at 0 and the loop compares the current letter word[i] with the next letter word[i+1]. If the next letter comes earlier in the alphabet, the order is broken and the function returns False. If no break is found, it returns True. (p. 108)
Why stop at len(word)-2? Because word[i+1] must exist. Take 'flossy', which has length 6. The last pass has i = 4, comparing the second-to-last letter 's' with the last letter 'y'. That is exactly the final pair to check. (p. 108)
Palindromes with two indices. Another approach uses one index at the front moving up and one at the back moving down:
def is_palindrome(word):
i = 0
j = len(word)-1
while i < j:
if word[i] != word[j]:
return False
i = i+1
j = j-1
return True
If the letters at i and j ever differ, the word is not a palindrome. If the indices meet in the middle with no mismatch, the word reads the same both ways. For 'level', the pairs checked are l–l and e–e, and then i and j both point to the middle 'v' (or cross), so the loop ends and the answer is True. (pp. 108–109)
Debugging and testing
A common wrong belief is that a program that passes your tests must be correct. In fact, testing can show that bugs exist but can never prove there are none. This idea is credited to Edsger Dijkstra. (p. 109)
Good testing means planning cases on purpose:
- Check both outcomes. For has_no_e, test words with an 'e' (expect False) and words without one (expect True).
- Check subcases. Try an 'e' at the beginning, middle and end, and test long, short and very short words.
- Check special cases. These are atypical cases where errors often hide. The empty string is a good example.
You can also run your function over words.txt and scan the output. Be careful, though. You may spot words that were included but should not be, and still miss words that should have been included but were left out. (p. 109)
Check yourself
- What does open('words.txt') return, and why can calling readline twice give two different words?
- Why does the string 'aa\n' cause trouble when you count letters, and which method fixes it?
- In the search pattern, why must return True come after the loop and not inside it?
- How do avoids and uses_only differ, even though their code looks almost the same?
- Name two test cases for has_no_e that are not obvious, and explain why passing a few tests still does not prove a function is correct.