Чому C # був створений за допомогою "нових" та "віртуальних + переопрацьованих" ключових слів на відміну від Java?


61

У Java є немає virtual, new, overrideключові слова для визначення методу. Тож роботу методу легко зрозуміти. Причина, якщо DerivedClass розширює BaseClass і має метод з тим самим ім'ям і такою ж підписом BaseClass, то переосмислення відбуватиметься при поліморфізмі під час виконання (за умови, що методу немає static).

BaseClass bcdc = new DerivedClass(); 
bcdc.doSomething() // will invoke DerivedClass's doSomething method.

Тепер прийшов до C # може бути так багато плутанини і важко зрозуміти , як newабо virtual+deriveчи нове + віртуальне перевизначення працює.

Я не в змозі зрозуміти, чому у світі я збираюся додати метод у моєму DerivedClassімені та такому ж підписі, як BaseClassі визначити нову поведінку, але під час поліморфізму під час виконання цього BaseClassметоду буде застосовано! (що не переважає, але логічно повинно бути).

У випадку, virtual + overrideякщо логічна реалізація є правильною, але програміст повинен продумати, який метод він повинен дати дозволу користувачу на переосмислення під час кодування. Яка є якась про-кон (не будемо зараз їздити туди).

Так чому в C # є так багато місця для ООН -logical міркувань і плутанини. Тож чи можу я переробити своє запитання про те, в якому контексті реального світу я повинен думати про використання virtual + overrideзамість, newа також про використання newзамість нього virtual + override?


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

Тепер я маю на увазі питання. Як у Java, якби у мене був такий код

Vehicle vehicle = new Car();
vehicle.accelerate();

а згодом я роблю новий клас, SpaceShipпохідний від Vehicle. Тоді я хочу змінити все carна SpaceShipоб'єкт, я просто повинен змінити один рядок коду

Vehicle vehicle = new SpaceShip();
vehicle.accelerate();

Це не порушить мою логіку в будь-якій точці коду.

Але у випадку C #, якщо SpaceShipне перекриває Vehicleклас ' accelerateта use, newто логіка мого коду буде порушена. Це не недолік?


77
Ви просто звикли до того, як це робить Java, і просто не витратили час, щоб зрозуміти ключові слова C # за власними умовами. Я працюю з C #, зрозумів терміни негайно і знаходжу спосіб, як Java робить це дивним.
Роберт Харві

12
IIRC, C # зробив це, щоб "сприяти ясності". Якщо ви маєте чітко сказати "нове" чи "переосмислити", це дає чітко і негайно зрозуміти, що відбувається, а не вам доведеться факусуватись, намагаючись зрозуміти, переважає метод чиясь поведінка в базовому класі чи ні. Мені також дуже корисно мати можливість вказати, які методи я хочу вказати як віртуальні, а які - не. (Java робить це за допомогою final; це навпаки).
Роберт Харві

15
"У Java немає віртуальних, нових, переосмислених ключових слів для визначення методу". За винятком випадків @Override.
svick


3
@svick анотація та ключові слова - це не одне й те саме :)
Anirban Nag 'tintinmj'

Відповіді:


86

Оскільки ви запитали, чому C # зробив це таким чином, найкраще запитати у творців C #. Андер Хейлсберг, провідний архітектор C #, відповів, чому вони в інтерв'ю вирішили не користуватися віртуальною програмою (як на Java) , відповідні фрагменти нижче.

Майте на увазі, що Java за замовчуванням має віртуальне кінцеве ключове слово для позначення методу як невіртуального. Ще два поняття, які слід вивчити, але багато людей не знають про остаточне ключове слово або не використовують його активно. C # змушує використовувати віртуальне та нове / переосмислювати свідомо приймати ці рішення.

Причин кілька. Один - це продуктивність . Ми можемо спостерігати, що коли люди пишуть код на Java, вони забувають позначити свої методи остаточними. Тому ці методи є віртуальними. Оскільки вони віртуальні, вони також не дуже ефективні. Є просто накладні витрати, пов'язані з тим, що це віртуальний метод. Це одне питання.

Більш важливим питанням є версія . Існують дві школи думок щодо віртуальних методів. Академічна школа думок говорить: "Все повинно бути віртуальним, тому що я, можливо, захочу його перемогти коли-небудь". Прагматична школа думки, яка походить від побудови реальних додатків, що працюють в реальному світі, говорить: "Ми повинні бути реальними уважними щодо того, що ми робимо віртуальними".

