Jaka byłaby wada definiowania klasy jako podklasy samej listy?

48

W moim ostatnim projekcie zdefiniowałem klasę z następującym nagłówkiem:

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

Jednak po rozmowie z moim profesorem CS stwierdził, że klasa będzie zarówno „okropna dla pamięci”, jak i „zła praktyka”. Nie uważam, że pierwsze jest szczególnie prawdziwe, a drugie subiektywne.

Moje uzasadnienie tego użycia jest takie, że wpadłem na pomysł obiektu, który musiał zostać zdefiniowany jako coś, co może mieć dowolną głębię, w którym zachowanie instancji można zdefiniować albo za pomocą niestandardowej implementacji, albo poprzez zachowanie kilku podobnych obiektów wchodzących w interakcje . Pozwoliłoby to na abstrakcję obiektów, których fizyczna implementacja składałaby się z wielu współdziałających podskładników

Z drugiej strony widzę, jak może to być zła praktyka. Pomysł zdefiniowania czegoś jako listy własnej nie jest prosty ani fizyczny.

Czy istnieje jakiś ważny powód, dla którego nie powinienem tego używać w kodzie, biorąc pod uwagę moje użycie?


¹ Z przyjemnością to wyjaśnię, chętnie to zrobię; Próbuję tylko zachować zwięzłość tego pytania.

Addison Crump
źródło
1
@gnat Ma to więcej wspólnego ze ścisłym zdefiniowaniem klasy jako samej listy, a nie zawieraniem list zawierających listy. Myślę, że jest to wariant podobnej rzeczy, ale nie do końca taki sam. To pytanie odnosi się do odpowiedzi podobnej do odpowiedzi Roberta Harveya .
Addison Crump,
16
Dziedziczenie z czegoś, co samo jest sparametryzowane, nie jest niczym niezwykłym, jak pokazuje powszechnie używany idiom CRTP w C ++. W przypadku kontenera kluczowym pytaniem jest jednak uzasadnienie, dlaczego należy użyć dziedziczenia zamiast kompozycji.
Christophe,
1
Podoba mi się ten pomysł. W końcu każdy węzeł w drzewie jest (pod) drzewem. Zazwyczaj drzewo węzła jest ukryte przed widokiem typu (klasyczny węzeł nie jest drzewem, lecz pojedynczym obiektem) i zamiast tego jest wyrażone w widoku danych (pojedynczy węzeł umożliwia dostęp do gałęzi). Tutaj jest na odwrót; nie zobaczymy danych oddziału, jest w typie. Jak na ironię, faktyczny układ obiektów w C ++ byłby prawdopodobnie bardzo podobny (dziedziczenie jest realizowane bardzo podobnie do przechowywania danych), co sprawia, że ​​myślę, że mamy do czynienia z dwoma sposobami wyrażania tego samego.
Peter - przywróć Monikę
1
Może trochę mi brakuje, ale czy naprawdę potrzebujesz rozszerzyć ArrayList<Node> , w porównaniu do implementacji List<Node> ?
Matthias,

Odpowiedzi:

107

Szczerze mówiąc, nie widzę tutaj potrzeby dziedziczenia. To nie ma sensu; Node jest ArrayList z Node?

Jeśli jest to tylko rekurencyjna struktura danych, wystarczy napisać coś takiego:

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

Co ma sens; węzeł ma listę węzłów potomnych lub potomnych.

Robert Harvey
źródło
34
To nie to samo. Lista listy ad-infinitum jest bez znaczenia, chyba że przechowujesz coś poza nową listą na liście. Po to Item jest i Itemdziała niezależnie od list.
Robert Harvey
6
Och, teraz rozumiem. I zbliżył się Node to ArrayList z Nodejak Node zawiera 0 do wielu Node obiektów. Ich funkcja jest zasadniczo taka sama, ale zamiast być listą podobnych obiektów, ma listę podobnych obiektów. Pierwotny projekt napisanej klasy polegał na przekonaniu, że każdy Nodemoże być wyrażony jako zbiór napisów Node, co robią obie implementacje, ale ten z pewnością jest mniej mylący. +1
Addison Crump
11
Powiedziałbym, że tożsamość między węzłem a listą ma tendencję do powstawania „naturalnie”, gdy piszesz pojedynczo połączone listy w C lub Lisp lub cokolwiek innego: w niektórych projektach węzeł główny jest „listą, w sensie bycia jedynym coś, co kiedyś reprezentowało listę. Nie mogę więc całkowicie rozwieść wrażenia, że ​​w niektórych kontekstach być może węzeł „jest” (a nie tylko „ma”) kolekcję węzłów, mimo że zgadzam się, że w Javie jest to EvilBadWrong.
Steve Jessop,
12
@ nl-x To, że jedna składnia jest krótsza, nie oznacza, że ​​jest lepsza. Nawiasem mówiąc, dostęp do przedmiotów na takim drzewie jest dość rzadki. Zwykle struktura nie jest znana w czasie kompilacji, więc zwykle nie przechodzisz przez takie drzewa według stałych wartości. Głębokość również nie jest stała, więc nie można nawet iterować wszystkich wartości przy użyciu tej składni. Kiedy dostosowujesz kod do korzystania z tradycyjnego problemu z przechodzeniem przez drzewo, kończysz pisanie Childrentylko raz lub dwa, i zwykle lepiej jest napisać go w takich przypadkach ze względu na przejrzystość kodu.
Servy
8
+1 za preferowanie kompozycji zamiast dziedziczenia .
Menelaos Kotsollaris
57

