BackendBit

К другим SOLID принципам

Принцип подстановки Барбары Лисков (LSP) – если S является подтипом T, то объекты типа T в программе могут быть заменены объектами типа S без нарушения корректности программы.

Проще говоря: подтипы должны быть подставляемы вместо базовых типов.

Требования к подтипам для соблюдения LSP

  • У подтипа должны быть методы с ковариантными типами возвращаемых значений;
  • У подтипа должны быть методы с контравариантными типами параметров;
  • Предусловия не могут быть усилены в подтипе;
  • Постусловия не могут быть ослаблены в подтипе;
  • Инварианты должны сохраняться в подтипе;
  • В методах подтипа не должны бросаться новые исключения, кроме случаев, когда они являются подтипами исключений, бросаемых в методах супертипа.

Разберём каждое из требований.

Ковариантность возвращаемых типов

Если дочерний класс переопределяет метод родительского класса, то он может возвращать более специфичный тип, чем метод родительского класса. При этом дочерний метод НЕ может возвращать менее специфичный (более общий) тип.

Пример: есть абстрактный класс Animal и два наследника – Dog и Cat:

package covariance;

public abstract class Animal {
    protected final String name;

    public Animal(String name) {
        this.name = name;
    }

    public abstract void speak();
}
java
package covariance;

public final class Dog extends Animal {
    public Dog(String name) {
        super(name);
    }

    @Override
    public void speak() {
        System.out.println(name + " лает");
    }
}
java
package covariance;

public final class Cat extends Animal {
    public Cat(String name) {
        super(name);
    }

    @Override
    public void speak() {
        System.out.println(name + " мяукает");
    }
}
java

Также есть интерфейс AnimalShelter (приют) с методом adopt, который возвращает объект класса Animal:

package covariance;

public interface AnimalShelter {
    Animal adopt(String name);
}
java

И две имплементации: CatShelter (кошачий приют) и DogShelter (собачий приют):

package covariance;

public final class CatShelter implements AnimalShelter {
    @Override
    public Cat adopt(String name) {
        return new Cat(name);
    }
}
java
package covariance;

public final class DogShelter implements AnimalShelter {
    @Override
    public Dog adopt(String name) {
        return new Dog(name);
    }
}
java

Обратим внимание, что метод adopt у CatShelter и DogShelter возвращают более специфичные значения, чем Animal: Cat и Dog соответственно. Почему мы можем сузить тип?

package covariance;

static Animal welcomeNewPet(String name, AnimalShelter shelter) {
    System.out.println("Забираем животное по имени " + name + " из приюта " + shelter.getClass().getSimpleName());
    return shelter.adopt(name);
}
java
Animal animal = welcomeNewPet("Барсик", new DogShelter());
animal.speak();
java

Клиентский код, который зависит от интерфейса AnimalShelter, зависит также и от возвращаемого типа Animal. А значит, он может использовать только методы и свойства, которые есть в Animal. В данном случае мы используем метод speak.

Cat и Dog оба наследуются от класса Animal и содержат в себе те методы и свойства, которые есть в Animal (оба содержат метод speak). Поэтому сломать клиентский код мы не сможем, а значит LSP соблюдается, даже если мы сузим возвращаемый тип.

А что если попробовать расширить возвращаемый тип? В Java все классы наследуются от Object – попробуем вернуть его вместо Cat и Dog:

public final class CatShelter implements AnimalShelter {
    @Override
    public Object adopt(String name) {
        return new Cat(name);
    }
}

public final class DogShelter implements AnimalShelter {
    @Override
    public Object adopt(String name) {
        return new Dog(name);
    }
}
java

Компилятор Java не позволит это сделать:

