Перейти к содержимому

4. SOLID: Liskov Substitution

Объекты дочернего класса должны быть способны заменить объекты родительского класса без нарушения корректности программы.

Сформулировала принцип Барбара Лисков в 1987 году. Грубо говоря: если ты ожидаешь Animal, а тебе подсунули Dog — всё должно работать одинаково.


Классический пример нарушения: прямоугольник и квадрат

Заголовок раздела «Классический пример нарушения: прямоугольник и квадрат»

Математически квадрат — это частный случай прямоугольника. Логично сделать Square extends Rectangle? Нет!

// ❌ Нарушение LSP
class Rectangle {
constructor(
protected width: number,
protected height: number,
) {}
setWidth(width: number): void { this.width = width; }
setHeight(height: number): void { this.height = height; }
getArea(): number { return this.width * this.height; }
}
class Square extends Rectangle {
// Квадрат должен сохранять равенство сторон
setWidth(width: number): void {
this.width = width;
this.height = width; // ← Нарушаем контракт родителя!
}
setHeight(height: number): void {
this.width = height; // ← И здесь тоже!
this.height = height;
}
}
// Функция работает с Rectangle
function increaseWidth(rect: Rectangle): void {
const originalHeight = rect.getArea() / rect.width;
rect.setWidth(rect.width + 10);
// Ожидаем: площадь изменится, высота — нет
console.assert(
rect.getArea() === (rect.width) * originalHeight,
'Area should change proportionally'
);
}
const square = new Square(10, 10);
increaseWidth(square); // Assertion fails! Square нарушает контракт

// ✅ Общий интерфейс без нарушения контракта
interface Shape {
getArea(): number;
}
class Rectangle implements Shape {
constructor(
private width: number,
private height: number,
) {}
setWidth(width: number): void { this.width = width; }
setHeight(height: number): void { this.height = height; }
getArea(): number { return this.width * this.height; }
}
class Square implements Shape {
constructor(private side: number) {}
setSide(side: number): void { this.side = side; }
getArea(): number { return this.side * this.side; }
}

Три вопроса:

  1. Предусловия: дочерний класс не должен требовать более строгих условий, чем родитель
  2. Постусловия: дочерний класс должен гарантировать хотя бы то, что гарантирует родитель
  3. Инварианты: свойства, верные для родителя, должны быть верны для дочернего класса
class Bird {
fly(): void {
console.log('Flying...');
}
move(distance: number): void {
this.fly();
console.log(`Moved ${distance}m`);
}
}
// ❌ Нарушение — пингвин не может летать
class Penguin extends Bird {
fly(): void {
throw new Error('Penguins cannot fly!'); // Нарушаем контракт Bird
}
}
// ✅ Правильная иерархия
interface CanFly {
fly(): void;
}
class Animal {
move(distance: number): void {
console.log(`Moved ${distance}m`);
}
}
class FlyingBird extends Animal implements CanFly {
fly(): void { console.log('Flying...'); }
move(distance: number): void {
this.fly();
super.move(distance);
}
}
class Penguin extends Animal {
swim(): void { console.log('Swimming...'); }
move(distance: number): void {
this.swim();
super.move(distance);
}
}

LSP особенно важен при работе с интерфейсами в TypeScript:

interface Logger {
log(message: string): void;
error(message: string): void;
}
class ConsoleLogger implements Logger {
log(message: string): void { console.log(message); }
error(message: string): void { console.error(message); }
}
class SilentLogger implements Logger {
log(_message: string): void { /* Intentionally silent */ }
error(_message: string): void { /* Intentionally silent */ }
}
// ✅ SilentLogger полностью заменяет ConsoleLogger
function processData(data: any[], logger: Logger): void {
logger.log('Processing started');
// ... обработка ...
logger.log('Processing finished');
}
// Оба работают корректно
processData(data, new ConsoleLogger());
processData(data, new SilentLogger()); // В тестах!

  1. Найди нарушения LSP в следующем коде и исправь:
class Storage {
save(key: string, value: string): void { localStorage.setItem(key, value); }
load(key: string): string { return localStorage.getItem(key)!; }
}
class ReadOnlyStorage extends Storage {
save(_key: string, _value: string): void {
throw new Error('This storage is read-only!');
}
}
  1. Создай иерархию Vehicle → Car, Truck, ElectricCar без нарушения LSP. Учти что у электромобиля нет refuel(), а у Truck есть loadCargo().

  2. Напиши тест который проверяет соответствие LSP для пары родитель/потомок.

  3. Как LSP связан с принципом «Composition over Inheritance»?

  4. Посмотри на React: как React.Component и React.PureComponent соблюдают LSP?