Что может быть недостатком для определения класса как подкласса списка самого себя?

48

В моем недавнем проекте я определил класс со следующим заголовком:

public class Node extends ArrayList<Node> {
    ...
}

Однако, после обсуждения с моим профессором CS, он заявил, что урок будет «ужасным для памяти» и «плохой практикой». Я не обнаружил, что первое особенно верно, а второе субъективно.

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

С другой стороны, я вижу, как это может быть плохой практикой. Идея определения чего-либо как списка сама по себе не является простой или физически осуществимой.

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


¹ Если мне нужно объяснить это дальше, я был бы рад; Я просто пытаюсь сделать этот вопрос лаконичным.

Аддисон Крамп
источник
1
@gnat Это больше связано со строгим определением класса как списка, а не со списками, которые содержат списки. Я думаю, что это вариант подобной вещи, но не совсем то же самое. Этот вопрос относится к чему-то вроде ответа Роберта Харви .
Эддисон Крамп
16
Наследование от чего-то, что параметризуется само по себе, не является чем-то необычным, как показывает часто используемая идиома CRTP в C ++. Однако в случае с контейнером ключевой вопрос состоит в том, чтобы обосновать, почему вы должны использовать наследование вместо композиции.
Кристоф
1
Мне нравится эта идея. В конце концов, каждый узел в дереве является (под) деревом. Обычно древовидность узла скрывается от представления типа (классический узел - не дерево, а отдельный объект) и вместо этого выражается в представлении данных (единственный узел обеспечивает доступ к ветвям). Здесь все наоборот; мы не увидим данные о ветвях, это тип. По иронии судьбы, фактическое расположение объектов в C ++, вероятно, было бы очень похоже (наследование реализовано очень похоже на хранение данных), что заставляет меня думать, что мы имеем дело с двумя способами выражения одного и того же.
Питер - Восстановить Монику
1
Может быть, мне немного не хватает, но вам действительно нужно расширять ArrayList<Node> , по сравнению с внедрением List<Node> ?
Матиас

Ответы:

107

Честно говоря, я не вижу необходимости наследования здесь. Это не имеет смысла; Node это ArrayList из Node?

Если это просто рекурсивная структура данных, вы просто напишите что-то вроде:

public class Node {
    public String item;
    public List<Node> children;
}

Что имеет смысл; узел имеет список дочерних или дочерних узлов.

Роберт Харви
источник
34
Это не одно и то же. Список из списка списка бесконечен бессмысленно, если только вы не сохраняете что-то, кроме нового списка в списке. Вот для чего Item и Itemработает независимо от списков.
Роберт Харви
6
О, теперь я вижу. Я подошел Node это ArrayList в Nodeкачестве Node содержит 0 для многих Node объектов. По сути, они одинаковы, но вместо того, чтобы быть списком похожих объектов, он имеет список похожих объектов. Первоначальный дизайн написанного класса заключался в убеждении, что любой из них Nodeможет быть выражен как набор подпрограмм Node, что и в обеих реализациях, но этот, безусловно, менее запутан. +1
Эддисон Крамп
11
Я бы сказал, что идентичность между узлом и списком имеет тенденцию возникать «естественно», когда вы пишете односвязные списки на C, Lisp или чем-то еще: в некоторых конструкциях головной узел «является» списком, в смысле единственного вещь, которая когда-либо использовалась для представления списка. Поэтому я не могу полностью развеять ощущение, что в некоторых контекстах, возможно, узел «является» (а не просто «имеет») коллекцией узлов, хотя я согласен, что в Java это выглядит как EvilBadWrong.
Стив Джессоп
12
@ nl-x То, что один синтаксис короче, не значит, что оно лучше. Кроме того, между прочим, довольно редко можно получить доступ к таким элементам дерева. Обычно структура не известна во время компиляции, поэтому вы не склонны обходить такие деревья постоянными значениями. Глубина также не фиксирована, поэтому вы даже не можете перебирать все значения, используя этот синтаксис. Когда вы адаптируете код для использования традиционной задачи обхода дерева, вы в конечном итоге пишете Childrenвсего один или два раза, и, как правило, предпочтительнее выписывать его в таких случаях для ясности кода.
Servy
8
+1 за Любимую композицию за наследство .
Менелаос Коцолларис
57

