Course program← Объектно ориентированное программирование
1История ООП
2Объект и Класс
3Базовые концепции ООП
4Принцип проектирования GRASP
5Принцип проектирования SOLID
6Паттерны GOF
7Методические материалы

LSP – the Liskov Substitution Principle

This lesson was translated automatically – it may contain errors.

Objects of derived classes must completely replace objects of the base classes without changing the correctness of the program.

Wherever a base type is used, a subtype must work there as well without breaking the logic. A descendant must not tighten the contracts of the parent or break its behaviour.

What a violation looks like

public class Rectangle
{
    public virtual int Width { get; set; }
    public virtual int Height { get; set; }

    public int Area => Width * Height;
}

// A square «is» a rectangle in geometry, but not in code
public class Square : Rectangle
{
    public override int Width
    {
        set { base.Width = value; base.Height = value; }
    }

    public override int Height
    {
        set { base.Width = value; base.Height = value; }
    }
}

// Code written for Rectangle breaks on Square
void Resize(Rectangle r)
{
    r.Width = 4;
    r.Height = 5;

    // We expect 20, for a square we get 25
    Console.WriteLine(r.Area);
}
class Rectangle {
public:
    virtual void set_width(int value) { width_ = value; }
    virtual void set_height(int value) { height_ = value; }

    int Area() const { return width_ * height_; }

    virtual ~Rectangle() = default;

protected:
    int width_ = 0;
    int height_ = 0;
};

// A square «is» a rectangle in geometry, but not in code
class Square : public Rectangle {
public:
    void set_width(int value) override {
        width_ = value;
        height_ = value;
    }

    void set_height(int value) override {
        width_ = value;
        height_ = value;
    }
};

// Code written for Rectangle breaks on Square
void Resize(Rectangle& r) {
    r.set_width(4);
    r.set_height(5);

    // We expect 20, for a square we get 25
    std::cout << r.Area() << std::endl;
}
// Go has no inheritance, and swapping behaviour through embedding will not work:
// the method of the base type keeps working with the base fields.
// Here an LSP violation shows up on interfaces.
type Shape interface {
	Resize(width, height int)
	Area() int
}

type Rectangle struct {
	width, height int
}

func (r *Rectangle) Resize(w, h int) { r.width, r.height = w, h }
func (r *Rectangle) Area() int       { return r.width * r.height }

type Square struct {
	side int
}

// The square is forced to ignore one of the arguments – the contract is broken
func (s *Square) Resize(w, h int) { s.side = h }
func (s *Square) Area() int       { return s.side * s.side }

func Resize(s Shape) {
	s.Resize(4, 5)

	// We expect 20, for a square we get 25
	fmt.Println(s.Area())
}
class Rectangle:
    def __init__(self):
        self._width = 0
        self._height = 0

    @property
    def width(self):
        return self._width

    @width.setter
    def width(self, value):
        self._width = value

    @property
    def height(self):
        return self._height

    @height.setter
    def height(self, value):
        self._height = value

    @property
    def area(self):
        return self._width * self._height

# A square «is» a rectangle in geometry, but not in code
class Square(Rectangle):
    @Rectangle.width.setter
    def width(self, value):
        self._width = value
        self._height = value

    @Rectangle.height.setter
    def height(self, value):
        self._width = value
        self._height = value

def resize(r: Rectangle) -> None:
    r.width = 4
    r.height = 5

    # We expect 20, for a square we get 25
    print(r.area)
class Rectangle {
    protected w = 0;
    protected h = 0;

    set width(value: number) {
        this.w = value;
    }

    set height(value: number) {
        this.h = value;
    }

    get area(): number {
        return this.w * this.h;
    }
}

// A square «is» a rectangle in geometry, but not in code
class Square extends Rectangle {
    set width(value: number) {
        this.w = value;
        this.h = value;
    }

    set height(value: number) {
        this.w = value;
        this.h = value;
    }
}

function resize(r: Rectangle): void {
    r.width = 4;
    r.height = 5;

    // We expect 20, for a square we get 25
    console.log(r.area);
}
public class Rectangle {
    protected int width;
    protected int height;

    public void setWidth(int width) {
        this.width = width;
    }

    public void setHeight(int height) {
        this.height = height;
    }

    public int getArea() {
        return width * height;
    }
}

// A square «is» a rectangle in geometry, but not in code
public class Square extends Rectangle {
    @Override
    public void setWidth(int width) {
        this.width = width;
        this.height = width;
    }

    @Override
    public void setHeight(int height) {
        this.width = height;
        this.height = height;
    }
}

void resize(Rectangle r) {
    r.setWidth(4);
    r.setHeight(5);

    // We expect 20, for a square we get 25
    System.out.println(r.getArea());
}
open class Rectangle {
    protected var w = 0
    protected var h = 0

    open var width: Int
        get() = w
        set(value) {
            w = value
        }

    open var height: Int
        get() = h
        set(value) {
            h = value
        }

    val area: Int get() = w * h
}

