Вкратце, в этой статье речь пойдёт о правиле наследования Лисков, о различии контрактов NotifyCollectionChangedAction.Reset в версиях .NET Framework 4 и .NET Framework 4.5, и о том, какой из этих двух контрактов истинный, а какой — ошибочный.



Согласно принципу Лисков, класс-наследник должен наследовать контракт базового класса (с возможностью добавления своей специфики, не противоречащей исходному контракту).

Приведу пример. Представьте, что метод Add List-а виртуальный. Если вы создаёте наследника от List<>, то метод Add в нём должен добавлять в коллекцию ровно один элемент. Если элемент будет добавляться только при выполнении некоторого условия, или же будут добавляться элемент и его копия, то пользовательский код, который ожидает, что после вызова Add Count увеличивается ровно на единицу, станет неработоспособным. Поведение наследуемых классов должно быть ожидаемым для кода, использующего переменную базового типа.

Теперь давайте представим, что вы собрались использовать в своём коде List<>. Судя по названию Add и параметрам (один элемент), метод должен добавить один элемент в коллекцию. Вы много раз пользовались листом, и уверены, что так оно и есть. Вы можете спросить у коллеги, и он не задумываясь подтвердит, что так оно и есть. Но давайте на минуту представим, что вы заходите на msdn, смотрите документацию, а там написано, что Add просто «изменяет исходную коллекцию», т.е. делает что угодно. В таком случае будем называть тот контракт, который характерен для базового класса, и на который все полагаются, истинным, а тот, который описан в документации — формальным.

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

Пример расхождения формального и истинного контракта — NotifyCollectionChangedAction.Reset. До версии 4.5 Reset означал, что содержимое коллекции сильно изменилось. Что значит «сильно»? Для кого-то добавление трёх элементов сильное изменение, а для кого-то и нет.

В общем, Reset означал «коллекция изменилась как угодно». Начиная с версии 4.5 Reset стал означать очистку коллекции. Некоторые могут подумать, что это изменение было внесено напрасно, т.к. оно нарушает обратную совместимость, но я скажу, что ребята молодцы — они вовремя заметили, что истинный контракт расходится с формальным, и оперативно исправили свою оплошность. Используя ObservableCollection, можно встретить Reset, только если у объекта был вызван метод Clear(). Программисты, регулярно работающие с ObservableCollection, привыкли к этому и считают это нормой. «Когда может встретиться Reset?», — спросите вы их, и они, не задумываясь, ответят: «Когда был вызван Clear!». Естественно, они интуитивно считают, что это поведение, де-факто ставшее стандартом, должно сохраняться и в наследниках. Поэтому в документации должно быть сказано, что Reset — признак очистки коллекции.

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

Используя Reset, считайте, что он может означать что угодно. Наследуя ObservableCollection, считайте, что Reset означает очистку коллекции.

P.S. Если вам интересно моё мнение по поводу Reset, я считаю, что разработчикам класса ObservableCollection следует оставить контракт Reset-а в том виде, в котором он есть на сегодняшний день (признак очистки коллекции), но добавить в перечисление элемент, сигнализирующий о том, что коллекция изменилась как угодно, и который не использовался бы в оригинальном ObservableCollection. Дело в том, что единственный элемент перечисления, сигнализирующий о том, что изменилось несколько элементов коллекции — это Reset, остальные элементы перечисления сигнализируют об изменении единичного элемента. Однажды, для достижения приемлемого быстродействия, одному программисту потребовалось сначала изменить несколько элементов в коллекции, и потом послать ровно один сигнал об изменении коллекции. И у него не осталось другого выбора, кроме как сигнализировать об изменении коллекции в своём наследнике от ObservableCollection посредством Reset-а, за неимением других альтернатив.