Argument „okropny dla pamięci” jest całkowicie błędny, ale jest to obiektywnie „zła praktyka”. Dziedzicząc po klasie, nie dziedziczysz tylko pól i metod, którymi jesteś zainteresowany. Zamiast tego dziedziczysz wszystko . Każda metoda, którą deklaruje, nawet jeśli nie jest dla Ciebie przydatna. A co najważniejsze, dziedziczysz także wszystkie umowy i gwarancje, które zapewnia klasa.

Skrót SOLID zapewnia pewną heurystykę dla dobrego projektowania obiektowego. Tutaj ja nterface Segregacja Zasada (ISP) i L iskov Zmiana Pricinple (LSP) mają coś do powiedzenia.

ISP mówi nam, aby nasze interfejsy były jak najmniejsze. Ale dziedzicząc po ArrayList, masz wiele metod. Jest to sensowne get(), remove(), set()(wymienić) albo add()(wkładka) węzeł dziecko w określonym indeksie? Czy ma to sens ensureCapacity()w przypadku bazowej listy? Co to oznacza dla sort()węzła? Czy użytkownicy Twojej klasy naprawdę powinni to zrobić subList()? Ponieważ nie możesz ukryć metod, których nie chcesz, jedynym rozwiązaniem jest posiadanie ArrayList jako zmiennej składowej i przekazywanie wszystkich metod, których faktycznie potrzebujesz:

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

Jeśli naprawdę chcesz wszystkich metod, które widzisz w dokumentacji, możemy przejść do LSP. LSP mówi nam, że musimy być w stanie korzystać z podklasy wszędzie tam, gdzie oczekiwana jest klasa nadrzędna. Jeśli funkcja przyjmuje ArrayListparametr jako, a Nodezamiast tego przekazujemy nasze , nic nie powinno się zmienić.

Zgodność podklas zaczyna się od prostych rzeczy, takich jak podpisy typów. Podczas przesłonięcia metody nie można zaostrzyć typów parametrów, ponieważ może to wykluczać zastosowania, które były legalne dla klasy nadrzędnej. Ale to jest coś, co kompilator sprawdza dla nas w Javie.

Ale LSP działa znacznie głębiej: musimy zachować zgodność ze wszystkim, co obiecuje dokumentacja wszystkich klas nadrzędnych i interfejsów. W swojej odpowiedzi Lynn znalazł jeden taki przypadek, w którym Listinterfejs (który odziedziczyłeś za pośrednictwem ArrayList) gwarantuje, jak powinny działać metody equals()i hashCode(). Na hashCode()jesteś nawet biorąc pod uwagę konkretny algorytm, który musi być realizowany dokładnie. Załóżmy, że napisałeś 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);
  }

  ...

}

Wymaga to, że valuenie mogą przyczyniać się hashCode()i nie mogą wpływać equals(). ListInterfejs - które obiecują cześć przez dziedziczenie z nim - wymaga new Node(0).equals(new Node(123)), aby mogło być prawdziwe.


Ponieważ dziedziczenie po klasach zbyt łatwo przypadkowo złamać obietnicę złożoną przez klasę nadrzędną, a ponieważ zwykle ujawnia więcej metod, niż zamierzałeś, ogólnie sugeruje się, że wolisz kompozycję niż dziedziczenie . Jeśli musisz coś odziedziczyć, zaleca się dziedziczenie tylko interfejsów. Jeśli chcesz ponownie wykorzystać zachowanie określonej klasy, możesz zachować ją jako osobny obiekt w zmiennej instancji, w ten sposób wszystkie obietnice i wymagania nie zostaną narzucone.

