Дополнение к LSP

26 февраля 2012 г.

Прежде, чем прочитать дополнение 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.

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

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

  1. Да, это хороший пример.
    Я обсуждал этот вопрос с коллегами. Все, и я в том числе, сказали, что DoubleList плохо. Но прийти к такому выводу формально смогли с трудом.

    ОтветитьУдалить
  2. Да, контракт у IList  в первом примере нарушен, а именно в функции int add(Object value);. Кто-нибудь так действительно делает? 
    Ведь как бы понятно, что список сам через себя же и выражается, следовательно можно просто сделать функцию IList f(IList list) { *вернуть список, где каждой элемент удвоен* }, а не заводить лишний тип. :)

    ОтветитьУдалить
  3. Ссылка с ошибкой.

    ОтветитьУдалить
  4. Нарушение контракта (да, именно контракта, а не 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. ИМХО, меня это немного сбивало с толку.

    ОтветитьУдалить
  5. Александр, кстати вот еще один пример усиления постусловия при наследовании.

    Многие объектно-ориентированные языки программированию (в частности, С++, Java, Eiffel, C# - нет), позволяют возвращать более конкретный тип при переопределении виртуального метода.

    Так, если сигнатура метода в базовом классе возвращает object, то наследник при переопределении может вернуть string:

    class Base
    {
        public abstract object someMethod();
    }

    class Derived extends Base
    {
       @Override
       public override string someMethod() {return "";}
    }

    В C# это тоже нашло свое выражение, но в виде ковариантности и контравариантности делегатов и интерфейсов, но вариант с наследованием - более христоматийный, что ли.

    ОтветитьУдалить
  6. Александр, небольшое дополнение. 

    К сожалению (да, именно к сожалению), но класс 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, но в данном случае нельзя апеллировать к тому, что подобная реализация будет нарушать принцип замещения Лисков, мы можем лишь говорить о принципе наименьшего удивления и о том, что подобное поведение может привести к тонким проблемам при анализе кода, даже не смотря на его видимую корректность.

    ОтветитьУдалить
  7. Ответ на ваш вопрос в разделе Ваши примеры и в коде проекта, там где встречаются операторы is и as. Если вы никогда не делаете даункасты в коде, то все отлично :)

    ОтветитьУдалить
  8. Сергей, спасибо за дополнение. Я не сторонник явных описаний контрактов. Скажите, вы в своих проектах описываете контракты для абстракций?

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

    ОтветитьУдалить
  9. Я сторонник разумной формализации; я не думаю, что мы можем с помощью контрактов доказать корректность ПО с помощью во время компиляции, но контракты могу дать более точную информацию о том, что должен делать тот или иной код.

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

    З.Ы. Все таки, а как насчет ICollection of T, HashSet-а и List-а? Этот же пример доказывает, что метод Add новоиспеченного класса DoubleList ничего не нарушает?

    ОтветитьУдалить
  10. > Я сторонник разумной формализации; Т.е. в своих проектах вы не описываете контракты явным образом?

    > Все таки, а как насчет ICollection of T, HashSet-а и List-а? Этот же пример доказывает, что метод Add новоиспеченного класса DoubleList ничего не нарушает? 

    Поясните, почему метод Add класса DoubleList ничего не нарушает?

    ОтветитьУдалить
  11. Артём Мурадов27 февраля 2012 г. в 12:34

    А я вот не понял примера. Почему при наследовании DoubleList от IList что то нарушается? А разве код if (urls.Count > list.Count) - не костыль?
    Давайте порассуждаем: для чего нам нужны интерфейсы - чтобы скрыть реализацию, так? Но если мы пользуемся интерфейсом IList и ожидаем, что количество элементов в нем будет такое же, как в List - это разве не костыль? Разве тут мы не привязываемся к конкретной реализации? Пардон, но если ты в коде ждешь, что у тебя элемент не будет дублироваться, а находится в списке только 1 раз - то тогда делай интерфейс ISingleList что ли (я имею ввиду первый пример), и вот если в ISingleList элементы продублируются - тогда да, неверное наследование.

    ОтветитьУдалить
  12. Двумя постами выше указано :)

    ОтветитьУдалить
  13. > А разве код if (urls.Count > list.Count) - не костыль? 

    Нет, это бизнес-логика.

    > тогда делай интерфейс ISingleList 

    Артем, разговор как раз про то, что для DoubleList надо сделать отдельный интерфейс (описано как решение http://blog.byndyu.ru/2009/10/blog-post_29.html). Наследование DoubleList от IList нарушает контракт интерфейса и ведет к костылям в коде.

    ОтветитьУдалить
  14. Артём Мурадов27 февраля 2012 г. в 13:46

     Ага, я понял, что ты имеешь ввиду. То есть в контексте данного примера следовало бы выделить конкретные интерфейсы для типов списков (будь то сингл лист или дабл лист), потому что на этом завязана логика системы. Я просто думаю, что проблема в данном примере не только в том, отчего наследуется дабл лист, а также в том, что простой IList в коде воспринимается как сингл лист. Что я имею ввиду. Если я работаю с IList, то я ожидаю, что это что то такое, что можно перечислить, куда можно что то добавить/удалить и можно узнать количество элементов внутри. Но я не взялся бы утверждать, что если я добавлю туда 1 элемент, то количество элементов увеличится на 1. А в первом примере на этом завязана логика. На самом деле интерфейс IList никаким макаром клиента не предупреждает, что количество элементов в нем будет равно количеству добавлений. Из твоего примера видно, к чему приводит нарушение принципа. Но я не назвал бы пример удачным. Хотя сам подобрать удачный пример затрудняюсь.

    ОтветитьУдалить
  15. Спасибо, перечитал.

    > Этот же пример доказывает, что метод Add новоиспеченного класса DoubleList ничего не нарушает?

    Если в коде, где используется функция Add, будет передаваться ICollection, а приходить и HashSet, и List, то можно понять, что наследники не будут заменяемы своими базовыми типами. Обязательно появятся даункасты.

    ОтветитьУдалить
  16. Спасибо, Артем. Если я найду хороший пример из кода, то опубликую Дополнение №2 :) 

    Я надеюсь, что после этой статьи у программистов где-то в подсознании отложилась нелюбовь к IS, AS и даункастам и понимание к чему это приведет в коде.

    ОтветитьУдалить
  17. По-моему, это будет только если этот код будет опираться на фиксированную деталь реализации определенного типа коллекции

    ОтветитьУдалить
  18. Такое понимание есть и без LSP

    ОтветитьУдалить
  19. Принцип нужен для формализации этого понимания, чтобы новичкам не приходилось познавать всё с нуля. Ну и с LSP бок о бок идет программирование по контракту.

    ОтветитьУдалить
  20. Владимир Чернышев29 февраля 2012 г. в 05:17

    По-моему, вы начали спорить, не договорившись о терминах. Можно понимать "интерфейс" в терминах ЯП (обычно - перечисление сигнатур), а можно в терминах и условиях предметной области (в том числе пост- и предусловия, и инварианты). Увы, но у популярных языков нет средств  выражать условия предметной области кроме как типизацией и проверкой в рантайме, а у некоторых и типизации нет, в лучшем случае type hinting. И хорошо, если эту проверку можно увидеть не залезая в исходники класса, а, хотя бы, средствами IDE, то есть статическими методами.

    ОтветитьУдалить


  21. Извините, можно я подведу итог из того что я понял, а Вы скажете правильно это или нет?

    Значит, суть принципа заключается в том, что классы, наследуемые от базового класса, должны вести себя как базовый класс, чтобы в любой момент в программе можно было заменить объекты наследуемых классов базовым (вопрос 1: из этого следует также взаимозаменяемость наследуемых классов между собой?), так?

    То есть, на примере системы логирования, если у класса BasicLogger есть метод log, который сохраняет текстовые данные в файл и возвращает true или false, и от него наследуется DBLogger, сохраняющий текстовые данные в некой базе данных, то метод log класса DBLogger:
    A) может принимать любые данные, но обязательно текстовые в их числе - чтобы соответствовать поведению BasicLogger;
    B) не должен требовать других аргументов кроме сохраняемых данных (вопрос 2: как в таком случае поступать если для этого логгера, ну например, нужно указывать таблицу в базе?);
    C) может делать любые операции по своему усмотрению (в данном случае - сохранение в базу данных вместо файла), но возвращать true или false, как и базовый класс.

    Поправьте пожалуйста если я где-то ошибаюсь. Я только постигаю эти принципы. Спасибо.

    ОтветитьУдалить
  22. Віталій, спасибо за вопросы, давайте разберем ваш пример.

    > вопрос 1: из этого следует также взаимозаменяемость наследуемых классов между собой?

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

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

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

    > A) может принимать любые данные, но обязательно текстовые в их числе - чтобы соответствовать поведению BasicLogger;

    Судя по описанию - да :) Наследник должен выполнять контакт базового класса.

    > B) не должен требовать других аргументов кроме сохраняемых данных (вопрос 2: как в таком случае поступать если для этого логгера, ну например, нужно указывать таблицу в базе?);

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

    Если название таблицы надо указывать извне (а у нас могут быть еще и другие логгеры, которые требуют доп. конфигурации, то имеет смыл сделать еще один метот в логгере:

    inerfrace ILogger
    {
    bool Log(string message);
    bool Log(string message, ILogConfiguration configuration);
    }

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

    > C) может делать любые операции по своему усмотрению (в данном случае - сохранение в базу данных вместо файла), но возвращать true или false, как и базовый класс.



    Да.

    ОтветитьУдалить
  23. Интерфейс он всегда в голове. Это не только набор паблик методов, но и контракт поведения: собственного и во взаимодействии с другими объектами и явлениями, с учётом разнообразных факторов навроде последовательности, времени и эм... температуры окружающей среды например :).
    Тех искуственных ограничений кои представляют из себя реализации ООП в современных языках программирования недостаточно даже близко. Они позволяют в высшей степени схематично обрисовать модель: её понимание и правильное использование всегда ложится на плечи разработчика.

    ОтветитьУдалить
  24. Александр, можно ли сказать , что в этом примере нарушен 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 - наш собственный интерфейс и для него это может быть типичное поведение.

    ОтветитьУдалить
  25. Спасибо :)
    Я зашел проверить Ваш комментарий спустя месяц, успев за это время много чего еще прочитать и теперь для меня все намного яснее, а мой вопрос уже кажется глупым.

    Но в любом случае, спасибо за ответ.

    ОтветитьУдалить
  26. Александр Павлыш24 декабря 2014 г. в 10:52

    Коллеги, я один не понимаю, почему
    realCount != list.Count ?
    И обоих случая количество элементов в списке =list.Count
    Другое дело, что количество уникальных элементов в списке не равно, так оно и не должно быть не равно ?

    ОтветитьУдалить
  27. Александр, представьте себя пользователем интерфейса IList. Для вас имеет значение Count возращает число уникальных элементов или число всех элементов? Логично было ожидать такое поведение от свойства UniqueCount, но его в данном случае нет.


    Вы добавляете элемент в IList, а свойство Count изменяется непредсказуемым образом. А что если оно уменьшилось на 4? Или увеличилось в 2 раза?


    Есть хорошая цитата из Эванса: "Интерфейсы объектов не накладывают никаких ограничений на побочные эффекты, два подкласса, реализующих один и тот же интерфейс, могут иметь разные побочные эффекты". Поэтому без конкретных ограничений, которые реализованы в самом языке, программисты могут делать с наследованием что захотят. Можно надеятся только на их здравый смысл и следование LSP. Иначе, придется проверять абстракции на конкретный тип, тогда к чему нам ООП?

    ОтветитьУдалить

Моя книга «Антихрупкость в IT»

Как достигать результатов в IT-проектах в условиях неопределённости. Подробнее...