Обережно, справжні контракти класів можуть відрізнятися від формальних

Обережно, справжні контракти класів можуть відрізнятися від формальних +8

  • 24.11.15 1:35 •
  • FiresShadow •
  • # 271533 •
  • Хабрахабр •
  • 16 •
  • 7600

- такий же як Forbes, тільки краще.

Якщо коротко, в цій статті мова піде про правило успадкування Лісков, про відмінність контрактів 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-а, за браком інших альтернатив.

Так що я вважаю, що зміна документації вирішило одну проблему, але одночасно створило іншу (для вирішення якої потрібно додати ще один елемент в перерахування). Забавно, але іноді використовуються зарезервовані елементи здатні принести користь.

Взагалі-то NotifyCollectionChangedEventArgs містить властивості NewItems і OldItems, які можуть містити один або кілька об'єктів. Так що вислів єдиний елемент перерахування, що сигналізує про те, що змінилося кілька елементів колекції - це Reset в загальному випадку невірно. Add і Remove точно так само можуть сигналізувати про множині зміні. І це як раз істинний контракт, а в msdn вказано формальний: нібито ці дії можуть бути тільки для одного елемента.

Але на практиці в складі .Net Framework немає жодного компонента який коректно працює з (NewItems.Count> 1) і (OldItems.Count> 1). У кращому случає кидається виняток, в гіршому - все елементи крім першого просто ігноруються.

Це не означає, що цей функціонал я не можу використовувати в своїх компонентах, чи не так? Якщо вже ці властивості є і вони коректно заповнюються при створенні екземпляра класу NotifyCollectionChangedEventArgs, то така можливість явно передбачалася.

Add і Remove точно так само можуть сигналізувати про множині зміні.
Працюючи з ObserverableCollection, ви на практиці хоча б раз стикалися з таким випадком? Можете будь ласка привести приклад?
У ObserverableCollection немає методу AddRange. Кожен раз при виклику методу Add (який приймає як параметр рівно один елемент) спрацьовує подія, у якого в NewItems знаходиться завжди рівно один елемент. Так що для NotifyCollectionChangedAction.Add то що написано в msdn істинно і на практиці.

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

Наведений в статті рада про вибір з двох контрактів актуальний незалежно від того, чи є один контракт інтуїтивними очікуваннями, або ж обидва контракти є формальними. Повторюся, в разі, якщо ви реалізуєте спадкоємця, спирайтеся на найбільш конкретний і специфічний контракт серед двох.
Формальний контракт в .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)
зате легалізував творчість розробників, які можуть прочитати специфікацію (+100500)
Згоден, я як раз писав, що
Так що я вважаю, що зміна документації вирішило одну проблему, але одночасно створило іншу (для вирішення якої потрібно додати ще один елемент в перерахування)

І всі проблеми пов'язані аж ніяк не з якимось «справжнім договором», а із застарілими багами в реалізації.
У будь-якій незрозумілій ситуації найпростіше не роздумуючи звинуватити у всьому кінцевих виконавців. Однак давайте подумаємо, а чому кінцеві виконавці реалізували «криві контроли з поставки WPF» саме так? Може бути вони були скривдженими на весь світ садистами, навмисно допустили помилку? Звучить бредово. А може бути їх інтуїтивні очікування щодо поведінки ObservableCollection відрізнялися від того, що написано в msdn. Звучить правдоподібно. Отже, є проблема - інтуїтивні очікування більшості розробників не збігаються з описом в документації. Які тут можуть бути рішення? Ну, простіше за все не роздумуючи покласти відповідальність за вирішення проблеми на кінцевих виконавців. Мовляв, хлопці, визубрити напам'ять весь msdn і регулярно його повторюйте. Допоможе це позбутися від багів? Я думаю, не допоможе. Варіант другий - зробити так, щоб інтуїтивні очікування більшості програмістів збігалися з описом в документації. Саме так я оцінюю випадок зі змінами в документації. Якщо є конструктивна критика - я буду радий почути чужу думку. Фраза ж «все сказане нісенітниця, в усьому винні кінцеві виконавці, котрі наробили багів" не дуже схожа на конструктивну критику, тому що не розкрита думка, а чому саме нісенітниця, чому «всі проблеми пов'язані аж ніяк не з якимось« справжнім договором »».

Трохи поясню. В .NET Framework 4 сказано, що Add означає, що один або кілька елементів додані в колекцію, а розробники, судячи з усього, керувалися інтуїтивними очікуваннями, сформованими ObservableCollection, що Add означає додавання одного елемента. А якби в документації було сказано, що Add означає додавання одного елемента, то ніхто б не намагався реалізовувати колекції таким чином, що Add означає додавання кількох елементів, і не стикався б з проблемами. Ще було б непогано в документації вказати, що Reset означає очищення колекції і додати елемент, який сигналізує про довільній зміні колекції. Збіг інтуїтивних очікувань і формальних вимог зменшує ймовірність помилки. В даному ж випадку, судячи з усього, формальна документація писалася для абстрактного інтерфейсу, а інтуїтивні очікування формувалися на підставі конкретного класу.

Ще було б непогано в документації вказати, що Reset означає очищення колекції і додати елемент, який сигналізує про довільній зміні колекції.
Це ще гірше. Додавання елемента в публічний enum ламає зворотну сумісність безпосередньо за кодом.
А якби в документації було сказано, що Add означає додавання одного елемента, то ніхто б не намагався реалізовувати колекції таким чином, що Add означає додавання кількох елементів, і не стикався б з проблемами.
«Інтуїтивні розробники» все одно написали б по-своєму, вони ж документацію не читають по визначенню.

Це не має значення - важливий результат.
А якщо причина не важлива, то як, питається, вчитися на чужих помилках? Як не робити подібних помилок в майбутньому? Одна з цілей моєї статті - застерегти читачів від подібних помилок. Якщо для вас все це не має значення - ну що ж, ваше право, можете далі ігнорувати інтуїтивні очікування розробників, це залишиться на вашій совісті.
Взагалі, мій питання було, чому ви говорите, що «всі проблеми пов'язані аж ніяк не з якимось« справжнім договором »», а у відповідь я почув лише купу емоцій з приводу порушення зворотної сумісності і оцінок компетентності розробників WPF. Ви, безсумнівно, маєте повне право на емоції, і спасибі що ними поділилися, але обговорення теми по суті було б набагато приємніше і корисніше.

«Інтуїтивні розробники» все одно написали б по-своєму, вони ж документацію не читають по визначенню. Інтуїтивні очікування формуються не випадковим чином, а на підставі поведінки конкретного класу. Я думав, що досить докладно описав цей процес, навіть два приклади навів. Мені здається, ви мене регулярно троль. У минулій статті ви мене 5 разів запитали про витоку пам'яті і слабкі події, і я вам 5 разів відповів: [1]. [2]. [3]. [4]. [5]. У цій статті все з ніг на голову намагаєтеся перевернути.

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