Как определить, что метод может быть переопределен более сильным обязательством, чем определение, что метод может быть вызван?

36

От: http://www.artima.com/lejava/articles/designprinciples4.html

Эрих Гамма: Я все еще думаю, что это правда, даже после десяти лет. Наследование - это крутой способ изменить поведение. Но мы знаем, что это хрупко, потому что подкласс может легко делать предположения о контексте, в котором вызывается метод, который он переопределяет. Между базовым классом и подклассом существует тесная связь из-за неявного контекста, в который будет вызываться код подкласса, который я подключаю. Композиция обладает более приятным свойством. Связность уменьшается благодаря наличию более мелких вещей, которые вы подключаете к чему-то большему, а более крупный объект просто вызывает меньший объект обратно. С точки зрения API определение того, что метод может быть переопределен, является более строгим обязательством, чем определение того, что метод может быть вызван.

Я не понимаю, что он имеет в виду. Может ли кто-нибудь объяснить это?

q126y
источник

Ответы:

63

Обязательство - это то, что уменьшает ваши будущие возможности. Публикация метода подразумевает, что пользователи будут вызывать его, поэтому вы не можете удалить этот метод без нарушения совместимости. Если бы вы сохранили это private, они не могли бы (напрямую) позвонить, и вы могли бы когда-нибудь перестроить его без проблем. Таким образом, публикация метода является более строгим обязательством, чем его публикация. Публикация переопределенного метода - еще более серьезное обязательство. Ваши пользователи могут вызывать это, и они могут создавать новые классы, где метод не делает то, что вы думаете, что он делает!

Например, если вы публикуете метод очистки, вы можете убедиться, что ресурсы должным образом освобождены, если пользователи не забывают вызывать этот метод как последнее, что они делают. Но если метод можно переопределить, кто-то может переопределить его в подклассе и не вызывать super. В результате, третий пользователь может использовать этот класс и вызвать утечку ресурсов, даже если он покорноcleanup() вызывается в конце ! Это означает, что вы больше не можете гарантировать семантику вашего кода, что очень плохо.

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

Килиан Фот
источник
11
Возможно, это лучший аргумент против наследования, который я когда-либо читал. Из всех причин, с которыми я столкнулся, я никогда раньше не сталкивался с этими двумя аргументами (связывание и нарушение функциональности посредством переопределения), но оба они являются очень сильными аргументами против наследования.
Дэвид Арно
5
@DavidArno Не думаю, что это аргумент против наследования. Я думаю, что это аргумент против "сделать все переопределяемым по умолчанию". Наследование не опасно само по себе, использовать его без мыслей.
svick
15
Хотя это звучит как хороший момент, я не могу понять, как «пользователь может добавить свой собственный код ошибки» является аргументом. Включение наследования позволяет пользователям добавлять недостающие функциональные возможности без потери возможности обновления, что позволяет предотвращать и исправлять ошибки. Если пользовательский код поверх вашего API нарушен, это не является недостатком API.
Себб
4
Вы можете легко превратить этот аргумент в: первый кодер делает аргумент очистки, но делает ошибки и не все очищает. Второй кодер переопределяет метод очистки и хорошо выполняет свою работу, а кодер № 3 использует класс и не имеет утечек ресурсов, даже несмотря на то, что кодер № 1 все испортил.
Питер Б
6
@Doval Действительно. Вот почему это пародия на то, что наследование является уроком номер один в почти каждой вводной книге ООП и классе.
Кевин Крумвиде
30

Если вы публикуете обычную функцию, вы даете односторонний контракт:
что делает функция, если вызывается?

Если вы публикуете обратный вызов, вы также даете односторонний контракт:
когда и как он будет называться?

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

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

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

Deduplicator
источник
+1. Я не уверен, почему был принят другой ответ; в нем есть некоторые интересные моменты, но это определенно неправильный ответ на этот вопрос, поскольку это определенно не то, что подразумевается в цитируемом отрывке.
Руах
Это правильный ответ, но я не понимаю пример. Замена show and hide на setVisible (boolean), похоже, нарушает код, который тоже не использует наследование. Я что-то пропустил?
eigensheep
3
@eigensheep: showи hideдо сих пор существуют, они просто @Deprecated. Таким образом, изменение не нарушает код, который просто вызывает их. Но если вы переопределили их, ваши переопределения не будут вызываться клиентами, которые переходят на новый setVisible. (Я никогда не использовал Swing, поэтому я не знаю, как часто это отменять; но так как это произошло давным-давно, я представляю себе причину, по которой Deduplicator вспоминает, что это причиняло ему / ей боль.)
Руах
12