error: adopt(String) in CatShelter cannot implement adopt(String) in AnimalShelter
    public Object adopt(String name) {
                  ^
  return type Object is not compatible with Animal

error: adopt(String) in DogShelter cannot implement adopt(String) in AnimalShelter
    public Object adopt(String name) {
                  ^
  return type Object is not compatible with Animal

Даже если бы язык это позволял, мы бы нарушили LSP и сломали клиентский код. Клиент зависит от типа Animal и вызывает метод speak() – а у Object такого метода нет.

Контравариантность типов параметров

С параметрами всё наоборот: дочерний класс может принимать более общий тип, чем родительский. При этом он НЕ может принимать более специфичный (менее общий) тип.

Вернемся к примеру с животными. Добавим животным возможность употреблять пищу:

package contravariance;

public class Food {
    protected final String name;

    public Food(String name) {
        this.name = name;
    }
}
java
package contravariance;

public class AnimalFood extends Food {
    public AnimalFood(String name) {
        super(name);
    }
}
java
package contravariance;

public abstract class Animal {
    protected final String name;

    public Animal(String name) {
        this.name = name;
    }

    public void eat(AnimalFood food) {
        System.out.println(name + " ест " + food.getClass().getSimpleName());
    }

    public abstract void speak();
}
java

Мы добавили два новых класса Food и AnimalFood. Также мы добавили метод eat с готовой реализацией в абстрактный класс Animal. eat принимает объект класса AnimalFood. Иными словами мы можем накормить любого животного едой для животных.

Напишем простой клиентский код.

package contravariance;

static void feed(Animal animal, AnimalFood food) {
    System.out.println("Кормим животное");
    animal.eat(food);
}
java
feed(new Dog("Барсик"), new AnimalFood("Сухой корм"));
java

Представим, что собака ест не только еду для животных, но и всю еду вообще. Попробуем расширить тип параметра в дочернем классе.

package contravariance;

public final class Dog extends Animal {
    public Dog(String name) {
        super(name);
    }

    @Override
    public void speak() {
        System.out.println(name + " лает");
    }

    // Собака ест любую еду, не только AnimalFood.
    // Это расширение типа параметра – контравариантность.
    public void eat(Food food) {
        System.out.println(name + " ест " + food.getClass().getSimpleName());
    }
}
java

Если бы язык позволял переопределить метод с более широким типом параметра, сломался бы клиентский код?

Нет, потому что клиент всегда передаёт в метод eat класса Animal объект типа AnimalFood (или его наследника). А тип AnimalFood включает в себя все методы и свойства класса Food, поскольку наследуется от него. То есть всё, что может использоваться в теле Dog.eat(Food), по умолчанию будет внутри объекта типа AnimalFood.

Теперь представим, что мы хотим кормить собаку исключительно едой для собак. Создадим для этого класс DogFood.

package contravariance;

public final class DogFood extends AnimalFood {
    public DogFood(String name) {
        super(name);
    }
}
java

Позволительно ли использовать этот класс в методе eat у наследника?

package contravariance.narrow;

import contravariance.Animal;
import contravariance.DogFood;

public final class Dog extends Animal {
    public Dog(String name) {
        super(name);
    }

    @Override
    public void speak() {
        System.out.println(name + " лает");
    }

    @Override
    public void eat(DogFood food) {
        System.out.println(name + " ест еду для собак");
    }
}
java

Нет, такой код в Java вызовет ошибку компиляции:

error: method does not override or implement a method from a supertype
    @Override
    ^

И это правильно – даже если бы язык позволял сузить тип параметра, клиентский код сломался бы. Ведь Dog теперь ожидает DogFood, а не AnimalFood.

feed(new Dog("Барсик"), new AnimalFood("Сухой корм"));
java
Иерархии наследования для ковариантности и контравариантностиДве колонки. Левая – возвращаемый тип: Object (небезопасно) наверху, Animal посередине, Cat (безопасно) внизу – наследование сужается. Правая – тип параметра: Food (безопасно) наверху, AnimalFood посередине, DogFood (небезопасно) внизу – наследование расширяется.Возвращаемый типObject ✗AnimalCat ✓Тип параметраFood ✓AnimalFoodDogFood ✗

Design by contract: предусловия, постусловия, инварианты

Design by contract (программирование по контракту) – это подход к проектированию, при котором у каждого компонента выделяется контракт:

  • Предусловия – условия, которые должны соблюдаться перед вызовом метода;
  • Постусловия – условия, которые должны соблюдаться после вызова метода;
  • Инварианты – условия, которые должны соблюдаться на протяжении всего жизненного цикла компонента.

Рассмотрим пример: интерфейс стека и его реализацию на Go.

Стек – абстрактный тип данных, который представляет собой коллекцию элементов и поддерживает две обязательные операции:

  • push – добавляет элемент в коллекцию;
  • pop – удаляет и возвращает последний добавленный элемент из коллекции.
package stack

type Stack interface {
	Push(element any)
	Pop() any
	Size() int
	IsEmpty() bool
}

type SliceStack struct {
	elements []any
}

func NewSliceStack() *SliceStack {
	return &SliceStack{}
}

func (s *SliceStack) Push(element any) {
	s.elements = append(s.elements, element)
}

func (s *SliceStack) Pop() any {
	if s.IsEmpty() {
		panic("stack is empty")
	}

	top := s.elements[len(s.elements)-1]
	s.elements = s.elements[:len(s.elements)-1]

	return top
}

func (s *SliceStack) Size() int {
	return len(s.elements)
}

func (s *SliceStack) IsEmpty() bool {
	return len(s.elements) == 0
}
go

Выделим контракт:

МетодПредусловиеПостусловие
PushОтсутствуетЭлемент добавлен на вершину стека, размер увеличился на единицу
PopСтек не пустПоследний элемент удалён и возвращён, размер уменьшился на единицу.
SizeОтсутствуетВозвращено количество элементов, стек не изменился
IsEmptyОтсутствуетВозвращено true, если стек пуст

Инварианты:

  • Размер стека не может быть отрицательным.
  • Элементы возвращаются в порядке LIFO (последним вошёл – первым вышел).

Вернёмся к требованиям LSP и покажем, как подтипы могут нарушить контракт.

Усиление предусловий

Если предусловий стало больше – значит они были усилены. Если предусловий не было вообще, а в подтипе они появились, – это тоже усиление.

Предположим, мы хотим ограничить количество элементов в стеке. Создаём BoundedStack, который реализует интерфейс Stack:

package stack

import "fmt"

type BoundedStack struct {
	elements []any
	limit    int
}

func NewBoundedStack(limit int) *BoundedStack {
	return &BoundedStack{limit: limit}
}

func (s *BoundedStack) Push(element any) {
	if s.Size() == s.limit {
		panic(fmt.Sprintf("stack is full (limit: %d)", s.limit))
	}

	s.elements = append(s.elements, element)
}

func (s *BoundedStack) Pop() any {
	if s.IsEmpty() {
		panic("stack is empty")
	}

	top := s.elements[len(s.elements)-1]
	s.elements = s.elements[:len(s.elements)-1]

	return top
}

func (s *BoundedStack) Size() int {
	return len(s.elements)
}

func (s *BoundedStack) IsEmpty() bool {
	return len(s.elements) == 0
}
go

У метода Push появилось новое предусловие: стек не должен быть полным. Мы усилили предусловия. Теперь клиентский код, который зависит от интерфейса Stack, может сломаться, поскольку он не ожидает от метода Push никаких паник.

package stack

// addCat – клиентский код, который зависит от интерфейса Stack.
// Он не ожидает от Push никаких паник.
func addCat(s Stack, name string) {
	s.Push(cat{name: name})
}

func registerCats() {
	s := NewBoundedStack(2)

	addCat(s, "Вася")
	addCat(s, "Матильда")
	addCat(s, "Багира") // вызовет панику
}
go

Решение – отказаться от мысли, что BoundedStack – это подтип Stack. Переименуем Push в MustPush – имя метода явно сигнализирует о возможной панике. Бонус: сигнатура больше не совпадает с Stack, и Go перестаёт считать BoundedStack реализацией Stack:

package stack

import "fmt"

type BoundedStack struct {
	stack Stack
	limit int
}

func NewBoundedStack(limit int) *BoundedStack {
	return &BoundedStack{
		stack: NewSliceStack(),
		limit: limit,
	}
}

func (s *BoundedStack) MustPush(element any) {
	if s.stack.Size() == s.limit {
		panic(fmt.Sprintf("stack is full (limit: %d)", s.limit))
	}

	s.stack.Push(element)
}

func (s *BoundedStack) Pop() any {
	return s.stack.Pop()
}

func (s *BoundedStack) Size() int {
	return s.stack.Size()
}

func (s *BoundedStack) IsEmpty() bool {
	return s.stack.IsEmpty()
}
go

Клиентский код, который зависит от Stack, не может получить BoundedStack – типы несовместимы. Там, где нужно ограничение размера, код явно зависит от BoundedStack.

Ослабление постусловий

Если в базовом типе были постусловия, а в подтипе их стало меньше – постусловия были ослаблены.

Контракт Stack.Push гарантирует: после вызова размер стека увеличивается на единицу. Создадим DeduplicatingStack, который игнорирует дубликаты:

package stack

type DeduplicatingStack struct {
	elements []any
	seen     map[any]bool
}

func NewDeduplicatingStack() *DeduplicatingStack {
	return &DeduplicatingStack{seen: make(map[any]bool)}
}

func (s *DeduplicatingStack) Push(element any) {
	if s.seen[element] {
		return
	}

	s.seen[element] = true
	s.elements = append(s.elements, element)
}

func (s *DeduplicatingStack) Pop() any {
	if s.IsEmpty() {
		panic("stack is empty")
	}

	top := s.elements[len(s.elements)-1]
	s.elements = s.elements[:len(s.elements)-1]
	delete(s.seen, top)

	return top
}

func (s *DeduplicatingStack) Size() int {
	return len(s.elements)
}

func (s *DeduplicatingStack) IsEmpty() bool {
	return len(s.elements) == 0
}
go

Если элемент уже есть в стеке, Push завершается без добавления – размер не меняется. Постусловие «размер увеличился на единицу» ослаблено.

Представим, что мы разрабатываем редактор текста. Почти в каждом редакторе есть возможность отменять последние операции – наш не исключение. Мы храним историю операций в стеке и зависим от интерфейса stack.Stack, который мы показывали выше.

У нас есть функция UndoAll, которая отменяет все последние операции.

package texteditor

import "example.com/stack"

// UndoAll откатывает все операции редактора в обратном порядке.
func UndoAll(history stack.Stack) []string {
	ops := []string{"вставка", "удаление", "форматирование", "удаление", "вставка"}
	for _, op := range ops {
		history.Push(op)
	}

	undone := make([]string, 0, len(ops))
	for !history.IsEmpty() {
		undone = append(undone, history.Pop().(string))
	}
	return undone
}
go

Если мы передадим в эту функцию DeduplicatingStack, то столкнёмся с тем, что будут отменены только 3 операции из 5, поскольку повторные «вставка» и «удаление» были проигнорированы.

Ослабив постусловие «Элемент добавлен на вершину стека, размер увеличился на единицу», мы нарушили контракт stack.Stack и сломали ожидаемое поведение программы.

Нарушение инвариантов

Представим, что разработчик хочет добавить операцию Peek – получение верхнего элемента без удаления. Он создаёт PeekableStack:

package stack

type PeekableStack struct {
	elements []any
	top      int
}

func NewPeekableStack() *PeekableStack {
	return &PeekableStack{top: -1}
}

func (s *PeekableStack) Push(element any) {
	s.top++

	if s.top < len(s.elements) {
		s.elements[s.top] = element
	} else {
		s.elements = append(s.elements, element)
	}
}

func (s *PeekableStack) Pop() any {
	if s.IsEmpty() {
		panic("stack is empty")
	}

	element := s.elements[s.top]
	s.top--

	return element
}

func (s *PeekableStack) Size() int {
	return s.top + 1
}

func (s *PeekableStack) IsEmpty() bool {
	return s.top == -1
}

func (s *PeekableStack) Peek() any {
	if s.IsEmpty() {
		panic("stack is empty")
	}

	element := s.elements[s.top]

	s.top-- // баг: декремент top

	return element
}
go

Разработчик по ошибке добавил декремент s.top после получения верхнего элемента в методе Peek. Эта ошибка ломает инвариант: top больше не указывает на последний элемент стека.

Представим, что мы разрабатываем систему деплоя. Функция Rollback откатывает последнюю версию: сначала получает текущую версию через Peek для записи в лог, затем удаляет её через Pop и снова вызывает Peek, чтобы узнать, какая версия стала активной.

package deployer

import "example.com/stack"

// Rollback откатывает последний деплой.
// Peek показывает текущую версию для лога,
// затем Pop удаляет её из истории деплоев.
func Rollback(deploys *stack.PeekableStack) (rolled, active string) {
	rolled = deploys.Peek().(string)
	deploys.Pop()
	active = deploys.Peek().(string)
	return rolled, active
}
go

Если мы вызовем Rollback с историей ["v1.0", "v2.0", "v3.0"], функция сообщит, что откатила v3.0 и активна v1.0 – вместо v2.0. Система откатится на две версии назад вместо одной: v2.0 будет пропущена, а в продакшене окажется v1.0 – потенциально с совсем другим поведением.

Четыре снимка стека деплоя во время Rollback с багом в Peek – top дрейфует вниз, ломая инвариантВо всех панелях базовый массив одинаков: v1.0, v2.0, v3.0. Меняется только индекс top. Шаг 1: исходное состояние, top = 2, указывает на v3.0, инвариант соблюдён. Шаг 2: после Peek top = 1, указывает на v2.0, v3.0 стал недостижимым, инвариант нарушен. Шаг 3: после Pop top = 0, указывает на v1.0, возвращён v2.0 вместо ожидаемого v3.0. Шаг 4: после второго Peek top = -1, возвращён v1.0 вместо ожидаемого v2.0, active = v1.0.Вызов RollbackИнвариант: top указывает на вершину стека1. Исходное состояние v1.0v2.0v3.0 top = 2инвариант соблюдён2. После Peek v1.0v2.0v3.0 top = 1Peek() → v3.0инвариант нарушен3. После Pop v1.0v2.0v3.0 top = 0Pop() → v2.0вместо v3.04. После Peek #2 v1.0v2.0v3.0 top = -1Peek() → v1.0вместо v2.0active = v1.0

Прямоугольник и квадрат

Классический пример нарушения LSP – наследование квадрата от прямоугольника. Этот пример требует наследования, поэтому рассмотрим его на Java.

package rectangle.before;

public class Rectangle {
    protected int width;
    protected int height;

    public Rectangle(int width, int height) {
        this.width = width;
        this.height = height;
    }

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

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

    public int area() {
        return width * height;
    }
}
java

Квадрат – это прямоугольник, у которого все стороны равны. Попробуем это выразить через наследование:

package rectangle.before;

public class Square extends Rectangle {
    public Square(int side) {
        super(side, side);
    }

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

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

Нам пришлось переопределить setWidth и setHeight, чтобы поддержать инвариант квадрата: ширина и высота всегда равны. На первый взгляд код корректен. Но рассмотрим клиентский код:

void assertAreaCalculatedCorrectly(Rectangle rectangle) {
    rectangle.setWidth(6);
    rectangle.setHeight(5);

    assert rectangle.area() == 30;
}
java

Автор функции знает, что площадь прямоугольника – произведение ширины и высоты. Он устанавливает ширину 6 и высоту 5 и ожидает площадь 30. Это разумное ожидание для прямоугольника.

Но если передать Square, после setHeight(5) обе стороны станут равны 5, и площадь будет 25 вместо 30. Инвариант Rectangle (ширина и высота изменяются независимо) нарушен.

Пошаговое сравнение Rectangle и Square после setWidth(6) и setHeight(5)Две колонки – Rectangle и Square. Обе начинаются с квадрата 4×4. После setWidth(6) у Rectangle становится 6×4, а у Square – 6×6, потому что setWidth квадрата меняет обе стороны. После setHeight(5) Rectangle становится 6×5 с площадью 30 (корректно, зелёный), а Square – 5×5 с площадью 25 (нарушение, красный).Rectangle4×4area = 16setWidth(6)6×4area = 24setHeight(5)6×5area = 30 ✓Инвариант соблюдаетсяSquare4×4area = 16setWidth(6)6×6area = 36setHeight(5)5×5area = 25 ✗Инвариант нарушен

Решение: общий интерфейс

Вместо наследования выделим общий интерфейс Shape:

package rectangle.after;

public interface Shape {
    int area();
}
java
package rectangle.after;

public final class Rectangle implements Shape {
    private int width;
    private int height;

    public Rectangle(int width, int height) {
        this.width = width;
        this.height = height;
    }

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

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

    @Override
    public int area() {
        return width * height;
    }
}
java
package rectangle.after;

public final class Square implements Shape {
    private int side;

    public Square(int side) {
        this.side = side;
    }

    public void setSide(int side) {
        this.side = side;
    }

    @Override
    public int area() {
        return side * side;
    }
}
java

Теперь Rectangle и Square – отдельные типы. Клиентский код может зависеть от Shape для вычисления площади или от конкретного типа, когда нужны специфичные операции.

В Go эта проблема решается так же – через общий интерфейс:

package shape

type Shape interface {
	Area() int
}

type Rectangle struct {
	Width  int
	Height int
}

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

type Square struct {
	Side int
}

func (s Square) Area() int {
	return s.Side * s.Side
}
go

Ошибки в методах подтипа

Ещё один антипаттерн – объект наследует метод интерфейса, который он не может реализовать.

package vehicle

import "errors"

type Vehicle interface {
	MaxSpeed() int
	StartEngine() error
}

type Car struct {
	maxSpeed int
}

func NewCar(maxSpeed int) *Car {
	return &Car{maxSpeed: maxSpeed}
}

func (c *Car) MaxSpeed() int {
	return c.maxSpeed
}

func (c *Car) StartEngine() error {
	// запуск двигателя автомобиля
	return nil
}

type Bicycle struct {
	maxSpeed int
}

func NewBicycle(maxSpeed int) *Bicycle {
	return &Bicycle{maxSpeed: maxSpeed}
}

func (b *Bicycle) MaxSpeed() int {
	return b.maxSpeed
}

func (b *Bicycle) StartEngine() error {
	return errors.New("bicycles do not have an engine")
}
go

У велосипеда нет двигателя, но интерфейс Vehicle заставляет его реализовать StartEngine. Единственный выход – вернуть ошибку. Клиентский код, который зависит от Vehicle, сломается при получении Bicycle:

package vehicle

import "fmt"

func startAll(vehicles []Vehicle) error {
	for _, v := range vehicles {
		if err := v.StartEngine(); err != nil {
			return err
		}
	}

	return nil
}

func demo() {
	vehicles := []Vehicle{
		NewCar(200),
		NewBicycle(30), // у велосипеда нет двигателя
	}

	err := startAll(vehicles)
	fmt.Println(err) // bicycles do not have an engine
}
go

Проблема не в том, что метод возвращает ошибку – ошибки ожидаемы. Проблема в том, что Bicycle.StartEngine не может выполнить операцию в принципе – он безусловно завершается неудачей. Контракт метода StartEngine() подразумевает, что вызов может завершиться успешно. Bicycle.StartEngine() безусловно возвращает ошибку – постусловие ослаблено до невозможности успешного выполнения.

Решение – не ожидать от каждого транспортного средства наличия двигателя. Поведение, связанное с двигателем, выносим в отдельный интерфейс:

package vehicle

type Vehicle interface {
	MaxSpeed() int
}

type EngineStarter interface {
	StartEngine() error
}

type Car struct {
	maxSpeed int
}

func NewCar(maxSpeed int) *Car {
	return &Car{maxSpeed: maxSpeed}
}

func (c *Car) MaxSpeed() int {
	return c.maxSpeed
}

func (c *Car) StartEngine() error {
	// запуск двигателя автомобиля
	return nil
}

type Bicycle struct {
	maxSpeed int
}

func NewBicycle(maxSpeed int) *Bicycle {
	return &Bicycle{maxSpeed: maxSpeed}
}

func (b *Bicycle) MaxSpeed() int {
	return b.maxSpeed
}
go
Разделение интерфейса Vehicle на Vehicle и EngineStarterДве панели. Сверху – до: один интерфейс Vehicle с методами Move и StartEngine, его реализуют Car и Bicycle; Bicycle выделен красным, потому что его StartEngine безусловно возвращает ошибку. Снизу – после: два интерфейса, Vehicle с одним Move и EngineStarter с StartEngine. Car реализует оба интерфейса, Bicycle реализует только Vehicle.До<<interface>>Vehicle+Move()+StartEngine() errorBicycleCarStartEngine()всегда возвращаетошибкуПосле<<interface>>Vehicle+Move()<<interface>>EngineStarter+StartEngine() errorBicycleCar

Теперь Car реализует и Vehicle, и EngineStarter. Bicycle реализует только Vehicle. Клиентский код, которому нужен двигатель, зависит от EngineStarter.

Реальный пример

io.Reader

Интерфейс io.Reader из стандартной библиотеки – пример чёткого контракта, который все реализации обязаны соблюдать:

type Reader interface {
	Read(p []byte) (n int, err error)
}
go

Контракт Read документирован в godoc:

  • Read читает до len(p) байт в p.
  • Возвращает количество прочитанных байт n (0 ≤ n ≤ len(p)) и ошибку.
  • Если данные закончились, возвращает n и io.EOF.
  • Реализация НЕ должна удерживать p после возврата.

strings.NewReader, bytes.NewReader, os.File, http.Response.Body – все эти типы реализуют io.Reader и соблюдают контракт. Именно поэтому они взаимозаменяемы: функция, принимающая io.Reader, корректно работает с любой из этих реализаций.

Если бы реализация, например, возвращала n > len(p) или не возвращала io.EOF при завершении потока – она нарушила бы LSP, и клиентский код сломался бы непредсказуемо.

Заключение

LSP требует, чтобы реализации интерфейсов были взаимозаменяемы без нарушения работы клиентского кода. Мы рассмотрели несколько способов нарушить этот принцип: усиление предусловий, ослабление постусловий, нарушение инвариантов и реализация методов, которые не могут быть выполнены.

В Go отсутствие наследования исключает целый класс нарушений LSP – ковариантность/контравариантность, проблему прямоугольника и квадрата. Но нарушения на уровне контракта по-прежнему возможны: реализация может не соблюдать предусловия, постусловия или инварианты интерфейса.

Определяйте чёткие контракты для интерфейсов и убедитесь, что каждая реализация их соблюдает.