Аргумент «ужасно для памяти» совершенно неверен, но это объективно «плохая практика». Когда вы наследуете от класса, вы не просто наследуете поля и методы, которые вас интересуют. Вместо этого вы наследуете все . Каждый метод, который он объявляет, даже если он бесполезен для вас. И самое главное, вы также наследуете все его контракты и гарантии, которые предоставляет класс.

Акроним SOLID обеспечивает некоторую эвристику для хорошего объектно-ориентированного дизайна. Здесь я nterface Сегрегация принцип (ISP) и L Иськов Замена Pricinple (LSP) есть что - то сказать.

Интернет-провайдер говорит нам, чтобы наши интерфейсы были как можно меньше. Но, наследуя от ArrayList, вы получаете много-много методов. Есть ли смысл get(), remove(), set()(заменить), или add()(вставка) дочерний узел с определенным индексом? Разумно ли это ensureCapacity()из основного списка? Что это значит для sort()узла? Пользователи вашего класса действительно должны получить subList()? Поскольку вы не можете скрыть ненужные методы, единственное решение состоит в том, чтобы ArrayList являлся переменной-членом и пересылать все методы, которые вам действительно нужны:

private final ArrayList<Node> children = new ArrayList();
public void add(Node child) { children.add(child); }
public Iterator<Node> iterator() { return children.iterator(); }

Если вам действительно нужны все методы, которые вы видите в документации, мы можем перейти к LSP. LSP говорит нам, что мы должны иметь возможность использовать подкласс везде, где ожидается родительский класс. Если функция принимает в ArrayListкачестве параметра, и мы Nodeвместо этого передаем наш , ничего не должно измениться.

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

Но LSP работает гораздо глубже: мы должны поддерживать совместимость со всем, что обещано документацией всех родительских классов и интерфейсов. В своем ответе , Линн обнаружила один такой случай , когда Listинтерфейс (который вы унаследовали через ArrayList) гарантирует , как equals()и hashCode()методы должны работать. Для hashCode()вас даже дан определенный алгоритм, который должен быть реализован точно. Давайте предположим, что вы написали это Node:

public class Node extends ArrayList<Node> {
  public final int value;

  public Node(int value, Node... children) {
    this.value = Value;
    for (Node child : children)
      add(child);
  }

  ...

}

Это требует, чтобы valueне мог внести свой вклад hashCode()и не может влиять equals(). ListИнтерфейс - что вы обещаете честь, унаследовав от него - требует , new Node(0).equals(new Node(123))чтобы быть правдой.


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

Иногда наш естественный язык предполагает наследственные отношения: автомобиль - это транспортное средство. Мотоцикл - это транспортное средство. Должен ли я определить классы Carи Motorcycleчто наследовать от Vehicleкласса? Объектно-ориентированный дизайн - это не отражение реального мира именно в нашем коде. Мы не можем легко закодировать богатые таксономии реального мира в нашем исходном коде.

Одним из таких примеров является проблема моделирования сотрудник-босс. У нас есть несколько Persons, каждый с именем и адресом. An Employeeесть Personи имеет Boss. А Bossтакже Person. Так я должен создать Personкласс, который наследуется Bossи Employee? Теперь у меня есть проблема: начальник - тоже работник и другой начальник. Так что вроде как Bossдолжно расширяться Employee. Но CompanyOwnerэто, Bossно не Employee? Любой вид графа наследования здесь как-то сломается.

ООП - это не иерархии, наследование и повторное использование существующих классов, а обобщение поведения . ООП - это «У меня есть куча объектов, и я хочу, чтобы они выполняли определенную работу, и мне все равно, как». Вот для чего нужны интерфейсы . Если вы реализуете Iterableинтерфейс для своего, Nodeпотому что хотите сделать его итеративным, это прекрасно. Если вы реализуете Collectionинтерфейс, потому что хотите добавить / удалить дочерние узлы и т. Д., Тогда это нормально. Но наследование от другого класса, потому что это дает вам все, чего нет, или, по крайней мере, нет, если вы не продумали его тщательно, как описано выше.

Амон
источник
10
Я напечатал последние 4 абзаца, назвал их « О ООП и Наследовании» и прикрепил их к стене на работе. Некоторым из моих коллег нужно понять, что, как я знаю, они будут благодарны за то, как вы это выразили :) Спасибо.
YSC
17

Расширение контейнера как таковое обычно считается плохой практикой. Там действительно очень мало причин, чтобы расширить контейнер вместо того, чтобы просто иметь его. Расширение контейнера себя просто делает его еще более странным.

DeadMG
источник
14

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

Контракт equalsметода для списков требует, чтобы список считался равным другому объекту

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

