Liskov violations that compile perfectly

OOP · lesson 42 of 43 · 6 min read

A subclass that satisfies the compiler and breaks every caller.

Open this lesson in the learning hub

Key points

  • Liskov substitution says code written against a type must keep working when given any subtype. The compiler only checks signatures; the contract is behaviour, and nothing enforces it.
  • The canonical violation is Square extending Rectangle. Both have width and height, but a Square that keeps them equal breaks any caller that sets them independently.
  • A subclass may weaken preconditions and strengthen postconditions, never the reverse. Throwing on input the parent accepted is a violation, however reasonable it seems locally.
  • UnsupportedOperationException is the loudest form. An immutable list is a valid List by signature and breaks every caller that adds to one - which is why Arrays.asList surprises people.
  • Behavioural subtyping is the reason "is-a" is a poor test. A Square is-a Rectangle in geometry and is not a valid subtype in code, because the operations differ.
  • When behaviour genuinely differs, prefer composition or a shared interface with no inherited implementation - inheritance is a promise about behaviour, not just about fields.

Example

import java.util.Arrays;
import java.util.List;

public class LiskovViolations {

    static class Rectangle {
        protected int width, height;
        void setWidth(int w)  { this.width = w; }
        void setHeight(int h) { this.height = h; }
        int area() { return width * height; }
    }

    // Compiles perfectly. Breaks every caller of the parent contract.
    static class Square extends Rectangle {
        @Override void setWidth(int w)  { this.width = w; this.height = w; }
        @Override void setHeight(int h) { this.width = h; this.height = h; }
    }

    // Written against Rectangle, and entirely reasonable.
    static int resizeAndMeasure(Rectangle r) {
        r.setWidth(5);
        r.setHeight(4);
        return r.area();          // any Rectangle should give 20
    }

    // Composition: no false promise, and the shapes stay separate.
    interface Shape { int area(); }
    record Rect(int width, int height) implements Shape {
        public int area() { return width * height; }
    }
    record Sq(int side) implements Shape {
        public int area() { return side * side; }
    }

    public static void main(String[] args) {
        System.out.println("resizeAndMeasure(Rectangle) = " + resizeAndMeasure(new Rectangle()));
        System.out.println("resizeAndMeasure(Square)    = " + resizeAndMeasure(new Square())
                + "   <- expected 20");

        // The other common violation: strengthening a precondition by refusing.
        List<String> fixed = Arrays.asList("a", "b");
        System.out.println();
        System.out.println("Arrays.asList is a List by signature:");
        System.out.println("  get(0) works        : " + fixed.get(0));
        try {
            fixed.add("c");
        } catch (UnsupportedOperationException e) {
            System.out.println("  add() throws        : UnsupportedOperationException");
            System.out.println("  -> any method taking List<String> can be broken by it");
        }

        System.out.println();
        System.out.println("Composition - no inherited promise to break:");
        List<Shape> shapes = List.of(new Rect(5, 4), new Sq(5));
        for (Shape s : shapes) {
            System.out.println("  " + s + " area = " + s.area());
        }
    }
}

The compiler checks signatures, not behaviour - a subclass that narrows what the parent accepted breaks callers that never knew it existed.

This is a reading copy. The full lesson — with the visual explainer, the interactive lab and a Run button for the code — lives in the OOP course, and every lesson in it is listed on the OOP contents page.