SOLID в деталях: Принцип подстановки Лисков

Третьим принципом в списке SOLID является Принцип подстановки Лисков (Liskov Substitution Principle; LSP), который из всех принципов имеет самую сложную формулировку, звучащую следующим образом:

Пусть q(x) является свойством, верным относительно объектов x некоторого типа T. Тогда q(y) также должно быть верным для объектов y типа S, где S является подтипом типа T.

Барбара Лисков

Несмотря на некоторую сложность формулировки сам принцип подстановки Лисков предельно понятен на мой взгляд. Однако Роберт Мартин упростил формулировку до следующего вида:

Функции, которые используют базовый тип, должны иметь возможность использовать подтипы базового типа, не зная об этом.

Роберт Мартин

Если попытаться совсем уж упростить две вышеуказанные формулировки, то получится что-то вроде: замена экземпляра класса на экземпляр совместимого класса-наследника не должна нарушать корректность работы программы. Впрочем, существует множество других уточняющих и дополняющих формулировок, которые встречаются как в Интернете, так и в книгах.

Ограничения

Принцип подстановки Барбары Лисков так же накладывает определённые ограничения на сигнатуры методов.

Аргументы методов должны быть контравариантны

В контрактном программировании есть аналогичное ограничение: «Предусловия не могут быть усилены в подклассе». В контексте языка программирования Java это означает, что метод класса-наследника не должен иметь аргументы более точных типов. Следующий пример демонстрирует нарушение этого ограничения:

Для Java данный код является абсолютно нормальным, никаких ошибок при работе с ним не возникнет. Однако, чтобы обратиться к методу accept класса CatShelter, вызывающий код должен знать, что он работает с CatShelter и Cat, а не с AnimalShelter и Animal (в противном случае будет вызываться метод accept класса Animal), а это прямое нарушение принципа подстановки Барбары Лисков.

И в данном примере класс CatShelter не переопределяет метод accept, а объявляет новый, демонстрируя перегрузку (overloading) методов. То же самое будет происходить, если мы в классе-наследнике объявим метод, принимающий в качестве аргумента экземпляр класса, стоящего выше по иерархии, чем Animal, что говорит нам о том, что контравариантность аргументов методов в Java отсутствует.

Из всего вышесказанного можно сделать следующий вывод: в Java методы класса-наследника должны принимать аргументы тех же типов, что и методы родительского класса.

Возвращаемые значения методов должны быть ковариантны

В контрактном программировании также есть аналогичное ограничение: «Постусловия не могут быть ослаблены в подклассе». Это означает, что метод класса-наследника при переопределении (overriding) может возвращать экземпляры подтипов класса, возвращаемого в методе родительского класса. Код ниже демонстрирует соблюдение этого ограничения:

В данном случае CatShelter переопределяет метод get, сужая возвращаемые объекты до класса Cat. Принцип подстановки Барбары Лисков в данном случае соблюдён, и никаких проблем или побочных эффектов в использовании CatShelter вместо AnimalShelter не возникнет.

Попытка вернуть в методе класса-наследника значение контравариантного типа приведёт к ошибке компиляции.

Методы подтипов не могут объявлять новые исключения

В случае с проверяемыми исключениями логика полностью аналогична типам возвращаемых значений: метод класса-наследника может выбросить исключение ковариантного типа, объявленного у метода класса-родителя.

Ниже приведён код, демонстрирующий соблюдение этого ограничения:

IOException является наследником Exception, поэтому данный код не вызывает проблем.

Попытка объявить у метода класса-наследника исключение контравариантного типа приведёт к ошибке компиляции.

Прочие ограничения

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

Пример нарушения принципа

Классическим примером нарушения принципа подстановки Лисков является проблема «квадрат/прямоугольник». Есть два класса: Rectangle, описывающий прямоугольник, и Square, описывающий квадрат. Rectangle предоставляет два метода для установки размеров сторон: setHeight и setWidth, а так же метод getArea для получения его площади. Square переопределяет setHeight и setWidth, так как в квадрате размеры сторон изменяются одновременно.

Структура классов Rectangle и Square

В результате этого следующий код вызовет проблемы:

Поскольку в Square методы setHeight и setWidth переопределяются, то размер сторон будет 3, следовательно, площадь будет равняться 9, а не 12. И единственным решением этой проблемы будет проверка типа объекта:

Нарушением принципа подстановки Барбары Лисков в данном случае является переопределение логики изменения значений родительского класса, о чём было сказано выше.

Практический пример

Давайте представим себе следующий интерфейс:

FindTaskByIdSpi — простой функциональный интерфейс, декларирующий единственный метод — findTaskById для поиска задачи в базе данных. У этого интерфейса есть базовая реализация при помощи MappingSqlQuery — FindTaskByIdMappingSqlQuery:

Допустим, я хочу добавить кэширование и для обеспечения соответствия кода Принципу Открытости/Закрытости добавляю новый класс — CachedFindTaskByIdMappingSqlQuery:

CachedFindTaskByIdMappingSqlQuery расширяет поведение FindTaskByIdMappingSqlQuery, используя его поведение, а не переопределяя, как это было в примере с квадратом и прямоугольником. Это позволяет соблюсти и Принцип подстановки Лисков, и принцип открытости/закрытости.

Подводя итог

В язык программирования Java уже заложены ограничения, защищающие лишь от некоторых ошибок при работе с наследованием, поэтому важно соблюдать принцип подстановки Барбары Лисков.