Dlaczego dziedziczenie Square z Rectangle byłoby problematyczne, jeśli przesłoniliśmy metody SetWidth i SetHeight?

105

Jeśli kwadrat jest rodzajem prostokąta, to dlaczego kwadrat nie może odziedziczyć po prostokącie? Lub dlaczego jest to zły projekt?

Słyszałem, jak ludzie mówią:

Jeśli sprawiłeś, że Kwadrat pochodzi z prostokąta, kwadrat powinien być użyteczny wszędzie tam, gdzie oczekujesz prostokąta

Jaki jest tutaj problem? I dlaczego Square miałby być użyteczny wszędzie tam, gdzie oczekujesz prostokąta? Byłoby to użyteczne tylko wtedy, gdy utworzymy obiekt Square, a jeśli zastąpimy metody SetWidth i SetHeight dla Square, to dlaczego miałby wystąpić jakiś problem?

Jeśli posiadałeś metody SetWidth i SetHeight w swojej klasie bazowej Rectangle i jeśli twoja referencja Rectangle wskazywała na Square, to SetWidth i SetHeight nie mają sensu, ponieważ ustawienie jednej zmieniłoby drugą, aby ją dopasować. W tym przypadku Square nie przejdzie testu podstawienia Liskowa prostokątem, a abstrakcja dziedziczenia kwadratu po prostokącie jest zła.

Czy ktoś może wyjaśnić powyższe argumenty? Ponownie, jeśli przekroczymy metody SetWidth i SetHeight w Square, czy nie rozwiąże tego problemu?

Słyszałem także / czytałem:

Prawdziwy problem polega na tym, że nie modelujemy prostokątów, lecz „prostokąty, które można zmieniać”, tj. Prostokąty, których szerokość lub wysokość można modyfikować po utworzeniu (i nadal uważamy, że jest to ten sam obiekt). Jeśli spojrzymy na klasę prostokąta w ten sposób, jasne jest, że kwadrat nie jest „prostokątem przekształcalnym”, ponieważ kwadrat nie może zostać przekształcony i nadal być kwadratem (ogólnie). Matematycznie nie widzimy problemu, ponieważ zmienność nie ma nawet sensu w kontekście matematycznym

Tutaj uważam, że „zmiana rozmiaru” jest właściwym terminem. Prostokąty mają „możliwość zmiany rozmiaru”, podobnie jak kwadraty. Czy brakuje mi czegoś w powyższym argumencie? Kwadrat można zmienić tak jak dowolny prostokąt.

użytkownik793468
źródło
15
To pytanie wydaje się strasznie abstrakcyjne. Istnieją miliardy sposobów korzystania z klas i dziedziczenia, niezależnie od tego, czy pewne klasy dziedziczą po niektórych klasach, jest to dobry pomysł, zazwyczaj zależy to głównie od tego, jak chcesz z nich korzystać. Bez praktycznego przypadku nie widzę, w jaki sposób na to pytanie można uzyskać odpowiednią odpowiedź.
aaaaaaaaaaaa
2
Posługując się zdrowym rozsądkiem, przypomina się, że kwadrat jest prostokątem, więc jeśli obiekt klasy kwadratowej nie może być użyty tam, gdzie wymagany jest prostokąt, prawdopodobnie jest to jakaś wada projektu aplikacji.
Cthulhu
7
Myślę, że lepsze jest pytanie Why do we even need Square? To tak, jakby mieć dwa długopisy. Jeden długopis niebieski i jeden czerwony długopis niebieski, żółty lub zielony. Niebieski długopis jest zbędny - tym bardziej w przypadku kwadratu, ponieważ nie ma żadnych korzyści finansowych.
Gusdor
2
@eBusiness Jego abstrakcyjność sprawia, że ​​jest to dobre pytanie do nauki. Ważne jest, aby umieć rozpoznać, które zastosowania podtypów są złe niezależnie od konkretnych przypadków użycia.
Doval
5
@Cthulhu Nie bardzo. Podpisywanie polega na zachowaniu, a zmienny kwadrat nie zachowuje się jak zmienny prostokąt. Dlatego metafora „jest ...” jest zła.
Doval

Odpowiedzi:

189

Zasadniczo chcemy, aby rzeczy zachowywały się rozsądnie.

Rozważ następujący problem:

Dano mi grupę prostokątów i chcę zwiększyć ich powierzchnię o 10%. Więc to, co robię, to ustawiam długość prostokąta na 1,1 razy większą niż wcześniej.

public void IncreaseRectangleSizeByTenPercent(IEnumerable<Rectangle> rectangles)
{
  foreach(var rectangle in rectangles)
  {
    rectangle.Length = rectangle.Length * 1.1;
  }
}

Teraz w tym przypadku wszystkie moje prostokąty mają teraz długość zwiększoną o 10%, co zwiększy ich powierzchnię o 10%. Niestety, ktoś faktycznie przekazał mi mieszaninę kwadratów i prostokątów, a kiedy zmieniła się długość prostokąta, zmieniła się również szerokość.

Moje testy jednostkowe zdały, ponieważ napisałem wszystkie moje testy jednostkowe, aby użyć zbioru prostokątów. Wprowadziłem teraz do mojej aplikacji subtelny błąd, który może pozostać niezauważony przez wiele miesięcy.

Co gorsza, Jim z księgowości widzi moją metodę i pisze inny kod, który wykorzystuje fakt, że jeśli przekazuje kwadraty do mojej metody, uzyskuje bardzo ładny wzrost wielkości o 21%. Jim jest szczęśliwy i nikt nie jest mądrzejszy.

