Pozwala to uniknąć problemu delikatnej klasy podstawowej . Każda klasa ma zestaw niejawnych lub wyraźnych gwarancji i niezmienników. Zasada substytucji Liskowa stanowi, że wszystkie podtypy tej klasy muszą również zapewniać wszystkie te gwarancje. Jednak bardzo łatwo jest to złamać, jeśli nie korzystamy final
. Na przykład, sprawdźmy hasło:
public class PasswordChecker {
public boolean passwordIsOk(String password) {
return password == "s3cret";
}
}
Jeśli pozwolimy na zastąpienie tej klasy, jedna implementacja może zablokować wszystkich, inna może dać każdemu dostęp:
public class OpenDoor extends PasswordChecker {
public boolean passwordIsOk(String password) {
return true;
}
}
Zwykle nie jest to OK, ponieważ podklasy mają teraz zachowanie, które jest bardzo niezgodne z oryginałem. Jeśli naprawdę zamierzamy rozszerzyć klasę o inne zachowania, łańcuch odpowiedzialności byłby lepszy:
PasswordChecker passwordChecker =
new DefaultPasswordChecker(null);
// or:
PasswordChecker passwordChecker =
new OpenDoor(null);
// or:
PasswordChecker passwordChecker =
new DefaultPasswordChecker(
new OpenDoor(null)
);
public interface PasswordChecker {
boolean passwordIsOk(String password);
}
public final class DefaultPasswordChecker implements PasswordChecker {
private PasswordChecker next;
public DefaultPasswordChecker(PasswordChecker next) {
this.next = next;
}
@Override
public boolean passwordIsOk(String password) {
if ("s3cret".equals(password)) return true;
if (next != null) return next.passwordIsOk(password);
return false;
}
}
public final class OpenDoor implements PasswordChecker {
private PasswordChecker next;
public OpenDoor(PasswordChecker next) {
this.next = next;
}
@Override
public boolean passwordIsOk(String password) {
return true;
}
}
Problem staje się bardziej widoczny, gdy bardziej skomplikowana klasa wywołuje własne metody, a metody te można zastąpić. Czasami spotykam się z tym, gdy ładnie drukuję strukturę danych lub piszę HTML. Każda metoda odpowiada za niektóre widżety.
public class Page {
...;
@Override
public String toString() {
PrintWriter out = ...;
out.print("<!DOCTYPE html>");
out.print("<html>");
out.print("<head>");
out.print("</head>");
out.print("<body>");
writeHeader(out);
writeMainContent(out);
writeMainFooter(out);
out.print("</body>");
out.print("</html>");
...
}
void writeMainContent(PrintWriter out) {
out.print("<div class='article'>");
out.print(htmlEscapedContent);
out.print("</div>");
}
...
}
Teraz tworzę podklasę, która dodaje nieco więcej stylizacji:
class SpiffyPage extends Page {
...;
@Override
void writeMainContent(PrintWriter out) {
out.print("<div class='row'>");
out.print("<div class='col-md-8'>");
super.writeMainContent(out);
out.print("</div>");
out.print("<div class='col-md-4'>");
out.print("<h4>About the Author</h4>");
out.print(htmlEscapedAuthorInfo);
out.print("</div>");
out.print("</div>");
}
}
Teraz ignorując przez chwilę, że nie jest to bardzo dobry sposób na generowanie stron HTML, co się stanie, jeśli chcę ponownie zmienić układ? Musiałbym stworzyć SpiffyPage
podklasę, która jakoś otacza tę treść. Widzimy tu przypadkowe zastosowanie wzorca metody szablonu. Metody szablonów są dobrze zdefiniowanymi punktami rozszerzenia w klasie bazowej, które mają zostać zastąpione.
A co się stanie, jeśli zmieni się klasa podstawowa? Jeśli zawartość HTML zmieni się za bardzo, może to spowodować uszkodzenie układu zapewnionego przez podklasy. Dlatego później nie jest naprawdę bezpiecznie zmieniać klasy podstawowej. Nie jest to oczywiste, jeśli wszystkie twoje klasy są w tym samym projekcie, ale bardzo zauważalne, jeśli klasa podstawowa jest częścią opublikowanego oprogramowania, na którym budują inni ludzie.
Gdyby ta strategia rozszerzenia była zamierzona, moglibyśmy pozwolić użytkownikowi na zmianę sposobu generowania każdej części. Albo może istnieć strategia dla każdego bloku, która może być zapewniona zewnętrznie. Lub możemy zagnieździć Dekoratorów. Byłoby to równoważne z powyższym kodem, ale o wiele bardziej wyraźne i znacznie bardziej elastyczne:
Page page = ...;
page.decorateLayout(current -> new SpiffyPageDecorator(current));
print(page.toString());
public interface PageLayout {
void writePage(PrintWriter out, PageLayout top);
void writeMainContent(PrintWriter out, PageLayout top);
...
}
public final class Page {
private PageLayout layout = new DefaultPageLayout();
public void decorateLayout(Function<PageLayout, PageLayout> wrapper) {
layout = wrapper.apply(layout);
}
...
@Override public String toString() {
PrintWriter out = ...;
layout.writePage(out, layout);
...
}
}
public final class DefaultPageLayout implements PageLayout {
@Override public void writeLayout(PrintWriter out, PageLayout top) {
out.print("<!DOCTYPE html>");
out.print("<html>");
out.print("<head>");
out.print("</head>");
out.print("<body>");
top.writeHeader(out, top);
top.writeMainContent(out, top);
top.writeMainFooter(out, top);
out.print("</body>");
out.print("</html>");
}
@Override public void writeMainContent(PrintWriter out, PageLayout top) {
... /* as above*/
}
}
public final class SpiffyPageDecorator implements PageLayout {
private PageLayout inner;
public SpiffyPageDecorator(PageLayout inner) {
this.inner = inner;
}
@Override
void writePage(PrintWriter out, PageLayout top) {
inner.writePage(out, top);
}
@Override
void writeMainContent(PrintWriter out, PageLayout top) {
...
inner.writeMainContent(out, top);
...
}
}
(Dodatkowy top
parametr jest konieczny, aby upewnić się, że wywołania writeMainContent
przechodzące przez górną część łańcucha dekoratora. Emuluje to funkcję podklasy zwaną otwartą rekurencją .)
Jeśli mamy wielu dekoratorów, możemy je teraz swobodnie mieszać.
O wiele częściej niż chęć nieznacznego dostosowania istniejącej funkcjonalności jest chęć ponownego wykorzystania części istniejącej klasy. Widziałem przypadek, w którym ktoś chciał klasy, w której można dodawać przedmioty i powtarzać je wszystkie. Prawidłowym rozwiązaniem byłoby:
final class Thingies implements Iterable<Thing> {
private ArrayList<Thing> thingList = new ArrayList<>();
@Override public Iterator<Thing> iterator() {
return thingList.iterator();
}
public void add(Thing thing) {
thingList.add(thing);
}
... // custom methods
}
Zamiast tego stworzyli podklasę:
class Thingies extends ArrayList<Thing> {
... // custom methods
}
To nagle oznacza, że cały interfejs ArrayList
stał się częścią naszego interfejsu. Użytkownicy mogą remove()
rzeczy lub get()
rzeczy o określonych indeksach. To było przeznaczone w ten sposób? DOBRZE. Ale często nie zastanawiamy się dokładnie nad wszystkimi konsekwencjami.
Dlatego wskazane jest, aby
- nigdy
extend
klasa bez dokładnego przemyślenia.
- zawsze zaznaczaj swoje klasy jako
final
wyjątki, chyba że zamierzasz zastąpić dowolną metodę.
- twórz interfejsy, w których chcesz zamienić implementację, np. do testowania jednostkowego.
Istnieje wiele przykładów, w których ta „reguła” musi zostać złamana, ale zwykle prowadzi do dobrego, elastycznego projektu i pozwala uniknąć błędów spowodowanych niezamierzonymi zmianami w klasach podstawowych (lub niezamierzonym użyciem podklasy jako instancji klasy podstawowej ).
Niektóre języki mają bardziej rygorystyczne mechanizmy egzekwowania:
- Wszystkie metody są domyślnie ostateczne i muszą być wyraźnie oznaczone jako
virtual
- Zapewniają prywatne dziedziczenie, które nie dziedziczy interfejsu, a jedynie implementację.
- Wymagają, aby metody klasy bazowej były oznaczone jako wirtualne i wymagają również oznaczenia wszystkich przesłonięć. Pozwala to uniknąć problemów, gdy podklasa zdefiniowała nową metodę, ale metoda o tej samej sygnaturze została później dodana do klasy podstawowej, ale nie była przeznaczona jako wirtualna.
final
? Wiele osób (w tym ja) uważa, że dobrym pomysłem jest stworzenie każdej klasy nieabstrakcyjnejfinal
.