Как стать автором
Обновить

Пошушукаемся о Барбаре Лисков или раз и навсегда запоминаем принцип подстановки

Уровень сложностиСредний
Время на прочтение8 мин
Количество просмотров10K
КДПВ
КДПВ

Здравствуйте, всем! Хотя это моя первая публикация на Хабре, тему я хочу затронуть важную и далеко не всегда понятную новичкам. Не обращайте внимание на странный заголовок. Считайте, что это – ружье на стене, которое по ходу пьесы обязательно выстрелит.

Материал по этой теме здесь уже имеется, но на мой взгляд, информация там подана не совсем удачно и полно. Рискну внести свою лепту в дело понимания и запоминания такого фундаментального принципа.

В данной статье я постараюсь по максимуму избежать кода. Сделано это в целях повышения универсальности материала, он должен быть интересен всем читателям независимо от их языка программирования. В тех местах, где код неизбежно понадобится, он будет оформлен в синтаксисе Java. Не пугайтесь, все объясню, сложно не будет (во всяком случае, по моим расчетам). Итак, поехали!

Как вы уже поняли, речь пойдет об LSP. Нет нет, к сожалению, не об этой LSP, а всего-навсего о Liskov Substitution Principle – принципе подстановки Барбары Лисков. Вкратце скажу, что это один из принципов SOLID (под какой буквой он прячется в этой аббревиатуре – догадайтесь сами). Сейчас я не буду затрагивать подходы к грамотному ООП-дизайну, материалов и так написана куча, сосредоточимся исключительно на обозначенной теме.

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

Принцип подстановки Лисков (англ. Liskov Substitution Principle, LSP) — принцип организации подтипов в объектно-ориентированном программировании, предложенный Барбарой Лисков в 1987 году: если q(x) является свойством, верным относительно объектов x некоторого типа T, тогда q(y) также должно быть верным для объектов y типа S, где S является подтипом типа T.

Ага, отлично. Открыли Википедию, закрыли Википедию. Имеет смысл рассмотреть «бытовое» определение данного принципа. Возьму то, которое нравится мне больше всего и которым сам пользуюсь. Возможно, что я даже сам его сформулировал, но это неточно:

ППБЛ – принцип, согласно которому клиентский код должен работать с объектом класса-наследника точно также, как и с объектом базового класса. Класс-наследник должен расширять функционал родительского класса, а не сужать его.

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

UML-диаграмма классов. SuperClass – родитель, ChildClass – потомок, ClientCodeClass – клиентский код
UML-диаграмма классов. SuperClass – родитель, ChildClass – потомок, ClientCodeClass – клиентский код