Источник: https://docs.oracle.com/javase/7/docs/api/java/util/List.html#equals(java.lang.Object)

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

Линн
источник
1
Это отличная причина удалить это из моего кода, кроме плохой практики. : P
Эддисон Крамп
3

По поводу памяти:

Я бы сказал, что это вопрос перфекционизма. Конструктор по умолчанию ArrayListвыглядит следующим образом:

public ArrayList(int initialCapacity) {
     super();

     if (initialCapacity < 0)
         throw new IllegalArgumentException("Illegal Capacity: "+ initialCapacity);

     this.elementData = new Object[initialCapacity];
 }

public ArrayList() {
     this(10);
}

Источник . Этот конструктор также используется в Oracle-JDK.

Теперь рассмотрите возможность создания односвязного Списка с вашим кодом. Вы только что успешно увеличили потребление памяти в 10 раз (точнее, даже чуть выше). Для деревьев это тоже может быть довольно плохо без особых требований к структуре дерева. Использование LinkedListальтернативного или другого класса должно решить эту проблему.

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

РЕДАКТИРОВАТЬ: разъяснения относительно комментариев (@amon, чтобы быть точным). Этот раздел ответа не касается вопроса наследования. Сравнение использования памяти производится по сравнению с односвязным списком и с лучшим использованием памяти (в реальных реализациях коэффициент может немного измениться, но он все еще достаточно велик, чтобы суммировать до некоторой степени потраченной памяти).

Относительно "Плохой практики":

Определенно. Это не стандартный способ реализации графика по одной простой причине: граф-узел имеет дочерние узлы-, не является в списке дети-узлы. Точное выражение того, что вы имеете в виду в коде, является основным навыком. Будь то в именах переменных или выражая структуры, подобные этой. Следующий пункт: поддержание интерфейсов минимальным: вы сделали каждый метод ArrayListдоступным для пользователя класса посредством наследования. Нет способа изменить это, не нарушая всю структуру кода. Вместо этого сохраните Listкак внутреннюю переменную и сделайте необходимые методы доступными через метод-адаптер. Таким образом, вы можете легко добавлять и удалять функциональные возможности из класса, не все портя.

Павел
источник
1
В 10 раз выше по сравнению с чем ? Если у меня есть ArrayList, сконструированный по умолчанию, в качестве поля экземпляра (вместо того, чтобы наследовать от него), я фактически использую немного больше памяти, поскольку мне нужно хранить дополнительный указатель на ArrayList в моем объекте. Даже если мы сравниваем яблоки с апельсинами и сравниваем наследование от ArrayList с отсутствием использования ArrayList, учтите, что в Java мы всегда имеем дело с указателями на объекты, а не объектами по значению, как это делает C ++. Предполагая, что new Object[0]используется всего 4 слова, a new Object[10]будет использовать только 14 слов памяти.
Амон
@amon Я не указал это явно, извините за это. Хотя контекста должно быть достаточно, чтобы увидеть, что я сравниваю с LinkedList. И это называется ссылка, а не указатель между прочим. И дело с памятью было не в том, используется ли класс через наследование или нет, а просто в том, что (почти) неиспользуемые ArrayLists занимают довольно много памяти.
Пол
-2

Это похоже на семантику составного паттерна . Он используется для моделирования рекурсивных структур данных.

oopexpert
источник
13
Я не буду опровергать это, но именно поэтому я действительно ненавижу книгу GoF. Это чертово дерево! Почему нам нужно было изобрести термин «составной шаблон» для описания дерева?
Дэвид Арно
3
@DavidArno Я полагаю, что весь аспект полиморфных узлов определяет его как «составной шаблон», использование древовидной структуры данных является лишь частью этого.
JAB
2
@RobertHarvey, я не согласен (с вашими комментариями к вашему ответу и здесь). public class Node extends ArrayList<Node> { public string Item; }версия наследования вашего ответа. Ваш проще рассуждать, но оба достигают некоторой вещи: они определяют недвоичные деревья.
Дэвид Арно
2
@DavidArno: Я никогда не говорил, что это не сработает, я просто сказал, что это, вероятно, не идеально.
Роберт Харви
1
@DavidArno здесь не о чувствах и вкусе, а о фактах. Дерево состоит из узлов, и у каждого узла есть потенциальные потомки. Данный узел является листовым узлом только в данный момент времени. В композите листовой узел является листовым по построению, и его природа не изменится.
Кристоф