Чому клас повинен бути чимось іншим, як "абстрактним" або "остаточним / запечатаним"?


29

Після 10+ років програмування java / c # я можу створити:

  • абстрактні класи : контракт, який не повинен бути поміркований таким, який є.
  • заключні / запечатані класи : реалізація не повинна служити базовим класом для чогось іншого.

Я не можу придумати жодної ситуації, коли простий "клас" (тобто ні абстрактний, ні остаточний / запечатаний) був би "мудрим програмуванням".

Чому клас повинен бути чимось окрім "абстрактного" чи "остаточного / запечатаного"?

EDIT

Ця чудова стаття пояснює мої проблеми набагато краще, ніж я можу.


21
Тому що його називають Open/Closed principle, а неClosed Principle.
StuperUser

2
Про які речі ви пишете професійно? Це може дуже добре вплинути на вашу думку з цього приводу.

Ну, я знаю деяких платформних розробників, які все запечатують, бо хочуть мінімізувати інтерфейс успадкування.
К ..

4
@StuperUser: Це не причина, це виправданість. ОП не запитує, що таке вигідність, він запитує, чому . Якщо за принципом немає причини, немає причин звертати на нього увагу.
Майкл Шоу

1
Цікаво, скільки фреймворків інтерфейсу порушиться, змінивши клас Window на герметичний.
Реакційний

Відповіді:


46

Як не дивно, я вважаю навпаки: використання абстрактних класів - це виняток, а не правило, і я, як правило, нахмурився на підсумкові / запечатані класи.

Інтерфейси є більш типовим механізмом проектування за контрактом, оскільки ви не вказуєте жодних внутрішніх справ - ви не переживаєте за них. Це дозволяє кожному виконанню цього договору бути незалежним. Це важливо в багатьох областях. Наприклад, якщо ви створювали ORM, було б дуже важливо, щоб ви могли передати запит до бази даних рівномірно, але реалізація може бути зовсім іншою. Якщо ви використовуєте для цього абстрактні класи, ви закінчуєте проводку в компонентах, які можуть або не стосуються всіх реалізацій.

Для підсумкових / запечатаних класів єдиний привід, який я коли-небудь бачу за їх використання, - це коли насправді небезпечно дозволити переосмислення - можливо, алгоритм шифрування чи щось таке. Крім цього, ви ніколи не знаєте, коли ви можете розширити свою функціональність з локальних причин. Герметизація класу обмежує ваші варіанти отримання вигод, яких у більшості сценаріїв немає. Набагато гнучкіше писати свої заняття таким чином, щоб їх можна було продовжити згодом вниз.

Цей останній погляд був зв'язаний для мене завдяки роботі з сторонніми компонентами, які запечатали класи, тим самим запобігаючи деякій інтеграції, яка б значно полегшила життя.


17
+1 за останній абзац. Я часто знаходив занадто багато інкапсуляції в сторонніх бібліотеках (або навіть стандартних бібліотечних класах!), Щоб бути більшою причиною болю, ніж занадто мало інкапсуляції.
Мейсон Уілер

5
Звичайний аргумент для класів із запечатуванням полягає в тому, що ви не можете передбачити безліч різних способів, якими клієнт міг би перекрити ваш клас, і тому ви не можете давати жодних гарантій щодо його поведінки. Дивіться blogs.msdn.com/b/ericlippert/archive/2004/01/22/…
Роберт Харві

12
@RobertHarvey Я знайомий з аргументом, і це звучить чудово в теорії. Ви як дизайнер об’єктів не можете передбачити, як люди можуть розширити ваші заняття - саме тому їх не слід закривати печаткою. Ви не можете все підтримати - добре. Не варто. Але також не знімайте варіанти.
Майкл

6
@RobertHarvey Чи не просто люди, які ламають LSP, отримують те, що заслуговують?
StuperUser

2
Я також не є великим прихильником закритих занять, але я міг зрозуміти, чому деякі компанії, як Microsoft (яким часто доводиться підтримувати речі, які вони не зламали), вважають їх привабливими. Користувачі фреймворку не обов'язково повинні мати знання про внутрішній клас класу.
Роберт Харві

