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, gdyy.equals(x)
- Przechodniość – dla każdej trójki x, y i z, jeśli
x.equals(y)
orazy.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.