На ней мы видим базовый класс или супер-класс (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() супер-класса в классе-потомке, чтобы при этом не сломался метод класса-клиента.

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

  1. Модификатор доступа. Может быть такой же или шире. Проверяется компилятором. Смотрим в код: отношение public/protected. Абсолютно логично. Если бы мы могли сузить модификатор в переопределенном методе, клиентский код, обратившись к объекту, мог бы в некоторых случаях просто не найти этот метод, что привело бы к ошибке времени выполнения (Runtime exception). Идём дальше.

  2. Возвращаемое значение. Такое же или уже. Проверяется компилятором. В примере: SubType/MiddleType. Если представить, что переменная t клиентского метода ловит возвращаемое значение в кастрюльку среднего размера, определенную типом MiddleType, то поместиться в нее может что-то совпадающее по размерам, либо меньшее. Соответственно, более широкий тип – SuperType, неожиданно прилетевший в качестве возвращаемого значения, в «кастрюлю» не поместится и вызовет ошибку.

  3. Параметры метода. Такие же, или шире. В примере: MiddleType/MiddleType. По факту, компилятор не даст ни сузить, ни расширить тип параметра метода. Это связано с особенностями механизма переопределения методов в Java. Любое изменение типа параметра приведет к тому, что метод будет перегружен, а не переопределен.

    Но все-таки, если было бы можно, то почему шире? В примере мы видим, что клиентский код сам создает объект типа MiddleType, чтобы передать его в метод. Если бы в переопределенном методе тип параметра изменился на SubType, в вызове метода в клиентском коде произошла бы ошибка. Созданный объект типа MiddleType туда бы просто «не влез», как более широкий. Мы же помним про кастрюльку?

  4. Выбрасываемое исключение. Такое же или уже. Проверяется компилятором. В примере: SubException/MiddleException. Здесь такая же кастрюльная история: если клиентский метод ждет исключение MiddleException, умеет его обрабатывать, то неожиданно прилетевшее более широкое исключение SuperException неизбежно вызовет ошибку.

  5. Предусловия. Такие же, или мягче. Не проверяется компилятором, будьте бдительны! В примере какие-либо условия не раскрыты, но, предположим, что метод супер-класса не проверял бы свой параметр перед началом работы основной логики, то есть предусловие бы отсутствовало, а вот в переопределенном методе мы бы решили его добавить. Тогда клиентский код, который привычно передает в метод параметр (в нашем примере – объект класса MiddleType), мог бы неожиданно столкнуться с тем, что параметр проходит по типу, но не проходит по какому-то внутреннему условию метода, что может привести как к явной ошибке, так и к некорректным результатам работы.

  6. Пост-условия. Такие же или жестче. Не проверяется компилятором! Ситуация похожа на ту, что была описана в предыдущем пункте, только наоборот.  Предположим, что возвращаемый объект типа MiddleType родительского метода в качестве результата содержит целое число, причем его значение всегда больше нуля. Это ограничение контролируется внутренней логикой метода супер-класса. Что случится, если метод класса-потомка расширит это условие и начнет передавать в качестве результата в том числе и отрицательные числа? Это может привести к ошибке или некорректной работе клиентского кода, «привыкшего» работать с более узким диапазоном чисел.

    На этом этапе мы заканчиваем исследование метода и переходим к наследованию в целом. Как я писал ранее, упомяну следующие проверки вскользь, потому что зубастые монстры, которые смотрят на нас из бездны контрактного программирования, вежливо рекомендуют воздержаться от путешествия в эти глубины с низким уровнем фундаментальных знаний за плечами.

  7. Инварианты класса. Класс-потомок не должен изменять инварианты своего родителя (на то они, собственно, инварианты). Само собой, не проверяется компилятором. Инварианты класса – логические условия, общие для всех экземпляров класса. Именно они накладывают ограничения целостности на класс и справедливы в том числе и для классов-наследников. Это, как и было сказано, относится к контрактному программированию. Если захотите, почитайте подробно здесь, но вы не захотите, я знаю.

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

Итак, если класс-потомок соответствует приведенным выше требованиям, то он не несет
опасности для клиентского кода и можно утверждать, что принцип подстановки Барбары Лисков соблюден.

Фух, вступительную часть статьи можно считать законченной. Как писал Сергей Довлатов: «Однако, предисловие затянулось». Теперь к сути. У любого адекватного читателя возникнет вполне закономерный вопрос: «как запомнить весь этот бред, что там уже, где там шире?» И, конечно же, ответ у меня имеется. Обратимся к иллюстрации:

Наш переопределенный метод. Внимательно смотрим и выявляем закономерности
Наш переопределенный метод. Внимательно смотрим и выявляем закономерности

На картинке мы видим наш переопределенный метод, который обходили. Направление обхода обозначено очень красной и очень изогнутой стрелкой. Проследим закономерность: Шире-Уже-Шире-Уже-Мягче(Шире)-Жестче(Уже). Сожмем это до ШУШУШУ и добавим остальное: И (инварианты) приватные поля. В итоге получаем фразу-запоминалку: «Шушушу И приватные поля». Теперь, даже если вы ничего не поняли в проверках, вы все равно их перечислите, просто воспроизведя в памяти синтаксис объявления метода в Java и эту заветную фразу. И вряд ли забудете, даже если очень захотите.

Теги:
Хабы:
Всего голосов 10: ↑7 и ↓3+7
Комментарии38

Публикации

Истории

Работа

Java разработчик
299 вакансий

Ближайшие события

28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
2 – 18 декабря
Yandex DataLens Festival 2024
МоскваОнлайн
11 – 13 декабря
Международная конференция по AI/ML «AI Journey»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань