Projektowanie klasy pod dziedziczenie
Na co zwrócić uwagę, jeśli uznamy, że jest stosowne
Ten wpis jest częścią serii, w której tworzę wpisy na podstawie wybranego tematu z książki Effective Java (3rd edition 2018), której autorem jest Joshua Bloch. Jest to uaktualnione wydanie pod Jave 9 jednej z najlepszych książek o Javie. Nie ograniczam się jednak tylko do książki, więc czasem temat będzie rozbudowany i trafią się informacje z innych źródeł na ten sam temat.
Ten wpis nawiązuje do tematu z Item 19 z rozdziału 4:
Classes and Interfaces
- Item 15: Minimize the accessibility of classes and members
- Item 16: In public classes, use accessor methods, not public fields
- Item 17: Minimize mutability
- Item 18: Favor composition over inheritance
- Item 19: Design and document for inheritance or else prohibit it
- Item 20: Prefer interfaces to abstract classes
- Item 21: Design interfaces for posterity
- Item 22: Use interfaces only to define types
- Item 23: Prefer class hierarchies to tagged classes
- Item 25: Limit source files to a single top-level class
- Item 24: Favor static member classes over nonstatic
W poprzednim poście była mowa o wadach dziedziczenia i kompozycji jako lepszego zamiennika. W tym wpisie omówię, o czym nie zapomnieć projektując klasę pod dziedziczenie, jeśli uznamy, że jest ono stosowne.
Jeśli projektujemy klasę do publicznego użytku, powinniśmy jasno udokumentować to, czy metody, które jesteśmy w stanie nadpisać, używają pod spodem innych metod, które również mogą zostać nadpisane. Jeśli ma to miejsce, to powinniśmy jasno opisać, jak to się dzieje. Jest do tego specjalne miejsce w Javadocach - “Implementation Requirements”, które jest generowane za pomocą tagu @implSpec
(dodany w Javie 8).
Dla przykładu, dla java.util.AbstractCollection
wygląda to tak:
public boolean remove(Object o)
Removes a single instance of the specified element from this collection, if it is present (optional operation). More formally, removes an element
e
such thatObjects.equals(o, e)
, if this collection contains one or more such elements. Returnstrue
if this collection contained the specified element (or equivalently, if this collection changed as a result of the call).Implementation Requirements: This implementation iterates over the collection looking for the specified element. If it finds the element, it removes the element from the collection using the iterator’s
remove
method. Note that this implementation throws anUnsupportedOperationException
if the iterator returned by this collection’siterator
method does not implement theremove
method and this collection contains the specified object.
Jest to tu jasno udokumentowane, że nadpisywanie metody iterator
będzie miało wpływ na zachowanie remove
.
Podając szczegóły implementacyjne, naruszamy enkapsulację i zobowiązujemy się nigdy tego nie zmieniać, ale jest to konsekwencja używania dziedziczenia. Jest to wymagane, aby można było bezpiecznie tworzyć podklasy danej klasy.
Musimy również zadbać, aby wszystkie kluczowe pola były dostępne dla podklas. Jedyny sensowny sposób, aby przetestować naszą klasę, którą projektujemy pod dziedziczenie i przekonać się co jest niezbędne, to napisać kilka podklas (najlepiej, żeby przynajmniej jedna nie była pisana przez nas). Wtedy jasno zobaczymy, co powinniśmy ukryć, a co udostępnić podklasom.
Kolejną ważną rzeczą jest to, aby konstruktory nie wywoływały metod, które mogą zostać nadpisane. Jeśli ta metoda zależy od pola zainicjowanego przez konstruktor, to nie będzie wtedy działać prawidłowo. Konstruktor nadklasy wywoływany jest przed konstruktorem podklasy, więc nadpisana metoda podklasy będzie wywołana, zanim wywołany zostanie jej konstruktor. Przykład:
public class Super {
// Broken - constructor invokes an overridable method
public Super() {
overrideMe();
}
public void overrideMe() {
}
}
public final class Sub extends Super {
// Blank final, set by constructor
private final Instant instant;
Sub() {
instant = Instant.now();
}
// Overriding method invoked by superclass constructor
@Override
public void overrideMe() {
System.out.println(instant);
}
public static void main(String[] args) {
Sub sub = new Sub();
sub.overrideMe();
}
}
Spodziewalibyśmy się, że ten program pokaże nam instant
dwa razy, a jak się okazuje, za pierwszym razem będzie to null
. Tak jak była mowa wcześniej, metoda overrideMe
została wywołana przez konstruktor Super
zanim konstruktor podklasy Sub
miał okazję ją zainicjować - łatwy sposób na NullPointerException
.
Interfejsy Cloneable
i Serializable
stwarzają kolejne trudności, gdy projektujemy klasy pod dziedziczenie, więc musimy to wziąć pod uwagę lub po prostu zrezygnować z możliwości rozszerzania klasy, jeśli implementuje któryś z tych interfejsów.
Podsumowując, jeśli wyeliminujemy z klasy użycia wewnętrznych metod, które mogą zostać nadpisane i udokumentujemy to w przejrzysty sposób, to klasę będzie można dużo łatwiej rozszerzać i tworzyć jej podklasy. Jeśli nie zapewnimy tego, to podklasy mogą być zależne od detalów implementacyjnych nadklasy i mogą przestać działać, jeśli implementacja nadklasy się zmieni.