Jim zostaje awansowany za doskonałą pracę do innego oddziału. Alfred dołącza do firmy jako junior. W swoim pierwszym raporcie o błędach Jill z działu reklamy podał, że przekazanie kwadratów tej metodzie powoduje wzrost o 21% i chce, aby błąd został naprawiony. Alfred widzi, że kwadraty i prostokąty są używane wszędzie w kodzie, i zdaje sobie sprawę, że przerwanie łańcucha dziedziczenia jest niemożliwe. Nie ma również dostępu do kodu źródłowego Księgowości. Więc Alfred naprawia błąd w ten sposób:

public void IncreaseRectangleSizeByTenPercent(IEnumerable<Rectangle> rectangles)
{
  foreach(var rectangle in rectangles)
  {
    if (typeof(rectangle) == Rectangle)
    {
      rectangle.Length = rectangle.Length * 1.1;
    }
    if (typeof(rectangle) == Square)
    {
      rectangle.Length = rectangle.Length * 1.04880884817;
    }
  }
}

Alfred jest zadowolony ze swoich umiejętności hakerskich, a Jill sygnalizuje, że błąd został naprawiony.

W przyszłym miesiącu nikt nie otrzyma zapłaty, ponieważ księgowość była zależna od możliwości przekazania kwadratów do IncreaseRectangleSizeByTenPercentmetody i zwiększenia powierzchni o 21%. Cała firma przechodzi w tryb „poprawki błędu 1”, aby wyśledzić źródło problemu. Śledzą problem do rozwiązania Alfreda. Wiedzą, że muszą zadowolić zarówno księgowość, jak i reklamę. Rozwiązują więc problem, identyfikując użytkownika za pomocą wywołania metody w następujący sposób:

public void IncreaseRectangleSizeByTenPercent(IEnumerable<Rectangle> rectangles)
{
  IncreaseRectangleSizeByTenPercent(
    rectangles, 
    new User() { Department = Department.Accounting });
}

public void IncreaseRectangleSizeByTenPercent(IEnumerable<Rectangle> rectangles, User user)
{
  foreach(var rectangle in rectangles)
  {
    if (typeof(rectangle) == Rectangle || user.Department == Department.Accounting)
    {
      rectangle.Length = rectangle.Length * 1.1;
    }
    else if (typeof(rectangle) == Square)
    {
      rectangle.Length = rectangle.Length * 1.04880884817;
    }
  }
}

I tak dalej i tak dalej.

Ta anegdota oparta jest na rzeczywistych sytuacjach, z którymi codziennie spotykają się programiści. Naruszenie zasady substytucji Liskowa może wprowadzić bardzo subtelne błędy, które zostaną wykryte dopiero po latach od ich napisania, do tego czasu naprawienie naruszenia spowoduje uszkodzenie wielu rzeczy, a nie naprawienie go rozwścieczy twojego największego klienta.

Istnieją dwa realistyczne sposoby rozwiązania tego problemu.

Pierwszym sposobem jest uczynienie Rectangle niezmiennym. Jeśli użytkownik Prostokąta nie może zmienić właściwości Długość i Szerokość, problem zniknie. Jeśli chcesz mieć prostokąt o innej długości i szerokości, utwórz nowy. Kwadraty z radością dziedziczą po prostokątach.

Drugim sposobem jest przerwanie łańcucha spadkowego między kwadratami i prostokątami. Jeżeli kwadrat jest definiowana jako posiadające jedną SideLengthnieruchomość i prostokąty mają Lengthi Widthmienia i nie ma dziedziczenia, to niemożliwe, aby przypadkowo złamać rzeczy oczekując prostokąt i coraz kwadrat. W języku C # możesz sealzdefiniować klasę prostokąta, co gwarantuje, że wszystkie prostokąty, które kiedykolwiek otrzymasz, są w rzeczywistości prostokątami.

W tym przypadku podoba mi się sposób „rozwiązania problemu”. Tożsamość prostokąta to jego długość i szerokość. Ma sens, że gdy chcesz zmienić tożsamość obiektu, tak naprawdę chcesz nowego obiektu. Jeśli stracisz starego klienta i zyskasz nowego, nie zmienisz Customer.Idpola ze starego klienta na nowego, tworzysz nowego Customer.

Naruszenia zasady substytucji Liskowa są powszechne w świecie rzeczywistym, głównie dlatego, że wiele kodu jest napisanych przez ludzi, którzy są niekompetentni / pod presją czasu / nie dbają / popełniają błędy. Może i prowadzi do bardzo nieprzyjemnych problemów. W większości przypadków zamiast tego preferujesz kompozycję zamiast dziedziczenia .

Stephen
źródło
7
Liskov to jedno, a przechowywanie to kolejna sprawa. W większości implementacji instancja Square dziedzicząca z Rectangle będzie wymagała miejsca do przechowywania dwóch wymiarów, nawet jeśli tylko jeden jest potrzebny.
el.pescado
29
Genialne wykorzystanie historii do zilustrowania sensu
Rory Hunter
29
Fajna historia, ale się nie zgadzam. Przypadek użycia to: zmień obszar prostokąta. Poprawka powinna polegać na dodaniu nadpisywalnej metody „ChangeArea” do prostokąta, który specjalizuje się w kwadracie. Nie spowodowałoby to przerwania łańcucha dziedziczenia, wyraźnego określenia, co użytkownik chciał zrobić, i nie spowodowałoby błędu wprowadzonego przez wspomnianą poprawkę (który zostałby złapany w odpowiednim obszarze testowym).
Roy T.
33
@RoyT .: Dlaczego prostokąt powinien wiedzieć, jak ustawić swój obszar? Jest to właściwość wyprowadzona całkowicie z długości i szerokości. Co więcej, który wymiar powinien zmienić - długość, szerokość lub oba?
cHao
32
@ Roy T. Bardzo miło jest powiedzieć, że rozwiązalibyśmy problem inaczej, ale faktem jest, że jest to przykład - choć uproszczony - sytuacji w świecie rzeczywistym, z którymi programiści codziennie spotykają się przy utrzymaniu starszych produktów. I nawet jeśli zastosowałeś tę metodę, nie powstrzyma to spadkobierców naruszających LSP i wprowadzających podobne do tego błędy. Dlatego prawie każda klasa w .NET jest zapieczętowana.
Stephen
30