11

Те є велика стаття Ерік Ліпперт, але я не думаю , що вона підтримує вашу точку зору.

Він стверджує, що всі класи, що піддаються використанню іншими, повинні бути опечатані або іншим чином зроблені нерозширюваними.

Ваша передумова - всі класи повинні бути абстрактними або скріпленими печаткою.

Велика різниця.

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


Але ви можете застосувати той же аргумент до класів, які не є частиною загальнодоступного інтерфейсу. Однією з основних причин зробити остаточний клас є те, що він виступає в якості запобіжного заходу для запобігання неохайному коду. Цей аргумент так само справедливий для будь-якої кодової бази, якою поділяються різні розробники з часом, або навіть якщо ви єдиний розробник. Він просто захищає семантику коду.
DPM

Я думаю, що ідея про те, що класи мають бути абстрактними або запечатаними, є частиною більш широкого принципу, який полягає в тому, що слід уникати використання змінних типів миттєвих класів. Таке уникнення дозволить створити тип, який може бути використаний споживачами певного типу, не маючи при цьому успадковувати всіх його приватних членів. На жаль, такі конструкції насправді не працюють із синтаксисом public-конструктора. Натомість код повинен був би замінити new List<Foo>()чимось подібним List<Foo>.CreateMutable().
supercat

5

З точки зору Java, я вважаю, що підсумкові заняття не такі розумні, як здається.

Багато інструментів (особливо AOP, JPA тощо) працюють із тимчасовим відхиленням завантаження, тому їм доведеться продовжувати ваші пропозиції. Інший спосіб - створити делегати (а не .NET), делегуючи рекламу все для початкового класу, який був би набагато бруднішим, ніж розширення класів користувачів.


5

Два поширених випадки, коли вам знадобиться ванільна, незапечатана класи:

  1. Технічний: Якщо у вас є ієрархія, глибша на два рівні , і ви хочете мати можливість щось інстанціювати посередині.

  2. Принцип: Іноді бажано писати класи, які мають чіткість, призначені для безпечного розширення . (Це трапляється багато, коли ви пишете такий API, як Ерік Ліпперт, або коли ви працюєте в команді над великим проектом). Іноді хочеться написати клас, який добре працює самостійно, але він розроблений з урахуванням розширюваності.

Думки Еріки Ліпперта на ущільнювальному має сенс, але він також визнає , що вони роблять дизайн для розширюваності, залишивши клас «відкритим».

Так, багато класів запечатано в BCL, але величезна кількість класів не є, і їх можна продовжити всілякими чудовими способами. Один із прикладів, що спадає на думку, - це у Windows Forms, де ви можете додавати дані або поведінку практично будь-яким Controlшляхом спадкування. Звичайно, це можна було зробити і іншими способами (візерунок декораторів, різні типи композиції тощо), але спадкування теж дуже добре працює.

Дві конкретні нотатки .NET:

  1. У більшості випадків класи герметизації часто не є критичними для безпеки, тому що спадкоємці не можуть возитися з вашою нефункціональністю virtual, за винятком явних реалізацій інтерфейсу.
  2. Іноді підходящою альтернативою є створення Constructor internalзамість герметизації класу, що дозволяє йому успадковуватись у вашій кодовій базі даних, але не поза нею.

4

Я вважаю, що справжньою причиною, по якій багато людей вважають, що заняття повинні бути final/ sealedє те, що більшість неабразованих розширюваних класів не є належним чином задокументованими .

Дозвольте мені детальніше. Починаючи здалеку, серед деяких програмістів існує думка, що спадкування як інструмент в ООП широко зловживається та зловживається. Всі ми читали принцип заміни Ліскова, хоча це не заважало нам порушувати його сотні (можливо, навіть тисячі) разів.

Річ у тому, що програмісти люблять повторно використовувати код. Навіть коли це не така гарна ідея. І успадкування є ключовим інструментом у цьому "повторному перезапуску" коду. Повернутися до остаточного / запечатаного питання.

