Inheritance and composition6 min · 7 of 16

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.

Same reference type, same call, two postconditions. The caller never agreed to the second one.
A caller that sets width on a Rectangle reference, and the assertion that survives a Rectangle but not a Squareis-ah = 4h = 5Callerr.setWidth(5)Rectanglepost: h unchangedSquarepost: h = wassert h == 4assert h == 4Same reference type, same call, two postconditions. Thecaller never agreed to the second one.

Scroll to zoom · drag to pan · 0 fits · Esc closes

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 Shape an area() 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. Square holds a Rectangle and exposes only side. 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 add is strengthened: ArrayList.add accepts any element and FixedList accepts none. Every caller holding a List reference must now either wrap add in a try or type-test first — the check List was 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 Rectangle immutable — with_width returns a new Rectangle — so Square can extend it safely; you give up in-place mutation and allocate on every change. Or have Square hold a private Rectangle and expose only side; you give up passing a Square where a Rectangle is expected, which was never sound anyway.