// A square «is» a rectangle in geometry, but not in code
class Square : Rectangle() {
    override var width: Int
        get() = w
        set(value) {
            w = value
            h = value
        }

    override var height: Int
        get() = h
        set(value) {
            w = value
            h = value
        }
}

fun resize(r: Rectangle) {
    r.width = 4
    r.height = 5

    // We expect 20, for a square we get 25
    println(r.area)
}

Geometry did not lie: a square really is a rectangle. The code lied – the descendant overrode the setters and thereby cancelled the parent’s promise that «width and height can be changed independently». What follows from that:

  • it is someone else’s code that breaks, not the descendant – Resize is written honestly and by the contract, yet it fails on an object it knew nothing about;
  • the compiler is happy – as far as types go everything is fine, the error shows up only on particular data;
  • it gets patched with type checks – if (r is Square) appears in Resize, and the polymorphism the inheritance was started for disappears;
  • the fixing happens in the callers – and there may be twenty of them, all to be found.

How to fix it

Inheritance is redundant here: all the figures have in common is the area, and that is exactly what belongs in the contract. The setters go away with it – without them there is nothing to break:

// The figures have exactly one thing in common – the area
public interface IShape
{
    int Area { get; }
}

public class Rectangle : IShape
{
    public Rectangle(int width, int height) => (Width, Height) = (width, height);

    public int Width { get; }
    public int Height { get; }
    public int Area => Width * Height;
}

// The square is not a descendant: it has its own shape and its own constructor
public class Square : IShape
{
    public Square(int side) => Side = side;

    public int Side { get; }
    public int Area => Side * Side;
}
// The figures have exactly one thing in common – the area
class IShape {
public:
    virtual int Area() const = 0;
    virtual ~IShape() = default;
};

class Rectangle : public IShape {
public:
    Rectangle(int width, int height) : width_(width), height_(height) {}

    int Area() const override { return width_ * height_; }

private:
    int width_;
    int height_;
};

// The square is not a descendant: it has its own shape and its own constructor
class Square : public IShape {
public:
    explicit Square(int side) : side_(side) {}

    int Area() const override { return side_ * side_; }

private:
    int side_;
};
// The figures have exactly one thing in common – the area
type Shape interface {
	Area() int
}

type Rectangle struct {
	Width  int
	Height int
}

func (r Rectangle) Area() int { return r.Width * r.Height }

// The square is not a «descendant»: it is a separate type with its own field
type Square struct {
	Side int
}

func (s Square) Area() int { return s.Side * s.Side }
# The figures have exactly one thing in common – the area
class Shape(Protocol):
    @property
    def area(self) -> int: ...

@dataclass(frozen=True)
class Rectangle:
    width: int
    height: int

    @property
    def area(self) -> int:
        return self.width * self.height

# The square is not a descendant: it has its own shape and its own constructor
@dataclass(frozen=True)
class Square:
    side: int

    @property
    def area(self) -> int:
        return self.side * self.side
// The figures have exactly one thing in common – the area
interface Shape {
    readonly area: number;
}

class Rectangle implements Shape {
    constructor(
        readonly width: number,
        readonly height: number,
    ) {}

    get area(): number {
        return this.width * this.height;
    }
}

// The square is not a descendant: it has its own shape and its own constructor
class Square implements Shape {
    constructor(readonly side: number) {}

    get area(): number {
        return this.side * this.side;
    }
}
// The figures have exactly one thing in common – the area
public interface Shape {
    int area();
}

public record Rectangle(int width, int height) implements Shape {
    @Override
    public int area() {
        return width * height;
    }
}

// The square is not a descendant: it has its own shape and its own constructor
public record Square(int side) implements Shape {
    @Override
    public int area() {
        return side * side;
    }
}
// The figures have exactly one thing in common – the area
interface Shape {
    val area: Int
}

data class Rectangle(val width: Int, val height: Int) : Shape {
    override val area get() = width * height
}

// The square is not a descendant: it has its own shape and its own constructor
data class Square(val side: Int) : Shape {
    override val area get() = side * side
}

A function that needs the area now only needs Shape – and it works with any figure, including the ones that do not exist yet. A function that needs to change the sides separately asks for a Rectangle – and a square simply will not be passed to it: that is caught by the compiler, not by a tester.

The LSP check is simple: take the promises of the base type and try to keep them in the descendant. If you cannot – the relation is not «is a», and what is needed is composition or a narrower shared interface.

What following it gives

  • Polymorphism actually works. Code written for the base type is written once and needs no edits when a new descendant appears.
  • Type checks disappear. if (obj is Square) goes away – and with it the question «have I fixed every place after adding a new subclass».
  • The tests of the base type check every descendant. One set of contract checks, run for every implementation, catches violations automatically.
  • The hierarchy can be read top down. Knowing the base class is enough to understand what any of its descendants does.

How to spot it

An LSP violation almost always looks the same: a check of the descendant’s type appears in the code, or a method that one of the descendants answers with «not supported». Both signs mean the hierarchy is built wrong and the relation between the classes is not «is a».