Hackle's blog
between the abstractions we want and the abstractions we get.

A Square IS A Rectangle - If Designed Correctly

An age-old example from Object-Oriented literature for the classic Liskov Substitution Principle is the much acclaimed "A square is not a rectangle".

It goes roughly as, Square is a sub-type of Rectangle, just as the intuition that a square is a special rectangle with equal height and width. In plain simple TypeScript,

class Rectangle0 {
    constructor(public width: number, public height: number) {}
}

class Square0 extends Rectangle0 {
    constructor(side: number) {
        super(side, side)
    }
}

For dramatic effect, here we say "so far so good, but..." the intuition breaks down when we try to set Square.height, the Square is no longer a Square. Bummer!!!

const square0 = new Square0(5);
// Oh NO! height != width This is no longer a Square 
square0.height = 4;

A more sophisticated version invests generously in setters/accessors. I'll spare you the ugly code listing and show only the gist in the Square sub-type.

class Square1 extends Rectangle1 {
    // same goes for: override set width(value: number)
    override set height(value: number) {
        super.height = super.width = value;
    }
}

This looks reasonable for the Square, but is surprising if we consider the base class - Square's behaviour deviates from that of Rectangle, whose width and height are expected to change independently. As LSP points out, a sub-type should satisfy ALL expectations set out by the base class, both in appearance and in spirit!

Here Object-Oriented literature laments the violation of the mathematical intuition, but not without joy in pointing out the moral of the lesson: that software design is NOT always what you think. No sir, it's a sophisticated and arduous endeavour, definitely not for the faint-hearted.

Sigh, oh well, a Square Is Not a Rectangle!

Oh but hold on! It's time we put a stop to such nonsensical teaching based on a terribly broken example, kept in centre stage for decades with the perpetuation of questionable mainstream thinking.

Let's bring some sanity by resorting to common sense: if I change the width (and width only) of a square, should I still get a square? Think hard before jumping to any answers.

Of course not! I get a rectangle!

The height of the shape should not change automagically with the width - it only does so in badly designed software with false assumptions.

The right behaviour is not hard to implement either, if we pay a little respect to common sense, and renounce one or two die-hard habits.

class Rectangle2 {
    constructor(readonly width: number, readonly height: number) {
    }

    setHeight(height: number): Rectangle2 {
        return new Rectangle2(this.width, height);
    }
}

class Square2 extends Rectangle2 {
    constructor(readonly side: number) {
        super(side, side);
    }
}

const rect2 = new Rectangle2(3, 8);
const square2 = new Square2(4);

// Hello! It's a Rectangle, not a Square
const rect3 = square2.setHeight(5);

The gist is with setHeight(height: number): Rectangle,

  1. setting the height of (or "stretching") a Rectangle, including the special case of Square, will result in another Rectangle.
  2. calling setHeight on a Square should NEVER magically set its width. In fact, automagically setting width when the caller calls setHeight, is the smacking violation of LSP.

Now let's zoom in. Do you see what die-hard habits I was alluding to that should be "renounced"? Let me spell it out,

Mutation! We prohibit the likes of Square.height = 5 by making all fields readonly. Now Square is immutable and must stay as constructed. Therefore, it is a much stronger type, whose values cannot be (at least not easily) manipulated out of shape (pun intended).

If we go a bit further, another source of evil is the entrenched teaching to mix data and behaviour, which hopefully is quickly going out of favour: more modern languages such as Rust and Go encourage us to keep data and behaviour separate, while offering a flavour of dot notation similar to that of classes.

Hope we are now on the same page on the age-old nonsense, "A square is not a rectangle". Quite the broken example steeped in the intrinsic shortcoming of the unfortunate combination of classes and mutation, and it does 0 justice to the principle by the great Babara Liskov.

PS. with all that said about the solution, I am not stating that this problem is meaningless and should be discarded for good. Instead, it can be a great exercise if presented as a design challenge: how do we model Square and Rectangle so their behaviours are inline with our mathematical understanding?

This way, we can now pose questions such as: what if I want change the size of a Square to get another Square?

And the answer may be: you must construct a new Square. Why? Because we choose to design it so that the constraint of "equal width and height" is enforced through the constructor. Free mutation of width and height side-steps the constructor, therefore must be disallowed to ensure correctness.