Належна документація для остаточного / запечатаного класу порівняно невелика: ви описуєте, що робить кожен метод, які аргументи, повернене значення тощо. Всі звичні речі

Однак, коли ви правильно документуєте клас, що розширюється, ви повинні включити принаймні наступне :

  • Залежності між методами (який метод викликає, який і т.д.)
  • Залежності від локальних змінних
  • Внутрішні контракти, які повинен виконувати клас, що розширюється
  • Конвенція виклику для кожного методу (наприклад, коли ви переосмислите його, ви викликаєте superреалізацію? Ви називаєте це на початку методу чи в кінці? Подумайте конструктор проти деструктора)
  • ...

Це просто з моєї голови. І я можу навести вам приклад того, чому кожне з них є важливим, і, якщо пропустити його, це призведе до розширення класу.

А тепер подумайте, скільки зусиль для документування повинно бути належним чином для документування кожної з цих речей. Я вважаю, що клас із 7-8 методами (що в ідеалізованому світі може бути занадто багато, але в реальному занадто мало) може також мати документацію про 5 сторінок, лише для тексту. Отже, замість цього ми витягуємося на півдорозі і не запечатуємо клас, щоб інші користувачі могли ним користуватися, але і не документувати його належним чином, оскільки це займе величезну кількість часу (і, знаєте, це може знадобитися ніколи не продовжувати все одно, так навіщо турбуватися?).

Якщо ви проектуєте клас, ви можете відчути спокусу запечатати його, щоб люди не могли його використовувати таким чином, який ви не передбачили (і підготували до нього). З іншого боку, коли ви використовуєте чужий код, іноді з загальнодоступного API немає видимих ​​причин для класу остаточним, і ви можете подумати "Чорт, це просто коштувало мені 30 хвилин на пошук обходу".

Я думаю, що деякі елементи рішення:

  • По-перше, переконайтеся, що розширення - це гарна ідея, коли ви клієнт коду, і дійсно надавати перевагу композиції над успадкуванням.
  • По-друге, прочитайте посібник у повному обсязі (знову ж таки як клієнт), щоб переконатися, що ви не забуваєте про щось, що згадується.
  • По-третє, коли ви пишете фрагмент коду, який використовуватиме клієнт, напишіть належну документацію для коду (так, довгий шлях). Як позитивний приклад, я можу навести документи Apple iOS від Apple. Вони не досить для користувача , щоб завжди належним чином розширити свої класи, але вони , по крайней мере , включати деяку інформацію про спадкування. Це більше, ніж я можу сказати для більшості API.
  • По-четверте, насправді спробуйте розширити власний клас, щоб переконатися, що він працює. Я великий прихильник включення багатьох зразків і тестів в API, і коли ви робите тест, ви можете також протестувати ланцюжки спадкування: вони все-таки є частиною вашого контракту!
  • По-п’яте, у ситуаціях, коли ви сумніваєтесь, вкажіть, що клас не призначений для розширення і що це робити - це погана ідея (tm). Вкажіть, що ви не повинні нести відповідальність за таке ненавмисне використання, але все одно не запечатуйте клас. Очевидно, це не охоплює випадків, коли клас повинен бути на 100% запечатаний.
  • Нарешті, запечатуючи клас, надайте інтерфейс як проміжний гачок, щоб клієнт міг "переписати" власну модифіковану версію класу та обійти ваш "запечатаний" клас. Таким чином він може замінити герметичний клас своєю реалізацією. Тепер це має бути очевидним, оскільки це нещільне з’єднання у найпростішому вигляді, але все-таки варто згадати.

Варто також відзначити наступне «філософський» питання: чи є клас sealed/ finalчастиною контракту для класу або деталей реалізації? Зараз я не хочу ступати туди, але відповідь на це також повинна впливати на ваше рішення, закріпити клас чи ні.


3

Клас не повинен бути ні остаточним, ні запечатаним, ні абстрактним, якщо:

  • Це корисно самостійно, тобто корисно мати екземпляри цього класу.
  • Для цього класу вигідно бути підкласом / базовим класом інших класів.