Jeśli wszystkie twoje obiekty są niezmienne, nie ma problemu. Każdy kwadrat jest również prostokątem. Wszystkie właściwości prostokąta są również właściwościami kwadratu.

Problem zaczyna się, gdy dodasz możliwość modyfikowania obiektów. A tak naprawdę - kiedy zaczynasz przekazywać argumenty do obiektu, a nie tylko czytasz gettery właściwości.

Istnieją modyfikacje, które można wykonać w prostokącie, które zachowują wszystkie niezmienniki klasy Rectangle, ale nie wszystkie niezmienniki kwadratowe - na przykład zmieniając szerokość lub wysokość. Nagle zachowanie się prostokąta to nie tylko jego właściwości, ale także możliwe modyfikacje. To nie tylko to, co dostajesz z Prostokąta, ale także to, co możesz włożyć .

Jeśli twój prostokąt ma metodę setWidthudokumentowaną jako zmiana szerokości i nie modyfikująca wysokości, wówczas Square nie może mieć zgodnej metody. Jeśli zmienisz szerokość, a nie wysokość, wynik nie będzie już prawidłowym kwadratem. Jeśli zdecydujesz się zmodyfikować zarówno szerokość, jak i wysokość Kwadratu podczas używania setWidth, nie wdrażasz specyfikacji Prostokąta setWidth. Po prostu nie możesz wygrać.

Kiedy spojrzysz na to, co możesz „umieścić” w prostokącie i kwadracie, jakie wiadomości możesz do nich wysłać, prawdopodobnie zauważysz, że każdą wiadomość, którą możesz poprawnie wysłać na kwadrat, możesz również wysłać do prostokąta.

Jest to kwestia współwariancji kontra kontra wariancji.

Metody odpowiedniej podklasy, takie, w których instancje mogą być używane we wszystkich przypadkach, w których oczekuje się nadklasy, wymagają, aby każda metoda:

  • Zwracane są tylko wartości, które zwróciłaby nadklasa - to znaczy typ zwracany musi być podtypem typu zwracanego przez metodę nadklasy. Zwrot jest wariantem alternatywnym.
  • Zaakceptuj wszystkie wartości, które zaakceptowałby nadtyp - to znaczy typy argumentów muszą być nadtypami typów argumentów metody nadklasy. Argumenty są przeciwne.

Wróćmy więc do prostokąta i kwadratu: to, czy kwadrat może być podklasą prostokąta, zależy całkowicie od tego, jakie metody ma prostokąt.

Jeśli prostokąt ma indywidualne ustawienia szerokości i wysokości, Square nie będzie dobrą podklasą.

Podobnie, jeśli sprawisz, że niektóre metody będą alternatywne w argumentach, jak compareTo(Rectangle)na przykład Prostokąt i compareTo(Square)Kwadrat, będziesz miał problem z użyciem Kwadratu jako prostokąta.

Jeśli zaprojektujesz swój Kwadrat i Prostokąt, aby były kompatybilne, prawdopodobnie będzie działać, ale należy je opracować razem, inaczej założę się, że to nie zadziała.

lrn
źródło
„Jeśli wszystkie twoje obiekty są niezmienne, nie ma problemu” - to pozornie nieistotne stwierdzenie w kontekście tego pytania, które wyraźnie wspomina o ustawiaczach szerokości i wysokości
komentuje
11
Uważam to za interesujące, nawet gdy „najwyraźniej nie ma znaczenia”
Jesvin Jose
14
@gnat Twierdzę, że jest to istotne, ponieważ prawdziwą wartością pytania jest rozpoznanie, kiedy istnieje poprawny związek podtypów między dwoma typami. To zależy od tego, jakie operacje deklaruje nadtyp, dlatego warto wskazać, że problem zniknie, jeśli odejdą metody mutatora.
Doval
1
@gnat również, setery są mutatorami , więc lrn zasadniczo mówi: „Nie rób tego i to nie jest problem”. Zgadzam się z niezmiennością prostych typów, ale masz rację: w przypadku złożonych obiektów problem nie jest taki prosty.
Patrick M,
1
Rozważ to w ten sposób, jakie zachowanie gwarantuje klasa „Prostokąt”? Że możesz zmienić szerokość i wysokość NIEZALEŻNIE od siebie. (tj. metoda setWidth i setHeight). Teraz, jeśli Kwadrat pochodzi z prostokąta, Kwadrat musi zagwarantować takie zachowanie. Ponieważ kwadrat nie może zagwarantować takiego zachowania, jest to złe dziedzictwo. Jeśli jednak metody setWidth / setHeight zostaną usunięte z klasy Rectangle, nie ma takiego zachowania i dlatego można uzyskać klasę Square z Rectangle.
Nitin Bhide
17

Jest tu wiele dobrych odpowiedzi; W szczególności odpowiedź Stephena dobrze ilustruje, dlaczego naruszenia zasady substytucji prowadzą do rzeczywistych konfliktów między zespołami.

Pomyślałem, że mógłbym krótko porozmawiać o konkretnym problemie prostokątów i kwadratów, zamiast używać go jako metafory innych naruszeń LSP.

Istnieje dodatkowy problem z kwadratem-specjalnym rodzajem prostokąta, o którym rzadko się wspomina, a mianowicie: dlaczego zatrzymujemy się za pomocą kwadratów i prostokątów ? Jeśli chcemy powiedzieć, że kwadrat jest szczególnym rodzajem prostokąta, z pewnością powinniśmy również powiedzieć:

  • Kwadrat to szczególny rodzaj rombu - to romb o kątach kwadratowych.
  • Romb to szczególny rodzaj równoległoboku - to równoległobok o równych bokach.
  • Prostokąt to szczególny rodzaj równoległoboku - to równoległobok o kątach kwadratowych
  • Prostokąt, kwadrat i równoległobok są szczególnym rodzajem trapezu - są to trapezoidy z dwoma zestawami równoległych boków
  • Wszystkie powyższe są specjalnymi rodzajami czworoboków
  • Wszystkie powyższe są specjalnymi rodzajami płaskich kształtów
  • I tak dalej; Mógłbym tu jeszcze iść.

