Strona główna » Dziedziczenie a kompozycja

Dziedziczenie a kompozycja

by Grzegorz Sowa

Co jest nie tak z tym dziedziczeniem? Czemu w ogóle konflikt kompozycja vs dziedziczenie? I czemu, według większości programistów, kompozycja jest lepsza? Spróbujmy sobie odpowiedzieć na te pytania, patrząc na konkretne przykłady.

Kod wykorzystany w tym artykule znajdziecie tutaj.

Po co dziedziczyć?

Zarówno dziedziczenie, jak i kompozycja to w świecie programowania obiektowego mechanizmy, które prowadzą do ponownego używania raz już napisanego kodu. Bez nich pisalibyśmy tylko spaghetti, poprawiali jedną rzecz w wielu miejscach, a głównym mechanizmem re-użycia kodu byłoby Ctrl+C Ctrl+V. Oprócz tego, mechanizmy te pomagają nam odwzorować zależności ze świata realnego w naszym kodzie. Na przykład, skoro kwadrat jest prostokątem to klasa Square powinna dziedziczyć po klasie Rectangle. Albo skoro samochód posiada koła, kierownicę i silnik to obiekt klasy Car zdecydowanie powinien posiadać SteeringWheel, Engine i co najmniej trzy obiekty Wheel, plus może koło zapasowe.

HAS-A vs IS-A

Przyjęło się, że kompozycja oddaje relację HAS-A a dziedziczenie – relację IS-A. Czyli Car HAS-A Engine ale Car IS-A Vehicle. O ile każdy się zgodzi, że kompozycja jest najlepsza do oddania relacji HAS-A, o tyle dziedziczenie już nie zawsze jest faworytem dla IS-A. Jeśli ktoś twierdzi, że kompozycja jest „lepsza” od dziedziczenia, to pewnie ma właśnie na myśli jej przewagę w oddawaniu relacji IS-A.  Jednak należy pamiętać, że takie stwierdzenie jest pewnym uogólnieniem i nie powinno być traktowane jako dogmat. Aby dobrze ocenić problem zobaczmy, czemu ktoś może uważać, że dziedziczenie jest złym wyborem.

Zasada podstawienia Liskov

Żeby przejść dalej, przypomnijmy sobie jedną z zasad tworzenia SOLIDnego oprogramowania, zasadę podstawienia Liskov:

Funkcje które używają wskaźników lub referencji do klas bazowych, muszą być w stanie używać również obiektów klas dziedziczących po klasach bazowych, bez dokładnej znajomości tych obiektów.

Barbra Liskov

Oznacza to, że jeśli mamy wagę, czyli obiekt klasy Scale i ważymy na nim obiekt Vehicle – to powinniśmy być w stanie uzyskać jego masę niezależnie, czy to Car czy Bike. Co więcej, waga nie powinna nawet wiedzieć, czy waży samochód czy motocykl.

Problem elipsy

Problem elipsy jest często używanym zagadnieniem w kontekście porównania kompozycji do dziedziczenia. Załóżmy, że mamy zdefiniowaną klasę Ellipse:

class Ellipse {

    private float r1;
    private float r2;

    public Ellipse(float r1, float r2) {
        this.r1 = r1;
        this.r2 = r2;
    }

    public void increaseR1(float dr) {
        this.r1 = this.r1 + dr;
    }

    public void increaseR2(float dr) {
        this.r2 = this.r2 + dr;
    }

    public double area() {
        return Math.PI * r1 * r2;
    }

}

Tak zdefiniowana klasa ma swoje dwa promienie, które można dowolnie skalować. Jeśli teraz mamy też zaimplementować okrąg, to kod Ellipse aż się prosi o re-użycie w klasie Circle. Zobaczmy więc, jak możemy ten problem ugryźć.

Czy okrąg jest elipsą?

Zdefiniujmy sobie klasę Circle dziedziczącą po Ellipse:

class Circle extends Ellipse {

    public Circle(float r) {
        super(r, r);
    }
    // …
}

Na razie idzie nam świetnie, ale co z metodami increaseR1() i increaseR2()? Żadna z nich nie ma sensu logicznego, ponieważ jeśli zwiększymy tylko jeden promień to okrąg przestanie być elipsą. Spróbujmy jednak obejść to ograniczenie, zwiększając jednocześnie drugi promień:

    @Override
    public void increaseR1(float dr) {
        super.increaseR1(dr);
        super.increaseR2(dr);
    }

    @Override
    public void increaseR2(float dr) {
        super.increaseR1(dr);
        super.increaseR2(dr);
    }

Robiąc to, złamaliśmy zasadę podstawienia Liskov. Żeby to zobrazować, wyobraźmy sobie klasę Magnifier, której obiekty mają jeden cel – powiększać elipsy dwukrotnie.

class Magnifier {

    public void magnify(Ellipse ellipse) {
        ellipse.increaseR1(ellipse.getR1() * 2);
        ellipse.increaseR2(ellipse.getR2() * 2);
    }

}

