Формулировка №1: eсли для каждого объекта o1 типа S существует объект o2 типа T, который для всех программ P определен в терминах T, то поведение P не изменится, если o2 заменить на o1 при условии, что S является подтипом T.
Формулировка №2: Функции, которые используют ссылки на базовые классы, должны иметь возможность использовать объекты производных классов, не зная об этом.
Короткая версия: Derived classes must be substitutable for their base classes
Примеры
Проверка абстракции на тип
Я уже приводил код проверки абстракции на тип на примере нарушения принципа открытости/закрытости. Теперь мы видим, что класс Repository нарушает еще и принцип замещения Лисков. Дело в том, что внутри класса Repository мы оперируем не только абстрактной сущностью AbstractEntity, но и унаследованными типами. А это значит, что в данном случае подтипы AccountEntity и RoleEntity не могут быть заменены типом, от которого они унаследованы. По определению имеем нарушение.
Надо заметить, что принципы проектирования взаимосвязаны. Нарушение одного из принципов скорее всего приведет к нарушению одного или нескольких других принципов.
Ошибочное наследование
Проблема
Мы хотим реализовать свой список с интерфейсом IList. Его особенностью будет то, что все записи в нем дублируются.
public class DoubleList<T> : IList<T> { private readonly IList<T> innerList = new List<T>(); public void Add(T item) { innerList.Add(item); innerList.Add(item); } ...
Данная реализация не представляет никакой опасности, если рассматривать ее изолированно. Взглянем на использование этого класса с точки зрения клиента. Клиент, абстрагируясь от реализаций, пытается работать со всеми объектами типа IList одинаково:
[Fact] public void CheckBehaviourForRegularList() { IList<int> list = new List<int>(); list.Add(1); Assert.Equal(1, list.Count); } [Fact] public void CheckBehaviourForDoubleList() { IList<int> list = new DoubleList<int>(); list.Add(1); Assert.Equal(1, list.Count); // fail }
Поведение списка DoubleList отличается от типичных реализаций IList. Получается, что наш DoubleList не может быть заменен базовым типом. Это и есть нарушение принципа замещения Лисков.
Проблема заключается в том, что теперь клиенту необходимо знать о конкретном типе объекта, реализующем интерфейс IList. В качестве такого объекта могут передать и DoubleList, а для него придется выполнять дополнительную логику и проверки.
Решение
Правильным решением будет использовать свой собственный интерфейс, например, IDoubleList. Этот интерфейс будет объявлять для пользователей поведение, при котором добавляемые элементы удваиваются. Более подробно об этом можно прочитать в дополнении к LSP.
Проектирование по контракту
Есть формальный способ понять, что наследование является ошибочным. Это можно сделать с помощью проектирования по контракту. Бернард Мейер, его автор, сформулировал следующий принцип:
Наследуемый объект может заменить родительское пред-условие на такое же или более слабое и родительское пост-условие на такое же или более сильное. (перефразировано)
Рассмотрим пред- и пост-условия для интерфейса IList. Для функции Add:
- пред-условие: item != null
- пост-условие: count = oldCount + 1
Для нашего DoubleList и его функции Add:
- пред-условие: item != null
- пост-условие: count = oldCount + 2
Теперь стало видно, что по контракту пост-условие базового класса не выполняется.
Другими словами, когда мы используем интерфейс IList, то как пользователи этого базового класса знаем только его пред- и пост-условия. Нарушая принцип проектирования по контракту мы меняем поведение унаследованного объекта.
Я не призываю начать пользоваться языками, которые используют встроенную поддержку проектирования по контракту, а только хочу показать, что есть способ формального определения нарушения принципа замещения Лисков.
Этот пост входит в серию Принципы проектирования классов (S.O.L.I.D.):
- Принцип единственности ответственности (The Single Responsibility Principle)
- Принцип открытости/закрытости (The Open Closed Principle)
- Принцип замещения Лисков (The Liskov Substitution Principle)
- Принцип разделения интерфейса (The Interface Segregation Principle)
- Принцип инверсии зависимости (The Dependency Inversion Principle)
Ссылки
R. Martin: The Liskov Substitution Principle
LosTechies: The Liskov Substitution Principle
Wikipedia: Liskov substitution principle
CodingEfficiency: SOLID – L: Liskov Substitution Principle
DimeCasts: # 92 - Creating SOLID Code: Liskov Substitution Principle
Я думаю, что под словом "программа" в 1-й формулировке принципа следует понимать не программу как некий законченый программный модуль, а функцию. Иначе это сбивает с толку и затрудняет понимание, не так ли?
ОтветитьУдалитьВ целом хочу сказать спасибо за популяризацию этих принципов. Оказалось, многие из них я применял интуитивно, не зная об их существовании, но теперь как пелена с глаз :)
Объясни пожалуйста, что значит ослабление или усиление условий в проектировании по контракту?
ОтветитьУдалить@efix
ОтветитьУдалить> Я думаю, что под словом "программа" в 1-й формулировке принципа следует понимать не программу как некий законченый программный модуль, а функцию
Ага, так лучше.
> Объясни пожалуйста, что значит ослабление или усиление условий в проектировании по контракту?
Если для функции Add родительского класса справедливо:
* пред-условие: item != null
* пост-условие: count = oldCount + 1
То для дочернего можно использовать:
* пред-условие: -
* пост-условие: count = oldCount + 1 && is_changed = true
Так образом, пользователи дочернего могут обращаться с ним, как с родительским.
Здравствуйте Александр!!!
ОтветитьУдалитьУ меня возник один вопрос, возможно не к месту и, возможно, глупый, но все же,не могли бы вы объяснить связь между проектированием по контракту и модульным тестированием , или же это просто два совершенно разных подхода к проектированию классов!
я недавно разбирался с Проектированием по контракту по книге Бертрана Мейера там ничего о тестировании не говориться!
@pArTiZaN
ОтветитьУдалитьПривет.
Это два подхода к разработке (если под "модульным тестированием" ты подразумеваешь TDD). Их можно применять вместе. Видимо только это их и связывает.
>>то поведение P не измениться
ОтветитьУдалитьИсправь, пожалуйста
Некоторое время назад я обнаружил принципиальное отличие в подходе к сериализации в .NET и Java. Заключается оно в том, что в Java для пометки класса сериализуемым необходимо отнаследовать его от маркерного (пустого) интерфейса (есть и другие подходы к сериализации, но сейчас остановимся именно на этом), поэтому дочерние классы автоматически становятся сериализуемыми. В .NET для этих целей есть атрибут Serializable, который не наследуется в дочерних классах.
Получается, с одной стороны, Java ведет себя правильнее по Лискоу, с другой, - сериализуемость, доставшаяся по наследству, может принести массу проблем. Так кто прав: .NET или Java? Этот вопрос я попытался прояснить в этом топике на rsdn, но не очень успешно.
@Idsa
ОтветитьУдалитьЯ думаю, что аттрибут - это более правильный подход. Вообще не рекомендую использование пустых интерфейсов для "пометки" объекта.
На счет того, должны ли наследники класса быть сериализуемыми или нет, вопрос спорный. Я в практике еще никогда не сталкивался с подобной проблемой. Точнее мне не приходилось запрещать сериализацию какого-либо класса, поэтому будет класс сериализуемым или нет, не так критично.
>>Я думаю, что аттрибут - это более правильный подход.
ОтветитьУдалитьЗдесь суть не в выборе атрибут/интерфейс. Ведь в .NET есть возможность сделать атрибут наследуемым, но разработчики .NET почему-то сделали "не как в Java", пойдя наперекор принципу Лискоу (ты считаешь, что принцип Лискоу здесь нарушается?). Если развивать эту логику, получается, что любые ненаследуемые метаданные нарушают принцип Лискоу.
>>Вообще не рекомендую использование пустых интерфейсов для "пометки" объекта.
Мне тоже не очень нравится этот подход, да и в .NET я его ни разу не видел. Хотя в том же топике я пытался заодно узнать, используют ли маркерные интерфейсы после появления в Java аннотаций, и там привели интересный юзкейс, в котором аннотации (как и наши родные атрибуты) бессильны: "можно делать методы someMethod(MarkerInterface o) — с аннотациями такое не прокатит". Это может пригодиться, если нам нужно вынести зависимость класса от объекта с некоторыми метаданными.
@Idsa
ОтветитьУдалить> ты считаешь, что принцип Лискоу здесь нарушается?
Если я не могу использовать вместо наследуемого класса интерфейс ISerializable, то по определению нарушается :)
> Если развивать эту логику, получается, что любые ненаследуемые метаданные нарушают принцип Лискоу
Т.е. ты предполагаешь, что аттрибуты тоже должны передаваться наследникам?
> можно делать методы someMethod(MarkerInterface o) — с аннотациями такое не прокатит
Не понял этот пример, можешь подробнее?
>>Если я не могу использовать вместо наследуемого класса интерфейс ISerializable, то по определению нарушается :)
ОтветитьУдалитьСлушай, точно :) Я в этом направлении не размышлял. Выходит, атрибут C# нарушает принцип замещения Лискоу всегда (хотя тут можно спорить...), а поведение сериализации в Java не определено: наличие маркерного интерфейса не гарантирует сериализацию.
>>Т.е. ты предполагаешь, что аттрибуты тоже должны передаваться наследникам?
Они и передаются, если в AttributeUsage задан Inherited = true. Я склоняюсь к мысли, что любой ненаследуемый атрибут нарушает принцип замещения Лискоу.
>>Не понял этот пример, можешь подробнее?
Допустим, у тебя зависимость "любой объект, поддающийся бинарной сериализации". В Java для этого достаточно получить объект, реализующий интерфейс ISerializable, а в C# - никак.
@Idsa
ОтветитьУдалить> Я склоняюсь к мысли, что любой ненаследуемый атрибут нарушает принцип замещения Лискоу
Это не так, потому что ты не можешь использовать аттрибут вместо класса. Но ты можешь использовать интерфейс вместо класса. Т.е. аттрибут вообще никак не влияет на нарушение или не нарушение этого принципа.
> В Java для этого достаточно получить объект, реализующий интерфейс ISerializable, а в C# - никак
Можно пример кода?
>>Т.е. аттрибут вообще никак не влияет на нарушение или не нарушение этого принципа.
ОтветитьУдалитьЯ тоже так сначала думал, но ребята с rsdn меня переубедили. Пример:
ClassA classInstance = new ClassA();
ClassA derivedClassInstance = new ClassB();
ClassA помечен атрибутом Serializable, а ClassB - нет.
Пытаемся сериализовать derivedClassInstance и получаем исключение. Это ли не нарушение принципа замещения Лискоу?
>>Можно пример кода?
public class SerializableEntityManager
{
private ISerializable _serializableEntity;
/*Зависимость должна быть бинарно-сериализуемой. В Java это выглядело бы так, а в С# - хз. Ну и в общем подобный подход - аргумент за маркерные интерфейсы как средство задания метаданных*/
public Serializator(ISerializable serializableEntity)
{
_serializableEntity = serializableEntity;
}
}
@Idsa
ОтветитьУдалитьЕсли ты реализуешь ISerializable у класса, то любой его наследник будет сериализуем. Т.е. по принципу: вместо наследников мы можем использовать их родителей (в данном случае абстракцию - интерфейс ISerializable).
Речь не идет о метаданных. Метаданные не участвуют во взаимодействии объектов. Мы оперируем в коде не аттрибутами или названиями методов, а классами, абстрактными классами или интерфейсами.
>>Если ты реализуешь ISerializable у класса, то любой его наследник будет сериализуем. Т.е. по принципу: вместо наследников мы можем использовать их родителей (в данном случае абстракцию - интерфейс ISerializable).
ОтветитьУдалитьПогоди, погоди. Это ты сейчас насчет чего? Я же вверху про C# и атрибуты говорил, а не про Java и ISerializable. И то, что ты говоришь, не совсем правда: стандартный механизм "сделать класс несериализуемым, если у него сериализуемый родитель" - выкидывать исключение в каком-то там методе (не помню уже). Так что один из потомков может оказаться несериализуемым (я выше писал про "вероятностную сериализацию" в Java :) ).
>>Речь не идет о метаданных. Метаданные не участвуют во взаимодействии объектов. Мы оперируем в коде не аттрибутами или названиями методов, а классами, абстрактными классами или интерфейсами.
Метаданные не участвуют во взаимодействии объектов? Хм... А то, что BinaryFormatter при взаимодействии с сериализуемым объектом проверяет наличие у него атрибута Serializable - это ли не взаимодействие?
@Idsa
ОтветитьУдалитьТы путаешь теплое с мягким. Мы говорим про принцип проектирования, который влияет на дизайн системы. Аттрибуты здесь никакую роль не играют.
Возможно, действительно путаю. Однако никак не могу уловить существенной разницы между добавлением двух элементов вместо одного в твоем примере и наличием/отсутствия атрибута в моем.
ОтветитьУдалитьБолее того, если мысленно объединить эти примеры, и представить логику "Count возвращает 0, если для класса не задан атрибут [ReturnNotZeroCount]", разница между примерами еще более размывается.
Формулировка №2: подтипы должны быть заменяемы базовыми типами.
ОтветитьУдалитьПо моему наоборот.
И пример с листом мне кажется не корректным. Два класса реализуют свое поведение, наследуясь от интерфейса. Где здесь нарушение? Вот если б DoubleList наследовался от конкретного List, тогда да, а так по-моему все нормально.
ОтветитьУдалить@Андрей
ОтветитьУдалить> Формулировка №2: подтипы должны быть заменяемы базовыми типами
> По моему наоборот
Забавно.
> ...по-моему все нормально
Происходит нарушения контракта IList, этому не нормально.
А где описан контракт IList и в частности его поведение "Count должен увеличиваться на 1 при однократном успешном вызове Add и уменьшаться на 1 при однократном успешном вызове Remove) ?
ОтветитьУдалитьCount - кол-во элементов в коллекции. Делать какие либо предположения о нём глупо(как и вообще делать наивные предположения). Нужно узнать кол-во - дёрните св-ва и узнаете. И для DoubleList там будет 2 после одного вызова.
Возможно что хрупкие ментальные модели пользователей нарушатся от такого, но это неизбежно.
@Григорий Перепечко
ОтветитьУдалитьВ том, то и дело, что нигде не описано, разве что может быть в документации.
А как бы вы отнеслись к .NET фреймворку, если бы в нем был объект SetList унаследованный от IList, который добавлял в себя элементы два раза?
Думаю, что после первого знакомства с этой его "особенностью" вы бы поостереглись им пользоваться.
Но самое главное здесь в другом. Посмотрите код:
public void Method1(IList myList)
{
///
}
Метод принимает на вход IList, и вы ждете от объекта myList его "нормального" поведения, другими словами выполнения контракта интерфейса IList. Если этот контракт будет нарушен одним из наследников этот интерфейса и этого наследника передадут в метод Method1, то вам придется внутри Method1 делать даункаст, чтобы проверить подтип.
> Формулировка №2: подтипы должны быть заменяемы базовыми типами
ОтветитьУдалить> По моему наоборот
Андрея смутила русская википедия. Там действительно пурга написана:
SOLID (объектно-ориентированное программирование)
Принцип подстановки Лисков
Все хорошо, но уберите лишний мягкий знак в "поведение P не измениться"
ОтветитьУдалитьСпасибо, поправил.
ОтветитьУдалитьЧто-то я не пойму.
ОтветитьУдалитьДопустим у нас есть два класса.
1. Button
2. ImageButton
и второй это наследник первого
и в програме используется везде ImageButton
И если я заменю ImageButton везде на Button
то программа явно перестанет работать или будет работать по другому, так как у Button-a нету иконки. то есть как минимум пропадут все иконки с кнопок, либо вообще перестанет компилироваться в тех местах где мы указываем иконки в кнопке.
Дмитрий, у Button может быть еще 10-20 наследников.
ОтветитьУдалитьПринцип рекомендует использовать самый базовый класс там, где это возможно. Почему так?
Дело в том, что чем более абстрактный класс вы используете, тем меньше система знает обо всех наследниках класса Button. Почему нам надо скрывать всех наследников?
Дело в том, что:
1. наследники появляются и исчезают, из-за этого приходится менять код. Если информации о наследниках нет (в методы передаются только Button), то добавить/удалить наследника ничего не стоит.
2. допустим у вас есть метод IsButtonVisible(ImageButton button), который принимает ImageButton и проверяет видима кнопка или нет. Для проверки видимости используются только свойства базового класса. У вас появляется второй наследник класса Button, например, 3dButton, которой нужна такая же функция проверки на видимость. Что делать? Можно создать еще один метод IsButtonVisible(3dButton button). С другой стороны можно сделать универстальный метод IsButtonVisible(Button button), он будет принимать _любого_ наследника класса Button и корректно работать.
Бред сивой кобылы тут написан. Автор не понимает LSP.
ОтветитьУдалить>> Формулировка №2: подтипы должны быть заменяемы базовыми типами
ОтветитьУдалитьНе понимаю, как такое может быть, ведь у поддтипов могут быть методы, которых нет в базовом типе. В этом случае при замене более специфичного типа базовым получим ошибку компиляции, если такой метод использовался в коде программы.
А вот наоборот уже получается логично. Если есть использование объекта базового класса, то при замене его объектом подкласса, все должно попрежнему работать корректно.
Илья, это действительно так.
ОтветитьУдалитьРассмотрим ситуацию, когда у нас есть Volatile Dependency, которая закрыта абстракцией. Я думаю тут очевидно, что надо использовать абстракцию, а не реализацию.
Другая ситуация, у нас есть доменный объект и у него несколько наследников. В любом случае будет код, который использует конкретных наследников, но весь остальной код проекта не должен про них знать. Этот пример можно увидеть, если вы передадите в метод List list и внутри метода будете использовать параметр list только в foreach. Тогда Resharper предложит вам заменить List на IEnumerable, чтобы уйти как можно дальше от конкретной реализации в сторону приемлемой абстракции.
Александр, прошу прощения, я не очень понял ваш первый пример, т.к. работаю в основном с java.
ОтветитьУдалитьЧто касается второго примера, то вы привели конкретный случай того, как решарпер предлагает делать рефакторинг. Допустим, что метод, в который мы передали List, использует специфический метод этого List, которого нет в IEnumerable. Тогда очевидно, что его нельзя заменить на IEnumerable, т.к. будет ошибка компиляции. Правильно я понимаю, что этот участок кода противоречит обсуждаемому принципу Лисков?
Некорректная 2я формулировка: подтипы должны быть заменяемы базовыми типами.
ОтветитьУдалитьДолжно быть наоборот: базовые типы должны быть заменяемы подтипами без любых последствий для модуля(программы)
Это как? Просто интересно на пример посмотреть и суть такой подстановки.
ОтветитьУдалитьПеречитал первое определение. Считаю его тоже некорректным.
ОтветитьУдалитьhttps://ru.wikipedia.org/wiki/%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%91%D0%B0%D1%80%D0%B1%D0%B0%D1%80%D1%8B_%D0%9B%D0%B8%D1%81%D0%BA%D0%BE%D0%B2 :
В последующей статье[2] Лисков кратко сформулировала свой принцип следующим образом:
Пусть q(x) является свойством, верным относительно объектов x некоторого типа T . Тогда также должно быть верным q(y) для объектов y типа S , где S является подтипом типа T.
Роберт С. Мартин определил[3] этот принцип так:
Функции, которые используют базовый тип, должны иметь возможность использовать подтипы базового типа, не зная об этом.
Если мы заменим экземпляром класса Object экземпляр класса String, то -logInFile(object) выпадет в креше.
Вообще сам по себе принцип не более чем руководство к наследованию: если Вы наследуетесь от "НаземныеТранспортныеСредства" не получится ли у Вас при вызове функции "поехали" вылететь в стратосферу
Забавно, но у этого принципа есть строгое математическое доказательсво, к сожалению, вы его не поняли. Видимо статья не достаточно показывает применимость. Попробуйте поискать в других источниках.
ОтветитьУдалитьЕсли вам так нравятся определения, то посмотрите http://butunclebob.com/ArticleS.UncleBob.PrinciplesOfOod на "Derived classes must be substitutable for their base classes"
Александр, если ссылка, которую Вы кинули является для Вас авторитетной, то, пожалуйста, перечитайте еще раз. Будь там by предлог, то вы бы перевели верно, однако там for и суть меняется. Если перейти по ссылке и далее по ссылке LSP, то откроется пдфка и уже на второй странице будет тезис
ОтветитьУдалитьИлья, посмотрите мое сообщение ниже, Александр просто некорректно перевел статью.
ОтветитьУдалитьДмитрий, спасибо большое, что аргументировано поправили Александра. И спасибо, что меня упомянули, а то я уже думал, что с ума начинаю сходить :)
ОтветитьУдалитьИлья, без проблем =) Странно, что раньше никто в более оживленную полемику не вступил...
ОтветитьУдалить> Странно, что раньше никто в более оживленную полемику не вступил
ОтветитьУдалитьМожет потому что в этом нет ошибки и вы ее надумали?
> Вы кинули является для Вас авторитетной
ОтветитьУдалитьНу это Боб Мартин, который все эти принципы и систематизировал. Т.е. да, для меня эта ссылка авторитетна :)
Дмитрий, на самом деле мой перевод правильный. На всякий случай уточнил у лингвиста, не сошел ли я с ума, но не суть.
Самое главное в этом принципе, что при наследовании должны соблюдаться контракты. Более подробно рекомендую посмотреть в презентации http://www.slideshare.net/etyumentcev/solid-42457729
Александр, это уклонение от моего прежнего поста, пожалуйста, прокоментируйте: FUNCTIONS THAT USE POINTERS OR REFERENCES TO BASE CLASSES MUST BE ABLE TO USE OBJECTS OF DERIVED CLASSES WITHOUT KNOWING IT.
ОтветитьУдалитьЭто тоже самое вид сбоку, не вижу противоречий. Если бы так не было, то полиморфизм невозможно было корректно использовать.
ОтветитьУдалитьДумаю люди со знанием английского сделают свои выводы... На этом думаю нет смысла продолжать полемику. Каждый останется при своем мнении. Удачи.
ОтветитьУдалитьДмитрий, мне кажется я понял про что вы мне хотите сказать. На счет определение про "FUNCTIONS THAT USE POINTERS OR REFERENCES..." это действительно так, потому что в противном случае контракт будет нарушен и использовать такую подстановку будут небезопасно.
ОтветитьУдалитьЯ уверен, что мы с вами правильно понимаем суть этого принципа и его практическое приминение. Вся проблема в его "короткой" формулировке. Я понял, что она может взрывать мозг без контекста и пониматься двояко.
Пожалуй добавлю более длинное для начала, а корокое оставлю в английской формуляровке.
Спасибо, что обратили на это внимание, надеюсь, что никто не попал в заблуждение из-за такой интерпретации :)
Александр, рад что мы пришли хоть к какому-то консенсусу =)
ОтветитьУдалитьПрошу Вас обратить внимание также на первое определение: "если o1 заменить на o2" не корректно. Для соответствия определения принципу замещения Барбары Лисков, нужно поменять местами: "если о2 заменить на о1". Просьба, если не согласны, прочитайте свое определение (новое второе) пару раз: я уверен полемика не потребуется =)
Да, действительно. Я сам эти определения как 5 лет назад написал, так и не вдавался в них сильно. Сам читал про это всё только на английском и в голове видимо сложилось правильно, а по-русски сформулировать не так. Сейчас поправил.
ОтветитьУдалитьАлександр, рад, что донес все же информацию) Жалко, что долго пришлось дискутировать, если сейчас прочитаете мое первое и второе сообщение, то увидите, что дальнейшая беседа не имела смысла =)
ОтветитьУдалитьСудя по результату имела :) Не знаю куда на вас сослаться в интернете, поэтому написал просто имя https://twitter.com/alexanderbyndyu/status/556383999208484864 (фамилия же не склоняется?)
ОтветитьУдалить=) ну я в твиттере не сижу) если ссылку в комменте вставите https://vk.com/tcdiamond , буду признателен) может когда предложение об интересной вакансии поступит благодаря вашему посту =)
ОтветитьУдалить=)
ОтветитьУдалитьХочу отметить, что на конструкторы данное ограничение не распространяется. Подробнее - здесь:
ОтветитьУдалитьhttp://stackoverflow.com/questions/5490824/should-constructors-comply-with-the-liskov-substitution-principle
Спасибо за ссылку, посмотрел что там пишут.
ОтветитьУдалить> на конструкторы данное ограничение не распространяется
О каком ограничении идет речь?
Ну, собственно о принципе замещения Лисков. Сигнатуры конструкторов могут быть абсолютно разными.
ОтветитьУдалить