Co u licha powinny tu być wszystkie relacje? Języki oparte na dziedziczeniu klas, takie jak C # lub Java, nie zostały zaprojektowane do reprezentowania tego rodzaju złożonych relacji z wieloma różnymi rodzajami ograniczeń. Najlepiej po prostu całkowicie uniknąć pytania, nie próbując reprezentować tych wszystkich rzeczy jako klas z relacjami podtypów.

Eric Lippert
źródło
3
Jeśli obiekty kształtów są niezmienne, wówczas można mieć IShapetyp, który zawiera ramkę ograniczającą, i można go rysować, skalować i serializować oraz mieć IPolygonpodtyp z metodą raportowania liczby wierzchołków i metodą zwracania znaku IEnumerable<Point>. Można wtedy IQuadrilateralpodtyp wynikającemu z IPolygon, IRhombusi IRectangle, wywodzą się z tym, i ISquarewywodzą się z IRhombusi IRectangle. Zmienność wyrzuca wszystko przez okno, a wielokrotne dziedziczenie nie działa z klasami, ale myślę, że w przypadku niezmiennych interfejsów jest w porządku.
supercat
Tutaj skutecznie nie zgadzam się z Erikiem (choć nie wystarczy na -1!). Wszystkie te relacje są (prawdopodobnie) istotne, jak wspomina @supercat; to tylko kwestia YAGNI: nie wdrażasz go, dopóki go nie potrzebujesz.
Mark Hurd
Bardzo dobra odpowiedź! Powinien być znacznie wyższy.
andrew.fox
1
@MarkHurd - nie jest to kwestia YAGNI: zaproponowana hierarchia oparta na dziedziczeniu ma kształt opisanej taksonomii, ale nie można jej napisać w celu zagwarantowania relacji, które ją definiują. W jaki sposób IRhombusgwarancja, że ​​wszystkie Pointzwracane od Enumerable<Point>zdefiniowanego przez IPolygonodpowiadają krawędziom o równej długości? Ponieważ sama implementacja IRhombusinterfejsu nie gwarantuje, że konkretny obiekt to romb, dziedziczenie nie może być odpowiedzią.
A. Rager
14

Z matematycznego punktu widzenia kwadrat jest prostokątem. Jeśli matematyk modyfikuje kwadrat, aby przestał przestrzegać kontraktu kwadratowego, zmienia się w prostokąt.

Ale w projektowaniu OO jest to problem. Obiekt jest tym, czym jest, i obejmuje to zarówno zachowania, jak i stan. Jeśli trzymam kwadratowy obiekt, ale ktoś inny modyfikuje go tak, aby był prostokątem, narusza to kontrakt kwadratu bez mojej winy. To powoduje, że dzieją się różnego rodzaju złe rzeczy.

Kluczowym czynnikiem jest tutaj zmienność . Czy kształt może się zmienić po jego zbudowaniu?

  • Zmienny: jeśli kształty mogą się zmieniać po zbudowaniu, kwadrat nie może mieć związku z prostokątem. Kontrakt prostokąta zawiera ograniczenie, że przeciwległe boki muszą być równej długości, ale sąsiednie boki nie muszą. Kwadrat musi mieć cztery równe boki. Modyfikacja kwadratu za pomocą interfejsu prostokąta może naruszać kwadratową umowę.

  • Niezmienny: jeśli kształty nie mogą się zmienić po zbudowaniu, to kwadratowy obiekt musi również zawsze wypełniać kontrakt prostokątny. Kwadrat może mieć związek z prostokątem.

W obu przypadkach można poprosić kwadrat o utworzenie nowego kształtu w oparciu o jego stan z jedną lub większą liczbą zmian. Na przykład można powiedzieć „utwórz nowy prostokąt na podstawie tego kwadratu, z tym wyjątkiem, że przeciwne boki A i C są dwa razy dłuższe”. Ponieważ budowany jest nowy obiekt, oryginalny kwadrat nadal przestrzega swoich umów.


źródło
1
This is one of those cases where the real world is not able to be modeled in a computer 100%. Dlaczego tak? Nadal możemy mieć funkcjonalny model kwadratu i prostokąta. Jedyną konsekwencją jest to, że musimy poszukać prostszej konstrukcji do abstrakcji na tych dwóch obiektach.
Simon Bergot,
6
Prostokąty i kwadraty mają więcej wspólnego niż to. Problem polega na tym, że tożsamość prostokąta i tożsamość kwadratu to jego długości boczne (i kąt na każdym przecięciu). Najlepszym rozwiązaniem jest dziedziczenie kwadratów z prostokątów, ale jednocześnie niezmienność.
Stephen
3
@Stephen uzgodniony. W rzeczywistości uczynienie ich niezmiennymi jest rozsądnym rozwiązaniem, niezależnie od problemów z podtytułem. Nie ma powodu, aby można je było modyfikować - nie jest trudniej zbudować nowy kwadrat lub prostokąt niż zmutować, więc po co otwierać robaki? Teraz nie musisz się martwić o aliasing / efekty uboczne i możesz użyć ich jako kluczy do map / nagrań w razie potrzeby. Niektórzy powiedzą „wydajność”, do której powiem „przedwczesna optymalizacja”, dopóki nie zmierzą i nie udowodnią, że hot spot jest w kodzie kształtu.
Doval
Przepraszam chłopaki, było późno i byłem bardzo zmęczony, kiedy napisałem odpowiedź. Przepisałem to, aby powiedzieć, co naprawdę miałem na myśli, którego sednem jest zmienność.
13

