Zasada podstawienia Liskov

Przyjrzyjmy się dziś zasadzie podstawienia Liskov. To jedna z pięciu zasad SOLID w programowaniu obiektowym. Spróbujemy rozwiązać klasyczny problem kwadratu i prostokąta. Zastanowimy się też do czego służy dziedziczenie i dlaczego jest złe. ;)

Zasada podstawienia została podana przez Barbarę Liskov w 1987 roku. W skrócie brzmi ona tak:

Klasy dziedziczące nigdy nie powinny psuć definicji klas, z których dziedziczą.

Albo patrząc na to od drugiej strony:

Gdziekolwiek używamy klasy bazowej, powinniśmy móc użyć klasy dziedziczącej po niej.

Najlepiej sprawdzać działanie tej zasady w testach jednostkowych. Jeśli jakiś test będzie korzystał z klasy X to możemy wstawić tam klasę Y dziedziczącą po X i testy powinny działać.

Kwadratura prostokątu

Klasyczny przykład z prostokątem i kwadratem dobrze tłumaczy tą zasadę. Załóżmy, że mamy klasę Prostokąt wyglądającą tak (w PHP):

class Rectangle {

   // ustaw szerokość
   public function setWidth($width) { ... }

   // ustaw wysokość
   public function setHeight($height) { ... }

   // podaj pole
   public function getArea() { ... }
}

Pomijam implementację metod. Tworzymy teraz test w PHPUnit:

class RectangleTest extends PHPUnit_Framework_TestCase {

   public function testArea() {
      $width = 8;
      $height = 3;

      $rectangle = new Rectangle();
      $rectangle->setWidth($width);
      $rectangle->setHeight($height);
      $area = $rectangle->getArea();
      $this->assertEquals($area,($width*$height);
   }
}

Wszystko łądnie i pięknie. Test przejdzie, pole powierzchni się zgadza.

Ale stwórzmy teraz klasę definiującą kwadrat. Przecież kwadrat jest prostokątem. Tym razem pełen kod:

class Square extends Rectangle {

   // długość boku
   var $edge;

   // ustaw szerokość
   public function setWidth($width) {
      $this->edge = $width;
   };

   // ustaw wysokość
   public function setHeight($height) {
   $this->edge = $height;
   };

   // podaj pole
   public function getArea() {
      return $this->edge ^ 2;
   };
}

Co się stanie gdy podamy tą klasę do poprzedniego testu? Błąd! Pole powierzchni nie będzie się zgadzać. W końcu ustawiliśmy dwie różne wartości a kwadrat przyjmuje tylko jedną.

Możemy kombinować. Np zablokować jedną z metod setWidth lub setHeight rzucając wyjątkiem. Możemy dodać trzecią metodę ustawiania krawędzi. Jakkolwiek to zrobimy, kwadrat nie przejdzie naszego testu.

Zmienianie testów jest najgorszym pomysłem. Widać, że mamy problem w projekcie. To prowadzi nas do filozoficznego pytania:

Do czego służy dziedziczenie?

Gdy szykujemy się do dziedziczenia, musimy zadać sobie kilka ważnych pytań:

  • Czy dziedziczenie coś nam daje? W tym przykładzie kwadrat nic nie dawał. Mogliśmy spokojnie używać klasy Rectangle do przechowywania kwadratów. Funkcjonalność się nie zwiększyła.
  • Czy nie duplikujemy bytów? Nasza prostokątna aplikacja aż prosi się o problemy. W końcu kwadrat można opisać dwoma obiektami. Skończylibyśmy z systemem, gdzie niektóre kwadraty siedziałyby w klasie Square a inne w Rectangle. Pisanie konwerterów, sprawdzanie… bałagan.
  • Czy dobrze opisujemy problem? Na kwadratach i prostokątach świat się nie kończy. Wkrótce pojawi się koło. Jak wtedy ustawimy wymiary? A gdy zechcemy opisywać ciekawsze figury – elipsy, wielokąty nieforemne, piktogramy – tworzenie takich obiektów będzie dużym problemem.
  • Czy różne typy mają wspólny kod? W końcu w dziedziczeniu chodzi o współdzielenie kodu. Kwadrat i prostokąt inaczej liczą pole, inaczej ustawiają rozmiar. Kwadrat ma jedną zmienną a prostokąt dwie.

A może nie potrzebujemy kwadratów wogóle? W klasie prostokątnej dodamy metodę isSquare(), która powie nam kiedy jestem kwadratem. Jeśli jednak musimy mieć osobne byty, spróbujmy paru rozwiązań.

Rozwiązanie słabe – odwracamy dziedziczenie

Jeśli odwrócimy definicję klas mamy szansę naprawić problem. Na początek będziemy mieć klasę Square:

class Square {

