Jak zdefiniowanie, że metodę można zastąpić silniejszym zaangażowaniem, niż zdefiniowanie, że można wywołać metodę?

36

Od: http://www.artima.com/lejava/articles/designprinciples4.html

Erich Gamma: Nadal uważam, że to prawda, nawet po dziesięciu latach. Dziedziczenie to fajny sposób na zmianę zachowania. Wiemy jednak, że jest kruchy, ponieważ podklasa może łatwo przyjmować założenia dotyczące kontekstu, w którym wywoływana jest metoda zastępowana. Istnieje ścisłe powiązanie między klasą podstawową a podklasą z powodu niejawnego kontekstu, w którym wywoływany jest kod podklasy, który podłączam. Kompozycja ma ładniejszą właściwość. Sprzęganie jest redukowane przez tylko kilka mniejszych rzeczy, które podłączasz do czegoś większego, a większy obiekt po prostu odsyła mniejszy obiekt. Z punktu widzenia API zdefiniowanie, że metodę można zastąpić, jest silniejszym zobowiązaniem niż zdefiniowanie, że można wywołać metodę.

Nie rozumiem o co mu chodzi. Czy ktoś mógłby to wyjaśnić?

q126y
źródło

Odpowiedzi:

63

Zobowiązanie to coś, co zmniejsza twoje przyszłe opcje. Opublikowanie metody oznacza, że ​​użytkownicy ją wywołają, dlatego nie można usunąć tej metody bez naruszenia zgodności. Jeśli go zatrzymałeś private, nie mogliby (bezpośrednio) go nazwać, a pewnego dnia mógłbyś go bez problemu refaktoryzować. Dlatego opublikowanie metody jest większym zaangażowaniem niż jej niepublikowanie. Opublikowanie przeciążać metody jest jeszcze silniejsze zaangażowanie. Twoi użytkownicy mogą to nazwać i mogą tworzyć nowe klasy, w których metoda nie działa tak, jak myślisz!

Na przykład, jeśli opublikujesz metodę czyszczenia, możesz upewnić się, że zasoby są odpowiednio zwolnione, o ile użytkownicy pamiętają, aby wywoływać tę metodę jako ostatnią rzecz, którą robią. Ale jeśli metoda jest nadpisywalna, ktoś może zastąpić ją w podklasie i nie wywołać super. W rezultacie trzeci użytkownik może użyć tej klasy i spowodować wyciek zasobów, mimo że cleanup()na końcu zadzwonił ! Oznacza to, że nie możesz już zagwarantować semantyki kodu, co jest bardzo złe.

Zasadniczo nie można już polegać na żadnym kodzie działającym w metodach nadpisywanych przez użytkownika, ponieważ niektórzy pośrednicy mogą go zastąpić. Oznacza to, że musisz zaimplementować procedurę czyszczenia całkowicie privatemetodami, bez pomocy użytkownika. Dlatego zwykle dobrym pomysłem jest publikowanie tylko finalelementów, chyba że są one wyraźnie przeznaczone do zastąpienia przez użytkowników interfejsu API.

Kilian Foth
źródło
11
To prawdopodobnie najlepszy argument przeciwko spadkowi, jaki kiedykolwiek czytałem. Ze wszystkich powodów, z którymi się zetknąłem, nigdy wcześniej nie spotkałem się z tymi dwoma argumentami (łączenie i łamanie funkcjonalności poprzez przesłonięcie), ale oba są bardzo silnymi argumentami przeciwko dziedziczeniu.
David Arno,
5
@DavidArno Nie sądzę, że jest to argument przeciwko spadkowi. Myślę, że to argument przeciwko „domyślnie nadpisywać wszystko”. Dziedziczenie samo w sobie nie jest niebezpieczne, korzystanie z niego bez zastanowienia jest.
svick,
15
Choć brzmi to jak dobry punkt, tak naprawdę nie rozumiem, w jaki sposób „użytkownik może dodać swój własny błędny kod” jest argumentem. Włączenie dziedziczenia pozwala użytkownikom dodawać brakujące funkcje bez utraty możliwości aktualizacji, co może zapobiegać błędom i je naprawiać. Jeśli kod użytkownika na szczycie interfejsu API jest uszkodzony, nie jest to usterka interfejsu API.
Sebb
4
Możesz łatwo przekształcić ten argument w: pierwszy program kodujący wykonuje argument czyszczenia, ale popełnia błędy i nie usuwa wszystkiego. Drugi koder zastępuje metodę czyszczenia i wykonuje dobrą robotę, a koder nr 3 korzysta z klasy i nie ma żadnych wycieków zasobów, mimo że koder nr 1 zrobił z niego bałagan.
Pieter B
6
@Doval Rzeczywiście. Dlatego to parodia, że ​​dziedziczenie jest lekcją numer jeden w prawie każdej wprowadzającej książce OOP i klasie.
Kevin Krumwiede,
30