I dlaczego Square miałby być użyteczny wszędzie tam, gdzie oczekujesz prostokąta?

Ponieważ jest to część tego, co oznacza podtyp (patrz też: zasada podstawienia Liskowa). Możesz to zrobić, musisz być w stanie to zrobić:

Square s = new Square(5);
Rect r = s;
doSomethingWith(r); // written assuming a Rect, actually calls Square methods

Robisz to cały czas (czasem nawet bardziej niejawnie), używając OOP.

a jeśli przekroczymy metody SetWidth i SetHeight dla Square, to dlaczego miałby być jakiś problem?

Ponieważ nie można rozsądnie zastąpić tych Square. Ponieważ kwadratu nie można „zmienić rozmiaru jak jakikolwiek prostokąt”. Gdy zmienia się wysokość prostokąta, szerokość pozostaje taka sama. Ale gdy zmienia się wysokość kwadratu, szerokość musi się odpowiednio zmieniać. Problemem jest nie tylko zmiana rozmiaru, ale także zmiana rozmiaru w obu wymiarach niezależnie.

Deduplikator
źródło
W wielu językach nie potrzebujesz nawet Rect r = s;linii, możesz po prostu, doSomethingWith(s)a środowisko wykonawcze użyje wszelkich wywołań w scelu rozwiązania dowolnych Squaremetod wirtualnych .
Patrick M,
1
@PatrickM Nie potrzebujesz go w żadnym zdrowym języku, który ma podtytuły. Zawarłem tę linię do prezentacji, żeby być jawnym.
Zastąp więc setWidthi setHeightzmień zarówno szerokość, jak i wysokość.
Zbliża się do
@ValekHalfHeart Właśnie taką opcję rozważam.
7
@ValekHalfHeart: To właśnie narusza zasadę substytucji Liskowa, która będzie cię prześladować i sprawi, że spędzisz kilka nieprzespanych nocy próbując znaleźć dziwny błąd dwa lata później, gdy zapomnisz, jak powinien działać kod.
Jan Hudec
9

To, co opisujesz, narusza tak zwaną Zasadę Zastępstwa Liskowa . Podstawową ideą LSP jest to, że za każdym razem, gdy używasz instancji określonej klasy, zawsze powinieneś mieć możliwość zamiany w instancji dowolnej podklasy tej klasy, bez wprowadzania błędów.

Problem Prostokąta-kwadratu nie jest tak naprawdę dobrym sposobem na przedstawienie Liskova. Próbuje wyjaśnić ogólną zasadę na przykładzie, który jest dość subtelny, i działa z obojętnością na jedną z najczęstszych intuicyjnych definicji w całej matematyce. Niektórzy nazywają go z tego powodu problemem koła elipsy, ale w tym przypadku jest tylko nieco lepszy. Lepszym podejściem jest cofnięcie się o krok, używając czegoś, co nazywam problemem równoległościogram-prostokąt. To znacznie ułatwia zrozumienie.

Równoległobok jest czworokątny z dwiema równoległymi bokami. Ma również dwie pary przystających kątów. Nie jest trudno wyobrazić sobie obiekt równoległoboku wzdłuż tych linii:

class Parallelogram {
    function getSideA() {};
    function getSideB() {};
    function getAngleA() {};
    function getAngleB() {};
    function setSideA(newLength) {};
    function setSideB(newLength) {};
    function setAngleA(newAngle) {};
    function setAngleB(newAngle) {};
}

Jednym z powszechnych sposobów myślenia o prostokącie jest równoległobok z kątami prostymi. Na pierwszy rzut oka może wydawać się, że Rectangle jest dobrym kandydatem do dziedziczenia po Parallelogramie , dzięki czemu można ponownie wykorzystać cały ten pyszny kod. Jednak:

class Rectangle extends Parallelogram {
    function getSideA() {};
    function getSideB() {};
    function getAngleA() {};
    function getAngleB() {};
    function setSideA(newLength) {};
    function setSideB(newLength) {};

    /* BUG: Liskov violations ahead */
    function setAngleA(newAngle) {};
    function setAngleB(newAngle) {};
}

Dlaczego te dwie funkcje wprowadzają błędy w Rectangle? Problem polega na tym, że nie można zmieniać kątów w prostokącie : są one zdefiniowane jako zawsze 90 stopni, a zatem ten interfejs nie działa tak naprawdę dla Rectangle dziedziczącego z Parallelogram. Jeśli zamienię prostokąt na kod, który oczekuje równoległoboku, a ten kod spróbuje zmienić kąt, prawie na pewno wystąpią błędy. Wzięliśmy coś, co można było zapisać w podklasie, i uczyniło to tylko do odczytu, a to naruszenie Liskowa.

Jak to się ma do kwadratów i prostokątów?

Kiedy mówimy, że możesz ustawić wartość, zwykle mamy na myśli coś nieco silniejszego niż po prostu możliwość zapisania wartości. Zakładamy pewien stopień wyłączności: jeśli ustalisz wartość, a następnie wykluczysz jakieś nadzwyczajne okoliczności, pozostanie na tej wartości, dopóki jej nie ustawisz ponownie. Istnieje wiele zastosowań wartości, które można zapisać, ale nie pozostają ustawione, ale istnieje również wiele przypadków, które zależą od wartości pozostającej tam, gdzie jest, po jej ustawieniu. I tutaj napotykamy inny problem.

class Square extends Rectangle {
    function getSideA() {};
    function getSideB() {};
    function getAngleA() {};
    function getAngleB() {};

    /* BUG: More Liskov violations */
    function setSideA(newLength) {};
    function setSideB(newLength) {};

    /* Liskov violations inherited from Rectangle */
    function setAngleA(newAngle) {};
    function setAngleB(newAngle) {};
}