   // ustaw długość boku
   public function setEdge($edge) { ... }

   // pobierz pole
   public function getArea() { ... }
}

Możemy teraz napisać test dla kwadratów

class SquareTest extends PHPUnit_Framework_TestCase {

   public function testArea() {
      $edge = 3;

      $square = new Square();
      $square->setEdge($edge);
      $area = $square->getArea();
      $this->assertEquals($area,($edge^2));
   }
}

Test oczywiśce przejdzie. Jeśli nowa klasa Rectangle będzie rozszerzać kwadrat to wszystko nadal będzie działać:

class Rectangle extends Square {

   // ustaw długość boku czyli zrób kwadrat
   public function setEdge($edge) {
      $this->setWidth($edge);
      $this->setHeight($edge);
   }

   // ustaw szerokość i wysokość
   public function setWidth($width) { ... }
   public function setHeight($width) { ... }

   public function getArea() { ... }
}

Nowa klasa Rectangle nadal poprawnie przejdzie test.

Dlaczego takie rozwiązanie jest słabe? Nadal nie mamy tu miejsca na koło, wielokąty itp. Na dodatek musimy pisać kod setEdge() od nowa. Co nam daje dziedziczenie? Praktycznie nic.

Dobre rozwiązanie – interfejsy

Czas pożegnać się tutaj z dziedziczeniem. W niczym nam nie pomaga a tylko dodaje problemów. W naszym przykładzie interfejsy pokazują swoją siłę.

Wszystkie płaskie figury mają wspólne cechy. Można pobrać ich pole, obwód. Możemy pobrać lub nawet ustawić ich wymiary. Każda figura ma swoją szerokość i wysokość, nawet gdy nie jest prostokątem. Przygotujmy sobie interfejs:

interface Shape2D {

   // pole powierzchni
   public function getArea();

   // obwód
   public function getPerimeter();

   // wymiary, szerokość i wysokość
   public function getDimensions();

   // ustawienie szerokości i wysokości
   // przy złych wartościach rzuca wyjątek
   public function setDimensions($width, $height);
}

Teraz możemy tworzyć dowolne figury. Prostokąt jest najprostszy – po prostu implementujemy wszystkie metody.

Kwadrat będzie rzucał wyjątek gdy spróbujemy ustawić szerokość inną niż wysokość. Podobnie koło, gdzie szerokość będą jego średnicą. W ten sposób możemy też tworzyć elipsę.

Wielokąty, obrysy i inne skomplikowane figury stworzymy po prostu dodając odpowiednie metody. Tu ustawienie wymiarów spowoduje nam przeskalowanie figury w górę i w dół.

Podsumowanie

Widzimy, że czasem dziedziczenie robi więcej szkody niż pożytku. Nasz prostokąt i kwadrat nie mają żadnych wspólnych metod. Ustawianie wymiarów, obliczanie pola – każdy z nich liczy je inaczej. Nie ma żadnego wspólnego kodu. Nie ma więc potrzeby używać dziedziczenia. Proponuję taką zasadę:

Projektując system zacznij od interfejsów. Dopiero gdy widzisz duplikację kodu i oczywiste zależności, przejdź na dziedziczenie.

Interfejsy są naszymi testami przy projektowaniu. Obietnicą, kontraktem tego co klasa zrobi. Myśląc interfejsami możemy skupić się na architekturze a nie na kodowaniu.

Polecam również artykuł Interfejs czy klasa abstrakcyjna.

Dodaj komentarz

Twój adres email nie zostanie opublikowany. Pola, których wypełnienie jest wymagane, są oznaczone symbolem *