Здравствуйте, всем! Хотя это моя первая публикация на Хабре, тему я хочу затронуть важную и далеко не всегда понятную новичкам. Не обращайте внимание на странный заголовок. Считайте, что это – ружье на стене, которое по ходу пьесы обязательно выстрелит.
Материал по этой теме здесь уже имеется, но на мой взгляд, информация там подана не совсем удачно и полно. Рискну внести свою лепту в дело понимания и запоминания такого фундаментального принципа.
В данной статье я постараюсь по максимуму избежать кода. Сделано это в целях повышения универсальности материала, он должен быть интересен всем читателям независимо от их языка программирования. В тех местах, где код неизбежно понадобится, он будет оформлен в синтаксисе Java. Не пугайтесь, все объясню, сложно не будет (во всяком случае, по моим расчетам). Итак, поехали!
Как вы уже поняли, речь пойдет об LSP. Нет нет, к сожалению, не об этой LSP, а всего-навсего о Liskov Substitution Principle – принципе подстановки Барбары Лисков. Вкратце скажу, что это один из принципов SOLID (под какой буквой он прячется в этой аббревиатуре – догадайтесь сами). Сейчас я не буду затрагивать подходы к грамотному ООП-дизайну, материалов и так написана куча, сосредоточимся исключительно на обозначенной теме.
Прежде чем начать разбираться, давайте заглянем в какой-нибудь более-менее вменяемый источник с претензией на научность. Вот что написано в Википедии:
Принцип подстановки Лисков (англ. Liskov Substitution Principle, LSP) — принцип организации подтипов в объектно-ориентированном программировании, предложенный Барбарой Лисков в 1987 году: если q(x) является свойством, верным относительно объектов x некоторого типа T, тогда q(y) также должно быть верным для объектов y типа S, где S является подтипом типа T.
Ага, отлично. Открыли Википедию, закрыли Википедию. Имеет смысл рассмотреть «бытовое» определение данного принципа. Возьму то, которое нравится мне больше всего и которым сам пользуюсь. Возможно, что я даже сам его сформулировал, но это неточно:
ППБЛ – принцип, согласно которому клиентский код должен работать с объектом класса-наследника точно также, как и с объектом базового класса. Класс-наследник должен расширять функционал родительского класса, а не сужать его.
Вот в этом определении копаться гораздо интереснее. Во-первых, принцип называется «принцип подстановки», а это значит, что мы должны что-то подставить куда-то. Во-вторых,
должно быть наследование, которое каким-то образом не «сузит» родительский класс. В-третьих, должен быть какой-то загадочный «клиентский код», который со всем этим непотребством будет работать. Взглянем на схемку:
На ней мы видим базовый класс или супер-класс (SuperClass), унаследованный от него класс-потомок (ChildClass) и класс-клиент (ClientCodeClass), символизирующий собой клиентский код. Супер-класс имеет публичное поле field типа int и публичный метод doSomething(), принимающий параметр param произвольного типа MiddleType и возвращающий значение такого-же типа (почему наш совершенно произвольный тип данных имеет такое название будет объяснено далее). Класс-потомок имеет все то же самое, что и родительский класс (поля и методы родительского класса «вшиты» в него по умолчанию и не отображены на диаграмме). Клиентский класс имеет поле, которое к делу не относится, и метод, который к делу относится самым непосредственным образом. Метод clientMethod() в качестве входного параметра objectOfSuperClass принимает объект типа SuperClass и производит над ним какие-то действия. Следовательно, ClientCodeClass зависит от SuperClass, что и показано на UML-диаграмме прерывистой линией.
Таким образом, клиентский код – любой код приложения, имеющий межклассовые отношения (зависимость, агрегация, композиция и т.д.) с нашим супер-классом.
А что случится, если в наш клиентский метод передать в качестве параметра объект типа ChildClass?
Так вот, чтобы был соблюден принцип подстановки, случиться ничего не должно. Клиентский код должен спокойно «проглотить» объект класса-потомка вместо объекта супер-класса и не заметить разницы, не сломаться. Должна отсутствовать необходимость даже самым малейшим образом дорабатывать клиентский код.
Подведем промежуточный итог. Принцип подстановки заключается, как ни странно, в подстановке объекта класса-потомка в клиентский код вместо объекта базового класса, при этом клиентский код остается полностью неизменным и работоспособным.
А теперь давайте подумаем, что может случиться в процессе наследования одного класса от другого, что функционал потомка сузится, и с ним не сможет работать клиентский код? У потомка исчезнут какие-то поля или методы, характерные для родительского класса? Нет, это невозможно. Поля и методы супер-класса остаются с потомком на веки вечные согласно самой сути наследования.
Добавятся новые поля и методы? Да, тут возможно несколько проблем, которые слишком сложны и объемны для описания в этой статье, поэтому я их затрону вскользь чуть позже.
Остается только одна опция – можно изменить функционал какого-то родительского метода в классе-потомке или, как говорим мы – объектно-ориентированные программисты: переопределить метод. Вот тут и скрываются основные проблемы. Именно переопределенный в классе-потомке метод может нести опасность для клиентского кода.
Чтобы понять, правильно ли было выполнено наследование в целом и переопределение в частности, необходимо осуществить ряд проверок. Все проверки, собранные вместе, я впервые увидел в книге Александра Швеца «Погружение в паттерны проектирования». Об этих проверках я тоже расскажу. Но, прежде чем мы приступим к их описанию, давайте разберемся, что и как мы будем проверять.
Введем для удобства иерархию произвольных типов и произвольных исключений (Exceptions):
Далее приведен фрагмент кода:
//// Метод клиентского кода/////
public void clientMethod(SuperClass objectOfChildClass){
MiddleType t = objectOfChildClass.doSomething(new MiddleType());
//какие-то дальнейшие действия
}
////////Метод супер-класса/////////
protected MiddleType doSomething(MiddleType param) throws MiddleException {
//предусловие
...
//основная логика метода
...
//пост-условие
}
////////Метод класса-потомка/////////
@Override
public SubType doSomething(MiddleType param) throws SubException {
//предусловие
...
// основная логика метода
...
//пост-условие
}
Для удобства восприятия в коде примера опущены необходимые для компиляции в Java элементы программы: объявление классов, внутренний функционал методов, создание объектов и т.д. Давайте воспринимать эти методы как сферические, находящиеся в вакууме приведенной на первой картинке структуры классов. Здесь нам пригодились объявленные выше иерархии типов и исключений, сразу видно, что есть супер-тип, а что – подтип.
Параметр клиентского метода, как и ранее, объявлен типом SuperClass, но принимать он будет объект objectOfChildClass типа ChildClass. Далее в теле этого метода объявлена переменная t типа MiddleType, которая принимает возвращаемое значение метода doSomething(), вызываемого у объекта objectOfChildClass. Теперь наша задача – понять, как правильно переопределить метод doSomething() супер-класса в классе-потомке, чтобы при этом не сломался метод класса-клиента.
Приступим к проверкам. Более абстрактный класс, находящийся выше в иерархии наследования, мы будем считать более широким, а более конкретный, находящийся ниже, соответственно, узким. Пойдём слева направо по переопределенному методу в классе-потомке и сравним его с исходным в супер-классе:
Модификатор доступа. Может быть такой же или шире. Проверяется компилятором. Смотрим в код: отношение public/protected. Абсолютно логично. Если бы мы могли сузить модификатор в переопределенном методе, клиентский код, обратившись к объекту, мог бы в некоторых случаях просто не найти этот метод, что привело бы к ошибке времени выполнения (Runtime exception). Идём дальше.
Возвращаемое значение. Такое же или уже. Проверяется компилятором. В примере: SubType/MiddleType. Если представить, что переменная t клиентского метода ловит возвращаемое значение в кастрюльку среднего размера, определенную типом MiddleType, то поместиться в нее может что-то совпадающее по размерам, либо меньшее. Соответственно, более широкий тип – SuperType, неожиданно прилетевший в качестве возвращаемого значения, в «кастрюлю» не поместится и вызовет ошибку.
Параметры метода. Такие же, или шире. В примере: MiddleType/MiddleType. По факту, компилятор не даст ни сузить, ни расширить тип параметра метода. Это связано с особенностями механизма переопределения методов в Java. Любое изменение типа параметра приведет к тому, что метод будет перегружен, а не переопределен.
Но все-таки, если было бы можно, то почему шире? В примере мы видим, что клиентский код сам создает объект типа MiddleType, чтобы передать его в метод. Если бы в переопределенном методе тип параметра изменился на SubType, в вызове метода в клиентском коде произошла бы ошибка. Созданный объект типа MiddleType туда бы просто «не влез», как более широкий. Мы же помним про кастрюльку?
Выбрасываемое исключение. Такое же или уже. Проверяется компилятором. В примере: SubException/MiddleException. Здесь такая же кастрюльная история: если клиентский метод ждет исключение MiddleException, умеет его обрабатывать, то неожиданно прилетевшее более широкое исключение SuperException неизбежно вызовет ошибку.
Предусловия. Такие же, или мягче. Не проверяется компилятором, будьте бдительны! В примере какие-либо условия не раскрыты, но, предположим, что метод супер-класса не проверял бы свой параметр перед началом работы основной логики, то есть предусловие бы отсутствовало, а вот в переопределенном методе мы бы решили его добавить. Тогда клиентский код, который привычно передает в метод параметр (в нашем примере – объект класса MiddleType), мог бы неожиданно столкнуться с тем, что параметр проходит по типу, но не проходит по какому-то внутреннему условию метода, что может привести как к явной ошибке, так и к некорректным результатам работы.
Пост-условия. Такие же или жестче. Не проверяется компилятором! Ситуация похожа на ту, что была описана в предыдущем пункте, только наоборот. Предположим, что возвращаемый объект типа MiddleType родительского метода в качестве результата содержит целое число, причем его значение всегда больше нуля. Это ограничение контролируется внутренней логикой метода супер-класса. Что случится, если метод класса-потомка расширит это условие и начнет передавать в качестве результата в том числе и отрицательные числа? Это может привести к ошибке или некорректной работе клиентского кода, «привыкшего» работать с более узким диапазоном чисел.
На этом этапе мы заканчиваем исследование метода и переходим к наследованию в целом. Как я писал ранее, упомяну следующие проверки вскользь, потому что зубастые монстры, которые смотрят на нас из бездны контрактного программирования, вежливо рекомендуют воздержаться от путешествия в эти глубины с низким уровнем фундаментальных знаний за плечами.
Инварианты класса. Класс-потомок не должен изменять инварианты своего родителя (на то они, собственно, инварианты). Само собой, не проверяется компилятором. Инварианты класса – логические условия, общие для всех экземпляров класса. Именно они накладывают ограничения целостности на класс и справедливы в том числе и для классов-наследников. Это, как и было сказано, относится к контрактному программированию. Если захотите, почитайте подробно здесь, но вы не захотите, я знаю.
Значения приватных полей родительского класса. Не должны изменяться классом-наследником, например, с помощью рефлексии. Не проверяется компилятором.
Итак, если класс-потомок соответствует приведенным выше требованиям, то он не несет
опасности для клиентского кода и можно утверждать, что принцип подстановки Барбары Лисков соблюден.
Фух, вступительную часть статьи можно считать законченной. Как писал Сергей Довлатов: «Однако, предисловие затянулось». Теперь к сути. У любого адекватного читателя возникнет вполне закономерный вопрос: «как запомнить весь этот бред, что там уже, где там шире?» И, конечно же, ответ у меня имеется. Обратимся к иллюстрации:
На картинке мы видим наш переопределенный метод, который обходили. Направление обхода обозначено очень красной и очень изогнутой стрелкой. Проследим закономерность: Шире-Уже-Шире-Уже-Мягче(Шире)-Жестче(Уже). Сожмем это до ШУШУШУ и добавим остальное: И (инварианты) приватные поля. В итоге получаем фразу-запоминалку: «Шушушу И приватные поля». Теперь, даже если вы ничего не поняли в проверках, вы все равно их перечислите, просто воспроизведя в памяти синтаксис объявления метода в Java и эту заветную фразу. И вряд ли забудете, даже если очень захотите.