Nasza klasa Square odziedziczyła błędy po Rectangle, ale ma kilka nowych. Problem z setSideA i setSideB polega na tym, że żadnego z nich nie da się tak naprawdę ustawić: nadal możesz zapisać wartość w jednym z nich, ale zmieni się ono pod tobą, jeśli drugi zostanie napisany. Jeśli zamienię to na Parallelogram w kodzie, który zależy od możliwości ustawiania stron niezależnie od siebie, to wystraszy mnie.

Na tym polega problem i dlatego istnieje problem z użyciem Rectangle-Square jako wstępu do Liskova. Prostokąt-Kwadrat zależy od różnicy między zdolnością do pisania a zdolnością do ustawiania tego, i to jest o wiele bardziej subtelna różnica niż umiejętność ustawiania czegoś w porównaniu z ustawieniem tylko do odczytu. Prostokąt-kwadrat wciąż ma wartość jako przykład, ponieważ dokumentuje dość powszechną gotcha, na którą należy uważać, ale nie należy jej używać jako przykładu wprowadzającego . Pozwól uczniowi najpierw zdobyć trochę podstaw w podstawach, a następnie rzucić w niego czymś trudniejszym.

The Spooniest
źródło
8

Subtyping dotyczy zachowania.

Aby typ Bbył podtypem typu A, musi obsługiwać każdą operację obsługiwaną przez ten typ Aprzy użyciu tej samej semantyki (wymyślne określenie „zachowanie”). Posługiwanie się uzasadnieniem, że każde B jest literą A , nie działa - ostateczna ocena ma zgodność z zachowaniem. Przez większość czasu „B jest rodzajem A” pokrywa się z „B zachowuje się jak A”, ale nie zawsze .

Przykład:

Rozważ zestaw liczb rzeczywistych. W dowolnym języku, możemy spodziewać się ich do wspierania operacji +, -, *, i /. Rozważmy teraz zbiór dodatnich liczb całkowitych ({1, 2, 3, ...}). Oczywiście każda dodatnia liczba całkowita jest również liczbą rzeczywistą. Ale czy typ liczb całkowitych dodatnich jest podtypem liczb rzeczywistych? Spójrzmy na cztery operacje i zobaczmy, czy dodatnie liczby całkowite zachowują się tak samo jak liczby rzeczywiste:

  • +: Możemy bez problemu dodawać liczby całkowite.
  • -: Nie wszystkie odejmowania dodatnich liczb całkowitych dają dodatnie liczby całkowite. Np 3 - 5.
  • *: Możemy pomnożyć dodatnie liczby całkowite bez problemów.
  • /: Nie zawsze możemy podzielić dodatnie liczby całkowite i uzyskać dodatnią liczbę całkowitą. Np 5 / 3.

Tak więc pomimo dodatnich liczb całkowitych będących podzbiorem liczb rzeczywistych, nie są one podtypem. Podobny argument można podać dla liczb całkowitych o skończonym rozmiarze. Oczywiście każda 32-bitowa liczba całkowita jest również 64-bitową liczbą całkowitą, ale 32_BIT_MAX + 1daje różne wyniki dla każdego typu. Więc jeśli dałem ci jakiś program i zmieniłeś typ każdej 32-bitowej zmiennej całkowitej na 64-bitową liczbę całkowitą, istnieje duża szansa, że ​​program będzie się zachowywał inaczej (co prawie zawsze oznacza źle ).

Oczywiście możesz zdefiniować +dla 32-bitowych liczb całkowitych, aby wynik był 64-bitową liczbą całkowitą, ale teraz będziesz musiał zarezerwować 64 bity miejsca za każdym razem, gdy dodasz dwie liczby 32-bitowe. To może, ale nie musi być do zaakceptowania, w zależności od potrzeb pamięci.

Dlaczego to ma znaczenie?

Ważne jest, aby programy były poprawne. Jest to prawdopodobnie najważniejsza właściwość programu. Jeśli program jest poprawny dla jakiegoś typu A, jedynym sposobem na zagwarantowanie, że program będzie nadal poprawny dla niektórych podtypów, Bjest Bzachowanie się Apod każdym względem.

Masz więc typ Rectangles, którego specyfikacja mówi, że jego boki można zmieniać niezależnie. Napisałeś kilka programów, które używają Rectanglesi zakładają, że implementacja jest zgodna ze specyfikacją. Następnie wprowadziłeś podtyp o nazwie, Squarektórego boków nie można niezależnie zmienić rozmiaru. W rezultacie większość programów zmieniających rozmiar prostokątów będzie teraz niepoprawna.

Doval
źródło
6

Jeśli kwadrat jest rodzajem prostokąta, to dlaczego nie można dziedziczyć kwadratu po prostokącie? Lub dlaczego jest to zły projekt?

Po pierwsze, zadaj sobie pytanie, dlaczego uważasz, że kwadrat jest prostokątem.

Oczywiście większość ludzi nauczyła się tego w szkole podstawowej i wydaje się to oczywiste. Prostokąt ma kształt 4-stronny z kątami 90 stopni, a kwadrat spełnia wszystkie te właściwości. Czy kwadrat nie jest prostokątem?

Rzecz w tym, że wszystko zależy od tego, jakie są twoje początkowe kryteria grupowania obiektów, w jakim kontekście patrzysz na te obiekty. W geometrii kształty są klasyfikowane na podstawie właściwości ich punktów, linii i aniołów.

Zanim więc powiesz „kwadrat jest rodzajem prostokąta”, musisz najpierw zadać sobie pytanie, czy jest to oparte na kryteriach, na których mi zależy .

W zdecydowanej większości przypadków to nie będzie to, na czym ci zależy. Większość systemów modelujących kształty, takich jak GUI, grafika i gry wideo, nie dotyczy przede wszystkim geometrycznego grupowania obiektu, ale zachowanie. Czy kiedykolwiek pracowałeś nad systemem, który miał znaczenie, że kwadrat był rodzajem prostokąta w sensie geometrycznym. Co by ci to dało, wiedząc, że ma 4 boki i kąty 90 stopni?

