Inheritance and Polymorphism · practice
Respect Behavioral Expectations
A subclass can inherit correctly, override cleanly, and still break the program.
Every Question subclass was written against expectations: is_correct takes one response and returns a points is a number, and describe returns a NotImplementedError only to mark an incomplete family member; the base
A subclass that quietly disagrees is the subject of this lesson.
Breaking the promise
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:
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
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
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
rewordoff the base, and put it on aMutableQuestionsubclass;make questions frozen values, as Chapter 8 did, so nothing can be reworded;
give
Questionacan_rewordthe 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 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 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:
UnansweredAwarereturnsNonefor a blank response. A blank response is simply not correct.PointsReturningreturns the points earned instead of a. It should return a Boolean, and let the caller read pointsfor the number.StrictQuestionraisesfor a blank response. It should treat a blank response as incorrect. NumericQuestionis 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