Gdy podamy do naszej nowej klasy elipsę, pewnie zostanie powiększona dwukrotnie. Ale co, jeśli podamy zamiast niej okrąg? Okrąg przecież też jest elipsą i dzięki cudowi polimorfizmu możemy podać go do metody magnify(). Wtedy okaże się, że Magnifier, niczego nieświadomy powiększy okrąg czterokrotnie. To jest właśnie przykład złamania zasady podstawienia Liskov.

Kompozycja nie ratuje, ale zmienia perspektywę

Zobaczmy teraz, czy kompozycja wjedzie na białym koniu i rozwiąże problem. Zdefiniujmy sobie klasę Circle, która zamiast dziedziczyć po Ellipse będzie przechowywać jej instancję.

class Circle {

    private final Ellipse ellipse;

    public Circle(float r) {
        this.ellipse = new Ellipse(r, r);
    }

    public void increaseR(float dr) {
        this.ellipse.increaseR1(dr);
        this.ellipse.increaseR2(dr);
    }

    public float getR() {
        return this.ellipse.getR1();
    }

    public double area() {
        return ellipse.area();
    }
    // …
}

Świetnie, teraz już nikt nie może zarzucić nam, że mamy z Circle te „dziwne” metody increaseR1() i increaseR2(). Co jednak, jeśli chcemy wciąż używać klasy Magnifier? Nie mamy już polimorfizmu, więc czy kompozycja naprawdę pomogła? Otóż pomogła, bo sprawę polimorfizmu powinniśmy załatwić innym mechanizmem – interfejsem. To zmusi nas do zdefiniowania jednorodnego interfejsu dla figur geometrycznych, który pozwoli Magnifier powiększać je bez obaw o implementację.

interface Scalable {

    void scale(int factor);

}

Teraz klasa Magnifier będzie wyglądała trochę inaczej:

class Magnifier {
    
    public void magnify(Scalable scalable) {
        scalable.scale(2);
    }
    
}

Natomiast zarówno Circle, jak i Ellipse mogą spokojnie zaimplementować sobie metodę scale(). Inna sytuacja, kiedy łatwo przez dziedziczenie zasmucić Barbrę Liskov, pojawi się dalej.

Założenia metod podstawowych

W Javie podstawowe metody, takie jak na przykład hashCode(), equals(), clone() – są zdefiniowane w klasie Object i każda klasa, którą utworzymy będzie je posiadała, ponieważ będzie po klasie Object dziedziczyć. Możemy te metody nadpisywać, jednak mają one swoje założenia, które są często uznawane za pewnik w innych klasach API Javy. Dlatego jeśli je złamiemy, bardzo łatwo możemy paść ofiarą trudnych do zidentyfikowania bugów, kiedy na przykład nasze obiekty włożymy do HashMap. Jeśli chcesz dowiedzieć się, jak dokładnie działa HashMap w Javie, sprawdź ten artykuł.

Jedną z najważniejszych metod podstawowych jest equals(). Aby przejść dalej, omówmy sobie podstawowe jej założenia:

  • Zwrotność – dla każdego obiektu x – x.equals(x) musi zwrócić true
  • Symetria – dla każdej pary x i y – x.equals(y) musi zwracać true wtedy i tylko wtedy, gdy y.equals(x)
  • Przechodniość – dla każdej trójki x, y i z, jeśli x.equals(y) oraz y.equals(z) – to również x.equals(z)
  • Spójność – dla każdej pary x i y – x.equals(y) przy wielokrotnych wywołaniach musi zawsze zwracać ten sam wynik
  • dla dowolnego x – x.equals(null) musi zwracać false

Dziedzicz i łam założenia

Zdefiniujmy sobie teraz klasę Point, reprezentującą punkt w przestrzeni dwuwymiarowej.

public class Point {

    private final int x;
    private final int y;

    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }

    @Override
    public boolean equals(Object o) {
        if (o instanceof Point other) {
            return x == other.x && y == other.y;
        }
        return false;
    }
    
}

Point nadpisuje metodę equals() w taki sposób, że dwa punkty o tych samych współrzędnych są sobie równe. Co jednak, jeśli biznes przyjdzie do nas z osobliwą prośbą, aby zacząć rysować kolorowe punkty? Nie ma problemu, korzystając z dziedziczenia definiujemy ColorPoint:

class ColorPoint extends Point {

    private Color color;

    public ColorPoint(int x, int y, Color color) {
        super(x, y);
        this.color = color;
    }
    // …
}

Jak jednak powinniśmy zdefiniować metodę equals()? Jeśli w ogóle jej nie nadpiszemy, zostanie odziedziczona po klasie Point, więc będzie ignorować kolor w porównaniu, co jest niedopuszczalne. Zdefiniujmy więc najprostszą metodę equals():

    @Override
    public boolean equals(Object o) {
        if (o instanceof ColorPoint other) {
            return super.equals(other) && this.color == other.color;
        }
        return false;
    }

