вторник, 9 сентября 2014 г.

Liskov Substitution Principle

Цикл статей о SOLID принципах

--------------------------------------------------

Принцип подстановки Лисков (Liskov Substitution Principle, LSP):

Должна быть возможность вместо базового типа подставить любой его подтип.
Роберт К. Мартин "Принципы, паттерны и практики гибкой разработки", 2006

...если для каждого объекта o1 типа S существует объект o2 типа T такой, что для всех программ P, определенных в терминах T, поведение P не изменяется при замене o2 на o1, то S является подтипом (subtype) для T.
Барбара Лисков "Абстракция данных и иерархия", 1988

Наследование и полиморфизм является ключевым инструментом ОО разработчика при борьбе со сложностью, для получения простого и расширяемого решения. Наследование используется в большинстве паттернов проектирования и лежит в основе таких принципов, как Open-Closed Principle и Dependency Inversion Principle.

Большинство опытных разработчиков знает, что с наследованием не все так просто. Во-первых, наследование – это одна из самых сильных связей в ОО мире, которая крепко привязывает наследников к базовому классу (сильнее только лишь отношение дружбы). Во-вторых, не всегда легко ответить два вопроса: 1) когда наследование уместно и 2) как его реализовать корректным образом.

Принцип подстановки Лисков призван помочь в корректной реализации наследования, что также должно помочь отказаться от наследования, если его корректная реализация невозможна.

Наследование обычно моделирует отношение "ЯВЛЯЕТСЯ" (IS-A Relationship) между классами. Говорят, что экземпляр наследника также ЯВЛЯЕТСЯ экземпляром базового класса, что выражается в возможности использования экземпляров наследника везде, где ожидается использование базового класса.

ПРИМЕЧАНИЕ
Бертран Мейер в своей книге "Объектно-ориентированное конструирование программных систем" (глава 24) приводит 12 (!) различных видов наследования, включая наследование реализации (закрытое наследование), IS-A, Can-Do (реализация интерфейсов) и т.п.

