КДПВ
КДПВ

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

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

В данной статье я постараюсь по максимуму избежать кода. Сделано это в целях повышения универсальности материала, он должен быть интересен всем читателям независимо от их языка программирования. В тех местах, где код неизбежно понадобится, он будет оформлен в синтаксисе 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 и эту заветную фразу. И вряд ли забудете, даже если очень захотите.

Комментарии (25)


  1. panzerfaust
    08.08.2023 12:23
    +7

    Мне вот кажется, что проще всего объяснить LSP на примере от противного, который мы можем найти в JVM.

    Есть интерфейс java.utils.List, у него есть метод add. И должно это все работать так:

    List<String> list = new ArrayList<>();
    list.add("x");
    list.contains("x") //true

    Однако можно сделать так:

    List<String> list = Collections.unmodifiableList(new ArrayList<>());
    list.add("x"); // низззяяя, UnsupportedOperationException

    или так

    List<String> list = Collections.emptyList()
    list.add("x"); // низззяяя, UnsupportedOperationException

    То есть у нас на руках тип List, а работать как с листом мы с ним не можем. Косямба? Косямба. Вот LSP говорит, что так делать не надо.


    1. quaer
      08.08.2023 12:23

      Да и с расширением функциональности тоже не всё гладко. Всегда переезжать базовый метод или добалять новый? И как потом не запутаться какой вызывать?


    1. BobrDobr32rus Автор
      08.08.2023 12:23
      +1

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


    1. GospodinKolhoznik
      08.08.2023 12:23
      -3

      Если list.add("x") недоступен, значит это нечно не выполняет соглашение интерфейса List. Если при этом компилятор считает считает, что интерфейс List соблюдён, это баг компилятора.


      1. GospodinKolhoznik
        08.08.2023 12:23
        -2

        Уважаемые джавалюбы, приношу свои извинения, если я оскорбил ваши чувства, указав на несовершенство вашего обожаемого языка.


    1. sshikov
      08.08.2023 12:23

      Ну, вообще тут косяк зарыт гораздо глубже. Ну нет в Java изначально интерфейсов для иммутабл коллекций, как скажем есть в скале. Поэтому у вас формально-то List, а фактически некий ...ImmutableList, но бедность системы типов языка не позволяет это выразить. А так бы оно просто не скомпилировалось.


      1. ValeryIvanov
        08.08.2023 12:23

        Причём, если стремиться сохранить обратную совместимость, текущую иерархию уже никак не расширить. Впихнуть ReadableIterator, ReadableIterable, ReadableCollection, ReadableList, Readable* то можно, но вот что делать с утилитарными классами и методами(Collections, List::of), которые возвращают экземпляры List, Set, Map и т. д. это большой вопрос.


        1. sshikov
          08.08.2023 12:23

          Ну да, в том числе. В принципе, я пытался и пытаюсь в текущем проекте пользоваться скажем классами из Vavr, где к примеру у List есть сразу методы map или filter, и удобные конструкторы, а заодно TupleN. В какой-то степени так жить (с другой иерархией коллекций) можно — но только пока у вас мало используется чей-то чужой API, где все интерфейсы классические. Тогда начинается везде toJavaMap, а имплиситов как в скале — не завезли...


    1. Ksnz
      08.08.2023 12:23

      Ну если быть честным, то в документации add к интерфейсу List написано следующее.

      * @throws UnsupportedOperationException if the {@code add} operation * is not supported by this list

      конечно документация такой себе контракт, но все же контракт.

      И по хорошему клиентский код принимающий List должен учитывать что add может выбросить UnsupportedOperationException


  1. boblgum
    08.08.2023 12:23
    +1

    Да, тема не из самых простых :)
    Если посмотреть статью википедии на английском, то там упоминается интервью с бабой Варей. Видео есть на ютьюбе. В нем она называет этот принцип behavioral subtyping

    Самый простой способ на пальцах объяснить принцип:
    Есть класс List. Есть два наследника FirstInFirstOutList и LastInFirstOutList
    Следуя принципу, наследники по отношению к пользователю не взаимозаменяемы, потому что их поведение разное.

    как то так


  1. gdt
    08.08.2023 12:23
    +1

    Но все же, наследовать квадрат от прямоугольника, или наоборот?


    1. Zagrebelion
      08.08.2023 12:23
      +2

      или оба от BaseShape


      1. gdt
        08.08.2023 12:23
        +1

        Как по мне это и есть правильный ответ, вопрос больше на подумать


    1. GospodinKolhoznik
      08.08.2023 12:23
      +1

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


      1. mcferden
        08.08.2023 12:23
        +3

        На самом деле не все: при увеличении ширины в 2 раза, площадь прямоугольника увеличится в 2 раза, а квадрата — в 4.


        1. GospodinKolhoznik
          08.08.2023 12:23

          Мне кажется здесь проблема в том, что у геометрической фигуры прямоугольник в принципе нет такого метода, как "взять меня и породить новую фигуру со стороной в 2 раза шире". Вы конечно можете создать класс Rectungle с какими угодно методами, но тогда далеко не факт, что все свойства, которые выполняются для геометрической фигуры Прямоугольник продолжат выполняться и для объектов Rectungle с новыми методами.


          1. mayorovp
            08.08.2023 12:23

            Да, именно в этом и проблема, что у математической фигуры никаких поведенческих свойств нет, а у объекта в программировании — есть. Не будь "лишних" свойств — не было бы и проблем с наследованием.


  1. nin-jin
    08.08.2023 12:23
    -1

    Вот так вот мучаешь себя, запоминаешь, шушукаешься, а потом натыкаешься на это видео и шаблон начинает расходиться по швам:


    1. mayorovp
      08.08.2023 12:23
      +3

      Это то самое, где вы путаетесь в терминологии и придумываете свои значения для общепринятых терминов?


      1. nin-jin
        08.08.2023 12:23
        -1

        Какой терминологии?


    1. BobrDobr32rus Автор
      08.08.2023 12:23
      +2

      Честно, очень странное видео. Оно мне напоминает о старой математической шутке, где путем череды избыточных операций и едва едва заметной логической ошибки доказывается, что 2 х 2 = 5. На 3:17 автор зачем-то оборачивает исходные типы в контейнеры, хотя иерархия типов-контейнеров может быть абсолютно произвольной и может никак не соотноситься с исходной иерархией. Ну и далее там тоже имеются вопросики. Почему от этих рассуждений принцип подстановки должен расходиться по швам я так и не понял.

      И вообще, я с подозрением отношусь к "революционным" материалам, которые должны объяснить несостоятельность проверенных временем фундаментальных вещей.

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


      1. nin-jin
        08.08.2023 12:23
        -2

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


      1. mayorovp
        08.08.2023 12:23
        +2

        Статья вот, можете обсуждать: https://habr.com/ru/articles/521258/


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


        1. BobrDobr32rus Автор
          08.08.2023 12:23
          +1

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


          1. nin-jin
            08.08.2023 12:23
            -1

            Да-да, продолжение банкета не пропустите: https://habr.com/ru/articles/477448/