This lesson meets the puzzles a plain for loop cannot solve — comparing adjacent letters, and comparing a word with itself from both ends — gives three different solutions to the same problem, and closes with what testing can and cannot establish.
Subject: Python · 65 slides · code lesson
Open the interactive version of this deck
Title
Python · Chapter 9 — Case study: word play
§9.4-9.5, pp. 86-87
Objectives
Five things, each one you can check yourself at an interpreter prompt.
Think Python, 2nd edition — Allen B. Downey §9.4-9.5, pp. 86-87 — the pages these objectives are drawn from
Warm-up
The previous lesson's four functions all used a plain for loop. Find its limit.
Discussion prompt
You want to check whether a word's letters are in alphabetical order. Using for letter in word, what do you have access to on each pass — and what do you need that you do not have?
Hint: You need to compare two things.
Answer:
On each pass you have one letter and nothing else: not its position, and not the letter after it.
To check alphabetical order you need to compare each letter with the NEXT one, and the loop gives you them one at a time with no way to look ahead.
So this puzzle is genuinely different from the previous four. The book says so: for is_abecedarian we have to compare adjacent letters, which is a little tricky with a for loop — and it then gives three ways round it.
Concept
Every function in the previous lesson judged each letter on its own. These two judge each letter against another one — the next letter, or the letter the same distance from the other end — and that changes what the loop has to provide.
special case — A test case that is atypical or non-obvious, and therefore less likely to be handled correctly.
The book gives three solutions to the adjacent-letters problem, and comparing them is more useful than any one of them: a remembered previous value, recursion on a shorter word, and a while loop over indices.
Figure (svg): Two columns contrasting judging each letter alone with judging each letter against another
Think Python, 2nd edition — Allen B. Downey §9.4-9.5, pp. 86-86
Section
Section 1
Concept
A for loop gives you one letter per pass. If you remember the previous one in a variable, you can compare the two — which turns a problem about pairs into a problem about one letter and one memory.
def is_abecedarian(word):
previous = word[0]
for c in word:
if c < previous:
return False
previous = c
return True| Line | Its role | Note |
|---|---|---|
| previous = word[0] | initialize before the loop | the first letter |
| if c < previous | compare with what came before | the test |
| previous = c | remember for the next pass | the update |
| return True | after the loop | no break in the order |
The comparison uses the relational operator on strings from lesson 8c: c < previous asks whether c comes alphabetically before the letter that preceded it, which would be a break in the abecedarian trend.
Think Python, 2nd edition — Allen B. Downey §9.4-9.5, pp. 86-86
Picture it
The loop still sees one letter at a time; the variable supplies the other half of each pair.
Figure (svg): The word abet drawn as boxes with arrows showing each letter compared against the remembered previous one
Notice the count: n letters give n-1 adjacent pairs, which is the fencepost arithmetic from lesson 8a and the source of every off-by-one in this lesson.
Worked example
The first pass compares the first letter with itself. Work out why that is harmless.
def is_abecedarian(word):
previous = word[0]
for c in word:
if c < previous:
return False
previous = c
return True| Pass | What happens | Effect |
|---|---|---|
| pass 1 | c is word[0], previous is word[0] | compares a letter with itself |
| is c < previous? | a letter is not less than itself | False, so no early return |
| previous = c | unchanged on this pass | then the real comparisons begin |
Notice the first pass is a wasted comparison.
Why: c and previous are the same letter, so the test asks whether a letter is less than itself — which is always False.
See why that is safe rather than merely harmless.
Why: A False test means no early return, so the wasted pass cannot produce a wrong answer. It costs one comparison.
Consider the alternative.
Why: Starting the loop at the second letter would avoid the waste but needs an index, which is what the third solution does.
Figure (svg): A ladder showing the value of previous after each pass of the loop over the word abet
The first comparison is a letter against itself, which is always False, so it is wasted rather than wrong. Initializing previous to word[0] is what makes the first real pass work correctly.
Verify: Check what happens if previous is initialized to something else.
Why: Initializing to the empty string would make the first comparison c < '', which is False for every letter, so it also works by accident. Initializing to 'z' would make almost every word fail immediately. That the choice matters is worth knowing — the initialization is part of the algorithm, not boilerplate.
Prediction
Count the pairs, not the letters.
Predict first
For a six-letter word, how many ADJACENT PAIRS of letters are there?
Correct: Five — n letters give n-1 adjacent pairs.
Why: This is the fencepost arithmetic from lesson 8a: six posts have five gaps between them. It is the source of every off-by-one in this lesson, and it is why the index version's loop condition is i < len(word) - 1 rather than i < len(word) — the last letter has no next letter to be compared with.
Worked example
Find the pass where the trend breaks.
>>> is_abecedarian('flossy')
?| Pair | The test | What happens |
|---|---|---|
| f then l | 'l' < 'f'? no | continue |
| l then o | 'o' < 'l'? no | continue |
| o then s | 's' < 'o'? no | continue |
| s then s | 's' < 's'? no — equal is allowed | continue |
| s then y | 'y' < 's'? no | return True |
Check each adjacent pair.
Why: Five pairs for a six-letter word, and none of them breaks the order.
Notice the double letter.
Why: The pair s and s compares equal, and the test is strictly less-than, so it passes. Double letters are ok, which the exercise says explicitly.
Conclude.
Why: flossy is abecedarian: f, l, o, s, s, y are in non-decreasing alphabetical order.
Figure (svg): The state of the program after each line of Worked example a word that fails, drawn as a ladder with one rung per traced line
True. flossy passes, including its double s, because the test uses strict less-than and equal letters do not break the trend.
Verify: Check a word that should fail.
Why: is_abecedarian('banana') meets the pair b then a, and 'a' < 'b' is True, so it returns False on the second pass. Testing both a passing and a failing word is the minimum, and the double-letter case is a third worth adding because it is where a wrong comparison operator would show.
Trap
A student writes if c <= previous: return False, reasoning that the letters should be strictly increasing.
Read alphabetical order as strictly increasing
Why: In a dictionary no two adjacent entries are identical, so strict increase seems right.
Now every word with a double letter fails. flossy, hilly and abbey are all rejected, and the exercise says explicitly that double letters are ok.
A break in the trend is a letter that comes strictly BEFORE its predecessor.
Test with strict less-than
Why: if c < previous, which is False when the letters are equal, so a double letter passes.
Test a word with a double letter deliberately
Why: flossy is the book's own example, and it is exactly the case that distinguishes the two operators.
This is a case where the specification carries a detail the obvious reading misses. Reading double letters are ok and then choosing an operator that rejects them is a specification bug rather than a coding one.
Prediction
Watch the double letter.
>>> is_abecedarian('flossy')| Word | What happens | Result |
|---|---|---|
| f, l, o, s, s, y | each pair checked | five comparisons |
| the s, s pair | 's' < 's' is False | not a break |
| the result | no break found | True |
Predict first
What does is_abecedarian('flossy') return?
Correct: True — f, l, o, s, s, y are in non-decreasing order, and the double s does not break the trend because the test uses strict less-than.
Why: The exercise says double letters are ok, and the strict comparison is what implements that: 's' < 's' is False, so the equal pair does not trigger the early return. Using <= instead would reject flossy and every other word with a repeated letter — which is why flossy is the test case worth running.
Faded example
Two blanks: the initialization and the update.
Fill in the blanks
def is_abecedarian(word):
previous = word[0]
for c in word:
if c < previous:
return False
previous = c
return True
Why: previous starts as the first letter, which makes the first comparison a letter against itself — wasted but harmless. The update at the end of the body is what makes the next pass compare against this letter. Omitting the update would compare every letter against the first one, which answers a different question entirely: whether every letter comes after the first.
Socratic
It was enough for four functions and is not enough for this one.
Discussion prompt
The previous lesson's functions all used a plain for loop over characters. Explain precisely what is different about is_abecedarian, in terms of what each pass needs.
Hint: Count how many letters each decision involves.
Answer:
Those functions decided about each letter in isolation: is this an e, is this in the forbidden set. One letter, one decision.
is_abecedarian decides about a PAIR, and the for loop supplies one letter per pass with no access to the next — so half of each comparison has to come from somewhere else.
The three solutions differ only in where that other half comes from: a remembered variable, a recursive call on the rest of the word, or an index that can reach both positions. Seeing them as three answers to one question is more useful than learning three functions.
Section
Section 2
Concept
The book gives two more solutions to the same problem. Comparing them is the point: each supplies the missing half of the comparison differently, and each is idiomatic somewhere.
# recursion
def is_abecedarian(word):
if len(word) <= 1:
return True
if word[0] > word[1]:
return False
return is_abecedarian(word[1:])| Part | What it does | Note |
|---|---|---|
| the base case | a word of 0 or 1 letters | trivially in order |
| the comparison | word[0] against word[1] | both available at once |
| the recursive call | on the rest of the word | a shorter problem |
The third version uses a while loop with an index, comparing the character at i with the character at i+1. The loop starts at 0 and ends when i is len(word)-1, so the last comparison is between the second-to-last character and the last.
Think Python, 2nd edition — Allen B. Downey §9.4-9.5, pp. 86-86
Picture it
Three solutions, three sources for the second letter of each pair.
Figure (svg): Two columns comparing the three sources for the second half of each adjacent comparison
None is best in general. The index version is the most direct; the recursion is the shortest to reason about; the remembered variable needs no index arithmetic at all.
Worked example
Check it with the three questions from lesson 6b.
def is_abecedarian(word):
if len(word) <= 1:
return True
if word[0] > word[1]:
return False
return is_abecedarian(word[1:])| Check | What it establishes | Note |
|---|---|---|
| base case | 0 or 1 letters | correct: nothing can be out of order |
| smaller input | word[1:] is one shorter | termination guaranteed |
| the leap | assume the rest is in order | then check only the first pair |
Check the base case.
Why: A word of one letter or none has no adjacent pairs, so it is trivially in order. The test uses <= 1 rather than == 1, so the empty string is covered too.
Check that the input shrinks.
Why: word[1:] is one character shorter than word, so repeated calls reach the base case.
Take the leap of faith.
Why: Assuming the rest of the word is in order, this word is in order provided its first two letters are — which is exactly what the second condition checks.
Figure (svg): A call diagram showing is_abecedarian on abet delegating to bet, then et, then t
Correct, by all three checks. The recursion compares one pair per level and delegates the rest, which is the leap of faith from lesson 6b in its most direct form.
Verify: Check the base case boundary against a one-letter word.
Why: is_abecedarian('a') returns True immediately, with no comparison at all — which is right, since one letter has no pair to be out of order with. That the base case is <= 1 rather than == 0 is what makes it work, and it is the kind of detail lesson 6c warned about.
Prediction
The body reaches one position ahead.
i = 0
while i < len(word):
if word[i+1] < word[i]:
return False
i = i+1| Pass | What happens | Result |
|---|---|---|
| i = len-1 | the last valid index | word[i] is fine |
| word[i+1] | index len | out of range |
| the result | IndexError on the last pass | loud |
Predict first
What happens on the last pass of this loop?
Correct: An IndexError — on the last pass i is len-1, so word[i+1] is word[len], which is out of range.
Why: The bound has to account for how far ahead the body reaches, not only for the valid indices of i itself. This failure is at least loud: it raises immediately rather than producing a wrong answer. The quieter version of the same mistake is stopping at len-2, which would silently skip the last pair.
Worked example
The condition is not the usual one. The book explains why with a specific word.
def is_abecedarian(word):
i = 0
while i < len(word)-1:
if word[i+1] < word[i]:
return False
i = i+1
return True| Part | Value | Note |
|---|---|---|
| the condition | i < len(word)-1 | not the usual len(word) |
| for 'flossy' | len is 6, so i runs 0 to 4 | five passes |
| the last pass | i = 4 compares word[4] with word[5] | the last pair |
Notice the unusual condition.
Why: It stops one earlier than a normal traversal, because the body uses word[i+1] and the last letter has no successor.
Follow the book's own check.
Why: The length of flossy is 6, so the last time the loop runs is when i is 4, which is the index of the second-to-last character.
Confirm the last comparison is right.
Why: On the last iteration it compares the second-to-last character to the last, which is what we want.
Figure (svg): The word flossy with the index i shown running from zero to four and each comparison spanning two adjacent letters
The condition is i < len(word)-1 because the body reaches one position ahead. The loop makes five passes for a six-letter word, which is the number of adjacent pairs.
Verify: Check what the usual condition would do.
Why: With i < len(word), the last pass would have i = 5 and word[i+1] would be word[6], which is out of range — an IndexError. The unusual bound is not a stylistic choice; it is forced by the body reaching ahead, and it is the standard shape whenever a loop looks at i and i+1 together.
Trap
A student writes while i < len(word) with a body that compares word[i] and word[i+1].
Use the traversal condition learned in lesson 8a
Why: It is the correct bound for a loop that touches only word[i], and it has been right every time so far.
On the last pass i is len-1 and word[i+1] is out of range, raising an IndexError — which at least is loud.
The bound depends on the furthest position the body reaches.
If the body uses i+1, stop at len - 1
Why: So that the last pass has a valid i+1.
Count the passes and check against the number of pairs
Why: n letters have n-1 pairs, so the loop should run n-1 times.
Stating it as a rule about the body rather than about traversals makes it transfer: a loop reaching i+2 would stop at len - 2, and the same reasoning gives the bound each time.
Comparison
Fill the blanks. Each supplies the second letter of the pair differently.
Comparison matrix
| Version | Where the other letter comes from | What it needs |
|---|---|---|
| remembered variable | a variable holding the previous letter | an initialization and an update |
| recursion | word[0] and word[1], then a recursive call | a base case for short words |
| index loop | word[i] and word[i+1] together | a loop bound of len - 1 |
Each has one thing that can go wrong, and they are different things — which is a reason to know all three rather than only the one you like.
Ranking
Lesson 6b's three checks, applied in order.
Put in order
Why: The base case is checked first, because it involves no assumption. Then termination, which the leap of faith depends on. Then the leap itself. Then the every-path check from lesson 6a, which is about the function's shape rather than about the recursion. All four are needed, and the leap alone would pass a function with no reachable base case.
Elimination
All three are correct. One is the best fit for this particular problem.
Eliminate the wrong options
For is_abecedarian specifically, which version is the clearest?
Survives elimination: A
Why: The index version says exactly what the problem is: compare each letter with the next. Its one hazard is the loop bound, which is visible on the line where it appears. The other two are correct and each carries something the reader has to work out — a redundant first comparison, or a slice that copies the rest of the word. That said, this is a judgement rather than a fact, and the recursive version is the clearest of the three for a reader who is comfortable with recursion.
Section
Section 3
Concept
A palindrome reads the same forwards and backwards, so testing for one means comparing the first character with the last, the second with the second-to-last, and so on — which needs two indices moving in opposite directions.
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| Part | Its role | Note |
|---|---|---|
| i | starts at 0, moves up | the front |
| j | starts at len-1, moves down | the back |
| while i < j | stop when they meet or cross | the bound |
| the comparison | word[i] against word[j] | matching positions |
Compare this with the is_reverse function from lesson 8c, which had exactly this shape and two bugs in it. Here j starts at len(word)-1, which was the first of those bugs, and the condition is i < j, which addresses the second.
Think Python, 2nd edition — Allen B. Downey §9.4-9.5, pp. 86-87
Picture it
Each pass compares one pair and moves both indices inward.
Figure (svg): The word noon with arrows showing i moving right from the first character and j moving left from the last
The loop stops when i and j meet or cross, which for a four-letter word is after two passes — half the length, which is exactly right since each pass handles two positions.
Worked example
Two indices need a different kind of bound. Work out what it is.
# 'noon', length 4
# pass 1: i=0, j=3 compare 'n' and 'n'
# pass 2: i=1, j=2 compare 'o' and 'o'
# now i=2, j=1 i < j is False, stop
# 'racecar', length 7
# ... pass 3: i=2, j=4
# now i=3, j=3 i < j is False, stop| Case | How the loop ends | Note |
|---|---|---|
| even length | the indices cross | 2 and 1 |
| odd length | the indices meet | 3 and 3 |
| i < j | handles both | stops in either case |
Trace an even-length word.
Why: The indices cross without ever being equal: i becomes 2 while j becomes 1, and i < j is False.
Trace an odd-length word.
Why: They meet exactly, both landing on the middle character. i < j is False there too.
Notice why the middle character needs no comparison.
Why: In an odd-length palindrome the middle character is compared with itself, which is always equal — so skipping it is correct rather than a shortcut.
Figure (svg): A flow chart showing two indices being compared and then moved toward each other until they meet
i < j handles both cases: it becomes False when the indices meet or cross. The loop runs half as many times as the word is long, because each pass settles two positions.
Verify: Check a one-letter word and the empty string.
Why: For 'a', i is 0 and j is 0, so i < j is False immediately and the function returns True — correct, since a single letter is a palindrome. For the empty string, j is -1 and the condition is also False, giving True. Both special cases work without any extra code, which is worth confirming rather than assuming.
Prediction
Each pass handles two positions.
Predict first
For the six-letter word 'redder', how many times does the is_palindrome loop run?
Correct: Three — each pass compares one pair, and six letters make three pairs.
Why: The indices start at 0 and 5, then 1 and 4, then 2 and 3, and after that i is 3 and j is 2, so i < j is False. Three passes for six letters is half the length, which is the general rule: each pass settles two positions, so the loop runs len divided by two times, rounded down.
Worked example
The book offers a one-line alternative. Recognise the move.
def is_palindrome(word):
return is_reverse(word, word)| Function | What it asks | Note |
|---|---|---|
| is_reverse(a, b) | is a the reverse of b? | from lesson 8c |
| is_reverse(word, word) | is the word the reverse of itself? | which is a palindrome |
| the reduction | no new logic | one line |
Recall what is_reverse does.
Why: It returns True when one word is the reverse of the other — the function with two bugs from lesson 8c.
State the palindrome definition in those terms.
Why: A palindrome is a word that is its own reverse.
Write the reduction.
Why: is_palindrome(word) is is_reverse(word, word). Or we could reduce to a previously solved problem, as the book puts it.
Figure (svg): The state of the program after each line of Worked example reduction, again, drawn as a ladder with one rung per traced line
One line, using a function already written. This is the second reduction in two lessons, and it is worth noticing that both were found by restating the definition rather than by studying the code.
Verify: Check that the reduction depends on is_reverse being correct.
Why: is_reverse had two bugs in lesson 8c, one of which was left as an exercise. Reducing to a function that is not yet correct inherits its bugs — which is the maintenance argument for reduction working in reverse. A fix to is_reverse fixes both, and so does a bug.
Trap
A student writes while i != j, reasoning that the loop should stop when the indices meet.
Think only about the odd-length case
Why: There the indices really do meet exactly, so the condition works.
For an even-length word they cross without ever being equal — 2 and 1 — so the condition never becomes false and the indices run off both ends of the string.
Use i < j, which becomes false whether the indices meet or cross.
Test both an even-length and an odd-length word
Why: They exercise genuinely different paths through the loop's ending, and only one of them exposes this bug.
Prefer inequalities to equalities in loop conditions
Why: Which is lesson 6c's advice about base cases, in loop form: an exact test can be stepped over.
The parity of the length is a special case in the sense §9.5 means: non-obvious, and where errors lurk. A test suite of only odd-length palindromes would pass this bug entirely.
Invariant
Step through and watch the two indices approach each other.
Step through it
Why did the loop stop after two passes for a four-letter word, and what would happen with a condition of i != j?
Two passes for four letters, because each pass settles two positions. With i != j the indices would cross without ever being equal — 2 and 1 — and the loop would run past both ends of the string.
Discrimination
Ask which positions each comparison involves.
Sort into buckets
For each task, does it need one index with a lookahead, or two indices moving toward each other?
Explain it
The bug only shows for one parity of length.
Discussion prompt
A classmate's palindrome checker works for 'racecar' and crashes on 'noon'. Their condition is i != j. Explain what is happening and what test would have caught it.
Hint: The two words differ in one structural way.
Answer:
Say: racecar has an odd length, so the indices meet exactly at the middle and the condition becomes false. noon has an even length, so they cross without ever being equal — 2 and 1 — and the loop never stops.
The fix is i < j, which is false whether they meet or cross.
The test that would have caught it: any even-length word. This is what §9.5 means by a special case — the parity of the length is not an obvious dimension to test along, and it is exactly where the error lurks.
Section
Section 4
Concept
The functions in this chapter are relatively easy to test because you can check the results by hand. Even so, it is somewhere between difficult and impossible to choose a set of words that test for all possible errors.
The empty string is the book's own example of a special case, and it is the one most likely to be missed — precisely because it is not a word anybody would think to try.
Think Python, 2nd edition — Allen B. Downey §9.4-9.5, pp. 87-87
Picture it
Each dimension is a way a function can be wrong that the others would not catch.
Figure (svg): Two columns listing test dimensions against the bug each one is likely to catch
Notice that each row names a specific bug. A test case chosen without a bug in mind is much less likely to find one.
Worked example
The book's own analysis, made concrete.
# should return False (contains an e):
# 'end' e at the beginning
# 'the' e at the end
# 'bed' e in the middle
# should return True:
# 'jazz' no e at all
# 'a' a very short word
# '' the empty string| Test case | What it catches | Note |
|---|---|---|
| e at the start | catches a traversal starting at index 1 | silent bug |
| e at the end | catches a traversal stopping one short | silent bug |
| the empty string | catches code assuming word[0] exists | loud bug |
Cover both obvious cases.
Why: Words that have an e should return False, and words that do not should return True. You should have no trouble coming up with one of each.
Cover the subcases within each.
Why: Among the words that have an e, test words with an e at the beginning, the end, and somewhere in the middle.
Add the special case.
Why: Test long words, short words, and very short words, like the empty string.
Figure (svg): The state of the program after each line of Worked example a test set for has no e, drawn as a ladder with one rung per traced line
Six test cases along three dimensions. Each one is chosen with a specific failure in mind, which is what makes it worth running rather than merely reassuring.
Verify: Ask which of the six a naive test set would omit.
Why: Almost anybody tests one word with an e and one without. The position subcases and the empty string are the ones that get skipped — and they are precisely the ones that catch the two silent traversal bugs from lesson 8a and the boundary bug from this lesson.
Sorting
Each case is chosen to catch something specific.
Sort into buckets
For testing has_no_e, what does each case exercise?
Worked example
A hundred thousand words is a test too, and it has a specific blind spot.
# scan the output of has_no_e over words.txt
#
# you might catch: words that should NOT be included, but are
# you will not catch: words that SHOULD be included, but aren't| Error kind | Can scanning find it? | Note |
|---|---|---|
| false positives | a word with an e in the output | visible by scanning |
| false negatives | a word without an e missing | invisible: you cannot scan absences |
| the asymmetry | one kind is findable, the other is not | a real limitation |
Notice what scanning can find.
Why: In addition to your own test cases you can test with a word list, and by scanning the output you might be able to catch errors.
Notice what it cannot.
Why: But be careful: you might catch one kind of error — words that should not be included, but are — and not another — words that should be included, but are not.
Draw the conclusion.
Why: A large data set is a supplement to hand-chosen cases, not a replacement. It finds a different kind of error and is blind to its complement.
Figure (svg): Two columns comparing hand-chosen test cases with scanning a large output, showing what each can and cannot find
Scanning output finds words that should not be there and cannot find words that are missing, because absences are invisible. Both kinds of error exist, so both kinds of test are needed.
Verify: Consider how you would look for a missing word.
Why: You would have to pick a word you know should appear and check that it does — which is a hand-chosen test case again. The large data set never removes the need for cases you designed, which is the practical form of Dijkstra's point.
Trap
A student writes a function, tests it on the examples they had in mind while writing it, and it passes.
Test with the cases that shaped the code
Why: They are the ones to hand, and they exercise the logic you were thinking about.
Those are exactly the cases the code was built to handle. The errors lurk in the cases you did not think of, which is why they are still there.
Choose test cases along dimensions rather than by recall.
List the dimensions first: position, length, parity, emptiness
Why: Then pick one case per dimension, whether or not it occurred to you while writing.
For each case, name the bug it would catch
Why: A test case with no bug in mind is much less likely to find one.
The book's phrase for the ones that get missed is special case: one of the non-obvious cases where errors often lurk. Non-obvious is the operative word — obvious cases are already handled, because they were in mind while the code was written.
Prediction
One of the three is_abecedarian versions has a problem here.
# version 1: previous = word[0]
# version 2: if len(word) <= 1: return True
# version 3: while i < len(word)-1| Version | What happens on '' | Result |
|---|---|---|
| version 1 | word[0] on an empty string | IndexError |
| version 2 | len is 0, so <= 1 is True | returns True |
| version 3 | len-1 is -1, so 0 < -1 is False | returns True |
Predict first
Which version raises an error on the empty string?
Correct: The remembered-variable version — it initializes previous to word[0], which raises an IndexError for an empty string.
Why: The other two handle it without any special code: the recursive base case tests len <= 1 and the index loop's condition is immediately false when len-1 is -1. This is why the empty string is the book's example of a special case: three correct-looking versions of one function, and only one of them fails on it.
Faded example
One guardian, from lesson 6c.
Fill in the blanks
def is_abecedarian(word):
if len(word) == 0:
return True
previous = word[0]
for c in word:
if c < previous:
return False
previous = c
return True
Why: The guardian returns before word[0] is evaluated, which is what makes the initialization safe. An empty string is trivially in alphabetical order, so True is the right answer rather than an evasion. Note the ordering requirement from lesson 6c: the guard must come before the line it protects, or it protects nothing.
Explain it to yourself
The book's definition is short. Make it operational.
Discussion prompt
The book defines a special case as one that is atypical or non-obvious, and therefore less likely to be handled correctly. Explain why non-obviousness is the operative property, rather than rarity.
Hint: Think about when the code was written.
Answer:
Because a case you thought of while writing the code is already handled — the code was shaped around it. Rarity is irrelevant if it was in mind.
A non-obvious case is one that did not occur to you, which is precisely why the code may not handle it. The empty string is the standard example: nobody writing has_no_e is picturing an empty word.
So the practical technique is to generate cases mechanically along dimensions — position, length, parity, emptiness — rather than by asking yourself what to test. Asking yourself returns the cases you already thought of.
Section
Section 5
Concept
In general, testing can help you find bugs, but it is not easy to generate a good set of test cases — and even if you do, you cannot be sure your program is correct.
# Program testing can be used to show the presence of bugs,
# but never to show their absence!
#
# -- Edsger W. Dijkstra| Outcome | What it establishes | Note |
|---|---|---|
| a failing test | proves a bug exists | definitive |
| a passing test | proves nothing about other inputs | not definitive |
| all tests passing | no bug found YET | not the same as no bug |
This is the same asymmetry as the search pattern in the previous lesson, applied to programs rather than to letters: one counterexample disproves a universal claim, and no number of confirming examples proves one.
Think Python, 2nd edition — Allen B. Downey §9.4-9.5, pp. 87-87
Picture it
It is the same shape as the search pattern, and that is not a coincidence.
Figure (svg): Two columns contrasting what a failing test proves with what a passing test proves
The same asymmetry decided where the returns go in a search, why avoids needs the whole loop, and now what a green test suite means. It is one of the most transferable ideas in the book.
Worked example
The is_reverse function from lesson 8c is the case in point.
# after fixing the first bug:
>>> is_reverse('pots', 'stop')
True
# correct answer, and the loop ran three times for four letters| Observation | What it is | Note |
|---|---|---|
| the answer | True | correct |
| the loop count | three for four letters | one comparison skipped |
| what the test proved | this input works | nothing about others |
Note the test passed.
Why: The function returned the correct answer for pots and stop.
Note the bug was still there.
Why: The loop ran three times for four-character words, so one pair was never compared.
Draw the conclusion.
Why: The test passed and the function was wrong. A passing test shows that this input works, and nothing more.
Figure (svg): The state of the program after each line of Worked example a passing test that proves nothing, drawn as a ladder with one rung per traced line
The test proved that one input worked. The second bug survived it entirely, and was found by counting passes rather than by testing — which is exactly Dijkstra's point.
Verify: Ask what test WOULD have caught it.
Why: One where the skipped pair differs: two words that match everywhere except the position the loop never compares. Constructing such a case requires knowing where the gap is, which means the bug had to be found first. That circularity is why testing is a supplement to reasoning rather than a substitute for it.
Prediction
Be precise about the scope of the claim.
Predict first
A function passes all ten of its tests. What has been established?
Correct: That the function is correct for those ten inputs, and nothing about any other input.
Why: Testing can be used to show the presence of bugs, but never to show their absence. Ten passing tests are ten pieces of evidence about ten inputs, and a program with an infinite input space is not covered by any finite number of them. The fourth option overstates the limitation in the other direction — the tests do establish something real, just much less than correct.
Worked example
Testing is limited and it is still worth doing. Both halves matter.
# testing is limited, so:
# 1. choose cases along dimensions, not by recall
# 2. check the process, not only the answer
# 3. reason about the code as well as running it
# 4. treat a passing suite as 'no bug found yet'| Technique | What it adds | Note |
|---|---|---|
| dimensions | position, length, parity, emptiness | mechanical generation |
| the process | count the passes, check the bounds | catches silent bugs |
| reasoning | the leap of faith, termination proofs | covers all inputs at once |
Generate cases mechanically.
Why: Because the cases you think of are the ones already handled.
Check the process as well as the output.
Why: Counting loop passes found the second is_reverse bug when no test did.
Reason as well as test.
Why: The three checks for a recursive function from lesson 6b cover every input at once, which no finite test set can.
Figure (svg): A diagram showing testing and reasoning as two complementary ways to gain confidence in a program
Four techniques, and the last is an attitude: a passing suite means no bug has been found yet. Testing shows the presence of bugs, never their absence.
Verify: Compare with the recursion checks from lesson 6b.
Why: Those checks — base case correct, input shrinks, this level right given the leap — establish correctness for every input, not just tested ones. That is what reasoning buys over testing, and it is why the book teaches both.
Trap
Every test passes, so the function is declared correct and the work moves on.
Read all tests pass as no bugs
Why: It is the strongest evidence available and it feels conclusive.
It means no bug was found by these particular inputs. The is_reverse function passed its test with a comparison silently missing, and every additional passing test would have said the same.
Read a passing suite as no bug found yet.
Ask what the tests do NOT cover
Why: Which dimensions were not varied? Which paths through the code were never taken?
Supplement with reasoning about all inputs
Why: A termination proof or the leap of faith covers cases no test enumerated.
Dijkstra's remark is not a counsel of despair — testing genuinely finds bugs and is worth doing. It is a statement about what the result means, and getting that right changes how much confidence a green suite should buy.
Two truths and a lie
Two are true. Keep the lie.
Eliminate the wrong options
Rule out the two true statements.
Survives elimination: C
Why: C is the lie, and it is the most consequential misconception in software. Program testing can be used to show the presence of bugs, but never to show their absence. However comprehensive a finite test suite is, the untested inputs remain untested — and the is_reverse function passed its test with a comparison missing entirely.
Analogy
It is the same shape four times in this book.
Match the pairs
Why: All four are the same logical asymmetry: a universal claim is disproved by one counterexample and proved only by exhaustion or by argument. Recognising it as one idea rather than four coincidences is worth more than any of them individually — and it is why the Collatz sequence in lesson 7a remains unproved despite billions of confirming examples.
Real world
It is not a programming idea.
Discussion prompt
Give an example from outside programming where one counterexample settles a question and no number of confirming cases does. What do people do when they need confidence anyway?
Hint: Science is the standard case.
Answer:
A scientific hypothesis is the standard example: one reproducible contrary result refutes it, and no number of confirmations proves it. Popper made this the centre of his account of science.
What people do is exactly what programmers do: they test hard in the places most likely to fail, they reason about mechanisms rather than only collecting instances, and they hold their conclusions provisionally.
The practical translation for a program is the same. Test the special cases where errors lurk, reason about the loop bounds and the base cases, and treat a green suite as the absence of found bugs rather than the presence of correctness.
Comparison
Fill the blanks. Each supplies the second half of the comparison differently, and each has its own hazard.
Comparison matrix
| Version | How it sees two letters | What can go wrong |
|---|---|---|
| remembered variable | keeps the previous letter in a variable | word[0] on an empty string |
| recursion | compares word[0] with word[1], then recurses on word[1:] | a base case that misses the empty string |
| index loop | reaches word[i] and word[i+1] at once | a loop bound that does not allow for the lookahead |
Three correct solutions with three different failure modes — which is a good argument for knowing all three rather than only the one you find natural.
Pattern
Six steps, and the fourth is where the off-by-one lives.
Step 5 catches the bugs that step 6 might miss. The is_reverse function passed its test while making one comparison too few, and only the count revealed it.
Python documentation — More Control Flow Tools More Control Flow Tools
Check
The body reaches one position past i.
i = 0
while i < len(word)-1:
if word[i+1] < word[i]:
return False
i = i+1| Part | Value | Note |
|---|---|---|
| the body | uses word[i+1] | reaches one ahead |
| the bound | len - 1 | so i+1 is always valid |
| passes | n - 1 for n letters | the number of pairs |
Check your understanding
Why is the condition i < len(word)-1 rather than i < len(word)?
Answer: B
Why: The bound depends on the furthest position the body reaches. With word[i+1] in the body, the last valid value of i is len-2, so the condition must stop at len-1. A loop touching only word[i] would use the ordinary bound — the rule is about the body, not about traversals in general.
Check
One condition handles both parities of length.
i = 0
j = len(word)-1
while i < j:
...
i = i+1
j = j-1| Case | How it ends | Note |
|---|---|---|
| even length | the indices cross: 2 and 1 | i < j is False |
| odd length | the indices meet: 3 and 3 | i < j is False |
| i != j | would miss the crossing case | infinite loop |
Check your understanding
Why is the condition i < j rather than i != j?
Answer: B
Why: For a four-letter word the indices go 0 and 3, then 1 and 2, then 2 and 1 — crossing without ever being equal. With i != j the loop would never stop and both indices would run off the ends of the string. The inequality is false whether they meet or cross, which covers both parities.
Check
Be precise about the direction of the claim.
Check your understanding
According to Dijkstra, what can program testing establish?
Answer: B
Why: Program testing can be used to show the presence of bugs, but never to show their absence. A failing test is a counterexample and settles the matter; a passing test is one confirming instance and leaves every untested input open. This is the same asymmetry as the search pattern, applied to programs.
Real world
Comparing adjacent items is one of the most common operations there is.
Discussion prompt
Think of something you check by comparing consecutive items rather than looking at each alone — a sorted list, a sequence of readings, a series of dates. How many comparisons does it take for n items, and where do the errors happen?
Hint: The count is the fencepost answer.
Answer:
Checking a list is sorted, checking that dates are consecutive, checking that a sequence of readings never drops — all compare each item with its neighbour.
For n items there are n-1 comparisons, which is the fencepost count from lesson 8a: ten panels need eleven posts, and ten posts have nine gaps.
The errors happen at the ends. Either you compare one pair too many and run off the end, or one too few and miss the last pair — which is exactly the pair of bugs this lesson's loop bound is designed to avoid, and exactly where testing must be aimed.
Commit first
Answer, then rate your confidence. It combines the loop bound with the special case.
Predict first
The remembered-variable version of is_abecedarian starts with previous = word[0]. What happens when it is called with the empty string?
Correct: An IndexError — the empty string has no character at index 0, and the initialization runs before the loop that would have handled the empty case.
Why: The other two versions handle the empty string without any special code: the recursive base case tests len <= 1, and the index loop's condition is 0 < -1, which is immediately false. Only the remembered-variable version reaches for a character before checking that one exists. This is exactly what the book means by a special case — atypical, non-obvious, and where errors lurk — and it is worth noticing that three correct-looking versions of the same function differ precisely on the input nobody thinks to try.
Explain it
The limits of testing are worth stating carefully, because overstating them is as bad as ignoring them.
Discussion prompt
A classmate says that since testing cannot prove correctness, there is no point writing tests. Give them the accurate version of the claim and say what testing is genuinely for.
Hint: The asymmetry cuts one way, not both.
Answer:
Say: testing shows the presence of bugs, never their absence. A failing test is conclusive — it proves a bug exists — and that is enormously valuable.
What it cannot do is prove there are none, so a green suite means no bug found yet rather than correct. That is a statement about what the result means, not an argument against running them.
Then add what fills the gap: reasoning that covers every input at once — the three recursion checks, a termination argument, checking loop bounds against the number of pairs. Testing and reasoning find different things, and the is_reverse bug that survived its test was found by counting passes rather than by either testing harder or reasoning less.
Exit ticket
One honest answer. It decides what the next lesson opens with.
Predict first
Which of these is still least solid for you?
Correct: Whichever you picked is the right answer — this one is for you, not for a mark.
Why: The first is a single observation that unlocks the rest of the lesson. The lookahead bound is the most common off-by-one you will meet from here on, and stating it as a rule about the body rather than about traversals is what makes it transfer. The two-index shape recurs constantly — palindromes, merging, comparing sequences — and its condition is subtle enough to be worth deriving once. And the testing section is the most portable thing in the chapter: Dijkstra's asymmetry is the same one that shaped every search function you have written.
Connect it up
One page, from memory.
Draw it
Draw a six-letter word twice. On the first copy, mark the five adjacent pairs and write the loop bound that produces exactly those five comparisons. On the second, mark the three mirrored pairs and the path of the two indices, and write the condition that stops them correctly for both odd and even lengths. Then, below, list four test cases for one of these functions and name, for each, the specific bug it would catch.
Recap
Two pages, and chapter 9 is finished: the puzzles a plain for loop cannot solve.
| If you remember one thing | It is this |
|---|---|
| From the three versions | One problem, three ways, three different failure modes. |
| From the lookahead | The bound depends on how far ahead the body reaches, not on the traversal. |
| From two indices | i < j stops whether they meet or cross. i != j misses the even case. |
| From special cases | Non-obvious is the point. The cases you thought of are already handled. |
| From Dijkstra | Testing shows the presence of bugs, never their absence. |
Chapter 10 introduces the type that changes most of what you know: a list is a sequence like a string, and unlike a string it can be modified — which brings a new kind of power and an entirely new category of bug.
Think Python, 2nd edition — Allen B. Downey §9.4-9.5, pp. 86-87 — everything on these slides traces back here
Want this taught 1-on-1? Alexander tutors Python — $55/session, free consultation.