Takie zdefiniowanie metody equals() jest błędem – narusza zasadę symetrii. To znaczy, że jeśli zdefiniujemy x = Point(1, 2) oraz y = Point(1, 2, RED) to x.equals(y) zwróci nam true, a y.equals(x) – false. Możemy naprawić ten błąd, ignorując kolor, jeśli dostaniemy obiekt klasy Point:

    @Override
    public boolean equals(Object o) {
        if (o instanceof ColorPoint other) {
            return super.equals(other) && this.color == other.color;
        }
        if (o instanceof Point other) {
            return super.equals(other);
        }
        return false;
    }

Teraz x.equals(y) powinno już zwrócić to samo, co y.equals(x). Jednak  to rozwiązanie również jest błędne, ponieważ łamie zasadę przechodniości metody equals(). Aby to udowodnić, zdefiniujmy sobie 3 punkty:

ColorPoint x = new ColorPoint(1, 2, RED);
Point y = new Point(1,2);
ColorPoint z = new ColorPoint(1, 2, BLUE);

Ponieważ x.equals(y) jest prawdą i y.equals(z) też – oczekiwalibyśmy, że x.equals(z) również zwróci true. Tak jednak się nie stanie, co łamie zasadę przechodniości.

Nie ma łatwego sposobu na rozszerzanie definicji klasy o nowe składniki z zachowaniem zasad zdefiniowanych dla metody equals, chyba że rezygnujemy z zalet abstrakcji obiektowej.

Joshua Bloch, „Effective Java”, rozdział 3. temat 10.

getClass() zamiast instanceof

Niektórzy twierdzą, że możemy uratować zasadę przechodniości, modyfikując metodę equals() w bazowej klasie Point.

    @Override
    public boolean equals(Object o) {
        if (o != null && o.getClass() == this.getClass()) {
            Point other = (Point) o;
            return x == other.x && y == other.y;
        }
        return false;
    }

Faktycznie, mają rację – teraz x.equals(y) zwróci false, więc zasada przechodniości zostanie zachowana. Teraz jednak znów wjeżdża Barbra Liskov, cała na czarno i nakazuje nam zdefiniować okrąg jednostkowy i sprawdzić, czy punkt leży na tym okręgu:

public class UnitCircle {

    private final Set<Point> points;

    public UnitCircle(int x, int y) {
        this.points = new HashSet<>();
        this.points.add(new Point(x, y + 1));
        this.points.add(new Point(x, y - 1));
        this.points.add(new Point(x + 1, y));
        this.points.add(new Point(x - 1, y));
    }

    public boolean contains(Point point) {
        return this.points.contains(point);
    }
    
}

Nie jest to najszybsza, lecz jak najbardziej poprawna implementacja, ponieważ mamy prawo się spodziewać, że każda instancja klasy Point będzie funkcjonować jako Point. Gdy jednak dostaniemy ColorPoint, to niezależnie od jego współrzędnych metoda contains() zwróci false. To właśnie przejaw złamania zasady podstawienia Liskov.

Kompozycja ratuje dzień

Spróbujmy teraz zdefiniować ColorPoint za pomocą kompozycji:

class ColorPoint {

    private Point point;
    private Color color;

    public Point asPoint() {
        return this.point;
    }

    @Override
    public boolean equals(Object o) {
        if (o instanceof ColorPoint other) {
            return this.point.equals(other.point) && this.color.equals(other.color);
        }
        return false;
    }

}

Teraz dalej re-używamy kod, który napisaliśmy w klasie Point, jednak czy kiedykolwiek porównanie obiektów ColorPoint i Point zwróci true? Odpowiedź brzmi: nie, i nie musi. Do każdego kontekstu wymagającego klasy Point możemy przekazać asPoint() – Liskov syty i założenia metody equals() całe.

Kiedy dziedziczenie?

Trzeba jeszcze zauważyć, że powyższe problemy nie występują, jeśli dziedziczymy po klasach abstrakcyjnych. Nie możemy w końcu mieć problemów z metodą equals(), jeśli nie możemy utworzyć obiektu klasy bazowej 🙂 Pamiętajmy jednak, że długie łańcuchy dziedziczenia sprawiają, że kod staje się nieczytelny i trudny do utrzymania.

Podsumowanie

Porównanie między dziedziczeniem a kompozycją to nie zawsze trywialny problem. Dlatego warto zdawać sobie sprawę z wad i zalet każdego podejścia, aby podejmować świadome decyzje. Inspiracją do tego artykułu był ten wpis na Wikipedii, oraz książka Joshua Bloch – „Effective Java”. Zachęcam serdecznie do ich lektury, na pewno nie będzie to stracony czas.

You may also like

2 komentarze

Rafał Leżanko 9 września, 2021 - 8:57 pm

Kwadrat nie powinien dziedziczyć po prostokącie – to naruszenie zasady Liskov z SOLID 🙂

Reply
Grzegorz Sowa 14 września, 2021 - 7:21 pm

Dokładnie, tak samo jak Circle po Ellipse 🙂

Reply

Leave a Comment