Прежде, чем прочитать дополнение LSP, изучите и попробуйте применить Принцип замещения Лисков (Liskov Substitution Principle).
Недавно у меня состоялся разговор с опытным программистом, который разбирался, как программирование по контракту связано с LSP. В примере статьи я использовал интерфейс IList, создал объект DoubleList и унаследовал DoubleList от IList. Дальше, при каждом использовании DoubleList в проекте будет происходить нарушение LSP. Это можно сразу понять, если обратить внимание на контракт интерфейса IList.
Вопрос
Сейчас я более детально напишу, какие будут возникать проблемы, если не исправить ошибочное наследование. Это будет ответ на вопрос из нашего с Мурадом чата: «Не понимаю, почему тебе IDoubleList не нравится? DoubleList ведь наследуется от IList».
Пример
У нас в проекте есть интерфейс IList и два наследника: List и DoubleList. DoubleList своеобразно реализует метод Add - он добавляет переданный ему объект дважды. Это значит, когда мы добавим один элемент в DoubleList, свойство Count вернет цифру 2, а по индексу 0 и 1 вернутся два одинаковых элемента.
Рассмотрим пример, где в публичный метод SomeMethod передается абстракция IList:
public void SomeMethod(IList<string> list) { List<string> urls = urlService.GetUrls(); foreach(string url in urls) { if (SomeBoolLogic(url)) list.Add(url); } if (urls.Count > list.Count) throw Exception(); }
Результаты сравнения свойств Count (11 строка) будут отличаться в зависимости от того, какой из наследников интерфейса IList будет передан в метод SomeMethod. Если вы передадите экземпляр класса List, то элементов будет меньше, а если DoubleList, то больше (функция Add добавляет по два объекта за раз). Т.е. результат этой функции будет зависеть от конкретной реализации IList.
Программист, который привык, что эта функция работает с любым наследником IList, посидев однажды в дебагере, поймет, что иногда (когда приходит DoubleList) эта функция не работает. Программист добавит код-костыль, чтобы поправить последствия неправильного наследования DoubleList от IList:
public void SomeMethod(IList<string> list) { List<string> urls = urlService.GetUrls(); foreach(string url in urls) { if (SomeBoolLogic(url)) list.Add(url); } // начало костыля int realCount; if (list is DoubleList) realCount = list.Count / 2; else realCount = list.Count; // конец костыля if (urls.Count > realCount) throw Exception(); }
Нарушение контракта интерфейса IList привело к использованию оператора is (или as). Если какой-то разработчик создаст TripleList, унаследует его от IList, то еще больше усложнит жизнь своего проекта.
Корни проблемы лежат в непонимании принципов ООП. Вы должны свободно использовать абстракцию у себя в коде, без лишних проверок на тип. Использование операторов is и as должно быть очень веско обосновано, т.к. чаще всего они являются первыми признаками нарушения LSP.
Пример №2
Или давайте еще проще. Когда вы берете элементы по индексу, то ожидаете, что индекс будет считаться с 0. Возьмите наследника IList, сделайте индекс не с 0, а с 1 и вас возненавидит вся команда. Причем желательно, чтобы в системе был другой IList, который считает с 0.Ваши примеры
Пример с IList очень простой. Возьмите какой-нибудь интерфейс/абстрактный класс из своей системы, опиши его контракт, сделайте наследника, в котором контракт будет нарушен. После этого передайте неправильного наследника в функции, где использовалась абстракция. Вы сразу поймете, что нарушение контракта будет подталкивать вас к нарушению LSP.
Я рад, что теории вызывают сомнения и требуют дополнительных доказательств. Давайте углубляться в эти темы, чтобы всплывали интересные грани.
Да, это хороший пример.
ОтветитьУдалитьЯ обсуждал этот вопрос с коллегами. Все, и я в том числе, сказали, что DoubleList плохо. Но прийти к такому выводу формально смогли с трудом.
Да, контракт у IList в первом примере нарушен, а именно в функции int add(Object value);. Кто-нибудь так действительно делает?
ОтветитьУдалитьВедь как бы понятно, что список сам через себя же и выражается, следовательно можно просто сделать функцию IList f(IList list) { *вернуть список, где каждой элемент удвоен* }, а не заводить лишний тип. :)
Ссылка с ошибкой.
ОтветитьУдалитьНарушение контракта (да, именно контракта, а не LSP) было бы более очевидным, при использовании Design By Contract и введением соответствующих постусловий метода Add. Без явного постусловия контракт метода Add является неформальным, а значит может (и будет) трактоваться по разному разными разработчиками.
ОтветитьУдалитьБудь эти правила выражены более формально, например так:
public interface IList { void Add(object o); int Count { get; } } [ContractClassFor(typeof(IList))] internal class IListContract : IList { public void Add(object o) { Contract.Requires(o != null); Contract.Ensures(Count == Contract.OldValue(Count) + 1); } public int Count {get {throw new NotSupportedException();}} }
То тогда класс DoubleList, при включенных постусловиях, просто падал бы с исключением во время выполнения. И нарушение принципа замещения также было бы более очевидным. Кстати, именно благодаря подобной выразительности камрад Мейер и продвигает парадигму проектирования по контракту, поскольку без нее подобные ошибки допустить значительно легче.
З.Ы. Александр, я сразу не совсем понял о каком интерфейсе IList идет речь, ведь в статье явно речь идет об интерфейсе IList of T, а не просто об интерфейсе IList. ИМХО, меня это немного сбивало с толку.
Александр, кстати вот еще один пример усиления постусловия при наследовании.
ОтветитьУдалитьМногие объектно-ориентированные языки программированию (в частности, С++, Java, Eiffel, C# - нет), позволяют возвращать более конкретный тип при переопределении виртуального метода.
Так, если сигнатура метода в базовом классе возвращает object, то наследник при переопределении может вернуть string:
class Base
{
public abstract object someMethod();
}
class Derived extends Base
{
@Override
public override string someMethod() {return "";}
}
В C# это тоже нашло свое выражение, но в виде ковариантности и контравариантности делегатов и интерфейсов, но вариант с наследованием - более христоматийный, что ли.
Александр, небольшое дополнение.
ОтветитьУдалитьК сожалению (да, именно к сожалению), но класс DoubleList является совершенно законным классом, если он реализован на платформе .net.
Дело в том, что метод Add объявлен не в интерфейсе IList of T, а в интерфейсе ICollection of T и он действительно не налагает никаких ограничений на то, что должно произойти с количеством элементов коллекции (т.е. постусловия у этого метода нет, даже неформального).
Попробуйте запустить следующий пример в LINQPad-е:
ICollection hs = new HashSet();hs.Add("1");hs.Add("1");hs.Dump();ICollection ls = new List();ls.Add("1");ls.Add("1");ls.Dump();
В результате hs будет содержать 1 элемент, а ls - 2.
Т.е. разработчики самого .NET Framework-а не следуют принципу, что добавление элемента должно добавлять один элемент (т.е. постусловие вида Contract.Ensures(Count == Contract.OldValue + 1) отсутствует, а значит и реализация класса DoubleList, которая добавляет ни один элемент, а 2 - также является вполне корректной.
Кстати, я тоже против подобного поведения в методе Add, но в данном случае нельзя апеллировать к тому, что подобная реализация будет нарушать принцип замещения Лисков, мы можем лишь говорить о принципе наименьшего удивления и о том, что подобное поведение может привести к тонким проблемам при анализе кода, даже не смотря на его видимую корректность.
Ответ на ваш вопрос в разделе Ваши примеры и в коде проекта, там где встречаются операторы is и as. Если вы никогда не делаете даункасты в коде, то все отлично :)
ОтветитьУдалитьСпасибо.
ОтветитьУдалитьСергей, спасибо за дополнение. Я не сторонник явных описаний контрактов. Скажите, вы в своих проектах описываете контракты для абстракций?
ОтветитьУдалитьНа счет примера про IList, он очень простой и искусственный, единственная его цель, показать принцип.
Я сторонник разумной формализации; я не думаю, что мы можем с помощью контрактов доказать корректность ПО с помощью во время компиляции, но контракты могу дать более точную информацию о том, что должен делать тот или иной код.
ОтветитьУдалитьПросто данный пример, как раз и является характерным случаем, когда без формальной спецификации семантики метода Add можно бесконечно спорить о том, можно создавать DoubleList или нет. И мне показалось, что это не искусственный пример, а проблема, возникшая при решении реальных задач.
З.Ы. Все таки, а как насчет ICollection of T, HashSet-а и List-а? Этот же пример доказывает, что метод Add новоиспеченного класса DoubleList ничего не нарушает?
> Я сторонник разумной формализации; Т.е. в своих проектах вы не описываете контракты явным образом?
ОтветитьУдалить> Все таки, а как насчет ICollection of T, HashSet-а и List-а? Этот же пример доказывает, что метод Add новоиспеченного класса DoubleList ничего не нарушает?
Поясните, почему метод Add класса DoubleList ничего не нарушает?
А я вот не понял примера. Почему при наследовании DoubleList от IList что то нарушается? А разве код if (urls.Count > list.Count) - не костыль?
ОтветитьУдалитьДавайте порассуждаем: для чего нам нужны интерфейсы - чтобы скрыть реализацию, так? Но если мы пользуемся интерфейсом IList и ожидаем, что количество элементов в нем будет такое же, как в List - это разве не костыль? Разве тут мы не привязываемся к конкретной реализации? Пардон, но если ты в коде ждешь, что у тебя элемент не будет дублироваться, а находится в списке только 1 раз - то тогда делай интерфейс ISingleList что ли (я имею ввиду первый пример), и вот если в ISingleList элементы продублируются - тогда да, неверное наследование.
Двумя постами выше указано :)
ОтветитьУдалить> А разве код if (urls.Count > list.Count) - не костыль?
ОтветитьУдалитьНет, это бизнес-логика.
> тогда делай интерфейс ISingleList
Артем, разговор как раз про то, что для DoubleList надо сделать отдельный интерфейс (описано как решение http://blog.byndyu.ru/2009/10/blog-post_29.html). Наследование DoubleList от IList нарушает контракт интерфейса и ведет к костылям в коде.
Ага, я понял, что ты имеешь ввиду. То есть в контексте данного примера следовало бы выделить конкретные интерфейсы для типов списков (будь то сингл лист или дабл лист), потому что на этом завязана логика системы. Я просто думаю, что проблема в данном примере не только в том, отчего наследуется дабл лист, а также в том, что простой IList в коде воспринимается как сингл лист. Что я имею ввиду. Если я работаю с IList, то я ожидаю, что это что то такое, что можно перечислить, куда можно что то добавить/удалить и можно узнать количество элементов внутри. Но я не взялся бы утверждать, что если я добавлю туда 1 элемент, то количество элементов увеличится на 1. А в первом примере на этом завязана логика. На самом деле интерфейс IList никаким макаром клиента не предупреждает, что количество элементов в нем будет равно количеству добавлений. Из твоего примера видно, к чему приводит нарушение принципа. Но я не назвал бы пример удачным. Хотя сам подобрать удачный пример затрудняюсь.
ОтветитьУдалитьСпасибо, перечитал.
ОтветитьУдалить> Этот же пример доказывает, что метод Add новоиспеченного класса DoubleList ничего не нарушает?
Если в коде, где используется функция Add, будет передаваться ICollection, а приходить и HashSet, и List, то можно понять, что наследники не будут заменяемы своими базовыми типами. Обязательно появятся даункасты.
Спасибо, Артем. Если я найду хороший пример из кода, то опубликую Дополнение №2 :)
ОтветитьУдалитьЯ надеюсь, что после этой статьи у программистов где-то в подсознании отложилась нелюбовь к IS, AS и даункастам и понимание к чему это приведет в коде.
По-моему, это будет только если этот код будет опираться на фиксированную деталь реализации определенного типа коллекции
ОтветитьУдалитьТакое понимание есть и без LSP
ОтветитьУдалитьПринцип нужен для формализации этого понимания, чтобы новичкам не приходилось познавать всё с нуля. Ну и с LSP бок о бок идет программирование по контракту.
ОтветитьУдалитьПо-моему, вы начали спорить, не договорившись о терминах. Можно понимать "интерфейс" в терминах ЯП (обычно - перечисление сигнатур), а можно в терминах и условиях предметной области (в том числе пост- и предусловия, и инварианты). Увы, но у популярных языков нет средств выражать условия предметной области кроме как типизацией и проверкой в рантайме, а у некоторых и типизации нет, в лучшем случае type hinting. И хорошо, если эту проверку можно увидеть не залезая в исходники класса, а, хотя бы, средствами IDE, то есть статическими методами.
ОтветитьУдалить
ОтветитьУдалитьИзвините, можно я подведу итог из того что я понял, а Вы скажете правильно это или нет?
Значит, суть принципа заключается в том, что классы, наследуемые от базового класса, должны вести себя как базовый класс, чтобы в любой момент в программе можно было заменить объекты наследуемых классов базовым (вопрос 1: из этого следует также взаимозаменяемость наследуемых классов между собой?), так?
То есть, на примере системы логирования, если у класса BasicLogger есть метод log, который сохраняет текстовые данные в файл и возвращает true или false, и от него наследуется DBLogger, сохраняющий текстовые данные в некой базе данных, то метод log класса DBLogger:
A) может принимать любые данные, но обязательно текстовые в их числе - чтобы соответствовать поведению BasicLogger;
B) не должен требовать других аргументов кроме сохраняемых данных (вопрос 2: как в таком случае поступать если для этого логгера, ну например, нужно указывать таблицу в базе?);
C) может делать любые операции по своему усмотрению (в данном случае - сохранение в базу данных вместо файла), но возвращать true или false, как и базовый класс.
Поправьте пожалуйста если я где-то ошибаюсь. Я только постигаю эти принципы. Спасибо.
Віталій, спасибо за вопросы, давайте разберем ваш пример.
ОтветитьУдалить> вопрос 1: из этого следует также взаимозаменяемость наследуемых классов между собой?
Нет, не следует. Наследники могут делать разные вещи, не факт, что они взаимозаменяемы. Здесь главное - сохранение гибкости системы. Если мы в классах работаем не с базовым классом, а наследниками - это делает систему хрупкой.
Причина простая - вся система начинает знать про иерархию наследования, что делает трудным рефакторинг и изменение логики.
Если же вся система знает только про базовый класс (возможно интерфейс или абстрактный класс), то это делает систему гибкой. Например, мы легко можем выбросить все реализации абстракции и заменить другими реализациями или подключить сторонние реализации.
> A) может принимать любые данные, но обязательно текстовые в их числе - чтобы соответствовать поведению BasicLogger;
Судя по описанию - да :) Наследник должен выполнять контакт базового класса.
> B) не должен требовать других аргументов кроме сохраняемых данных (вопрос 2: как в таком случае поступать если для этого логгера, ну например, нужно указывать таблицу в базе?);
Если нужно указывать таблицу не извне, то это можно делать в конфигурации и внутри реализации логгера опрашивать файл конфигурации.
Если название таблицы надо указывать извне (а у нас могут быть еще и другие логгеры, которые требуют доп. конфигурации, то имеет смыл сделать еще один метот в логгере:
inerfrace ILogger
{
bool Log(string message);
bool Log(string message, ILogConfiguration configuration);
}
Такой интерфейс покроет нужды всех логгеров текущих и будущих в системе. Реализация конфигурации может быть разная, может создаваться через IoC на основе конвенции завязанной на типе логгера, может создаваться через фабрику при вызове логгера, может передаваться в логгер при его инициализации. В общем, вариантов много.
> C) может делать любые операции по своему усмотрению (в данном случае - сохранение в базу данных вместо файла), но возвращать true или false, как и базовый класс.
Да.
Интерфейс он всегда в голове. Это не только набор паблик методов, но и контракт поведения: собственного и во взаимодействии с другими объектами и явлениями, с учётом разнообразных факторов навроде последовательности, времени и эм... температуры окружающей среды например :).
ОтветитьУдалитьТех искуственных ограничений кои представляют из себя реализации ООП в современных языках программирования недостаточно даже близко. Они позволяют в высшей степени схематично обрисовать модель: её понимание и правильное использование всегда ложится на плечи разработчика.
Александр, можно ли сказать , что в этом примере нарушен LSP?
ОтветитьУдалитьpublic class DeviceMessagesParser : IParser
{
public DeviceMessage[] Parse(string text)
{
//some parsing
return new DeviceMessage[0];
}
}
public interface IParser
{
T Parse(string text);
}
public class DeviceMessage
{
public DateTime MessageTime { get; set; }
}
Формально всё правильно, т.к. мы возвращаем IEnumerable, но Parse реализует нетипичное поведение для списков, возвращая 1 элемент, с другой стороны, IParser - наш собственный интерфейс и для него это может быть типичное поведение.
Спасибо :)
ОтветитьУдалитьЯ зашел проверить Ваш комментарий спустя месяц, успев за это время много чего еще прочитать и теперь для меня все намного яснее, а мой вопрос уже кажется глупым.
Но в любом случае, спасибо за ответ.
Коллеги, я один не понимаю, почему
ОтветитьУдалитьrealCount != list.Count ?
И обоих случая количество элементов в списке =list.Count
Другое дело, что количество уникальных элементов в списке не равно, так оно и не должно быть не равно ?
Александр, представьте себя пользователем интерфейса IList. Для вас имеет значение Count возращает число уникальных элементов или число всех элементов? Логично было ожидать такое поведение от свойства UniqueCount, но его в данном случае нет.
ОтветитьУдалитьВы добавляете элемент в IList, а свойство Count изменяется непредсказуемым образом. А что если оно уменьшилось на 4? Или увеличилось в 2 раза?
Есть хорошая цитата из Эванса: "Интерфейсы объектов не накладывают никаких ограничений на побочные эффекты, два подкласса, реализующих один и тот же интерфейс, могут иметь разные побочные эффекты". Поэтому без конкретных ограничений, которые реализованы в самом языке, программисты могут делать с наследованием что захотят. Можно надеятся только на их здравый смысл и следование LSP. Иначе, придется проверять абстракции на конкретный тип, тогда к чему нам ООП?