Nie modelujesz systemu statycznego, modelujesz system dynamiczny, w którym coś się stanie (kształty będą tworzone, niszczone, zmieniane, rysowane itp.). W tym kontekście zależy Ci na wspólnym zachowaniu między obiektami, ponieważ najważniejsze jest to, co możesz zrobić z kształtem, jakie reguły należy zachować, aby nadal mieć spójny system.

W tym kontekście kwadrat zdecydowanie nie jest prostokątem , ponieważ zasady rządzące sposobem zmiany kwadratu nie są takie same jak prostokąt. Więc nie są to tego samego rodzaju rzeczy.

W takim przypadku nie modeluj ich jako takich. Dlaczego miałbyś? Nie zyskuje nic poza niepotrzebnymi ograniczeniami.

Byłoby to użyteczne tylko wtedy, gdy utworzymy obiekt Square, a jeśli zastąpimy metody SetWidth i SetHeight dla Square, to dlaczego miałby wystąpić jakiś problem?

Jeśli to zrobisz, to praktycznie stwierdzasz w kodzie, że to nie to samo. Twój kod mówi, że kwadrat zachowuje się w ten sposób, a prostokąt zachowuje się w ten sposób, ale nadal są takie same.

Oczywiście nie są one takie same w kontekście, na którym ci zależy, ponieważ właśnie zdefiniowałeś dwa różne zachowania. Dlaczego więc udawać, że są takie same, jeśli są podobne tylko w kontekście, na którym ci nie zależy?

To podkreśla poważny problem, gdy programiści wchodzą do domeny, którą chcą modelować. Bardzo ważne jest, aby wyjaśnić, jakim kontekstem jesteś zainteresowany, zanim zaczniesz myśleć o obiektach w domenie. Jakim aspektem jesteś zainteresowany. Tysiące lat temu Grecy dbali o wspólne właściwości linii i aniołów kształtów i na tej podstawie pogrupowali je. Nie oznacza to, że musisz kontynuować to grupowanie, jeśli nie jest to dla ciebie ważne (co w 99% przypadków modelujesz w oprogramowaniu, na którym ci nie zależy).

Wiele odpowiedzi na to pytanie koncentruje się na podtypach dotyczących zachowania grupującego , ponieważ są one regułami .

Ale bardzo ważne jest, aby zrozumieć, że nie robisz tego tylko po to, aby przestrzegać zasad. Robisz to, ponieważ w zdecydowanej większości przypadków tak naprawdę zależy ci na tym. Nie obchodzi cię, czy kwadrat i prostokąt mają te same wewnętrzne anioły. Dbasz o to, co mogą zrobić, będąc wciąż kwadratami i prostokątami. Dbasz o zachowanie obiektów, ponieważ modelujesz system, który koncentruje się na zmianie systemu w oparciu o reguły zachowania obiektów.

Cormac Mulhall
źródło
Jeśli zmienne typu Rectanglesą używane tylko do reprezentowania wartości , klasa Squaremoże odziedziczyć Rectanglei w pełni przestrzegać swojej umowy. Niestety, wiele języków nie czyni rozróżnienia między zmiennymi, które zawierają wartości, a tymi, które identyfikują jednostki.
supercat
Być może, ale w takim razie po co zawracać sobie głowę. Problemem prostokąta / kwadratu nie jest próba wymyślenia, jak sprawić, by relacja „kwadrat był prostokątem”, ale raczej uświadomienie sobie, że relacja tak naprawdę nie istnieje w kontekście, w którym używasz obiektów (behawioralnie) i jako ostrzeżenie przed nienakładaniem nieistotnych relacji na domenę.
Cormac Mulhall
Albo inaczej: nie próbuj zginać łyżki. To niemożliwe. Zamiast tego spróbuj tylko zrozumieć prawdę, że nie ma łyżki. :-)
Cormac Mulhall
1
Posiadanie niezmiennego Squaretypu, który dziedziczy po niezmiennym Rectnagletypie, może być przydatne, jeśli istnieją pewne rodzaje operacji, które można wykonać tylko na kwadratach. Jako realistyczny przykład tej koncepcji, rozważ ReadableMatrixtyp [typ podstawowy prostokątnej tablicy, która może być przechowywana na różne sposoby, w tym rzadko], oraz ComputeDeterminantmetodę. Może mieć sens ComputeDeterminantpraca tylko z ReadableSquareMatrixtypem, z którego pochodzi ReadableMatrix, co uważam za przykład Squarepochodzący od Rectangle.
supercat
5

Jeśli kwadrat jest rodzajem prostokąta, to dlaczego nie można dziedziczyć kwadratu po prostokącie?

Problem polega na myśleniu, że jeśli rzeczy są w jakiś sposób powiązane w rzeczywistości, muszą być powiązane w dokładnie taki sam sposób po modelowaniu.

Najważniejszą rzeczą w modelowaniu jest identyfikacja wspólnych atrybutów i typowych zachowań, zdefiniowanie ich w klasie podstawowej i dodanie dodatkowych atrybutów w klasach potomnych.

Problem z twoim przykładem jest całkowicie abstrakcyjny. Dopóki nikt nie wie, do czego zamierzasz użyć tych klas, trudno zgadnąć, jaki projekt powinieneś wykonać. Ale jeśli naprawdę chcesz mieć tylko wysokość, szerokość i rozmiar, logiczne byłoby:

  • zdefiniuj Kwadrat jako klasę podstawową, z widthparametrem i resize(double factor)zmieniając szerokość o podany współczynnik
  • zdefiniuj klasę Rectangle i podklasę Square, ponieważ dodaje ona kolejny atrybut heighti przesłania jego resizefunkcję, która wywołuje, super.resizea następnie zmienia rozmiar o podany współczynnik