Коли ми робимо щось віртуальне на платформі, ми даємо дуже багато обіцянок щодо того, як це розвиватиметься в майбутньому. Що стосується невіртуального методу, ми обіцяємо, що при виклику цього методу відбудеться х і у. Коли ми публікуємо віртуальний метод в API, ми не тільки обіцяємо, що при виклику цього методу, x і y відбудуться. Ми також обіцяємо, що коли ви скасуєте цей метод, ми назвемо його в цій певній послідовності стосовно цих інших, і держава буде в цій і тій інваріантній.

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

В інтерв’ю є більше дискусій про те, як розробники думають про дизайн спадкування класів, і як це призвело до їх рішення.

Тепер до наступного питання:

Я не в змозі зрозуміти, чому в світі я збираюся додати метод у свій DerivedClass з тим же ім'ям і такою ж підписом, як BaseClass, і визначити нову поведінку, але під час поліморфізму під час виконання буде застосовано метод BaseClass! (що не переважає, але логічно повинно бути).

Це було б тоді, коли похідний клас хоче заявити, що він не дотримується договору базового класу, але має метод з такою ж назвою. (Для всіх, хто не знає різниці між newі overrideв C #, дивіться цю сторінку MSDN ).

Дуже практичний сценарій такий:

  • Ви створили API, в якому є клас під назвою Vehicle.
  • Я почав використовувати ваш API і отримав Vehicle.
  • У вашому Vehicleкласі не було жодного методу PerformEngineCheck().
  • У своєму Carкласі я додаю метод PerformEngineCheck().
  • Ви випустили нову версію свого API та додали PerformEngineCheck().
  • Я не можу перейменувати свій метод, оскільки мої клієнти залежать від мого API, і він би порушив їх.
  • Тому коли я перекомпілюю ваш новий API, C # попереджає мене про цю проблему, наприклад

    Якщо бази PerformEngineCheck()не було virtual:

    app2.cs(15,17): warning CS0108: 'Car.PerformEngineCheck()' hides inherited member 'Vehicle.PerformEngineCheck()'.
    Use the new keyword if hiding was intended.

    А якщо база PerformEngineCheck()була virtual:

    app2.cs(15,17): warning CS0114: 'Car.PerformEngineCheck()' hides inherited member 'Vehicle.PerformEngineCheck()'.
    To make the current member override that implementation, add the override keyword. Otherwise add the new keyword.
  • Тепер я повинен чітко прийняти рішення, чи дійсно мій клас подовжує контракт базового класу, чи це інший контракт, але буває, що це одне й те саме ім'я.

  • Роблячи це new, я не ламаю своїх клієнтів, якщо функціональність базового методу відрізнялася від похідного методу. Будь-який код, на який посилається Vehicle, не побачить Car.PerformEngineCheck()виклику, але код, на який було посилання, Carі надалі буде бачити той самий функціонал, який я запропонував PerformEngineCheck().

Аналогічний приклад - коли інший метод базового класу може викликати виклик PerformEngineCheck()(особливо в новій версії), як завадити йому викликати PerformEngineCheck()похідний клас? У Java це рішення лежало б на базовому класі, але воно не знає нічого про похідний клас. У C # це рішення покладається як на базовий клас (за допомогою virtualключового слова), так і на похідний клас (через newі overrideключові слова).

Звичайно, помилки, які випускає компілятор, також служать корисним інструментом для програмістів, щоб несподівано не помилитися (тобто або переосмислити, або надати нову функціональність, не усвідомлюючи цього.)

Як сказав Андерс, реальний світ змушує нас до таких питань, які, якби ми починали з нуля, ми ніколи не хотіли б вступати.

EDIT: Додано приклад того, де newпотрібно було б використовувати для забезпечення сумісності інтерфейсу.

EDIT: Під час обговорення коментарів я також натрапив на написання Еріка Ліпперта (тоді одного з членів комітету дизайну C #) щодо інших прикладних сценаріїв (згаданих Брайаном).


ЧАСТИНА 2: На основі оновленого питання

Але у випадку C #, якщо SpaceShip не перевищує клас автомобіля, «пришвидшити та використовувати новий, то логіка мого коду буде порушена. Це не недолік?

Хто вирішує, чи SpaceShipнасправді переважає, Vehicle.accelerate()чи відрізняється? Це має бути SpaceShipрозробником. Тож якщо SpaceShipрозробник вирішить, що вони не дотримуються контракту базового класу, то ваш виклик не Vehicle.accelerate()повинен надходити SpaceShip.accelerate(), чи повинен? Саме тоді вони позначають це як new. Однак якщо вони вирішать, що він дійсно зберігає контракт, то вони насправді його позначать override. У будь-якому випадку ваш код буде поводитись правильно, викликаючи правильний метод на основі контракту . Як ваш код може вирішити, чи SpaceShip.accelerate()насправді це переважає Vehicle.accelerate()чи це зіткнення імені? (Дивіться мій приклад вище).

Однак, в разі неявного успадкування, навіть якщо SpaceShip.accelerate()не тримати контракт на Vehicle.accelerate()виклик методу буде по- , як і раніше йти SpaceShip.accelerate().


12
Точка виступу на сьогоднішній день повністю застаріла. Для підтвердження див. Мій показник, який показує, що доступ до поля за допомогою не остаточного, але ніколи не перевантаженого методу займає один цикл.
maaartinus

7
Звичайно, це може бути так. Питання полягало в тому, що коли C # вирішив це зробити, то чому це зробили в ТАКІЙ час, і отже, ця відповідь справедлива. Якщо питання все-таки має сенс, це вже інше обговорення, ІМХО.
Омер Ікбал

1
Я повністю з вами згоден.
maaartinus

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

3
@Nilzor: Звідси аргумент щодо академічного проти прагматичного. Прагматично відповідальність за те, що щось порушено, лежить на останньому змінні. Якщо ви змінюєте базовий клас, і існуючий похідний клас залежить від того, щоб він не змінювався, це не їхня проблема ("вони" можуть більше не існувати). Таким чином базові класи замикаються на поведінці їх похідних класів, навіть якщо цей клас ніколи не повинен був бути віртуальним . І як ви кажете, RhinoMock існує. Так, якщо ви правильно закінчите всі методи, все рівнозначно. Тут ми вказуємо на будь-яку побудовану систему Java.
deworde

94

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


7
Дозволити перекриття будь-якого довільного методу - неправильно. Ви повинні мати змогу замінити лише ті методи, які дизайнер суперкласу визначив безпечними для переосмислення.
Doval

21
Ерік Ліпперт детально обговорює це у своїй публікації, « Віртуальні методи» та «Крихкі базові класи» .
Брайан

12
У Java є finalмодифікатор, тож у чому проблема?
Sarge Borsch

13
@corsiKa "Об'єкти повинні бути відкриті для розширення" - чому? Якщо розробник базового класу ніколи не замислювався над успадкуванням, то майже гарантовано, що в коді є тонкі залежності та припущення, що призведе до помилок (пам’ятаєте HashMap?). Будьте спроектовані на спадщину та уточніть контракт або не переконайтеся, що ніхто не може успадкувати клас. @Overrideбуло додано як бандаїд, тому що вже пізно змінити поведінку до того часу, але навіть оригінальні розробники Java (особливо Джош Блох) погоджуються, що це було поганим рішенням.
Voo

9
@jwenting "Думка" - смішне слово; люди люблять прив’язувати це до фактів, щоб вони могли їх знехтувати. Але для того, що варто, це також "думка" Джошуа Блоха (див. Ефективна Java , пункт 17 ), "думка" Джеймса Гослінга (див. Це інтерв'ю ), і як зазначено у відповіді Омера Ікбала , це також "думка" Андерса Хейльсберга. (І хоча він ніколи не звертався до участі у відмові від відмови, Ерік Ліпперт чітко погоджується, що спадщина є небезпечною і.) Тож до кого йдеться?
Довал

33

Як сказав Роберт Харві, все в тому, до чого ти звик. Я вважаю відсутність такої гнучкості у гнучкості Java .

Це сказало, чому це в першу чергу? З тієї ж причини , що C # має public, internal(також «нічого»), protected, protected internal, і private, а Java просто є public, protectedнічого, і private. Він забезпечує більш точний контроль зерна над поведінкою того, що ви кодуєте, за рахунок того, щоб мати більше термінів та ключових слів, які слід відстежувати.

У випадку з newvs. virtual+override, це виглядає приблизно так:

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

На прикладі реального світу: проект, над яким я працював над обробленими електронними комерційними замовленнями з багатьох різних джерел. Існувала база, OrderProcessorяка мала більшу частину логіки, з певними abstract/ virtualметодами для перекриття дочірнього класу кожного джерела. Це спрацювало чудово, поки ми не отримали нове джерело, яке мало зовсім інший спосіб обробки замовлень, таким чином, що нам довелося замінити основну функцію. На даний момент у нас було два варіанти: 1) додати virtualв базовий метод і overrideв дитині; або 2) додати newдитині.

Хоча будь-який з них може працювати, перший дозволив би дуже легко перекрити цей конкретний метод у майбутньому. Наприклад, він відображатиметься в автоматичному завершенні. Однак це був винятковий випадок, тому ми вирішили використати newнатомість. Це зберегло стандарт "цього методу не потрібно перекривати", допускаючи особливий випадок, коли він був. Це смислова різниця, яка полегшує життя.

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


7
Я думаю, що навмисно використовувати newцей спосіб - це помилка, яка чекає, що трапиться. Якщо я називаю метод на якомусь екземплярі, я очікую, що він завжди буде робити те ж саме, навіть якщо я віддаю примірник його базовому класу. Але це не так, як newведуть себе ед-методи.
svick

@svick - Потенційно, так. У нашому конкретному сценарії цього ніколи не відбудеться, але саме тому я додав застереження.
Бобсон

Одне чудове використання new- у WebViewPage <TModel> в рамках MVC. Але мене також кілька newгодин кинула помилка , тому я не вважаю це необгрунтованим питанням.
pdr

1
@ C.Champagne: Будь-який інструмент може використовуватися погано, і цей особливо гострий інструмент - ви можете легко вирізати себе. Але це не привід для видалення інструменту з панелі інструментів та видалення параметра у більш талановитого дизайнера API.
пдр

1
@svick Правильно, тому зазвичай це попередження компілятора. Але наявність можливості "нового" охоплює крайові умови (наприклад, наведені), а ще краще, робить насправді очевидним, що ви робите щось дивне, коли приходите діагностувати неминучу помилку. "Чому цей клас і лише цей клас баггі ... ах-ха-ха," новий ", давайте перевіримо, де використовується SuperClass".
deworde

7

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

Це спрощує час виконання, але може спричинити деякі нещасні проблеми. Припустимо GrafBase, не реалізує void DrawParallelogram(int x1,int y1, int x2,int y2, int x3,int y3), але GrafDerivedреалізує його як публічний метод, який проводить паралелограм, обчислена четверта точка протилежно першому. Припустимо також, що пізніша версія GrafBaseреалізує публічний метод з тим же підписом, але обчислює його четверту точку навпроти другої. Клієнти, які отримують очікування, GrafBaseале отримують посилання на GrafDerivedзаповіт, розраховують DrawParallelogramобчислити четверту точку в моді нового GrafBaseметоду, але клієнти, які використовували GrafDerived.DrawParallelogramдо зміни базового методу, очікують на поведінку, GrafDerivedспочатку реалізовану.

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

У C #, якщо GrafDerivedне буде перекомпільовано, час виконання буде вважати, що код, який викликає DrawParallelogramметод за посиланнями типу GrafDerived, очікує поведінки, яка GrafDerived.DrawParallelogram()була, коли він востаннє був скомпільований, але код, який викликає метод за посиланнями типу, GrafBaseбуде очікувати GrafBase.DrawParallelogram(поведінка, що додано). Якщо GrafDerivedпізніше буде перекомпільовано в присутності розширеного GrafBase, компілятор буде проходити до тих пір, поки програміст не вкаже, чи призначений його метод бути дійсною заміною успадкованого члена GrafBase, чи його поведінку потрібно прив’язувати до посилань типу GrafDerived, але не слід замінити поведінку посилань типу GrafBase.

Можна обґрунтовано стверджувати, що наявність методу GrafDerivedзробити щось відмінне від члена, у GrafBaseякого є однаковий підпис, вказувало б на поганий дизайн, і як таке не слід підтримувати. На жаль, оскільки автор базового типу не може знати, які методи можуть бути додані до похідних типів, і навпаки, ситуація, коли клієнти базового класу та похідного класу мають різні очікування щодо методів, названих на зразок, по суті неминуча, якщо тільки ніхто не може додати будь-яке ім’я, яке може також додати хтось інший. Питання полягає не в тому, чи повинно відбуватися таке дублювання імен, а в тому, як мінімізувати шкоду, коли це наноситься.


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

Що таке DerivedGraphics? A class using GrafDerived`?
Ч. Шампань

@ C.Champagne: Я мав на увазі GrafDerived. Я почав користуватися DerivedGraphics, але відчув, що це трохи довго. Навіть GrafDerivedще трохи, але не знав, як найкраще назвати типи графічних рендерів, які повинні мати чітке співвідношення база / похідне.
supercat

1
@Bobson: Краще?
supercat

6

Стандартна ситуація:

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

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

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

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

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

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


0

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

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

Це основна властивість визначення мови. Це неправильно чи неправильно, це те, що є, і не може змінити.


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