Jeśli opublikujesz normalną funkcję, otrzymasz jednostronną umowę:
Co robi funkcja, jeśli zostanie wywołana?

Publikując oddzwanianie, udzielasz również jednostronnej umowy:
kiedy i jak będzie się nazywać?

A jeśli opublikujesz funkcję nadpisywalną, jest ona jednocześnie naraz, więc zawierasz dwustronną umowę:
Kiedy zostanie wywołana i co należy zrobić, jeśli zostanie wywołana?

Nawet jeśli użytkownicy nie nadużywają swoje API (łamiąc swoją część umowy, która może być drogie, aby wykryć), można łatwo zobaczyć, że te ostatnie potrzeby znacznie więcej dokumentacji, a wszystko Ci dokument jest zobowiązaniem, którego granice twoje dalsze wybory.

Przykładem reneging na taki dwustronny umowy jest ruch od showi hidedo setVisible(boolean)w java.awt.Component .

Deduplikator
źródło
+1. Nie jestem pewien, dlaczego druga odpowiedź została zaakceptowana; zawiera kilka interesujących punktów, ale zdecydowanie nie jest właściwą odpowiedzią na to pytanie, ponieważ zdecydowanie nie jest to, co rozumie się przez cytowany fragment.
ruakh
To poprawna odpowiedź, ale nie rozumiem tego przykładu. Wydaje się, że zamiana show and hide na setVisible (boolean) powoduje uszkodzenie kodu, który również nie korzysta z dziedziczenia. Czy coś brakuje?
eigensheep,
3
@eigensheep: showi hidenadal istnieją, są po prostu @Deprecated. Tak więc zmiana nie psuje żadnego kodu, który je po prostu wywołuje. Ale jeśli je przesłoniłeś, nie zostaną wywołane przez klientów migrujących do nowego „setVisible”. (Nigdy nie korzystałem ze Swinga, więc nie wiem, jak często jest to nadpisywane; ale ponieważ zdarzyło się to dawno temu, wyobrażam sobie, że powodem, dla którego Deduplicator pamięta, jest to, że go boleśnie ugryzł.)
ruakh
12

Odpowiedź Kiliana Fotha jest doskonała. Chciałbym tylko dodać kanoniczny przykład *, dlaczego jest to problem. Wyobraź sobie całkowitą klasę 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);
    }
}

Podklasujmy teraz, aby był punktem 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);
    }
}

Super proste! Wykorzystajmy nasze punkty:

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

Prawdopodobnie zastanawiasz się, dlaczego zamieszczam tak łatwy przykład. Oto haczyk:

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

Kiedy porównujemy punkt 2D z równoważnym punktem 3D, stajemy się prawdą, ale kiedy odwracamy porównanie, otrzymujemy fałsz (ponieważ p2a zawodzi instanceof Point3D).

Wniosek

  1. Zazwyczaj możliwe jest zaimplementowanie metody w podklasie w taki sposób, aby nie była już zgodna z tym, jak superklasa oczekuje, że zadziała.

  2. Zasadniczo niemożliwe jest zaimplementowanie funkcji equals () w znacznie innej podklasie w sposób zgodny z jej klasą nadrzędną.

