16b Modifiers, and Prototyping versus Planning

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

What this lesson covers

The lesson, slide by slide

1. Lesson 16b Modifiers, and Prototyping versus Planning

Title

Python · Chapter 16 — Classes and functions

§16.3-16.5, pp. 157-159

2. By the end of this lesson you can

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

3. Before we start: adding a hundred seconds

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.

4. The one idea behind this lesson: a Time is a base-60 number

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
AttributeWhich columnIts value
secondthe ones column1
minutethe sixties column60
hourthe thirty-six hundreds column3600

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

Once the columns are named, converting to a single number is arithmetic you already know.

Think Python, 2nd edition — Allen B. Downey §16.3-16.5, pp. 158-158

5. Modifiers

Section

Section 1

6. A function that changes what it is given

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
PartWhat it doesNote
the first linethe basic operationadd the seconds
the remainderthe special casesthe same carries as before
what it returnsnothingthe 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

7. Picture it: two contracts, two call sites

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

Lesson 10c's two contracts, now with the book's own names for them.

Most modifiers are void — they return None — which is what makes the misuse t = increment(t, 30) fail on the next operation.

8. Worked example: is this function correct?

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?
InputCarries neededWhat the code does
seconds = 30one carry at mostcorrect
seconds = 100needs two carriesthe if fires once
seconds = 10000needs manyfar 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.

9. Predict: what does the caller see?

Prediction

increment is a modifier.

t.second = 0
increment(t, 30)
print(t.second)
StepWhat happensResult
the calltime is an alias for tone object
time.second += 30modifies itin place
t.secondthe same object30

Predict first

What does this print?

  • 30
  • 0
  • None
  • An error, since increment returns nothing

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.

10. Worked example: the two ways to fix it

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
ApproachWhat it doesNote
the while versioncarries until donecorrect
the costone pass per sixty secondsslow for large inputs
the exercisea correct version with no loopsusing 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

The whole run at once: each drop is one line of the program.

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.

11. Trap: assigning the result of a modifier

Trap

The 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.

The fix

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.

12. Sort: modifier or pure function?

Sorting

Ask what the function does to its arguments.

Sort into buckets

For each description, which kind is it?

a modifier
adds seconds to the Time it is given; sorts the list it is passed; moves a Rectangle by changing its corner
a pure function
returns the sum of two Times as a new Time; returns a sorted copy of a list; returns the centre of a Rectangle as a new Point
mod
Each changes one or more of the objects it receives as arguments, so the caller sees the difference — and each returns None, which is what makes the wrong call shape fail.
pure
Each builds and returns something new, modifying nothing it was given. The caller must assign the result or it is lost.

13. Complete it: make increment correct for any input

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.

14. Think it through: why was add_time safe?

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.

15. Pure functions or modifiers?

Section

Section 2

16. A recommendation, with a reason and a caveat

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

17. Picture it: what each style buys

Picture it

A real trade, with the book coming down on one side.

Figure (svg): Two columns weighing pure functions against modifiers

Write pure functions whenever it is reasonable, and resort to modifiers only for a compelling advantage.

The efficiency point is real: building a new object every time costs something, and for large structures it can cost a great deal.

18. Worked example: the same operation both ways

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
VersionWhat it doesNote
the modifierchanges the caller's Timeshorter
the pure versioncopies, then changes the copylonger
the differenceone 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

The whole run at once: each drop is one line of the program.

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.

19. Two truths and a lie: the two styles

Two truths and a lie

Two are true. Keep the lie.

Eliminate the wrong options

Rule out the two true statements.

  • A. Anything that can be done with modifiers can also be done with pure functions
  • B. Functional programs tend to be less efficient
  • C. Modifiers should never be used

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.

20. Worked example: when a modifier is the compelling choice

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
VersionWhat it costsNote
the modifierappends in placeno copying
the pure versionbuilds a new listcopies everything
in a loopthe copying compoundsquadratic

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

The efficiency argument only bites at scale, which is where it becomes a compelling advantage.

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.

21. Trap: using a modifier for the convenience

Trap

The 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.

The fix

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.

22. Predict: what does the wrong call shape give?

Prediction

Assigning the result of a modifier.

t = increment(t, 30)
print(t)
StepWhat happensResult
incrementa modifierreturns None
t = ...stores Nonethe Time is lost
print(t)None

Predict first

What does this print?

  • None
  • The modified Time object
  • 30
  • An error, since increment takes two arguments

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.

23. Compare: the two contracts

Comparison

Fill the blanks. The chapter names both.

Comparison matrix

QuestionPure functionModifier
What does it change?nothingone or more of its arguments
What does it return?usually a new objectusually None
Correct call shaperesult = f(x)f(x)
Which does the book recommend?this one, whenever reasonableonly 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.

24. Explain it: why prefer pure functions?

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.

25. Designed development

Section

Section 3

26. The insight that removes the special cases

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
FunctionWhat it doesNote
time_to_intcolumns to one numberhours, then minutes, then seconds
divmodquotient and remainderthe carrying, done by arithmetic
int_to_timeone number back to columnsthe 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