Ответ Килиана Фота превосходен. Я просто хотел бы добавить канонический пример * того, почему это проблема. Представьте себе целочисленный класс Point:

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);
    }
}

Теперь давайте подкласс это, чтобы быть 3D-точкой.

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 () для существенно другого подкласса способом, совместимым с его родительским классом.

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

Отличным примером хорошо прописанного контракта является Comparator . Просто игнорируйте то, о чем говорится, .equals()по причинам, описанным выше. Вот пример того, как Comparator может делать то, что .equals()нельзя .

Заметки

  1. Источником этого примера был пункт 8 «Эффективная Java» Джоша Блоха, но Блох использует ColorPoint, который добавляет цвет вместо третьей оси и использует двойные вместо целых. Пример Java Блоха в основном продублирован Odersky / Spoon / Venners, которые сделали свой пример доступным в Интернете.

  2. Несколько человек возражали против этого примера, потому что, если вы сообщите родительскому классу о подклассе, вы можете исправить эту проблему. Это верно, если имеется достаточно небольшое количество подклассов и если родитель знает обо всех них. Но первоначальный вопрос был о создании API, для которого кто-то еще напишет подклассы. В этом случае вы обычно не можете обновить родительскую реализацию, чтобы она была совместима с подклассами.

бонус

Компаратор также интересен тем, что он решает проблему правильной реализации equals (). Более того, он следует шаблону для решения этого типа проблемы наследования: шаблону разработки стратегии. Классы типов, от которых люди в Haskell и Scala приходят в восторг, также являются образцом стратегии. Наследование не плохо или неправильно, это просто сложно. Для дальнейшего чтения посмотрите статью Филиппа Уодлера « Как сделать специальный полиморфизм менее специальным»

GlenPeterson
источник
1
SortedMap и SortedSet фактически не меняют определения того, equalsкак Map и Set определяют его. Равенство полностью игнорирует порядок, в результате чего, например, два SortedSets с одинаковыми элементами, но разными порядками сортировки, все равно сравниваются.
user2357112 поддерживает Monica
1
@ user2357112 Вы правы, и я удалил этот пример. Совместимость SortedMap.equals () с Map - это отдельная проблема, на которую я буду жаловаться. SortedMap - это обычно O (log2 n), а HashMap (каноническое значение Map) - O (1). Таким образом, вы будете использовать SortedMap, только если вы действительно заботитесь о заказе. По этой причине я считаю, что порядок достаточно важен, чтобы быть критическим компонентом теста equals () в реализациях SortedMap. Они не должны совместно использовать реализацию equals () с Map (они делают это через AbstractMap в Java).
ГленПетерсон
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);
    }
}

У нас есть ошибка: иногда мы добавляем «dog», и в хеш-таблицу вводится запись «dogss». Причина была в том, что кто-то предоставил реализацию пут, которую не ожидал человек, разрабатывающий класс Hashtable.

Расширяемость наследственных разрывов

Если вы позволяете своему классу быть разделенным на подклассы, вы обязуетесь не добавлять никаких методов в ваш класс. В противном случае это можно сделать, ничего не нарушая.

Когда вы добавляете новые методы в интерфейс, любой, кто унаследовал от вашего класса, должен будет реализовать эти методы.

eigensheep
источник
3

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

Если метод предназначен для переопределения, вам также необходимо тщательно продумать область действия метода: если область слишком велика, дочерний класс часто должен будет включать вставленный в копию код из родительского метода; если он слишком мал, необходимо переопределить многие методы, чтобы получить желаемую новую функциональность - это добавляет сложности и ненужного количества строк.

Поэтому создатель родительского метода должен сделать предположения о том, как класс и его методы могут быть переопределены в будущем.

Тем не менее, автор говорит о другой проблеме в цитируемом тексте:

Но мы знаем, что это хрупко, потому что подкласс может легко делать предположения о контексте, в котором вызывается метод, который он переопределяет.

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

Поэтому важнее то, что aон четко и недвусмысленно определен, хорошо документирован, «делает одно и делает это хорошо» - тем более, чем если бы это был метод, предназначенный только для вызова.

дождливый
источник