Так что я считаю, что изменение документации решило одну проблему, но одновременно создало другую (для решения которой нужно добавить ещё один элемент в перечисление). Забавно, но иногда неиспользуемые зарезервированные элементы способны принести пользу.

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


  1. maghamed
    24.11.2015 16:56
    +8

    У вас картинка не правильная, если пост про Лисков, то картинка должна быть такой
    image


    1. FiresShadow
      24.11.2015 17:01
      +3

      Ключевое слово probably. Забавный мем.


  1. dymanoid
    24.11.2015 23:08
    +3

    Вообще-то NotifyCollectionChangedEventArgs содержит свойства NewItems и OldItems, которые могут содержать один или несколько объектов. Так что высказывание

    единственный элемент перечисления, сигнализирующий о том, что изменилось несколько элементов коллекции — это Reset
    в общем случае неверно. Add и Remove точно так же могут сигнализировать о множественном изменении. И это как раз истинный контракт, а в msdn указан формальный: якобы эти действия могут быть только для одного элемента.


    1. novar
      25.11.2015 06:21
      +2

      Но на практике в составе .Net Framework нет ни одного компонента который корректно работает с (NewItems.Count > 1) и (OldItems.Count > 1). В лучшем случает бросается исключение, в худшем — все элементы кроме первого просто игнорируются.


      1. dymanoid
        25.11.2015 22:28
        +1

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


    1. FiresShadow
      25.11.2015 06:43

      Add и Remove точно так же могут сигнализировать о множественном изменении.

      Работая с ObserverableCollection, вы на практике хотя бы раз сталкивались с таким случаем? Можете пожалуйста привести пример?
      У ObserverableCollection нет метода AddRange. Каждый раз при вызове метода Add (который принимает в качестве параметра ровно один элемент) срабатывает событие, у которого в NewItems находится всегда ровно один элемент. Так что для NotifyCollectionChangedAction.Add то что написано в msdn истинно и на практике.


      1. dymanoid
        25.11.2015 22:32
        +1

        Я легко могу унаследоваться от ObservableCollection<> и добавить туда метод AddRange и даже RemoveRange, что я на практике и делаю, если мне это нужно. Кроме того, я могу реализовать свою коллекцию (например, ObservableHashSet<>), используя интерфейс INotifyCollectionChanged. В этой реализации точно так же могут быть методы AddRange и RemoveRange. Код приводить или мысль в принципе ясна?


        1. FiresShadow
          26.11.2015 05:50
          -1

          Приведённый в статье совет про выбор из двух контрактов актуален вне зависимости от того, является ли один контракт интуитивными ожиданиями, или же оба контракта являются формальными. Повторюсь, в случае, если вы реализуете наследника, опирайтесь на наиболее конкретный и специфичный контракт среди двух.
          Формальный контракт в .NET Framework 4 говорит, что Add означает «One or more items were added to the collection.». Формальный контракт в .NET Framework 4.5 для Add — «An item was added to the collection.».
          Сделав так, как вы говорите, вы нарушите публичный формальный контракт из версии 4.5.
          Нарушение контракта, являющегося интуитивными ожиданиями — тема тонкая и холиварная, но вот нарушение формального контракта — бесспорный факт. Рекомендую при наследовании использовать контракт с наибольшими ограничениями — т.е. добавление единственного элемента, а при использовании — с наименьшими ограничениями, т.е. добавление нескольких элементов.


  1. Bonart
    25.11.2015 09:46
    +1

    Для начала, рассматриваемая часть контракта ObservableCollection определяется интерфейсом INotifyCollectionChanged
    Самая горячая часть — NotifyCollectionChangedEventArgs
    И все проблемы связаны отнюдь не с неким «реальным контрактом», а с застарелыми багами в реализации.
    Например, никто не запрещает сообщать о добавлении сразу пачки элементов… кроме кривых контролов из поставки WPF, которые в ответ на такое плюются исключениями. Как это сказывается на производительности, надеюсь, всем понятно.
    До версии 4.5, впрочем, была возможность исправить ситуацию — NotifyCollectionChangedAction.Reset, который заставлял контрол принудительно
    обновиться полностью.
    Это позволяло создавать свои реализации INotifyCollectionChanged с хорошей производительностью при интенсивных обновлениях.
    Но что мы видим в 4.5?
    Добрый вендор:

    • изменил опубликованный интерфейс (-1)
    • сломал обратную совместимость (-1)
    • обрушил производительность (-1)
    • зато легализовал творчество разработчиков, неспособных прочесть спецификацию (+100500)

    Что интересно, в русской версия MSDN для 4.5 ничего не поменялось.


    1. FiresShadow
      25.11.2015 10:47

      обрушил производительность (-1)
      зато легализовал творчество разработчиков, неспособных прочесть спецификацию (+100500)

      Согласен, я как раз писал, что
      Так что я считаю, что изменение документации решило одну проблему, но одновременно создало другую (для решения которой нужно добавить ещё один элемент в перечисление)


      И все проблемы связаны отнюдь не с неким «реальным контрактом», а с застарелыми багами в реализации.

      В любой непонятной ситуации проще всего не раздумывая обвинить во всём конечных исполнителей. Однако давайте подумаем, а почему конечные исполнители реализовали «кривые контролы из поставки WPF» именно так? Может быть они были обиженными на весь мир садистами, намеренно допустившими ошибку? Звучит бредово. А может быть их интуитивные ожидания о поведении ObservableCollection отличались от того, что написано в msdn? Звучит правдоподобно. Итак, есть проблема — интуитивные ожидания большинства разработчиков не совпадают с описанием в документации. Какие тут могут быть решения? Ну, проще всего не раздумывая возложить ответственность за решение проблемы на конечных исполнителей. Мол, ребята, вызубрите наизусть весь msdn и регулярно его повторяйте. Поможет это избавиться от багов? Я думаю, не поможет. Вариант второй — сделать так, чтобы интуитивные ожидания большинства программистов совпадали с описанием в документации. Именно так я оцениваю случай с изменениями в документации. Если есть конструктивная критика — я буду рад услышать чужое мнение. Фраза же «всё сказанное чепуха, во всём виноваты конечные исполнители, наделавшие багов» не очень похожа на конструктивную критику, потому что не раскрыта мысль, а почему именно чепуха, почему «все проблемы связаны отнюдь не с неким «реальным контрактом»».


      1. FiresShadow
        25.11.2015 12:19
        -1

        Немного поясню. В .NET Framework 4 сказано, что Add означает, что один или несколько элементов добавлены в коллекцию, а разработчики, судя по всему, руководствовались интуитивными ожиданиями, сформированными ObservableCollection, что Add означает добавление одного элемента. А если бы в документации было сказано, что Add означает добавление одного элемента, то никто бы не пытался реализовывать коллекции таким образом, что Add означает добавление нескольких элементов, и не сталкивался бы с проблемами. Ещё было бы неплохо в документации указать, что Reset означает очистку коллекции и добавить элемент, сигнализирующий о произвольном изменении коллекции. Совпадение интуитивных ожиданий и формальных требований уменьшает вероятность ошибки. В данном же случае, судя по всему, формальная документация писалась для абстрактного интерфейса, а интуитивные ожидания формировались на основании конкретного класса.


        1. Bonart
          26.11.2015 12:38

          Ещё было бы неплохо в документации указать, что Reset означает очистку коллекции и добавить элемент, сигнализирующий о произвольном изменении коллекции.

          Это еще хуже. Добавление элемента в публичный enum ломает обратную совместимость непосредственно по коду.
          А если бы в документации было сказано, что Add означает добавление одного элемента, то никто бы не пытался реализовывать коллекции таким образом, что Add означает добавление нескольких элементов, и не сталкивался бы с проблемами.

          «Интуитивные разработчики» все равно написали бы по-своему, они же документацию не читают по определению.


      1. Bonart
        26.11.2015 12:32

        В любой непонятной ситуации проще всего не раздумывая обвинить во всём конечных исполнителей

        Нет никакой непонятной ситуации и нет никаких обвинений в адрес конечных исполнителей. Есть опубликованный и документированный интерфейс, есть его реализации, есть клиенты, которые его используют.
        И то, и другое и третье может содержать ошибки. В данном случае вместо исправления ошибок в клиентах внесли дефект в давно опубликованный и документированный интерфейс.
        Однако давайте подумаем, а почему конечные исполнители реализовали «кривые контролы из поставки WPF» именно так?

        Это не имеет значения — важен результат.
        А может быть их интуитивные ожидания о поведении ObservableCollection отличались от того, что написано в msdn? Звучит правдоподобно.

        Но тоже отнюдь не комплиментарно. «Интуитивные ожидания» в данном случае синоним непрофессионализма и глупости.
        Мол, ребята, вызубрите наизусть весь msdn и регулярно его повторяйте.

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

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


        1. FiresShadow
          26.11.2015 13:51

          Это не имеет значения — важен результат.

          А если причина не важна, то как, спрашивается, учиться на чужих ошибках? Как не совершать подобных ошибок в будущем? Одна из целей моей статьи — предостеречь читателей от подобных ошибок. Если для вас всё это не имеет значения — ну что же, ваше право, можете дальше игнорировать интуитивные ожидания разработчиков, это останется на вашей совести.
          Вообще, мой вопрос был, почему вы говорите, что «все проблемы связаны отнюдь не с неким «реальным контрактом»», а в ответ я услышал лишь кучу эмоций по поводу нарушения обратной совместимости и оценок компетентности разработчиков WPF. Вы, несомненно, имеете полное право на эмоции, и спасибо что ими поделились, но обсуждение темы по существу было бы гораздо приятнее и полезнее.


        1. FiresShadow
          26.11.2015 14:16

          «Интуитивные разработчики» все равно написали бы по-своему, они же документацию не читают по определению.
          Интуитивные ожидания формируются не случайным образом, а на основании поведения конкретного класса. Я думал, что довольно подробно описал этот процесс, даже два примера привёл. Мне кажется, вы меня регулярно троллите. В прошлой статье вы меня 5 раз спросили про утечки памяти и слабые события, и я вам 5 раз ответил: [1], [2], [3], [4], [5]. В этой статье всё с ног на голову пытаетесь перевернуть.

          Ладно, если по существу, чтобы не было бы разрыва между интуитивными ожиданиями и документацией, нужно было или в классы, реализующие интерфейс, добавить методы AddRange, RemoveRange, или, повторюсь, в документации написать, что можно добавлять только по одному элементу.