Czasami nasz język naturalny sugeruje związek spadkowy: samochód to pojazd. Motocykl to pojazd. Czy powinienem definiować klasy Cari Motorcycledziedziczyć je po Vehicleklasie? Projektowanie obiektowe nie polega na odzwierciedlaniu realnego świata dokładnie w naszym kodzie. W naszym kodzie źródłowym nie możemy z łatwością zakodować bogatej taksonomii realnego świata.

Jednym z takich przykładów jest problem modelowania pracownik-szef. Mamy wiele Personliter, każda z nazwą i adresem. An Employeejest a Personi ma Boss. A Bossjest także Person. Czy powinienem więc stworzyć Personklasę dziedziczoną przez Bossi Employee? Teraz mam problem: szef jest także pracownikiem i ma innego przełożonego. Wygląda na to, że Bosspowinien się rozszerzyć Employee. Ale to CompanyOwnerjest Bossale nie jest Employee? Jakikolwiek wykres dziedziczenia jakoś tu się załamie.

OOP nie dotyczy hierarchii, dziedziczenia i ponownego wykorzystywania istniejących klas, chodzi o uogólnienie zachowania . OOP polega na tym, że „mam mnóstwo obiektów i chcę, aby zrobiły określoną rzecz - i nie obchodzi mnie to, w jaki sposób”. Po to są interfejsy . Jeśli zaimplementujesz Iterableinterfejs dla siebie, Nodeponieważ chcesz, aby był iterowalny, to w porządku. Jeśli implementujesz Collectioninterfejs, ponieważ chcesz dodawać / usuwać węzły potomne itp., To w porządku. Ale dziedziczenie od innej klasy, ponieważ zdarza się, że daje ci wszystko, co nie jest, a przynajmniej nie, chyba że dokładnie przemyślisz to, jak opisano powyżej.

amon
źródło
10
Wydrukowałem ostatnie 4 akapity, zatytułowane O OOP i Dziedziczenie i umieściłem je na ścianie w pracy. Niektórzy z moich kolegów muszą to zobaczyć, ponieważ wiem, że doceniliby sposób, w jaki to ujęliście :) Dziękuję.
YSC
17

Samo przedłużanie pojemnika jest zwykle uważane za złą praktykę. Naprawdę nie ma powodu, aby rozszerzać pojemnik zamiast go mieć. Poszerzenie pojemnika sprawia, że ​​jest to bardzo dziwne.

DeadMG
źródło
14

Oprócz tego, co zostało powiedziane, istnieje nieco specyficzny dla Javy powód, aby unikać tego rodzaju struktur.

Kontrakt equalsmetody dla list wymaga, aby listę uznać za równą innemu obiektowi

jeśli i tylko jeśli określony obiekt jest również listą, obie listy mają ten sam rozmiar, a wszystkie odpowiadające im pary elementów na dwóch listach są równe .

Źródło: https://docs.oracle.com/javase/7/docs/api/java/util/List.html#equals(java.lang.Object)

W szczególności oznacza to, że posiadanie klasy zaprojektowanej jako lista sama w sobie może sprawić, że porównania równości będą kosztowne (i obliczenia skrótu również, jeśli listy są zmienne), a jeśli klasa ma pewne pola instancji, należy je zignorować w porównaniu równości .

Lynn
źródło
1
To doskonały powód do usunięcia tego z mojego kodu, oprócz złych praktyk. : P
Addison Crump
3

Odnośnie pamięci:

Powiedziałbym, że to kwestia perfekcjonizmu. Domyślny konstruktor ArrayListwygląda następująco:

public ArrayList(int initialCapacity) {
     super();

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

     this.elementData = new Object[initialCapacity];
 }

public ArrayList() {
     this(10);
}

Źródło . Ten konstruktor jest także używany w Oracle-JDK.

Teraz zastanów się nad stworzeniem pojedynczo połączonej listy z twoim kodem. Właśnie z powodzeniem zwiększyłeś zużycie pamięci 10-krotnie (a ściślej nawet nieco wyżej). W przypadku drzew może to być również bardzo złe, bez żadnych specjalnych wymagań dotyczących struktury drzewa. LinkedListAlternatywnie użycie innej klasy powinno rozwiązać ten problem.

