Як визначати, що метод може бути замінений більш сильним зобов'язанням, ніж визначення того, що метод може бути названий?


36

Від: http://www.artima.com/lejava/articles/designprinciples4.html

Еріх Гамма: Я все ще думаю, що це правда навіть через десять років. Спадкування - це класний спосіб змінити поведінку. Але ми знаємо, що він крихкий, тому що підклас може легко робити припущення щодо контексту, в якому називається метод, який він перекриває. Між базовим класом і підкласом існує щільна зв'язок, через неявний контекст, в якому буде викликаний код підкласу, до якого я підключаюсь. Склад має приємнішу властивість. З'єднання зменшується лише наявністю деяких менших речей, які ви підключаєте до чогось більшого, а більший об’єкт просто викликає менший об'єкт назад. З точки зору API визначення того, що метод може бути відмінено, є більш сильним зобов'язанням, ніж визначення того, що метод можна викликати.

Я не розумію, що він має на увазі. Хто-небудь, будь ласка, може пояснити це?

Відповіді:


63

Зобов'язання - це те, що зменшує ваші майбутні варіанти. Публікація методу означає, що користувачі будуть його називати, тому ви не можете видалити цей метод, не порушуючи сумісності. Якби ви зберегли його private, вони не змогли (безпосередньо) викликати його, і ви могли якось перешкодити це без проблем. Тому публікація методу є більш сильним зобов'язанням, ніж його публікація. Публікація методу, що перезаписується, є ще сильнішим зобов'язанням. Ваші користувачі можуть його називати та вони можуть створювати нові класи, де метод не робить те, що ви думаєте, що робить!

Наприклад, якщо ви публікуєте метод очищення, ви можете гарантувати правильне розміщення ресурсів до тих пір, поки користувачі пам'ятають називати цей метод останньою справою. Але якщо метод перезаписується, хтось може його замінити в підкласі і не викликати super. Як результат, третій користувач може використовувати цей клас і викликати витік ресурсів, навіть незважаючи на те, що вони слушно викликали cleanup()в кінці ! Це означає, що ви більше не можете гарантувати семантику свого коду, що дуже погано.

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