27. Picture it: the conversion round trip

Picture it

Out to a number, do the arithmetic, and back.

Figure (svg): A pipeline showing Times converted to integers, added, and converted back

Convert, compute, convert back — and no carrying appears anywhere.

The carrying has not gone away. It is inside divmod, written once and correct for any input.

28. Worked example: add_time, rewritten

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)
StepWhat happensNote
convert bothto secondsordinary integers
add themordinary additionno special cases
convert backint_to_timedivmod 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

Shorter, and easier to verify — which is the book's own verdict.

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.

29. Predict: what does time_to_int give?

Prediction

A time converted to seconds.

# t is 1:01:01
print(time_to_int(t))
ColumnIts contributionValue
hours1 x 36003600
minutes1 x 6060
seconds11

Predict first

What does this print?

  • 3661
  • 3600
  • 111
  • 61

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.

30. Worked example: how divmod does the carrying

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
CallWhat it computesNote
divmod(seconds, 60)how many whole minutes, and the restthe seconds carry
divmod(minutes, 60)how many whole hours, and the restthe minutes carry
no loophowever large the inputone 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

The whole run at once: each drop is one line of the program.

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.

31. Trap: patching once more instead of rethinking

Trap

The 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.

The fix

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.

32. Watch the conversion: 3661 seconds back to a Time

Invariant

Two calls to divmod.

Step through it

How many times does the carrying happen, and where?

  1. A single number of seconds, with no column structure at all.
  2. The first divmod splits it: sixty-one whole minutes with one second left over.
  3. The remainder becomes the seconds attribute and the quotient goes on to the next column.
  4. The second divmod splits the minutes the same way, giving one hour and one minute — so 3661 seconds is 1:01:01.

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.

33. Complete it: convert an integer back to a Time

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.

34. Explain it yourself: why is the rewrite easier to verify?

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.

35. Making a problem harder to make it easier

Section

Section 4

36. The chapter's closing observation

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
AspectWhich is easierNote
the abstract versionharder to think aboutbase conversion
the concrete versioneasier to think abouttimes
the codethe reverseabstract 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

37. Picture it: harder to think about, easier to write

Picture it

The two axes run in opposite directions.

Figure (svg): Two columns contrasting the intuitive framing of a problem with the general one

The harder framing produces the easier program.

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.

38. Worked example: adding subtraction

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
ApproachWhat subtraction costsNote
with conversionstwo linesreusing what exists
withoutborrowing logicnew special cases
the patternthe 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

The whole run at once: each drop is one line of the program.

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.

39. Predict: what does the round trip give?

Prediction

A consistency check on the conversions.

x = 3661
print(time_to_int(int_to_time(x)) == x)
StepWhat happensResult
int_to_time(3661)1:01:01distributed into columns
time_to_int(...)3600 + 60 + 13661
the comparisonTrueif both are correct

Predict first

What does this print, assuming both functions are correct?

  • True
  • False
  • 3661
  • 1:01:01

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.

40. Worked example: testing the conversions

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
AspectWhat is trueNote
the round tripout and backshould give x
what it testsboth functions at onceand their agreement
what it needsno expected answersjust 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

Out and back, with no expected answer to work out — so it runs over as many values as you like.

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.

41. Trap: assuming the general version is always better

Trap

The 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.

The fix

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.

42. Compare: the two development plans

Comparison

Fill the blanks. Both are named in the glossary.

Comparison matrix

QuestionPrototype and patchDesigned development
What do you start with?a rough drafthigh-level insight into the problem
How do errors get fixed?incrementally, as they are foundmostly by not arising
What does the code accumulate?special casesreusable pieces, like the conversions
When is each right?when you lack a deep understanding of the problemwhen 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.

43. Complete it: add_time with conversions

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.

44. Where a change of representation removes the difficulty

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.

45. Invariants and assertions

Section

Section 5

46. Conditions that should always be true

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
ConditionWhat it meansResult
negative fieldsinvalidreturn False
minute or second at 60invalidreturn False
otherwisewell formedreturn 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

47. Picture it: what the invariant would have caught

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.

48. Worked example: checking arguments at the start

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)
VersionWhat it doesNote
the raise versiona specific messagefor the caller
the assert versionshorterchecks and raises
bothat the beginning of the functionbefore 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

The whole run at once: each drop is one line of the program.

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.

49. Predict: does this Time pass?

Prediction

A minute of exactly sixty.

# t is 10:60:00
print(valid_time(t))
FieldValueVerdict
minute60not less than 60
the conditionminute >= 60True
the resultFalseinvalid

Predict first

What does valid_time report?

  • False — minute must be less than 60
  • True — 60 is within the allowed range
  • True — only negative values are rejected
  • An error, since 60 is not a valid minute

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.

50. Worked example: what makes an invariant useful

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
RequirementWhat it involvesNote
the rangescheckable in three comparisonscheap
the typeshour and minute integersecond may be fractional
always trueor something has gone wrongthe 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

