SOLID в деталях: Принцип подстановки Лисков
Третьим принципом в списке SOLID является Принцип подстановки Лисков (Liskov Substitution Principle; LSP), который из всех принципов имеет самую сложную формулировку, звучащую следующим образом:
"Пусть q(x) является свойством, верным относительно объектов x некоторого типа T. Тогда q(y) также должно быть верным для объектов y типа S, где S является подтипом типа T." - Барбара Лисков
Несмотря на некоторую сложность формулировки сам принцип подстановки Лисков предельно понятен на мой взгляд. Однако Роберт Мартин упростил формулировку до следующего вида:
"Функции, которые используют базовый тип, должны иметь возможность использовать подтипы базового типа, не зная об этом." - Роберт Мартин
Если попытаться совсем уж упростить две вышеуказанные формулировки, то получится что-то вроде: замена экземпляра класса на экземпляр совместимого класса-наследника не должна нарушать корректность работы программы. Впрочем, существует множество других уточняющих и дополняющих формулировок, которые встречаются как в Интернете, так и в книгах.
Ограничения
Принцип подстановки Барбары Лисков так же накладывает определённые ограничения на сигнатуры методов.
Аргументы методов должны быть контравариантны
В контрактном программировании есть аналогичное ограничение: "Предусловия не могут быть усилены в подклассе". В контексте языка программирования Java это означает, что метод класса-наследника не должен иметь аргументы более точных типов. Следующий пример демонстрирует нарушение этого ограничения:
class Animal {
}
class Cat extends Animal {
}
class AnimalShelter {
void accept(Animal animal);
}
class CatShelter extends AnimalShelter {
void accept(Cat cat);
}Для Java данный код является абсолютно нормальным, никаких ошибок при работе с ним не возникнет. Однако, чтобы обратиться к методу accept класса CatShelter, вызывающий код должен знать, что он работает с CatShelter и Cat, а не с AnimalShelter и Animal (в противном случае будет вызываться метод accept класса Animal), а это прямое нарушение принципа подстановки Барбары Лисков.
И в данном примере класс CatShelter не переопределяет метод accept, а объявляет новый, демонстрируя перегрузку (overloading) методов. То же самое будет происходить, если мы в классе-наследнике объявим метод, принимающий в качестве аргумента экземпляр класса, стоящего выше по иерархии, чем Animal, что говорит нам о том, что контравариантность аргументов методов в Java отсутствует.
Из всего вышесказанного можно сделать следующий вывод: в Java методы класса-наследника должны принимать аргументы тех же типов, что и методы родительского класса.
Возвращаемые значения методов должны быть ковариантны
В контрактном программировании также есть аналогичное ограничение: "Постусловия не могут быть ослаблены в подклассе". Это означает, что метод класса-наследника при переопределении (overriding) может возвращать экземпляры подтипов класса, возвращаемого в методе родительского класса. Код ниже демонстрирует соблюдение этого ограничения:
class AnimalShelter {
Animal get() {
return new Animal();
}
}
class CatShelter extends AnimalShelter {
@Override
Cat get() {
return new Cat();
}
}В данном случае CatShelter переопределяет метод get, сужая возвращаемые объекты до класса Cat. Принцип подстановки Барбары Лисков в данном случае соблюдён, и никаких проблем или побочных эффектов в использовании CatShelter вместо AnimalShelter не возникнет.
Попытка вернуть в методе класса-наследника значение контравариантного типа приведёт к ошибке компиляции.
Методы подтипов не могут объявлять новые исключения
В случае с проверяемыми исключениями логика полностью аналогична типам возвращаемых значений: метод класса-наследника может выбросить исключение ковариантного типа, объявленного у метода класса-родителя.
Ниже приведён код, демонстрирующий соблюдение этого ограничения:
class AnimalShelter {
Animal get() throws Exception {
return new Animal();
}
}
class CatShelter extends AnimalShelter {
Cat get() throws IOException {
return new Cat();
}
}IOException является наследником Exception, поэтому данный код не вызывает проблем.
Попытка объявить у метода класса-наследника исключение контравариантного типа приведёт к ошибке компиляции.
Прочие ограничения
Среди прочих ограничений можно выделить запрет на создание в подтипах новых методов, изменяющих значения свойств в классе-родителе, а так же запрет на создание методов, изменяющих значения свойств, изменение которых не было предусмотрено в классе-родителе.
Пример нарушения принципа
Классическим примером нарушения принципа подстановки Лисков является проблема "квадрат/прямоугольник". Есть два класса: Rectangle, описывающий прямоугольник, и Square, описывающий квадрат. Rectangle предоставляет два метода для установки размеров сторон: setHeight и setWidth, а так же метод getArea для получения его площади. Square переопределяет setHeight и setWidth, так как в квадрате размеры сторон изменяются одновременно.
class Rectangle {
int height;
int wight;
void setHeight(int height) {
this.height = height;
}
void setWidth(int width) {
this.wight = width;
}
int getArea() {
return height * width;
}
}
class Square extends Rectangle {
void setHeight(int height) {
this.setSide(height);
}
void setWidth(int width) {
this.setSide(width);
}
void setSide(int size) {
this.height = size;
this.width = size;
}
}В результате этого следующий код вызовет проблемы:
Rectangle rectangle = new Square(); rectangle.setWidth(4); rectangle.setHeight(3); assert rectangle.getArea() == 12;
Поскольку в Square методы setHeight и setWidth переопределяются, то размер сторон будет 3, следовательно, площадь будет равняться 9, а не 12. И единственным решением этой проблемы будет проверка типа объекта:
Rectangle rectangle = new Square();
rectangle.setWidth(4);
rectangle.setHeight(3);
if (rectangle instanceof Square) {
assert rectangle.getArea() == 9;
} else {
assert rectangle.getArea() == 12;
}Нарушением принципа подстановки Барбары Лисков в данном случае является переопределение логики изменения значений родительского класса, о чём было сказано выше.
Практический пример
Давайте представим себе следующий интерфейс:
interface FindTaskByIdSpi {
Optional<Task> findTaskById(TaskId id);
}FindTaskByIdSpi - простой функциональный интерфейс, декларирующий единственный метод - findTaskById для поиска задачи в базе данных. У этого интерфейса есть базовая реализация при помощи MappingSqlQuery - FindTaskByIdMappingSqlQuery:
class FindTaskByIdMappingSqlQuery extends MappingSqlQuery implements FindTaskById {
public Optional<Task> findTaskById(TaskId id) {
return Optional.ofNullable(this.findObjectByNamedParam(Map.of("id", id.value())));
}
}Допустим, я хочу добавить кэширование и для обеспечения соответствия кода Принципу Открытости/Закрытости добавляю новый класс - CachedFindTaskByIdMappingSqlQuery:
class CachedFindTaskByIdMappingSqlQuery extends FindTaskByIdMappingSqlQuery {
Cache cache;
public Optional<Task> findTaskById(TaskId id) {
return Optional.ofNullable(cache.get(id, Task.class))
.or(() -> super.findTaskById(id)
.map(task -> {
cache.put(task.getId(), task);
return task;
}));
}
}CachedFindTaskByIdMappingSqlQuery расширяет поведение FindTaskByIdMappingSqlQuery, используя его поведение, а не переопределяя, как это было в примере с квадратом и прямоугольником. Это позволяет соблюсти и Принцип подстановки Лисков, и принцип открытости/закрытости.
Подводя итог
В язык программирования Java уже заложены ограничения, защищающие лишь от некоторых ошибок при работе с наследованием, поэтому важно соблюдать принцип подстановки Барбары Лисков.