11
Це, мабуть, найкращий аргумент проти спадщини, який я коли-небудь читав. З усіх причин проти цього, з якими я стикався, я ніколи раніше не стикався з цими двома аргументами (з'єднання та порушення функціональності через переосмислення), проте обидва є дуже потужними аргументами проти успадкування.
Девід Арно

5
@DavidArno Я не думаю, що це аргумент проти спадкування. Я думаю, що це аргументи проти "зробити все вичерпним за замовчуванням". Спадщина сама по собі не є небезпечною, використовуючи її без думки.
svick

15
Хоча це звучить як вдалий момент, я не можу реально зрозуміти, як "користувач може додати свій власний код помилки" є аргументом. Увімкнення успадкування дозволяє користувачам додавати відсутні функціональні можливості, не втрачаючи оновлення, що дозволяє запобігти і виправити помилки. Якщо код користувача на вашому API порушено, це не є вадою API.
Себб

4
Ви можете легко перетворити цей аргумент: перший кодер робить аргумент очищення, але робить помилки і не очищує все. Другий кодер переосмислює метод очищення і робить хорошу роботу, а кодер №3 використовує клас і не має ніяких витоків ресурсів, хоча кодер №1 зіпсував його.
Пітер Б

6
@Doval Дійсно. Ось чому це пристрасть, що успадкування - це урок номер один майже в кожній вступній книзі та класі OOP.
Кевін Крумвієде

30

Якщо ви публікуєте звичайну функцію, ви даєте односторонній контракт:
що ця функція робить, якщо викликається?

Якщо ви опублікуєте зворотний дзвінок, ви також даєте односторонній контракт:
коли і як він буде викликаний?

І якщо ви публікуєте функцію, що перезаписується, вона є одночасно і обома, і ви даєте двосторонній контракт:
коли вона буде викликана, і що вона повинна робити, якщо дзвонить?

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

Приклад невиконанні такого двостороннього договору є перехід від showі hideдо setVisible(boolean)в java.awt.Component .


+1. Я не впевнений, чому інша відповідь була прийнята; це робить деякі цікаві моменти, але це точно не правильна відповідь на це питання, оскільки це точно не те, що мається на увазі під цитованим уривком.
ruakh

Це правильна відповідь, але я не розумію прикладу. Заміна показу і приховування setVisible (булева), схоже, порушує код, який також не використовує успадкування. Я щось пропускаю?
eigensheep

3
@eigensheep: showі hideдосі існують, вони просто @Deprecated. Таким чином, зміна не порушує жодного коду, який просто викликає їх. Але якщо ви їх перекрили, клієнти, які переходять на новий "setVisible", не скасовуватимуть їх скасування. (Я ніколи не використовував Swing, тому не знаю, як часто це переосмислювати; але, оскільки це сталося давно, я уявляю, що Дедуплікатор пам’ятає, що він болісно його кусав.)
ruakh

12

Відповідь Кіліана Фота відмінна. Я просто хотів би додати канонічний приклад * того, чому це проблема. Уявіть цілий клас точок:

class Point2D {
    public int x;
    public int y;

    // constructor
    public Point2D(int theX, int theY) { x = theX; y = theY; }

    public int hashCode() { return x + y; }

    public boolean equals(Object o) {
        if (this == o) { return true; }
        if ( !(o instanceof Point2D) ) { return false; }

        Point2D that = (Point2D) o;

        return (x == that.x) &&
               (y == that.y);
    }
}

Тепер давайте підклас це буде тривимірною точкою.

class Point3D extends Point2D {
    public int z;

    // constructor
    public Point3D(int theX, int theY, int theZ) {
        super(x, y); z = theZ;
    }

    public int hashCode() { return super.hashCode() + z; }

    public boolean equals(Object o) {
        if (this == o) { return true; }
        if ( !(o instanceof Point3D) ) { return false; }

        Point3D that = (Point3D) o;

        return super.equals(that) &&
               (z == that.z);
    }
}

Супер просто! Давайте скористаємося нашими балами:

Point2D p2a = new Point2D(3, 5);
Point2D p2b = new Point2D(3, 5);
Point2D p2c = new Point2D(3, 7);

p2a.equals(p2b); // true
p2b.equals(p2a); // true
p2a.equals(p2c); // false

Point3D p3a = new Point3D(3, 5, 7);
Point3D p3b = new Point3D(3, 5, 7);
Point3D p3c = new Point3D(3, 7, 11);

p3a.equals(p3b); // true
p3b.equals(p3a); // true
p3a.equals(p3c); // false

Напевно, вам цікаво, чому я публікую такий простий приклад. Ось улов:

p2a.equals(p3a); // true
p3a.equals(p2a); // FALSE!

Коли ми порівнюємо 2D-точку з еквівалентною 3D-точкою, ми отримуємо істину, але коли ми перевертаємо порівняння, ми отримуємо хибність (тому що p2a виходить з ладу instanceof Point3D).

Висновок

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

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

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

Чудовий приклад добре прописаного контракту - компаратор . Просто ігноруйте, про що йдеться, .equals()з причин, описаних вище. Ось приклад того, як компаратор може робити речі, .equals()не може .

Примітки

  1. Пункт 8 "Ефективна Java" Джоша Блоха був джерелом цього прикладу, але Bloch використовує ColorPoint, який додає колір замість третьої осі та використовує подвійні замість ints. Приклад Java Блоха в основному дублюється Одерським / Ложкою / Веннери, які зробили свій приклад доступним в Інтернеті.

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

Бонус

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


1
SortedMap та SortedSet насправді не змінюють визначення того, equalsяк визначають Map та Set. Рівність повністю ігнорує впорядкування, внаслідок чого, наприклад, два сортовані набори з однаковими елементами, але різні порядки сортування все ще порівнюють рівні.
user2357112 підтримує Моніку

1
@ user2357112 Ви маєте рацію, і я вилучив цей приклад. Сумісність SortedMap.equals () із Map - окрема проблема, на яку я продовжуватиму скаржитися. SortedMap, як правило, O (log2 n), а HashMap (канонічне втілення Map) - O (1). Тому ви б використовували SortedMap лише в тому випадку, якщо ви дійсно піклуєтесь про замовлення. З цієї причини я вважаю, що порядок є досить важливим, щоб він був критичним компонентом тесту рівняння () в реалізаціях SortedMap. Вони не повинні ділити рівну () реалізацію з Map (вони роблять через AbstractMap на Java).
GlenPeterson

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

7
Це жахливий приклад, Глен. Ви просто використовували спадщину таким чином, яким його не слід використовувати, недарма заняття працюють не так, як ви задумали. Ви порушили принцип заміни Ліскова, подавши неправильну абстракцію (2D бал), але те, що успадковування є поганим у вашому неправильному прикладі, не означає, що воно взагалі погано. Хоча ця відповідь може виглядати розумною, вона лише бентежить людей, які не усвідомлюють, що вона порушує найосновніше правило спадкування.
Енді

3
ELI5 з принципу заміщення Ліскова говорить: Якщо клас Bє дочірнім класом Aі чи слід створювати об'єкт класу B, ви повинні мати можливість передавати Bоб’єкт класу своєму батьківському користувачеві та використовувати API закинутої змінної, не втрачаючи жодної інформації про реалізацію дитина. Ви порушили правило, надавши третє майно. Як ви плануєте отримати доступ до zкоординати після Point3Dпередачі змінної Point2D, коли базовий клас не має уявлення про таке властивість? Якщо, кинувши дочірній клас на його базу, ви зламаєте загальнодоступний API, ваша абстракція неправильна.
Енді

4

Спадщина послаблює інкапсуляцію

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

Нижче представлений реальний (спрощений) приклад із колекцій Java-колекцій, люб’язний Пітер Норвіг ( http://norvig.com/java-iaq.html ).

Public Class HashTable{
    ...
    Public Object put(K key, V value){
        try{
            //add object to table;
        }catch(TableFullException e){
            increaseTableSize();
            put(key,value);
        }
    }
}

То що відбувається, якщо ми підкласируємо це?

/** A version of Hashtable that lets you do
 * table.put("dog", "canine");, and then have
 * table.get("dogs") return "canine". **/

public class HashtableWithPlurals extends Hashtable {

    /** Make the table map both key and key + "s" to value. **/
    public Object put(Object key, Object value) {
        super.put(key + "s", value);
        return super.put(key, value);
    }
}

У нас є помилка: інколи ми додаємо "собаку", і хештел отримує запис для "dogss". Причиною було те, що хтось реалізував проект, якого людина, що проектує клас Hashtable, не очікувала.

Розширення спадкових розривів

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

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


3

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

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

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

Однак автор розповідає про інше питання в цитованому тексті:

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

Розглянемо метод, aякий зазвичай називають методом b, але в деяких рідкісних і неочевидних випадках від методу c. Якщо автор переорієнтованого методу не звертає уваги на cметод та його очікування a, очевидно, як все може піти не так.

Тому важливіше те, що aвизначено чітко і однозначно, добре зафіксовано, "робить одне і робить це добре" - тим більше, ніж якби це був метод, призначений лише для виклику.

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