0%

Inheritance and Polymorphism · practice

Respect Behavioral Expectations

A subclass can inherit correctly, override cleanly, and still break the program.

Every that takes a concrete Question subclass was written against expectations: is_correct takes one response and returns a , points is a number, and describe returns a . The base raises NotImplementedError only to mark an incomplete family member; the base itself must not be placed in a quiz. Every concrete member promises the Boolean behavior. Python does not enforce that promise for us.

A subclass that quietly disagrees is the subject of this lesson.

Breaking the promise

Try it

No error, and the score is right by accident: None is falsy, so the blank answer scored nothing. Now watch the same subclass meet code that asks the question slightly differently:

Try it

Zero wrong answers, from a learner who answered nothing. None is False is False, so the blank response is counted as neither right nor wrong, and it vanishes from the report.

The subclass did something reasonable in isolation. It broke a promise the base had made to everyone else.

Substitutability

The rule this violates has a name worth knowing: a subclass should be usable anywhere its base is expected, without the surrounding code behaving differently. If a function works with Question and misbehaves when handed a TextQuestion, the subclass is wrong, not the function.

In practice this comes down to a few concrete promises:

Return the same kind of thing. If the base returns True or False, return True or False. Adding a third possibility means every caller ever written has an untested path.

Accept at least what the base accepts. A subclass whose is_correct requires a second breaks every existing call.

Do not add new requirements. If the base can be used any time, a subclass that raises unless something was configured first has narrowed the contract.

Do not raise what the base would not. Callers handle what the base documents. A new type travels straight past them.

Which subclass would be safe to use anywhere a Question is expected?

The hard case: a subclass that can do less

The classic version of this problem is worth seeing, because it looks so reasonable.

Suppose Question supports being reworded:

class Question:
    def reword(self, prompt):
        self.prompt = prompt

Now a LockedQuestion, used in a final exam, must never change:

class LockedQuestion(Question):
    def reword(self, prompt):
        raise PermissionError("a locked question cannot be reworded")

This reads as careful design, and it means that any code holding a Question can no longer safely call reword. Every caller now needs a check, or a try, and the promise the base made is worthless.

The mistake was earlier: LockedQuestion is not a kind of Question as Question was defined, because a question was defined as something rewordable. The options are all about design, not syntax:

  • take reword off the base, and put it on a MutableQuestion subclass;

  • make questions frozen values, as Chapter 8 did, so nothing can be reworded;

  • give Question a can_reword the caller can ask about, so refusing is part of the contract rather than a violation of it.

Any of these is better than a subclass that fails when used as its base.

A useful habit

When you write a subclass, look at the code that already uses the base, and ask whether it would still be correct if handed your subclass without being told.

That question is easier to answer with tests, which is why Chapter 13 returns to this: writing one set of tests against the base’s promises and running it over every member of the family is the cheapest way to catch these before a learner does.

The exercise gives you four subclasses. Three of them break a promise, and part of the work is saying which one does not.

Its diagnostic function also needs a class name: type(question).__name__ gives that name as a string. Unlike scoring code, this function is explicitly reporting which class needs repair. Check a returned with is True or is False: equality alone also accepts 1 and 0. Here except Exception is appropriate around the diagnostic call because any ordinary exception violates the promise; record the class instead of silently hiding the failure.

Task

Every concrete implementation of Question.is_correct(response) promises one thing: given any , return True or False. The base placeholder raises only because a bare Question is incomplete and must not enter the collection. Three of these four concrete subclasses break the promise.

Repair each one so it keeps the promise while still doing what it was trying to do:

  • UnansweredAware returns None for a blank response. A blank response is simply not correct.

  • PointsReturning returns the points earned instead of a . It should return a Boolean, and let the caller read points for the number.

  • StrictQuestion raises for a blank response. It should treat a blank response as incorrect.

  • NumericQuestion is already correct. Leave it alone.

Then complete check_family(questions), which returns the names of any classes that still break the promise. Give each question a blank response and a nonsense one, and report the class whenever the result is not exactly True or False, or whenever asking raises.

A correctly repaired family should make check_family return an empty .