The boundary at sixty and the fractional second are both stated deliberately.

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.

51. Trap: checking the invariant only at the end

Trap

The 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.

The fix

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.

52. Discriminate: an invariant, or an ordinary condition?

Discrimination

An invariant should always be true.

Sort into buckets

For each statement about a program, is it an invariant?

an invariant
a Time's minute is always between 0 and 60; a histogram's counts are never negative; a list's length is never negative
not an invariant
the user has entered a valid time; the file exists; the network is available
inv
Each should be true throughout a correct program, so a violation means something has gone wrong inside the program itself.
not
Each is a fact about the world outside the program, which can legitimately be false. Those are conditions to handle rather than invariants to assert.

53. Complete it: assert the 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.

54. Explain it: why assert rather than an if?

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.

55. Compare: the patched add_time and the rewritten one

Comparison

Fill the blanks. The same function, two development plans.

Comparison matrix

QuestionPatchedRewritten
How long?twelve linestwo lines
Special casestwo conditionalsnone — divmod handles it
Why is it correct?because the inputs are boundedbecause integer arithmetic is
What does subtraction cost?borrowing, with its own special casesone 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.

56. The procedure: replacing special cases with an insight

Pattern

Six steps, and the second is the one that requires thinking rather than typing.

  1. Notice that the special cases have something in common — here, they are all carrying.
  2. Name what that common thing actually is: addition in base 60.
  3. Write the conversion to and from a representation where the operation is ordinary.
  4. Test the conversions against each other with a round-trip consistency check.
  5. Rewrite each operation as convert, compute, convert back.
  6. Check the invariants on entry, so that a malformed value is caught where it arrives.

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

57. Check yourself 1 of 3: modifiers

Check

The call shape follows the contract.

Check your understanding

increment is a modifier. Which call is correct?

  • A. increment(t, 30) (correct)
  • B. t = increment(t, 30)
  • C. t.increment(30)
  • D. increment(30)

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.

Why B tempts people
This stores None, because increment returns nothing. The Time is modified and then thrown away.
Why C tempts people
Method syntax, which the next chapter introduces. At this stage increment is a plain function.
Why D tempts people
The function takes two arguments: the Time to modify and the number of seconds.

58. Check yourself 2 of 3: the insight

Check

One observation replaced every special case.

Check your understanding

What is the insight behind designed development in this chapter?

  • A. A Time object is really a three-digit number in base 60 (correct)
  • B. Modifiers are more efficient than pure functions
  • C. Times should be stored as strings
  • D. Carrying should be done with a while loop

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.

Why B tempts people
True in some cases and unrelated to the insight, which is about representation rather than style.
Why C tempts people
Nothing in the chapter suggests this, and it would make arithmetic harder rather than easier.
Why D tempts people
That is one fix for increment, which the book calls correct but not very efficient — a patch rather than an insight.

59. Check yourself 3 of 3: invariants

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?

  • A. So a malformed argument is caught in the function that received it, close to whatever produced it (correct)
  • B. Because the result cannot be checked
  • C. Because assert statements must be the first line
  • D. To make the function faster

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.

Why B tempts people
The result can be checked too, and doing both is reasonable. The argument check is the one that locates the cause.
Why C tempts people
assert is an ordinary statement and may appear anywhere.
Why D tempts people
Checking costs a little time rather than saving any. The benefit is diagnostic.

60. Where this shows up outside this course

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.

61. Confidence wager: commit before you check

Commit first

Answer, then rate your confidence.

Predict first

What is the insight that makes the rewritten add_time two lines instead of twelve?

  • A Time is a three-digit number in base 60, so converting to seconds makes it ordinary arithmetic
  • Modifiers are shorter than pure functions
  • divmod is faster than subtraction
  • The carrying can be done with a while loop

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.

62. Explain it to someone else

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.

63. Exit ticket

Exit ticket

One honest answer. It decides what the next lesson opens with.

Predict first

Which of these is still least solid for you?

  • Modifiers, and how their call sites differ
  • Why the book recommends pure functions, and the exception
  • The base-60 insight and the two conversion functions
  • Invariants, valid_time, and the assert statement

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.

64. Synthesis: draw the map of this lesson

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.

65. What you can do now

Recap

Three pages, and chapter 16 is finished: two development plans, and a reason to prefer one.

If you remember one thingIt is this
From modifiersMost return None, so assigning the result loses the object.
From the recommendationPure by default; a modifier needs a reason you could state.
From the insightThe carrying was base-60 addition all along.
From the rewriteTwo lines, correct because integer arithmetic is.
From invariantsA 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

Sources

  1. Think Python, 2nd edition — Allen B. Downey — Allen B. Downey, Think Python: How to Think Like a Computer Scientist, 2nd edition (Green Tea Press, 2015), §16.3-16.5, pp. 157-159
  2. Python documentation — Classes
  3. Python documentation — Errors and Exceptions

Want this taught 1-on-1? Alexander tutors Python — $55/session, free consultation.

Book on Wyzant · Text (657) 465-8108