Формулировка принципа LSP от Боба Мартина повторяет определение отношения «ЯВЛЯЕТСЯ», а исходное определение от Барбары Лисков выглядит академичной чепухой. Можем ли мы сформулировать этот принцип более вменяемым образом? Из всего, что мне попалось на глаза, следующее определение выглядит наиболее разумным (взято из http://c2.com/cgi/wiki?LiskovSubstitutionPrinciple):

Принцип подстановки Лисков: должна существовать возможность использовать объекты производного класса вместо объектов базового класса. Это значит, что объекты производного класса должны вести себя согласованно, согласно контракту базового класса.

Какую проблему мы пытаемся решить?

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

Зачастую мы можем «абстрагироваться» от конкретной реализации потоков ввода-вывода (Streams), коллекций и последовательностей (Enumerables), провайдеров, репозиториев и т.п. Но для корректного поведения, приложение должно отталкиваться от некоторых допущений, которые будут справедливы для всех реализаций абстракции: что возвращаемое значение никогда не будет null, что будут генерироваться исключения лишь определенного типа, что любая реализация должна сохранить данные в базу данных (не важно какую), или метод добавления элемента обязательно его добавит, и размер коллекции увеличится на единицу.

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

Если же реализация (т.е. наследники) не будет знать о протоколе «абстракции» или не будет ему следовать, то в приложении мы будем вынуждены обрабатывать конкретную реализацию специальным образом, что сводит на нет идею использования «абстракции» и наследования.

Почему важно следовать принципу подстановки Лисков?

  1. «Поскольку в противном случае иерархии наследования приведут к неразберихе. Неразбериха будет заключаться в том, что передача в метод экземпляра класса-наследника приведет к странному поведению существующего кода.
  2. Поскольку в противном случае юнит-тесты базового класса никогда не будут проходить для наследников.»

(Взято из http://c2.com/cgi/wiki?LiskovSubstitutionPrinciple)

Классический пример: квадраты и прямоугольники

Наследование моделирует отношение «ЯВЛЯЕТСЯ». Но поскольку это лишь слово, мы не можем использовать его применимость в качестве безоговорочного доказательства необходимости использования наследования. Можем ли мы сказать, что цветная фигура является фигурой, контрактник является сотрудником, который, в свою очередь является человеком, квадрат является прямоугольником, а круг – овалом?

Приведенные утверждения могут быть корректными или не корректными, в зависимости от того, какое поведение мы будем приписывать фигурам, сотрудникам или квадратом. Именно в зависимости от «контракта» этих сущностей (не важно, явного или нет) мы можем говорить о возможности использования наследования.

Давайте рассмотрим более подробно известный пример с квадратами и прямоугольниками. С точки зрения математики, квадрат является прямоугольником, но актуально ли это отношение для классов Rectangle и Square?

clip_image002

Чтобы понять, будет ли нарушать данная иерархия классов LSP, нужно постараться сформулировать контракты этих классов:

  • Контракт прямоугольника (инвариант): ширина и высота положительны.
  • Контракт квадрата (инвариант): ширина и высота положительны; ширина и высота равны.

Пока нельзя сказать, являются ли контракты согласованными. Поскольку у квадрата все стороны равны, то изменение его ширины должно приводить и к изменению его высоты, и наоборот. Это значит, контракт свойств Width и Height квадрата становится несогласованным с контрактом этих свойств прямоугольника! (С точки зрения клиента прямоугольника свойства Width и Height полностью независимы, а значит замена прямоугольника квадратом во время исполнения нарушит это предположение клиента.)

Но это не значит, что данная иерархия наследования является невозможной. Квадрат перестает быть нормальным прямоугольником, ТОЛЬКО если квадрат и прямоугольник являются изменяемыми! Так, если мы сделаем их неизменяемыми (immutable), то проблема с контрактами, принципом подстановки и нарушением поведения клиентского кода при замене прямоугольников квадратами пропадет. Если клиент не может изменить ширину и высоту, то его поведение будет одинаковым как для квадратов, так и для прямоугольников!

ПРИМЕЧАНИЕ
Именно этой же логики следуют правила ковариантности и контравариантности обобщений в языке C#. Так, именно отсутствие изменяемости, позволяет трактовать объект IEnumerable<string> как IEnumerable<object>. Поскольку мы можем лишь достать элемент из последовательности и не можем положить его обратно, мы такое преобразование является типобезопасным.

Данный пример показывает несколько важных момента. Во-первых, именно наличие контракта позволяет четко понять, нарушает ли производный класс LSP или нет. А во-вторых, пример с неизменяемостью показывает пользу неизменяемости в ОО мире за пределами параллелизма: контракт неизменяемых типов проще, поскольку контролируется лишь конструктором и инвариантом класса.

В-третьих, этот пример показывает, почему некоторые специалисты рекомендуют, чтобы классы были либо абстрактными, либо запечатанными, без возможности инстанциировать классы из середины иерархии наследования. Так, классическим решением проблемы квадратов/прямоугольников является выделение промежуточного абстрактного класса «четырехугольника», от которого уже наследуются квадрат и прямоугольник. И хотя в некоторых случаях такой совет оправдан, слишком строго следовать ему не стоит. Вместо этого лучше точнее описывать контракт базовых классов с помощью контрактов (не важно, формальных или нет).

Принцип подстановки Лисков и контракты

Не будет преувеличением сказать, что лишь с помощью принципов Проектирования по контракту вы можете точно понять, что представляет собой наследование.
Бертран Мейер «Объектно-ориентированное конструирование программных систем», раздел 16.1.

Существует несколько способов описать ожидаемое поведение типа, т.е. его спецификацию. Для этого мы можем использовать комментарии (их никто не читает!), юнит-тесты (лучше, но кто нам даст тесты для классов из BCL?) или контракты (заставить бы всех ими пользоваться!).

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

Если посмотреть на исходное описание принципа подстановки в трудах Барбары Лисков, то можно с удивлением обнаружить, что оно полностью основано таких понятиях, как предусловия, постусловия и инварианты. Другими словами, описание этого принципа полностью основано на принципах проектирования по контракту:

  1. Производные классы не должны усиливать предусловия (не должны требовать большего от своих клиентов).
  2. Производные классы не должны ослаблять постусловия (должны гарантировать, как минимум тоже, что и базовый класс).
  3. Производные классы не должны нарушать инварианты базового класса (инварианты базового класса и наследников суммируются)
  4. Производные классы не должны генерировать исключения, не описанные базовым классом.

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

Заключение

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

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

Дополнительные ссылки

10 комментариев:

  1. Спасибо за статью, Сергей!
    Должна быть возможность вместо базового типа подставить любоЕ его подтип.
    Опечатка - любоЙ

    ОтветитьУдалить
    Ответы
    1. Максим, спасибо, опечатку поправил.

      Удалить
  2. Не могу не отметить, что при всей своей заумности, определение Лисков позволяет описать такие вещи как duck typing (актуально для динамически-типизированных языков, вроде Ruby/SmallTalk/Python). И такую разновидность duck typing-а, как статический полиморфизм на шаблонах в C++ и D (а может и в Ada, я уже не помню тамошних деталей). Взять какой-нибудь std::find_if, который получает пару итераторов и предикат. Итераторы и предикаты определяют контракты, которые должны выполнять реальные типы в программе, дабы find_if работал. Но вот сами типы итераторов/предикатов вовсе не должны наследоваться от каких-то базовых типов.

    Т.е. выполняется LSP, есть понятие подтипа, но наследования в ООП-ном стиле нет.

    PS. Уже основательно подзабыл OCaml, но вроде как в статически-типизированных ФЯ вроде OCaml-а и Haskell-я используются приемы, аналогичные плюсовым шаблонам. Так что LSP в формулировке Лисков может и там найти свое место, имхо.

    ОтветитьУдалить
  3. Спасибо за статьи про SOLID, очень полезно!
    Тоже небольшая опечатка за глаз зацепилась)

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

    В последней фразе наверное "мы" лишнее.

    ОтветитьУдалить
  4. "Данный пример показывает несколько важных момента."

    Наверное имелось в виду "моментов"!

    ОтветитьУдалить
  5. строка: удивлением обнаружить, что оно полностью основано НА таких понятиях, как предусловия, постусловия и

    ОтветитьУдалить
  6. Добрый день, Сергей
    Никогда раньше не задумывался, что вот такая вот, казалось бы "очевидная" архитектура, нарушает LSP:

    http://ru.stackoverflow.com/questions/604678/%D0%9F%D1%80%D0%B8%D0%BD%D1%86%D0%B8%D0%BF-%D0%BF%D0%BE%D0%B4%D1%81%D1%82%D0%B0%D0%BD%D0%BE%D0%B2%D0%BA%D0%B8-%D0%9B%D0%B8%D1%81%D0%BA%D0%BE%D0%B2-%D0%B8-%D0%BF%D1%80%D0%B5%D0%B4%D1%83%D1%81%D0%BB%D0%BE%D0%B2%D0%B8%D1%8F

    А ведь это так, усиление предусловия в подклассе. Как же правильно тогда проектировать такую архитектуру, чтобы соблюдался LSP ?

    ОтветитьУдалить
    Ответы
    1. Ответил в вопросе по ссылке: но если кратко, то нужен явным метод проверки предусловия. При этом метод это тоже должен быть полиморфным.

      Удалить