Z punktu widzenia programowania w Square nie ma nic, czego nie ma Rectangle. Nie ma sensu tworzyć kwadratu jako podklasy prostokąta.

Żeglarz naddunajski
źródło
+1 Tylko dlatego, że kwadrat jest szczególnym rodzajem prostokąta w matematyce, nie znaczy, że jest taki sam w OO.
Lovis,
1
Kwadrat jest kwadratem, a prostokąt jest prostokątem. Relacje między nimi również powinny się utrzymywać w modelowaniu, w przeciwnym razie masz dość kiepski model. Rzeczywistymi problemami są: 1) jeśli uczynisz je zmiennymi, nie będziesz już modelował kwadratów i prostokątów; 2) przy założeniu, że tylko dlatego, że niektóre relacje „są” istnieją między dwoma rodzajami obiektów, możesz zastąpić jeden z nich bez rozróżnienia.
Doval
4

Ponieważ przez LSP tworzenie relacji dziedziczenia między nimi a nadpisywaniem setWidthoraz setHeightzapewnienie, że kwadrat ma to samo, wprowadza zachowanie mylące i nieintuicyjne. Powiedzmy, że mamy kod:

Rectangle r = createRectangle(); // create rectangle or square here
r.setWidth(10);
r.setHeight(20);
print(r.getWidth()); // expect to print 10
print(r.getHeight()); // expect to print 20

Ale jeśli metoda createRectanglezwróciła Square, ponieważ jest to możliwe dzięki Squaredziedziczeniu Rectange. Potem oczekiwania się łamią. W przypadku tego kodu oczekujemy, że ustawienie szerokości lub wysokości spowoduje jedynie zmianę odpowiednio szerokości lub wysokości. Chodzi o to, że podczas pracy z nadklasą nie masz żadnej wiedzy o żadnej podklasie pod nią. A jeśli podklasa zmieni zachowanie tak, aby było sprzeczne z naszymi oczekiwaniami dotyczącymi nadklasy, istnieje duże prawdopodobieństwo wystąpienia błędów. Tego rodzaju błędy są trudne do debugowania i naprawy.

Jedną z głównych idei dotyczących OOP jest to, że to zachowanie, a nie dane, które są dziedziczone (co jest również jednym z głównych nieporozumień IMO). A jeśli spojrzysz na kwadrat i prostokąt, same nie mają zachowania, które moglibyśmy odnosić w relacji dziedziczenia.

Euforyk
źródło
2

LSP mówi, że wszystko, co dziedziczy, Rectanglemusi być Rectangle. Oznacza to, że powinien zrobić cokolwiek Rectangle.

Prawdopodobnie Rectanglenapisano w dokumentacji , że zachowanie Rectanglenazwanego rwygląda następująco:

r.setWidth(10);
r.setHeight(20);
print(r.getWidth());  // prints 10

Jeśli Twój Plac nie ma takiego samego zachowania, to nie zachowuje się jak Rectangle. Więc LSP mówi, że nie może dziedziczyć Rectangle. Język nie może egzekwować tej reguły, ponieważ nie może powstrzymać cię przed zrobieniem czegoś złego w zastępowaniu metod, ale to nie znaczy, że „jest OK, ponieważ język pozwala mi zastąpić metody” jest przekonującym argumentem przemawiającym za tym!

Teraz byłoby to możliwe , aby napisać dokumentację Rectanglew taki sposób, że nie oznacza to, że powyższy kod drukuje 10, w którym to przypadku może twój Squaremoże być Rectangle. Możesz zobaczyć dokumentację, która mówi coś w stylu „robi to X. Ponadto implementacja w tej klasie robi Y”. Jeśli tak, to masz dobry powód do wyodrębnienia interfejsu z klasy i rozróżnienia między tym, co gwarantuje interfejs, a tym, co gwarantuje klasa. Ale kiedy ludzie mówią, że „zmienny kwadrat nie jest zmiennym prostokątem, podczas gdy niezmienny kwadrat jest niezmiennym prostokątem”, w zasadzie zakładają, że powyższe jest rzeczywiście częścią rozsądnej definicji modyfikowalnego prostokąta.

Steve Jessop
źródło
wydaje się to jedynie powtórzeniem punktów wyjaśnionych w odpowiedzi opublikowanej 5 godzin temu
komara
@gnat: czy wolałbyś, abym edytował tę inną odpowiedź w przybliżeniu do tej zwięzłości? ;-) Nie sądzę, żebym mogła, nie usuwając punktów, które zdaniem innego odpowiadającego prawdopodobnie są konieczne, aby odpowiedzieć na pytanie, a ja nie.
Steve Jessop
1

Podtypy, a co za tym idzie programowanie OO, często opierają się na zasadzie podstawienia Liskowa, że ​​dowolna wartość typu A może być użyta tam, gdzie wymagane jest B, jeśli A <= B. Jest to właściwie aksjomat w architekturze OO, tj. zakłada się, że wszystkie podklasy będą miały tę właściwość (a jeśli nie, podtypy są błędne i wymagają naprawy).

Okazuje się jednak, że zasada ta jest albo nierealistyczna / niereprezentatywna dla większości kodów, albo wręcz niemożliwa do spełnienia (w nietrywialnych przypadkach)! Problem ten, znany jako problem kwadratu-prostokąta lub problem elipsy-koła ( http://en.wikipedia.org/wiki/Circle-ellipse_problem ), jest jednym znanym przykładem tego, jak trudno jest go wypełnić.

Zauważ, że moglibyśmy zaimplementować coraz więcej równoważnych obserwacyjnie Kwadratów i Prostokątów, ale tylko poprzez wyrzucanie coraz większej liczby funkcji, dopóki rozróżnienie nie będzie bezużyteczne.

Jako przykład zobacz http://okmij.org/ftp/Computation/Subtyping/

Warbo
źródło