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, так как в квадрате размеры сторон изменяются одновременно.
В результате этого следующий код вызовет проблемы:
Поскольку в Square методы setHeight и setWidth переопределяются, то размер сторон будет 3, следовательно, площадь будет равняться 9, а не 12. И единственным решением этой проблемы будет проверка типа объекта:
Нарушением принципа подстановки Барбары Лисков в данном случае является переопределение логики изменения значений родительского класса, о чём было сказано выше.
Практический пример
Давайте представим себе следующий интерфейс:
FindTaskByIdSpi — простой функциональный интерфейс, декларирующий единственный метод — findTaskById для поиска задачи в базе данных. У этого интерфейса есть базовая реализация при помощи MappingSqlQuery — FindTaskByIdMappingSqlQuery :
Допустим, я хочу добавить кэширование и для обеспечения соответствия кода Принципу Открытости/Закрытости добавляю новый класс — CachedFindTaskByIdMappingSqlQuery :
CachedFindTaskByIdMappingSqlQuery расширяет поведение FindTaskByIdMappingSqlQuery , используя его поведение, а не переопределяя, как это было в примере с квадратом и прямоугольником. Это позволяет соблюсти и Принцип подстановки Лисков, и принцип открытости/закрытости.
Подводя итог
В язык программирования Java уже заложены ограничения, защищающие лишь от некоторых ошибок при работе с наследованием, поэтому важно соблюдать принцип подстановки Барбары Лисков.