0%

Errors as Part of Program Design · practice

Follow an Error Through Collaborating Objects

A raised does not stop where it was raised. It travels back up through everyone waiting for an answer, until something catches it or the program ends.

In a program made of one that is barely worth saying. In a program made of collaborating it is the most useful property exceptions have.

Watching it travel

Try it

Now hand it a number where text was expected:

run_quiz(attempt, questions, [42])

The TypeError was raised in Question.points_for. Nothing in Attempt.answer or run_quiz mentions errors at all, and both were interrupted anyway. The every one of them, innermost last:

  File "...", line 33, in run_quiz
    attempt.answer(question, response)
  File "...", line 27, in answer
    self.score = self.score + question.points_for(response)
  File "...", line 12, in points_for
    raise TypeError(f"a response must be text, not {type(response).__name__}")
TypeError: a response must be text, not int

Read a traceback bottom-up. The last line is what went wrong. The frame above it is where. The frames above that are how you got there.

points_for raises, and neither answer nor run_quiz has a try. What happens to Attempt.answer?

Why this is worth having

The middle of the program said nothing about errors, and that is the point. Attempt.answer has one job. Making it also decide what to do about every possible failure in a question would bury its actual work under handling for problems it cannot solve.

A that cannot fix a problem should not catch it. Letting the exception pass is not laziness; it is the correct handling, and it is free.

Compare it with the alternative, which programs without exceptions have to use:

def answer(self, question, response):
    earned, error = question.points_for(response)
    if error is not None:
        return None, error

    self.score = self.score + earned
    return earned, None

Every function returns a and an error, every caller checks, and the real work is now a third of the code. The exception version says the same thing by saying nothing.

What travels with it

An exception carries its type, its message, and the whole chain of frames it passed through. That chain is why an error raised four objects deep is still diagnosable at the top, and it is why swallowing an exception is expensive: catching it and continuing throws away the only record of what happened.

The partial-state problem

There is a real cost, and Chapter 5 has already prepared you for it. When an exception interrupts a method partway, whatever it had already done stays done:

Try it

A recorded response and no score for it. The list was appended to, then the exception arrived, and the object is now inconsistent.

The fix is the Chapter 5 rule, and it is why that rule mattered: do everything that can fail first, then change state. Ordering the two lines the other way makes this method safe.

The next lesson decides who catches, and where.

Task

Make Attempt.answer safe against an interruption. It currently records the response before asking the question for points, so an error leaves a response with no score behind it. Reorder it so that everything which can fail happens before any state changes.

Do not add a try to Attempt or to Question. Neither can fix a bad response, so neither should catch anything.

The supplied trace_of(action) helper walks error.__traceback__ and returns the names an passed through. Read and run it to make the concrete, but do not edit it; direct traceback manipulation is not the learner task.