Kiedy piszesz klasę, którą zamierzasz pozwolić ludziom na podklasę, naprawdę dobrym pomysłem jest napisanie kontraktu dotyczącego tego, jak każda metoda powinna się zachowywać. Jeszcze lepszy byłby zestaw testów jednostkowych, które ludzie mogliby przeprowadzić przeciwko wdrożeniu zastąpionych metod, aby udowodnić, że nie naruszają umowy. Prawie nikt tego nie robi, bo to za dużo pracy. Ale jeśli cię to obchodzi, to jest to, co należy zrobić.

Doskonałym przykładem dobrze napisanej umowy jest komparator . Po prostu zignoruj ​​to, o czym mówi .equals()z powodów opisanych powyżej. Oto przykład tego, jak Komparator może robić rzeczy, .equals()których nie może .

Notatki

  1. Źródłem tego przykładu był „Efektywna Java” Josh Blocha, pozycja 8, ale Bloch używa ColorPoint, który dodaje kolor zamiast trzeciej osi i używa podwójnych zamiast ints. Przykład Java Blocha jest w zasadzie powielony przez Odersky / Spoon / Venners, którzy udostępnili swój przykład online.

  2. Kilka osób sprzeciwiło się temu przykładowi, ponieważ jeśli poinformujesz klasę nadrzędną o podklasie, możesz rozwiązać ten problem. To prawda, jeśli istnieje wystarczająco mała liczba podklas i jeśli rodzic wie o nich wszystkich. Ale pierwotne pytanie dotyczyło stworzenia interfejsu API, dla którego ktoś inny napisze podklasy. W takim przypadku generalnie nie można zaktualizować implementacji nadrzędnej, aby była zgodna z podklasami.

Premia

Komparator jest również interesujący, ponieważ działa poprawnie w kwestii poprawnego implementowania funkcji equals (). Co więcej, jest to zgodne ze schematem naprawiania tego rodzaju problemu spadkowego: wzorzec projektowania strategii. Typy, którymi podekscytowani są ludzie Haskell i Scala, są również wzorem strategii. Dziedziczenie nie jest złe ani złe, jest po prostu trudne. Więcej informacji można znaleźć w artykule Philipa Wadlera Jak uczynić polimorfizm ad hoc mniej ad hoc

GlenPeterson
źródło
1
SortedMap i SortedSet tak naprawdę nie zmieniają definicji tego, w equalsjaki sposób Map i Set go definiują. Równość całkowicie ignoruje kolejność, w wyniku czego na przykład dwa SortedSets z tymi samymi elementami, ale różne kolejność sortowania wciąż się równa.
user2357112 obsługuje Monica
1
@ user2357112 Masz rację i usunąłem ten przykład. Kompatybilność z SortedMap.equals () z Mapą to osobna kwestia, na którą będę narzekać. SortedMap to na ogół O (log2 n), a HashMap (kanoniczna implementacja mapy) to O (1). Dlatego skorzystałbyś z SortedMap tylko, jeśli naprawdę zależy ci na zamawianiu. Z tego powodu uważam, że kolejność jest wystarczająco ważna, aby być kluczowym składnikiem testu equals () w implementacjach SortedMap. Nie powinni udostępniać implementacji equals () Mapowi (robią to poprzez AbstractMap w Javie).
GlenPeterson,
3
„Dziedziczenie nie jest złe ani złe, jest po prostu trudne”. Rozumiem, co mówisz, ale podstępne rzeczy zwykle prowadzą do błędów, błędów i problemów. Kiedy możesz osiągnąć te same rzeczy (lub prawie wszystkie te same rzeczy) w bardziej niezawodny sposób, trudniejszy sposób jest zły.
jpmc26,
7
To okropny przykład, Glen. Po prostu użyłeś dziedziczenia w sposób, w jaki nie powinien być używany, nic dziwnego, że klasy nie działają tak, jak chciałeś. Złamałeś zasadę podstawienia Liskowa, podając niewłaściwą abstrakcję (punkt 2D), ale to, że dziedziczenie jest złe w twoim złym przykładzie, nie oznacza, że ​​jest ogólnie złe. Chociaż ta odpowiedź może wydawać się rozsądna, dezorientuje tylko ludzi, którzy nie zdają sobie sprawy, że łamie ona najbardziej podstawową zasadę dziedziczenia.
Andy,
3
ELI5 Liskov's Substituton Principle mówi: Jeśli klasa Bjest dzieckiem klasy Ai powinna utworzyć instancję obiektu klasy B, powinieneś być w stanie rzutować Bobiekt klasy na jego obiekt nadrzędny i korzystać z interfejsu API rzutowanej zmiennej bez utraty szczegółów implementacji dziecko. Złamałeś regułę, zapewniając trzecią właściwość. Jak planujesz uzyskać dostęp do zwspółrzędnych po rzutowaniu Point3Dzmiennej Point2D, gdy klasa podstawowa nie ma pojęcia, że ​​taka właściwość istnieje? Jeśli rzucając klasę potomną do jej bazy, zepsujesz publiczny interfejs API, twoja abstrakcja będzie błędna.
Andy,
4

