Inheritance and substitutability
Decide whether a subclass is really a subtype by checking preconditions, postconditions and invariants — and recognise the three symptoms that say it is not.
class Square extends Rectangle compiles. It also breaks code that was written
against Rectangle and never mentions Square, and no compiler will tell you.
Inheritance is two things wearing one keyword: a mechanism for reusing fields
and methods, and a promise that the subclass can stand in for the parent
anywhere. Only the first is checked.
The promise, stated as rules
The Liskov substitution principle is usually quoted as a slogan about "is-a".
The useful form is mechanical. If S inherits from T, then for every method
S overrides:
- Preconditions may be weakened, never strengthened. The override must accept every input the parent accepted. It may accept more.
- Postconditions may be strengthened, never weakened. The override's guarantee must imply the parent's. It may promise more.
- Invariants of the parent must hold, and the override may not permit a state change the parent forbade.
Note the directions: requirements loosen going down the hierarchy, guarantees
tighten. A caller holding a T reference wrote its code against T's
requirements and guarantees, so anything that asks more of it or hands back less
is a break.
"Strengthen" and "weaken" mean implication, not clause count. A postcondition
P' is a legal strengthening of P only when P' ⇒ P. Adding a clause that
contradicts an existing one is not strengthening; it is a different contract
wearing the same signature.
Square is not a Rectangle
Write Rectangle's contract down before arguing about geometry:
class Rectangle:
def __init__(self, w, h):
self._w, self._h = w, h
@property
def width(self):
return self._w
@property
def height(self):
return self._h
def set_width(self, w):
# pre: w > 0
# post: self.width == w and self.height == old(self.height)
self._w = w
class Rectangle {
protected int w, h;
int width() { return w; }
int height() { return h; }
/** pre: w > 0; post: width() == w && height() == old(height()) */
void setWidth(int w) { this.w = w; }
}
Now Square, written the only way it can be written:
class Square(Rectangle):
def set_width(self, w):
# post: self.width == w and self.height == w
self._w = self._h = w
That postcondition looks stronger. It pins both fields to an exact value,
where Rectangle left one of them merely unchanged — a narrower set of allowed
final states, which is normally the legal direction. It fails the implication
test anyway:
width == w ∧ height == w ⇒ width == w ∧ height == old(height) ?
Only when old(height) == w. For every other starting shape the implication is
false. Square strengthens the promise about height into one that contradicts
the promise Rectangle already made, and the caller only ever agreed to
Rectangle's.
Here is the caller that breaks, and there is nothing wrong with it:
def widen(r: Rectangle) -> None:
h = r.height
r.set_width(r.width + 1)
assert r.height == h # trips on a Square
Nothing is wrong inside Square either. The defect lives on the inheritance
edge, which is why it survives review of both files. A square is a rectangle
as a set of values; a mutable Square is not a subtype of a mutable
Rectangle, because a type is its operations and their contracts, not its
values.
Mutability is doing the damage. Make the parent immutable — with_width(w)
returning a new object rather than mutating — and the contradiction disappears,
because there is no old(height) to preserve. Immutable value types are far
easier to subtype for exactly this reason.
C++ adds a second failure. Store a Square in a vector<Rectangle> and it is
copied into a Rectangle-sized slot: the Square-ness is sliced off and later
calls reach Rectangle's implementation. Python and Java hold references, so
they get the substitution bug without the slicing bug. The C++ fix is to hold
Rectangle* and mark the method virtual.
The three symptoms
At a review you rarely have contracts written down. These questions find the same defect without them.
Does the override throw where the parent did not? That is a strengthened
precondition — provided the parent actually promised the call would succeed.
class FixedList<E> extends ArrayList<E> whose add throws
UnsupportedOperationException is the genuine case: ArrayList.add is a
concrete method that always succeeds, so the parent did say "add works", the
child says "unless", and every caller now needs a try. Arrays.asList(a, b)
looks like the same thing and is not one. Its add throws, but the exception
comes from the inherited AbstractList.add, which the returned
Arrays$ArrayList never overrides, and the List interface documents add as
an optional operation explicitly allowed to refuse. No promise was withdrawn, so
no precondition was strengthened. Check the parent's contract before you count
an exception as a violation.
Does the override promise less? Returning null where the parent promised a
value, returning an approximate count where the parent returned an exact one,
silently doing nothing where the parent did the work. Weakened postcondition.
Does any caller need instanceof or isinstance to be correct? A type test
in a caller is a receipt for a failed substitution — the caller found that the
reference type does not tell it enough, and is compensating. One is a smell; a
chain of them is a hierarchy that should have been a
composition.
The cost of a wrong hierarchy is never dispatch. A
virtual call is an indirect
load and a jump, on the order of an L1 cache reference at 1 ns — against a
datacenter round trip at 0.5 ms, that is 500,000 dispatches in the time of one
network hop. The cost is the audit. If Rectangle has 40 call sites and you add
Square, all 40 must be re-read for an assumption about independent width and
height. At 3 minutes a site that is 2 hours, once now and again for every call
site anyone adds later.
When it does not hold
Three fixes, in the order worth trying:
- Move the contract up. Give
Shapeanarea()and no setters. Both honour it, and nothing stands in for something it cannot replace. - Make the parent immutable, so there is no state transition to contradict.
- Delegate instead.
Squareholds aRectangleand exposes onlyside. It promises nothing it cannot keep, and nothing in the base can reach in and break it — see the fragile base class.
Name the precondition before committing to the tool, exactly as in the
solving loop: here the tool is
extends and the precondition is substitutability.
In an interview
The interviewer is testing whether "is-a" is a slogan to you or a checkable
claim. When you propose a hierarchy, state the substitution in one sentence:
"every caller written against Account keeps working when I hand it a
SavingsAccount, because withdraw still accepts every amount Account
accepted and still leaves the balance non-negative." That sentence is the whole
answer, and it invites the follow-up you want.
The mistake that loses points is defending Square extends Rectangle on
geometry. Interviewers ask it precisely because the mathematical intuition and
the type-system answer disagree, and the candidate who reaches for "but a square
really is a rectangle" has shown they check hierarchies against the world rather
than against the callers. Say instead: "the values nest, the contracts do not,
because setWidth promises height is untouched."
The second trap is offering instanceof as the fix. It works, and it announces
that the abstraction failed — say so, then fix the hierarchy or name the debt.
Check yourself
FixedList extends ArrayList and throws UnsupportedOperationException
from add. Which rule is broken, and what does it cost a caller?
The precondition on
addis strengthened:ArrayList.addaccepts any element andFixedListaccepts none. Every caller holding aListreference must now either wrapaddin atryor type-test first — the checkListwas supposed to make unnecessary.
Rectangle.area() returns an int. A subclass overrides it to return the
area rounded to the nearest 10 "for display". Legal?
No. The parent promised an exact area; the override promises a value within 5 of it, which does not imply the parent's guarantee. Weakened postcondition. Rounding belongs in the caller or in a formatter, not in the override.
You need Square and you need setWidth on Rectangle. Give two designs
that both hold, and say what each gives up.
Make
Rectangleimmutable —with_widthreturns a newRectangle— soSquarecan extend it safely; you give up in-place mutation and allocate on every change. Or haveSquarehold a privateRectangleand expose onlyside; you give up passing aSquarewhere aRectangleis expected, which was never sound anyway.