Szczerze mówiąc, w większości przypadków jest to po prostu perfekcjonizm, nawet jeśli zignorujemy ilość dostępnej pamięci. A LinkedListnieco spowolniłby kod jako alternatywa, więc jest to kompromis między wydajnością a zużyciem pamięci. Jednak moim osobistym zdaniem w tej sprawie byłoby nie marnowanie tak dużej ilości pamięci w sposób, który można by tak łatwo obejść jak ten.

EDYCJA: wyjaśnienie dotyczące komentarzy (dokładniej @amon). Ta część odpowiedzi nie dotyczy kwestii dziedziczenia. Porównanie wykorzystania pamięci odbywa się w stosunku do pojedynczo połączonej listy i najlepszego wykorzystania pamięci (w rzeczywistych implementacjach czynnik może się nieco zmienić, ale wciąż jest wystarczająco duży, aby zsumować całkiem sporo zmarnowanej pamięci).

W odniesieniu do „złej praktyki”:

Zdecydowanie. To nie jest standardowym sposobem realizacji wykres z prostego powodu: wykres-węzeł ma podrzędne węzły, nie jest to lista dzieci-węzłów. Precyzyjne wyrażanie tego, co masz na myśli w kodzie, to główna umiejętność. Czy to w nazwach zmiennych, czy w strukturach wyrażających, takich jak ta. Następny punkt: utrzymywanie interfejsów na minimalnym poziomie: ArrayListUdostępniłeś każdą metodę użytkownikowi klasy poprzez dziedziczenie. Nie można tego zmienić bez zerwania całej struktury kodu. Zamiast tego zapisz Listjako zmienną wewnętrzną i udostępnij wymagane metody za pomocą metody adaptera. W ten sposób możesz łatwo dodawać i usuwać funkcje z klasy bez zepsucia wszystkiego.

Paweł
źródło
1
10 razy wyższa w porównaniu do czego ? Jeśli mam domyślnie skonstruowaną ArrayList jako pole instancji (zamiast dziedziczenia po niej), faktycznie używam nieco więcej pamięci, ponieważ muszę przechowywać w moim obiekcie dodatkową wskaźnik-ArrayList. Nawet jeśli porównamy jabłka z pomarańczami i porównamy dziedziczenie z ArrayList do nieużywania żadnej ArrayList, zauważ, że w Javie zawsze mamy do czynienia ze wskaźnikami do obiektów, nigdy obiektów pod względem wartości, tak jak zrobiłby to C ++. Zakładając, że new Object[0]używa łącznie 4 słów, a new Object[10]użyłoby tylko 14 słów pamięci.
amon
@amon Nie powiedziałem tego wprost, płacz za to. Chociaż kontekst powinien być wystarczający, aby zobaczyć, że porównuję do LinkedList. I nazywa się to referencją, a nie wskaźnikiem btw. A w przypadku pamięci nie chodziło o to, czy klasa jest używana poprzez dziedziczenie, czy nie, ale po prostu o fakt, że (prawie) nieużywane ArrayLists rezygnują z dość dużej ilości pamięci.
Paul
-2

Wygląda to na semantyczny wzór złożony . Służy do modelowania rekurencyjnych struktur danych.

oopexpert
źródło
13
Nie będę głosować za tym, ale dlatego naprawdę nienawidzę książki GoF. To krwawiące drzewo! Dlaczego potrzebowaliśmy wymyślić termin „wzór złożony”, aby opisać drzewo?
David Arno,
3
@DavidArno Uważam, że jest to cały aspekt węzła polimorficznego, który definiuje go jako „wzorzec złożony”, użycie struktury danych drzewa jest po prostu jego częścią.
JAB
2
@RobertHarvey, nie zgadzam się (zarówno z komentarzami do odpowiedzi, jak i tutaj). public class Node extends ArrayList<Node> { public string Item; }jest dziedziczoną wersją twojej odpowiedzi. Twoje jest łatwiejsze do uzasadnienia, ale oba osiągają jedno: definiują drzewa niebinarne.
David Arno,
2
@DavidArno: Nigdy nie mówiłem, że to nie zadziała, po prostu powiedziałem, że prawdopodobnie nie jest idealny.
Robert Harvey
1
@DavidArno nie chodzi tu o uczucia i smak, ale o fakty. Drzewo składa się z węzłów, a każdy węzeł ma potencjalne elementy potomne. Dany węzeł jest węzłem liścia tylko w danym momencie. W złożeniu węzeł liścia jest liściem z założenia i jego natura się nie zmieni.
Christophe,