Dziedziczenie osłabia enkapsulację

Publikując interfejs z dozwolonym dziedziczeniem, znacznie zwiększasz rozmiar swojego interfejsu. Każdą nadpisywalną metodę można zastąpić, dlatego należy ją traktować jako wywołanie zwrotne dostarczane do konstruktora. Implementacja zapewniona przez twoją klasę jest jedynie domyślną wartością wywołania zwrotnego. W związku z tym należy przewidzieć pewien rodzaj umowy wskazującej oczekiwania wobec metody. Zdarza się to rzadko i jest głównym powodem, dla którego kod obiektowy nazywany jest kruchością.

Poniżej znajduje się prawdziwy (uproszczony) przykład z ram kolekcji java, dzięki uprzejmości Petera Norviga ( 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);
        }
    }
}

Co się stanie, jeśli podzielimy to na klasy?

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

Mamy błąd: od czasu do czasu dodajemy „pies”, a tablica hasztująca otrzymuje wpis „pies”. Przyczyną było to, że ktoś zapewnił implementację put, czego nie oczekiwał projektant klasy Hashtable.

Dziedziczenie przerywa rozszerzalność

Jeśli pozwolisz, aby twoja klasa została podklasowana, zobowiązujesz się nie dodawać żadnych metod do swojej klasy. W przeciwnym razie można to zrobić bez zepsucia czegokolwiek.

Gdy dodasz nowe metody do interfejsu, każdy, kto odziedziczył po twojej klasie, będzie musiał zaimplementować te metody.

eigensheep
źródło
3

Jeśli metoda ma zostać wywołana, musisz tylko upewnić się, że działa poprawnie. to jest to! Gotowy.

Jeśli metoda ma zostać zastąpiona, musisz również dokładnie przemyśleć jej zakres: jeśli zakres jest zbyt duży, klasa potomna często będzie musiała dołączyć wklejony kod z metody nadrzędnej; jeśli jest zbyt mały, wiele metod będzie musiało zostać zastąpionych, aby uzyskać pożądaną nową funkcjonalność - zwiększa to złożoność i niepotrzebną liczbę linii.

Dlatego twórca metody nadrzędnej musi poczynić założenia, w jaki sposób klasa i jej metody mogą zostać przesłonięte w przyszłości.

W cytowanym tekście autor mówi jednak o innym problemie:

Wiemy jednak, że jest kruchy, ponieważ podklasa może łatwo przyjmować założenia dotyczące kontekstu, w którym wywoływana jest metoda zastępowana.

Rozważ metodę, aktóra jest zwykle wywoływana z metody b, ale w niektórych rzadkich i nieoczywistych przypadkach z metody c. Jeśli autor nadrzędnej metody przeoczy tę cmetodę i jej oczekiwania a, oczywiste jest, że coś może pójść nie tak.

Dlatego ważniejsze ajest to, aby jasno i jednoznacznie zdefiniować, dobrze udokumentowane, „robi jedną rzecz i robi to dobrze” - bardziej niż gdyby była to metoda przeznaczona wyłącznie do wywołania.

Deszczowy
źródło