This lesson writes a modifier and weighs it against a pure function, then replaces the whole approach with the insight that a Time is a base-60 number, and closes with invariants and the assert statement.
Subject: Python · 65 slides · code lesson
Open the interactive version of this deck
Title
Python · Chapter 16 — Classes and functions
§16.3-16.5, pp. 157-159
Objectives
Five things, each one you can check yourself at an interpreter prompt.
Think Python, 2nd edition — Allen B. Downey §16.3-16.5, pp. 157-159 — the pages these objectives are drawn from
Warm-up
The patched add_time carries once per column. Try something it was not built for.
Discussion prompt
You want to add 100 seconds to a Time. A single conditional that subtracts sixty and carries one minute is not enough. What would you have to do instead, and what does that tell you about the approach?
Hint: How many times must you carry?
Answer:
You would have to keep carrying until the seconds drop below sixty — so the if becomes a while, or you compute how many sixties there are.
Which means the two conditionals that made add_time correct are not enough here, and the reason is that add_time could rely on both arguments being valid Times.
So each new operation needs its own analysis of how many carries are possible. That accumulation is what the chapter is about to replace with a single idea.
Concept
An alternative development plan is designed development, in which high-level insight into the problem can make the programming much easier. In this case, the insight is that a Time object is really a three-digit number in base 60.
# second is the 'ones column'
# minute is the 'sixties column'
# hour is the 'thirty-six hundreds column'
# so add_time and increment were doing
# addition in base 60 - which is why we had
# to carry from one column to the next| Attribute | Which column | Its value |
|---|---|---|
| second | the ones column | 1 |
| minute | the sixties column | 60 |
| hour | the thirty-six hundreds column | 3600 |
This observation suggests another approach to the whole problem: we can convert Time objects to integers and take advantage of the fact that the computer knows how to do integer arithmetic.
Figure (svg): The three attributes of a Time shown as the three columns of a base-60 number
Think Python, 2nd edition — Allen B. Downey §16.3-16.5, pp. 158-158
Section
Section 1
Concept
Sometimes it is useful for a function to modify the objects it gets as parameters. In that case, the changes are visible to the caller, and functions that work this way are called modifiers.
modifier — A function that changes one or more of the objects it receives as arguments. Most modifiers are void; that is, they return None.
def increment(time, seconds):
time.second += seconds
if time.second >= 60:
time.second -= 60
time.minute += 1
if time.minute >= 60:
time.minute -= 60
time.hour += 1| Part | What it does | Note |
|---|---|---|
| the first line | the basic operation | add the seconds |
| the remainder | the special cases | the same carries as before |
| what it returns | nothing | the change IS the result |
increment, which adds a given number of seconds to a Time object, can be written naturally as a modifier. The first line performs the basic operation and the remainder deals with the special cases we saw before.
Think Python, 2nd edition — Allen B. Downey §16.3-16.5, pp. 157-157
Picture it
The shape of the call tells you which kind you are using.
Figure (svg): Two columns comparing the call site of a modifier with that of a pure function
Most modifiers are void — they return None — which is what makes the misuse t = increment(t, 30) fail on the next operation.
Worked example
The book asks, and the answer is no.
def increment(time, seconds):
time.second += seconds
if time.second >= 60:
time.second -= 60
time.minute += 1
...
# what happens if seconds is much greater than sixty?| Input | Carries needed | What the code does |
|---|---|---|
| seconds = 30 | one carry at most | correct |
| seconds = 100 | needs two carries | the if fires once |
| seconds = 10000 | needs many | far too few |
Ask the book's question.
Why: What happens if seconds is much greater than sixty?
Work out the requirement.
Why: In that case it is not enough to carry once; we have to keep doing it until time.second is less than sixty.
Note why add_time was safe and this is not.
Why: add_time added two valid Times, so each column summed to less than 120 and one carry sufficed. increment takes an arbitrary number, so no such bound exists.
Figure (svg): A panel showing increment succeeding for small inputs and failing for larger ones
Not correct for large inputs. The same two conditionals that were sufficient in add_time are insufficient here, because the reasoning that justified them no longer applies.
Verify: Trace increment with 100 seconds on a time at 0:00:00.
Why: The seconds become 100, the conditional subtracts sixty once leaving 40 with one minute — which happens to be right. With 200 it leaves 80 seconds and one minute, which is wrong. So the failure begins at 120, and testing with 100 would have passed. That is a boundary worth constructing deliberately rather than stumbling on.
Prediction
increment is a modifier.
t.second = 0
increment(t, 30)
print(t.second)| Step | What happens | Result |
|---|---|---|
| the call | time is an alias for t | one object |
| time.second += 30 | modifies it | in place |
| t.second | the same object | 30 |
Predict first
What does this print?
Correct: 30 — a modifier changes the object it is given, and the change is visible to the caller.
Why: Sometimes it is useful for a function to modify the objects it gets as parameters, and in that case the changes are visible to the caller. Nothing is returned, which is why the call is a statement rather than an assignment — writing t = increment(t, 30) would set t to None.
Worked example
The book suggests one and sets the other as an exercise.
# one solution: replace the ifs with whiles
while time.second >= 60:
time.second -= 60
time.minute += 1
# correct, but not very efficient:
# adding a million seconds means a million passes| Approach | What it does | Note |
|---|---|---|
| the while version | carries until done | correct |
| the cost | one pass per sixty seconds | slow for large inputs |
| the exercise | a correct version with no loops | using arithmetic |
Take the obvious fix.
Why: One solution is to replace the if statements with while statements — that would make the function correct.
Note the cost.
Why: But not very efficient: the loop runs once per sixty seconds, so a large input means a great many passes.
Note the exercise.
Why: Write a correct version of increment that doesn't contain any loops — which needs divmod, and is really the base-60 insight arriving early.
Figure (svg): The state of the program after each line of Worked example the two ways to fix it, drawn as a ladder with one rung per traced line
A loop makes it correct and slow; arithmetic makes it correct and fast. The exercise is pointing at the conversion functions before the chapter introduces them.
Verify: Ask what the loopless version would look like.
Why: divmod(time.second, 60) gives the number of minutes to carry and the seconds left, in one operation — no loop and no repeated subtraction. That is exactly what int_to_time does later in the chapter, which is why the exercise is a hint rather than a digression.
Trap
A caller writes t = increment(t, 30), by analogy with add_time.
Assign the result, as with any function
Why: Which is right for the pure function next to it in the same chapter.
Most modifiers are void: they return None. So t becomes None, the Time is lost, and the next operation on t fails with a NoneType error some distance away.
Call a modifier as a statement.
increment(t, 30)
Why: The change to t is the whole effect.
And check the documentation when unsure
Why: Whether a function modifies its argument is a postcondition, from lesson 4b.
This is lesson 10b's t = t.sort() disaster in a new setting. The convention that modifiers return None is what makes the misuse fail quickly instead of silently.
Sorting
Ask what the function does to its arguments.
Sort into buckets
For each description, which kind is it?
Faded example
One carry is not always enough.
Fill in the blanks
def increment(time, seconds):
time.second += seconds
while time.second >= 60:
time.second -= 60
time.minute += 1
Why: Replacing the if with a while makes the function carry until the seconds drop below sixty, which is correct for any input. The book notes it is not very efficient — one pass per sixty seconds — and sets a loopless version as an exercise, which needs divmod.
Socratic
The same two conditionals, and one function is correct.
Discussion prompt
add_time uses one carry per column and is correct; increment uses the same and is not. What is different about the inputs?
Hint: What can each column hold before the addition?
Answer:
add_time adds two valid Times, so each column sums to at most 118 — one subtraction of sixty always brings it back into range.
increment adds an arbitrary number of seconds, which has no bound at all. Nothing guarantees one carry suffices, and for anything over 119 it does not.
So the same code is correct in one function and wrong in the other, and only reasoning about the inputs distinguishes them. That is a real weakness of prototype and patch: each new operation needs its own analysis, and there is nothing to reuse.
Section
Section 2
Concept
Anything that can be done with modifiers can also be done with pure functions. In fact, some programming languages only allow pure functions.
functional programming style — A style of program design in which the majority of functions are pure.
The recommendation is deliberately hedged. It is not that modifiers are wrong — it is that the default should be the other one, and departing from it should have a reason you could state.
Think Python, 2nd edition — Allen B. Downey §16.3-16.5, pp. 157-158
Picture it
A real trade, with the book coming down on one side.
Figure (svg): Two columns weighing pure functions against modifiers
The efficiency point is real: building a new object every time costs something, and for large structures it can cost a great deal.
Worked example
The exercise the book sets, alongside the version it gave.
# the modifier
def increment(time, seconds):
time.second += seconds
...
# the pure version: creates and returns a new Time
def increment_pure(time, seconds):
t = Time()
t.hour = time.hour
t.minute = time.minute
t.second = time.second + seconds
...
return t| Version | What it does | Note |
|---|---|---|
| the modifier | changes the caller's Time | shorter |
| the pure version | copies, then changes the copy | longer |
| the difference | one object or two |
Write the modifier.
Why: It changes the argument directly, which is the shorter code.
Write the pure version.
Why: It creates and returns a new Time object rather than modifying the parameter — which means copying every attribute first.
Weigh them.
Why: The pure version is longer and cannot surprise a caller; the modifier is shorter and can.
Figure (svg): The state of the program after each line of Worked example the same operation both ways, drawn as a ladder with one rung per traced line
Two versions of one operation. Anything that can be done with modifiers can also be done with pure functions, at the cost of building a new object.
Verify: Check the pure version copies every attribute.
Why: Missing one would leave the new Time without that attribute, and the failure would appear later as an AttributeError. Building objects attribute by attribute is error-prone in exactly this way, which is a real cost of the pure style — and one that __init__ removes in the next chapter.
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 overstates a hedged recommendation. The book says to write pure functions whenever it is reasonable and resort to modifiers only if there is a compelling advantage — which grants that such advantages exist. Only if is a different claim from never.
Worked example
The efficiency argument, made concrete.
# modifying a large structure in place
def add_all(target, items):
for x in items:
target.append(x)
# the pure equivalent copies everything, every call
def add_all_pure(target, items):
return target + items # a whole new list| Version | What it costs | Note |
|---|---|---|
| the modifier | appends in place | no copying |
| the pure version | builds a new list | copies everything |
| in a loop | the copying compounds | quadratic |
Note where the cost lives.
Why: The pure version copies the whole structure, and doing that inside a loop copies it once per pass.
Note the scale.
Why: For a Time with three attributes, copying is free. For a list of a million elements, it is not.
Apply the recommendation.
Why: That is a compelling advantage — the kind of reason the book's only if is asking for.
Figure (svg): A growth chart contrasting in-place modification with copying inside a loop
A case where the modifier is right. Functional programs tend to be less efficient, and when the structure is large enough that becomes the deciding factor.
Verify: Check whether the advantage is real before claiming it.
Why: For three attributes the copying cost is unmeasurable, so efficiency would not be a compelling advantage for the Time class — it would be a guess. Chapter 13's advice applies: write the easier version and measure before optimising, rather than choosing a style on a theory.
Trap
A function modifies its argument because building a new object would take four more lines.
Take the shorter code
Why: Which is a real advantage, and the book grants that modifiers are convenient at times.
Convenience is not the compelling advantage the recommendation asks for. The caller now has to know the function modifies, and there is evidence that programs written this way are slower to develop and more error-prone.
Default to pure, and depart for a reason you could state.
Write pure functions whenever it is reasonable
Why: Which is the book's phrasing.
Resort to modifiers only if there is a compelling advantage
Why: Efficiency at scale is one; four fewer lines is not.
The test is whether you could explain the reason to someone reading the call site. It was shorter to write is not a property of the caller's situation, which is where the cost lands.
Prediction
Assigning the result of a modifier.
t = increment(t, 30)
print(t)| Step | What happens | Result |
|---|---|---|
| increment | a modifier | returns None |
| t = ... | stores None | the Time is lost |
| print(t) | None |
Predict first
What does this print?
Correct: None — most modifiers are void, so assigning the result stores None and loses the Time.
Why: The Time was modified correctly and then thrown away by the assignment, which is what makes this bug confusing: the operation worked and the variable is empty. It is lesson 10b's t = t.sort() in a new setting, and the convention that modifiers return None is what makes it fail on the next operation rather than silently.
Comparison
Fill the blanks. The chapter names both.
Comparison matrix
| Question | Pure function | Modifier |
|---|---|---|
| What does it change? | nothing | one or more of its arguments |
| What does it return? | usually a new object | usually None |
| Correct call shape | result = f(x) | f(x) |
| Which does the book recommend? | this one, whenever reasonable | only for a compelling advantage |
The recommendation is a default rather than a rule, and the caveat about efficiency is what keeps it from being one.
Explain it
The reason is about reasoning, not about correctness.
Discussion prompt
A classmate asks why the book prefers pure functions when both styles work. Give them the reason and the exception.
Hint: What do you need to know to understand a call?
Answer:
With a pure function, everything it does is the value it returns — so you can understand a call by looking at what came back, without checking what else changed.
With a modifier you also have to know which of the arguments changed, which is not visible at the call site. That extra thing to track is why there is evidence that such programs are slower to develop and more error-prone.
The exception is efficiency: functional programs tend to be less efficient, because building a new object costs something. For three attributes that is nothing, and for a large structure it can be the deciding factor — which is what a compelling advantage means.
Section
Section 3
Concept
An alternative to prototype and patch is designed development, in which high-level insight into the problem can make the programming much easier.
designed development — A development plan that involves high-level insight into the problem and more planning than incremental development.
def time_to_int(time):
minutes = time.hour * 60 + time.minute
seconds = minutes * 60 + time.second
return seconds
def int_to_time(seconds):
time = Time()
minutes, time.second = divmod(seconds, 60)
time.hour, time.minute = divmod(minutes, 60)
return time| Function | What it does | Note |
|---|---|---|
| time_to_int | columns to one number | hours, then minutes, then seconds |
| divmod | quotient and remainder | the carrying, done by arithmetic |
| int_to_time | one number back to columns | the inverse |
The insight is that a Time object is really a three-digit number in base 60, and when we wrote add_time and increment we were effectively doing addition in base 60 — which is why we had to carry from one column to the next.
Think Python, 2nd edition — Allen B. Downey §16.3-16.5, pp. 158-158
Picture it
Out to a number, do the arithmetic, and back.
Figure (svg): A pipeline showing Times converted to integers, added, and converted back
The carrying has not gone away. It is inside divmod, written once and correct for any input.
Worked example
Twelve lines become two.
def add_time(t1, t2):
seconds = time_to_int(t1) + time_to_int(t2)
return int_to_time(seconds)| Step | What happens | Note |
|---|---|---|
| convert both | to seconds | ordinary integers |
| add them | ordinary addition | no special cases |
| convert back | int_to_time | divmod does the carrying |
Convert both arguments.
Why: Take advantage of the fact that the computer knows how to do integer arithmetic.
Add.
Why: Nothing can overflow a column, because there are no columns — just a number of seconds.
Convert back.
Why: int_to_time distributes the total across the three fields, with divmod doing every carry at once.
Figure (svg): Two columns comparing the patched add_time with the version built on conversions
This version is shorter than the original, and easier to verify. Both conditionals are gone, and so is the reasoning that justified them.
Verify: Test it where the patched version needed both carries.
Why: 0:59:59 plus 0:00:01 gives 3599 + 1 = 3600 seconds, and int_to_time returns 1:00:00 — with no ordering to get wrong, since there are no separate carries. That case, which distinguished the two orderings before, now cannot be got wrong at all.
Prediction
A time converted to seconds.
# t is 1:01:01
print(time_to_int(t))| Column | Its contribution | Value |
|---|---|---|
| hours | 1 x 3600 | 3600 |
| minutes | 1 x 60 | 60 |
| seconds | 1 | 1 |
Predict first
What does this print?
Correct: 3661 — one hour is 3600 seconds, one minute is 60, and one second is 1.
Why: The function computes hour * 60 + minute to get total minutes, then minutes * 60 + second to get total seconds. That is exactly evaluating a three-digit base-60 number, which is the insight the whole approach rests on.
Worked example
The whole of the special-case handling, in two calls.
def int_to_time(seconds):
time = Time()
minutes, time.second = divmod(seconds, 60)
time.hour, time.minute = divmod(minutes, 60)
return time
# divmod(3661, 60) -> (61, 1) 61 minutes, 1 second
# divmod(61, 60) -> (1, 1) 1 hour, 1 minute| Call | What it computes | Note |
|---|---|---|
| divmod(seconds, 60) | how many whole minutes, and the rest | the seconds carry |
| divmod(minutes, 60) | how many whole hours, and the rest | the minutes carry |
| no loop | however large the input | one operation each |
Split off the seconds.
Why: divmod divides the first argument by the second and returns the quotient and remainder as a tuple — so the remainder is the seconds and the quotient is the minutes to carry.
Split off the minutes the same way.
Why: The same operation one column up, giving hours and remaining minutes.
Note what has disappeared.
Why: The loop that increment needed, and the conditionals add_time needed. divmod carries as many times as necessary in one step.
Figure (svg): The state of the program after each line of Worked example how divmod does the carrying, drawn as a ladder with one rung per traced line
Two calls, correct for any input however large. This is also the answer to the earlier exercise about a loopless increment.
Verify: Test with a number far larger than a day.
Why: divmod handles 100,000 seconds as easily as 61, giving an hour value above 24 — which is arithmetically correct and may or may not be what a clock should show. That is a design question the conversion functions expose rather than hide, and it is worth deciding deliberately rather than discovering.
Trap
increment is wrong for large inputs, so another special case is added to handle them.
Fix the failure you found
Why: Which is exactly what the plan says to do.
Each new operation then needs its own analysis of how many carries are possible, and none of that reasoning is reusable. Subtraction would need borrowing, multiplication something else again.
Look for the insight that removes the cases.
Notice what the carrying actually is
Why: Addition in base 60, which the computer already does in base 10.
Invest in the conversions once
Why: And every operation afterwards is ordinary arithmetic.
The book's example is subtraction: the naive approach would be to implement it with borrowing, and using the conversion functions would be easier and more likely to be correct. The investment pays off on the second operation.
Invariant
Two calls to divmod.
Step through it
How many times does the carrying happen, and where?
Twice, once per divmod — and each call carries as many sixties as necessary in one operation, however large the number. That is why no loop is needed and why the same code works for a million seconds.
Faded example
One operation gives both the carry and the remainder.
Fill in the blanks
def int_to_time(seconds):
time = Time()
minutes, time.second = divmod(seconds, 60)
time.hour, time.minute = divmod(minutes, 60)
return time
Why: divmod divides the first argument by the second and returns the quotient and remainder as a tuple, which tuple assignment unpacks into the carry and the remaining seconds. Using // and % separately would compute the same thing twice, which is exactly the inefficiency lesson 12a said divmod exists to avoid.
Explain it to yourself
The book says it is. Say why.
Discussion prompt
The rewritten add_time is two lines. Beyond being shorter, why is it easier to be confident it is correct?
Hint: What would you have to check in each version?
Answer:
The patched version's correctness depends on an argument about ranges — that each column sums to less than 120, so one carry suffices. That argument is invisible in the code and has to be reconstructed by a reader.
The rewritten version depends on integer addition being correct, which is not in doubt, and on the two conversions being inverses — which can be tested directly by checking that time_to_int(int_to_time(x)) equals x for many values of x.
So the correctness has been moved into two small functions that can be checked once and reused. That is what the book means by easier to verify, and it is why the same investment makes subtraction easy too.
Section
Section 4
Concept
In some ways, converting from base 60 to base 10 and back is harder than just dealing with times. Base conversion is more abstract; our intuition for dealing with time values is better.
# but with the insight and the conversions:
# shorter
# easier to read and debug
# more reliable
# and easier to add features later| Aspect | Which is easier | Note |
|---|---|---|
| the abstract version | harder to think about | base conversion |
| the concrete version | easier to think about | times |
| the code | the reverse | abstract wins |
Ironically, sometimes making a problem harder — or more general — makes it easier, because there are fewer special cases and fewer opportunities for error.
Think Python, 2nd edition — Allen B. Downey §16.3-16.5, pp. 159-159
Picture it
The two axes run in opposite directions.
Figure (svg): Two columns contrasting the intuitive framing of a problem with the general one
Which is the book's irony: the version that is harder to understand is the one that is shorter, easier to debug, and more reliable.
Worked example
The test of an approach is the second operation.
# with the conversions, subtraction is:
def subtract_time(t1, t2):
return int_to_time(time_to_int(t1) - time_to_int(t2))
# without them, it needs BORROWING -
# the mirror image of carrying, with its own
# special cases and its own ordering question| Approach | What subtraction costs | Note |
|---|---|---|
| with conversions | two lines | reusing what exists |
| without | borrowing logic | new special cases |
| the pattern | the investment pays off here |
Consider the naive approach.
Why: Imagine subtracting two Times to find the duration between them — the naive approach would be to implement subtraction with borrowing.
Consider the alternative.
Why: Using the conversion functions would be easier and more likely to be correct.
Note when the investment paid.
Why: Not on add_time, which was already written, but on the next operation — and on every one after that.
Figure (svg): The state of the program after each line of Worked example adding subtraction, drawn as a ladder with one rung per traced line
Two lines, reusing functions that already exist. It is also easier to add features later, which is where designed development earns its cost.
Verify: Check what subtraction should do with a negative result.
Why: The conversion version produces a negative number of seconds, which int_to_time will distribute into negative fields — arithmetically consistent and probably not a sensible Time. That is a design question the approach surfaces immediately, where the borrowing version would hit it as a special case in the middle of the algorithm.
Prediction
A consistency check on the conversions.
x = 3661
print(time_to_int(int_to_time(x)) == x)| Step | What happens | Result |
|---|---|---|
| int_to_time(3661) | 1:01:01 | distributed into columns |
| time_to_int(...) | 3600 + 60 + 1 | 3661 |
| the comparison | True | if both are correct |
Predict first
What does this print, assuming both functions are correct?
Correct: True — the two functions are inverses, so converting out and back gives the original number.
Why: This is the consistency check the book suggests: check that time_to_int(int_to_time(x)) == x for many values of x. It needs no hand-computed expected answers, so it can run over hundreds of inputs — which is exactly what makes it worth writing rather than checking cases by hand.
Worked example
One check covers both functions.
# for many values of x:
# time_to_int(int_to_time(x)) == x
# this is an example of a consistency check| Aspect | What is true | Note |
|---|---|---|
| the round trip | out and back | should give x |
| what it tests | both functions at once | and their agreement |
| what it needs | no expected answers | just many values of x |
Note what you have to convince yourself of.
Why: You might have to think a bit, and run some tests, to convince yourself that these functions are correct.
Note the test.
Why: One way is to check that time_to_int(int_to_time(x)) == x for many values of x.
Name it.
Why: This is an example of a consistency check — lesson 11c's technique, applied here.
Figure (svg): A flowchart showing a round-trip consistency check between the two conversion functions
A test that needs no hand-computed answers, so it can run over hundreds of values. It checks both functions and their agreement in one expression.
Verify: Ask what the check could miss.
Why: Two functions that are wrong in exactly compensating ways — if both used 100 instead of 60, the round trip would still return x. So the consistency check is strong evidence and not a proof, and one hand-checked case such as 3661 giving 1:01:01 pins down the absolute scale. Together they cover what neither does alone.
Trap
A student concludes that every problem should be generalised before being solved.
Take the chapter's lesson broadly
Why: The base-60 insight produced a dramatically better program.
Generalising without an insight produces abstraction without benefit — more machinery, no fewer special cases, and something harder to read for nothing. The insight came first here, and the generalisation followed from it.
Look for the insight, and generalise when you find one.
Ask what the special cases have in common
Why: Here: they are all carrying, which is base-60 addition.
Then check whether the general form removes them
Why: If it does not, the abstraction is not paying for itself.
The book's condition is fewer special cases and fewer opportunities for error. A generalisation that does not deliver those is just a harder program.
Comparison
Fill the blanks. Both are named in the glossary.
Comparison matrix
| Question | Prototype and patch | Designed development |
|---|---|---|
| What do you start with? | a rough draft | high-level insight into the problem |
| How do errors get fixed? | incrementally, as they are found | mostly by not arising |
| What does the code accumulate? | special cases | reusable pieces, like the conversions |
| When is each right? | when you lack a deep understanding of the problem | when an insight is available |
Neither replaces the other. The prototype is often how you reach the insight, and the insight is what lets you stop patching.
Faded example
Convert, compute, convert back.
Fill in the blanks
def add_time(t1, t2):
seconds = time_to_int(t1) + time_to_int(t2)
return int_to_time(seconds)
Why: The addition happens on plain integers, where nothing can overflow a column, and int_to_time redistributes the total across the three fields with divmod doing every carry. Two lines replace twelve, and both special cases disappear along with the reasoning that justified them.
Real world
The same problem, seen differently.
Discussion prompt
Think of a problem that became much easier once you thought about it differently rather than working harder at it. What changed?
Hint: A different unit, a different diagram, a different order.
Answer:
Converting everything to one unit before comparing; drawing a problem instead of describing it; reordering a task so the hard part comes first. The work did not shrink — the special cases did.
That is exactly the base-60 move: nothing about time arithmetic got simpler, and the representation stopped requiring a separate rule for each column boundary.
And the book's irony holds generally: the reframing is often harder to think about, and the thing you have to do afterwards is easier and more reliable. Fewer special cases means fewer opportunities for error, which is worth some abstraction.
Section
Section 5
Concept
A Time object is well-formed if the values of minute and second are between 0 and 60 — including 0 but not 60 — and if hour is positive. Requirements like these are called invariants because they should always be true.
invariant — A condition that should always be true during the execution of a program.
def valid_time(time):
if time.hour < 0 or time.minute < 0 or time.second < 0:
return False
if time.minute >= 60 or time.second >= 60:
return False
return True| Condition | What it means | Result |
|---|---|---|
| negative fields | invalid | return False |
| minute or second at 60 | invalid | return False |
| otherwise | well formed | return True |
To put it a different way: if they are not true, something has gone wrong. Writing code to check invariants can help detect errors and find their causes.
Think Python, 2nd edition — Allen B. Downey §16.3-16.5, pp. 159-159
Picture it
The prototype's result fails it immediately.
Figure (svg): A panel showing the 10:80:00 result failing the validity check
That is what makes invariants worth writing: they turn the output looks wrong into something the program itself can notice.
Worked example
Two ways to act on the check.
# raise a specific error
def add_time(t1, t2):
if not valid_time(t1) or not valid_time(t2):
raise ValueError('invalid Time object in add_time')
seconds = time_to_int(t1) + time_to_int(t2)
return int_to_time(seconds)
# or assert
def add_time(t1, t2):
assert valid_time(t1) and valid_time(t2)
seconds = time_to_int(t1) + time_to_int(t2)
return int_to_time(seconds)| Version | What it does | Note |
|---|---|---|
| the raise version | a specific message | for the caller |
| the assert version | shorter | checks and raises |
| both | at the beginning of the function | before any work |
Check at the start.
Why: At the beginning of each function you could check the arguments to make sure they are valid.
Raise with a message, or assert.
Why: An assert statement checks a given invariant and raises an exception if it fails.
Note why assert is worth having.
Why: assert statements are useful because they distinguish code that deals with normal conditions from code that checks for errors.
Figure (svg): The state of the program after each line of Worked example checking arguments at the start, drawn as a ladder with one rung per traced line
Two forms of the same check. The raise version gives a message the caller can act on; the assert version is shorter and marks itself as error-checking rather than logic.
Verify: Ask which to use when.
Why: A raise with a message when the caller might reasonably pass a bad value and should be told what was wrong; an assert when the condition should be impossible and its failure means a bug. That distinction is exactly lesson 11b's raise-or-return question, applied to arguments rather than results.
Prediction
A minute of exactly sixty.
# t is 10:60:00
print(valid_time(t))| Field | Value | Verdict |
|---|---|---|
| minute | 60 | not less than 60 |
| the condition | minute >= 60 | True |
| the result | False | invalid |
Predict first
What does valid_time report?
Correct: False — the values of minute and second must be between 0 and 60, including 0 but not 60.
Why: The boundary is exclusive at the top, which is why the check uses >= 60. Writing > 60 instead would accept 10:60:00 as well formed — exactly the state a missing carry produces, so the off-by-one would hide the family of bug the check exists to detect.
Worked example
It has to be checkable and it has to be true.
# a Time is well-formed if:
# 0 <= minute < 60
# 0 <= second < 60
# hour is positive
#
# hour and minute should be integers,
# but second may have a fraction part| Requirement | What it involves | Note |
|---|---|---|
| the ranges | checkable in three comparisons | cheap |
| the types | hour and minute integer | second may be fractional |
| always true | or something has gone wrong | the definition |
State it precisely.
Why: Between 0 and 60 including 0 but not 60 — the boundary is what a vague statement would get wrong.
Note the type detail.
Why: hour and minute should be integer values, but we might allow second to have a fraction part.
Note what makes it an invariant.
Why: It should always be true, so its failure means something has gone wrong — which is what the check is detecting.
Figure (svg): Four Time values tested against the well-formedness invariant
A precise, cheap condition that holds throughout a correct program. Both properties are needed: an expensive check will not be run, and a vague one will not catch anything.
Verify: Check the boundary the definition specifies.
Why: A minute of exactly 60 must be rejected, which means the comparison is >= 60 rather than > 60. Writing it the other way would accept 10:60:00 as well formed — precisely the state a missing carry leaves behind, so the off-by-one would hide the bug the check exists to find.
Trap
A function computes its result and asserts that the result is well formed, with no check on its arguments.
Check what you produce
Why: Which does catch a bad result.
It catches the failure and not the cause. If a malformed Time came in, the assertion fires in a function that did nothing wrong, and the actual mistake is somewhere earlier.
Check the arguments at the beginning.
assert valid_time(t1) and valid_time(t2)
Why: Which is where the book puts it.
Then a bad value is caught in the function that produced it
Why: Or as close to it as possible.
This is lesson 11b's principle again: report the failure where it happens. Checking arguments on entry means the first function to receive a malformed object is the one that complains, which narrows the search to whatever produced it.
Discrimination
An invariant should always be true.
Sort into buckets
For each statement about a program, is it an invariant?
Faded example
Check the arguments before doing any work.
Fill in the blanks
def add_time(t1, t2):
assert valid_time(t1) and valid_time(t2)
seconds = time_to_int(t1) + time_to_int(t2)
return int_to_time(seconds)
Why: An assert statement checks a given invariant and raises an exception if it fails. The book notes that assert statements are useful because they distinguish code that deals with normal conditions from code that checks for errors — a reader can see at a glance which lines are logic and which are safety.
Explain it
Both check a condition and can raise.
Discussion prompt
A classmate asks why the book shows both an if-with-raise and an assert for the same check. Explain what assert adds.
Hint: What does a reader learn from seeing the word?
Answer:
It is shorter, and more importantly it is labelled. assert statements distinguish code that deals with normal conditions from code that checks for errors — a reader sees immediately that the line is a safety check rather than part of the logic.
The if-with-raise version can say more: a specific message telling a caller what was wrong, which matters when the caller might reasonably have passed a bad value.
So the choice follows the meaning. Use assert when the condition should be impossible and its failure means a bug; raise with a message when a caller could plausibly get it wrong and deserves to be told what.
Comparison
Fill the blanks. The same function, two development plans.
Comparison matrix
| Question | Patched | Rewritten |
|---|---|---|
| How long? | twelve lines | two lines |
| Special cases | two conditionals | none — divmod handles it |
| Why is it correct? | because the inputs are bounded | because integer arithmetic is |
| What does subtraction cost? | borrowing, with its own special cases | one line, reusing the conversions |
The bottom row is where the investment pays off. The conversions cost something to write once and nothing on every operation afterwards.
Pattern
Six steps, and the second is the one that requires thinking rather than typing.
Step 4 matters because everything now depends on those two functions. A round trip over many values tests both at once and needs no expected answers.
Python documentation — Classes Classes
Check
The call shape follows the contract.
Check your understanding
increment is a modifier. Which call is correct?
Answer: A
Why: A modifier changes the object it is given, and most modifiers are void — they return None. So the call is a statement and the change to t is the whole effect. Assigning the result would set t to None and lose the Time.
Check
One observation replaced every special case.
Check your understanding
What is the insight behind designed development in this chapter?
Answer: A
Why: The second attribute is the ones column, the minute attribute the sixties column, and the hour attribute the thirty-six hundreds column — so add_time and increment were effectively doing addition in base 60, which is why they had to carry. Converting to integers lets ordinary arithmetic do it.
Check
A condition that should always be true.
Check your understanding
Why does the book put the validity check at the beginning of add_time rather than at the end?
Answer: A
Why: At the beginning of each function you could check the arguments to make sure they are valid — so a bad value is detected as soon as it arrives rather than after further computation has spread its effects. Checking only the result would catch the failure in a function that did nothing wrong.
Real world
Converting to a common unit before working.
Discussion prompt
Think of a calculation that got much easier once everything was converted to one unit first. What did the conversion remove?
Hint: Feet and inches, or pounds and pence.
Answer:
Adding lengths in feet and inches, or amounts in mixed units — the arithmetic needs a carrying rule for every boundary, and converting to a single unit removes all of them at once.
What the conversion removes is not work but special cases: one rule per boundary becomes no rules, because the boundaries stop existing until you convert back.
Which is precisely the base-60 move, and the same reason it is worth the abstraction. Fewer special cases means fewer opportunities for error, and the conversion functions are written once and reused by every operation afterwards.
Commit first
Answer, then rate your confidence.
Predict first
What is the insight that makes the rewritten add_time two lines instead of twelve?
Correct: A Time is a three-digit number in base 60, so converting to seconds makes it ordinary arithmetic.
Why: The second attribute is the ones column, the minute attribute is the sixties column, and the hour attribute is the thirty-six hundreds column — so when we wrote add_time and increment, we were effectively doing addition in base 60, which is why we had to carry from one column to the next. Converting to integers takes advantage of the fact that the computer knows how to do integer arithmetic, and the carrying disappears into divmod, written once and correct for any input. The result is shorter and easier to verify, and it makes every later operation cheap: subtraction, which would otherwise need borrowing with its own special cases, becomes one line. The book's closing observation is the general lesson — sometimes making a problem harder or more general makes it easier, because there are fewer special cases and fewer opportunities for error.
Explain it
Twelve lines became two, and nothing was lost.
Discussion prompt
A classmate cannot see where the carrying went in the rewritten add_time. Explain what happened to it.
Hint: It did not disappear.
Answer:
It moved into divmod. int_to_time calls divmod twice, and each call works out how many whole sixties there are and what is left — which is exactly what a carry does, done in one operation however many carries are needed.
So the carrying is written once, inside the conversion, rather than once per operation. add_time does not carry because by the time it adds anything there are no columns to overflow — just a number of seconds.
And that is why the investment pays off. Subtraction would have needed borrowing with its own special cases; with the conversions it is one line, reusing what already exists.
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 modifier contract is lesson 10c's, with the book's names attached, and the t = increment(t, 30) misuse is worth recognising on sight. The recommendation is hedged deliberately, and being able to state what counts as a compelling advantage is more useful than the recommendation itself. The base-60 insight is the chapter's whole point and the thing most worth carrying forward — including the closing irony about harder problems being easier. And invariants are what turn a semantic error like 10:80:00 into something the program can notice by itself.
Connect it up
One page, from memory.
Draw it
Draw a Time as three columns labelled with their place values in base 60, and write the total for 1:01:01. Beside it, write time_to_int and int_to_time from memory, marking where the carrying happens. Underneath, write add_time in both forms — patched and rewritten — and note beside each why it is correct. Finally write the invariant for a well-formed Time, being precise about the boundary at sixty.
Recap
Three pages, and chapter 16 is finished: two development plans, and a reason to prefer one.
| If you remember one thing | It is this |
|---|---|
| From modifiers | Most return None, so assigning the result loses the object. |
| From the recommendation | Pure by default; a modifier needs a reason you could state. |
| From the insight | The carrying was base-60 addition all along. |
| From the rewrite | Two lines, correct because integer arithmetic is. |
| From invariants | A condition that should always be true — so its failure means a bug. |
The next chapter turns these functions into methods: the same operations written inside the class, the __init__ method that finally assigns the attributes, __str__ for a useful printed form, and operator overloading so that two Times can be added with a plus sign.
Think Python, 2nd edition — Allen B. Downey §16.3-16.5, pp. 157-159 — everything on these slides traces back here
Want this taught 1-on-1? Alexander tutors Python — $55/session, free consultation.