Наприклад, візьміть ObservableCollection<T>клас у C # . Потрібно лише додати підвищення подій до звичайних операцій a Collection<T>, тому воно підкласи Collection<T>. Collection<T>це життєздатний клас сам по собі, і так ObservableCollection<T>.


Якби я відповідав за це, я, мабуть, буду робити CollectionBase(реферат), Collection : CollectionBase(запечатаний), ObservableCollection : CollectionBase(запечатаний). Якщо ви уважно подивитесь на колекцію <T> , то побачите, що це напів оцінений абстрактний клас.
Nicolas Repiquet

2
Яку перевагу ви отримуєте від трьох занять, а не лише двох? Крім того, як складається Collection<T>"напів оцінений абстрактний клас"?
FishBasketGordo

Collection<T>розкриває багато внутрішніх властивостей захищених методів та властивостей, і, очевидно, призначений бути базовим класом для спеціалізованої колекції. Але незрозуміло, куди ви повинні поставити свій код, оскільки немає абстрактних методів реалізації. ObservableCollectionуспадковується від Collectionі не опечатується, тому ви можете знову успадкувати його. І ви можете отримати доступ до Itemsзахищеної власності, що дозволяє додавати елементи в колекцію, не піднімаючи події ... Приємно.
Nicolas Repiquet

@NicolasRepiquet: Для багатьох мов та фреймворків звичайні ідіоматичні засоби створення об'єкта вимагають, щоб тип змінної, яка буде містити об'єкт, був таким самим, як тип створеного екземпляра. У багатьох випадках ідеальним використанням було б передавати посилання на абстрактний тип, але це змусило б багато коду використовувати один тип для змінних та параметрів та інший конкретний тип при виклику конструкторів. Навряд чи неможливо, але трохи незручно.
supercat

3

Проблема з заключними / запечатаними класами полягає в тому, що вони намагаються вирішити проблему, яка ще не сталася. Це корисно лише тоді, коли проблема існує, але засмучує, оскільки сторона наклала обмеження. Рідко клас герметизації вирішує поточну проблему, через що важко стверджувати, що це корисність.

Бувають випадки, коли клас має бути запечатаний. Як приклад; Клас управляє виділеними ресурсами / пам'яттю таким чином, що він не може передбачити, як майбутні зміни можуть змінити управління.

Протягом багатьох років я виявив, що інкапсуляція, зворотні виклики та події є набагато гнучкішими / кориснішими, ніж абстраговані класи. Я бачу далеко не багато коду з великою ієрархією класів, де інкапсуляція та події полегшили б життя розробнику.


1
Остаточно запечатаний матеріал у програмному забезпеченні вирішує проблему "Я відчуваю необхідність нав'язувати свою волю майбутнім виконавцям цього коду".
Каз

Не було остаточним / запечатати щось додане до OOP, тому що я не пам'ятаю, щоб це було, коли я був молодшим. Це здається особливістю продуманого роздуму.
Реакційний

Доопрацювання існувало як процедура інструментальної ланцюга в системах OOP до того, як OOP перестала бути використана такими мовами, як C ++ та Java. Програмісти працюють у Smalltalk, Lisp з максимальною гнучкістю: все можна розширити, весь час додавати нові методи. Тоді складене зображення системи підлягає оптимізації: робиться припущення, що система не буде розширена кінцевими користувачами, і це означає, що відправлення методу може бути оптимізовано на основі підсумовування методів і класи існують зараз .
Каз

Я не думаю, що це те саме, тому що це лише функція оптимізації. Я не пам’ятаю, щоб запечатаний був у всіх версіях Java, але я можу помилитися, оскільки не дуже його використовую.
Реакційний

Тільки тому, що це проявляється як деякі декларації, які ви повинні вводити в код, не означає, що це не те саме.
Каз

2

"Герметизація" або "доопрацювання" в об'єктних системах дозволяє здійснити певні оптимізації, оскільки відомий повний графік відправки.

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

В усіх інших аспектах це втрата. Системи повинні бути відкритими та розширюваними за замовчуванням. Додавання нових методів до всіх класів повинно бути простим і довільним.

Сьогодні ми не отримуємо жодної нової функціональності, вживаючи заходів для запобігання подальшого розширення.

Тож якщо ми робимо це заради самої профілактики, то те, що ми робимо, намагається контролювати життя майбутніх утримувачів. Йдеться про его. "Навіть коли я більше не працюю тут, цей код буде підтримуватися моїм шляхом, чорт забирай!"


1

Тестові заняття, здається, приходять в голову. Це класи, які викликаються автоматизованим способом або «за бажанням», виходячи з того, що намагається виконати програміст / тестер. Я не впевнений, що коли-небудь бачив і не чув про закінчений клас тестів, який є приватним.


Про що ти говориш? Яким чином тестові заняття не повинні бути заключними / запечатаними / абстрактними?

1

Час, коли вам потрібно розглянути клас, який планується продовжити, - це коли ви робите реальне планування на майбутнє. Дозвольте навести приклад із реального життя з моєї роботи.

Я багато часу витрачаю на написання інструментів інтерфейсу між нашим основним продуктом та зовнішніми системами. Коли ми робимо новий розпродаж, одна велика складова - це набір експортерів, призначених для роботи через регулярні інтервали, які генерують файли даних із деталізацією подій, що відбулися в той день. Потім ці файли даних споживаються системою замовника.

Це відмінна можливість для продовження занять.

У мене клас експорту, який є базовим класом кожного експортера. Він знає, як підключитися до бази даних, дізнатися, де він потрапив востаннє, і створити архіви файлів даних, які він створює. Він також забезпечує управління файлами властивостей, ведення журналів та деякі прості обробці винятків.

Крім цього, у мене є інший експортер для роботи з кожним типом даних, можливо, є діяльність користувача, транзакційні дані, дані управління готівкою тощо.

Поверх цього стеку я розміщую специфічний для клієнта шар, який реалізує структуру файлу даних, яка потребує замовник.

Таким чином, база експортера дуже рідко змінюється. Експортери основних даних іноді змінюються, але рідко, і, як правило, лише для обробки змін схеми бази даних, які в будь-якому разі повинні бути розповсюджені для всіх клієнтів. Єдина робота, яку мені доводиться робити кожному клієнту, - це та частина коду, яка є специфічною для цього замовника. Ідеальний світ!

Так структура виглядає так:

Base
 Function1
  Customer1
  Customer2
 Function2
 ...

Моя основна думка полягає в тому, що, архітуючи код таким чином, я можу використовувати спадщину насамперед для повторного використання коду.

Треба сказати, що я не можу придумати жодної причини, щоб пройти три шари.

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


1

Я вважаю абстрактні класи корисними, але не завжди потрібними, і герметичні класи стають проблемою при застосуванні одиничних тестів. Ви не можете знущатися або заглушувати закритий клас, якщо тільки не використовуєте щось на кшталт Teleriks justmock.


1
Це хороший коментар, але не відповідає безпосередньо на питання. Будь ласка, розглянути можливість розширити свої думки тут.

1

Я згоден з вашим поглядом. Я думаю, що в Java за замовчуванням класи повинні бути оголошені "остаточними". Якщо ви не зробите його остаточним, то спеціально підготуйте його і документуйте для розширення.

Основна причина цього - гарантувати, що будь-які екземпляри ваших класів будуть дотримуватися інтерфейсу, який ви спочатку розробляли та документували. В іншому випадку розробник, який використовує ваші класи, може потенційно створити крихкий і непослідовий код і, в свою чергу, передавати його іншим розробникам / проектам, роблячи об'єкти ваших класів не довірливими.

З більш практичної точки зору, це насправді є недоліком, оскільки клієнти інтерфейсу вашої бібліотеки не зможуть робити жодних налаштувань і використовувати ваші класи більш гнучкими способами, про які ви спочатку думали.

Особисто для всього коду поганої якості, який існує (а оскільки ми це обговорюємо на більш практичному рівні, я б стверджував, що розвиток Java більш схильний до цього). Я думаю, що ця жорсткість - це невелика ціна, яку потрібно заплатити за наш прагнення до легшого підтримувати код.

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.