Evita il problema della classe di base fragile . Ogni classe viene fornita con una serie di garanzie e invarianti impliciti o espliciti. Il principio di sostituzione di Liskov impone che anche tutti i sottotipi di quella classe debbano fornire tutte queste garanzie. Tuttavia, è davvero facile violarlo se non lo utilizziamo final
. Ad esempio, disponiamo di un controllo password:
public class PasswordChecker {
public boolean passwordIsOk(String password) {
return password == "s3cret";
}
}
Se permettiamo a quella classe di essere sovrascritta, un'implementazione potrebbe bloccare tutti, un'altra potrebbe dare a tutti l'accesso:
public class OpenDoor extends PasswordChecker {
public boolean passwordIsOk(String password) {
return true;
}
}
Questo di solito non è OK, poiché le sottoclassi ora hanno un comportamento molto incompatibile con l'originale. Se intendiamo davvero ampliare la classe con altri comportamenti, una catena di responsabilità sarebbe migliore:
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;
}
}
Il problema diventa più evidente quando più una classe complicata chiama i propri metodi e questi metodi possono essere ignorati. A volte mi capita di trovarlo quando stampo una struttura di dati o scrivo HTML. Ogni metodo è responsabile di alcuni widget.
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>");
}
...
}
Ora creo una sottoclasse che aggiunge un po 'più di stile:
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>");
}
}
Ora ignorando per un momento che questo non è un ottimo modo per generare pagine HTML, cosa succede se voglio cambiare di nuovo il layout? Dovrei creare una SpiffyPage
sottoclasse che avvolge in qualche modo quel contenuto. Quello che possiamo vedere qui è un'applicazione accidentale del modello di metodo del modello. I metodi modello sono punti di estensione ben definiti in una classe base che devono essere sovrascritti.
E cosa succede se la classe base cambia? Se i contenuti HTML cambiano troppo, ciò potrebbe interrompere il layout fornito dalle sottoclassi. Quindi non è davvero sicuro cambiare la classe base in seguito. Questo non è evidente se tutte le tue classi fanno parte dello stesso progetto, ma è molto evidente se la classe di base fa parte di alcuni software pubblicati su cui altre persone si basano.
Se si intendesse questa strategia di estensione, avremmo potuto consentire all'utente di scambiare il modo in cui ogni parte viene generata. In entrambi i casi, potrebbe esistere una strategia per ciascun blocco che può essere fornita esternamente. Oppure potremmo nidificare i decoratori. Questo sarebbe equivalente al codice sopra, ma molto più esplicito e molto più flessibile:
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);
...
}
}
(Il top
parametro aggiuntivo è necessario per assicurarsi che le chiamate writeMainContent
attraversino la parte superiore della catena del decoratore. Questo emula una funzione di sottoclasse chiamata ricorsione aperta .)
Se abbiamo più decoratori, ora possiamo mescolarli più liberamente.
Molto più spesso del desiderio di adattare leggermente la funzionalità esistente è il desiderio di riutilizzare una parte di una classe esistente. Ho visto un caso in cui qualcuno voleva una classe in cui si potevano aggiungere elementi e scorrere su tutti. La soluzione corretta sarebbe stata:
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
}
Invece, hanno creato una sottoclasse:
class Thingies extends ArrayList<Thing> {
... // custom methods
}
Ciò significa improvvisamente che l'intera interfaccia di ArrayList
è diventata parte della nostra interfaccia. Gli utenti possono remove()
cose o get()
cose su indici specifici. Questo era inteso in questo modo? OK. Ma spesso non pensiamo attentamente a tutte le conseguenze.
È quindi consigliabile
- mai
extend
una lezione senza un attento pensiero.
- contrassegnare sempre le classi come
final
tranne se si intende sostituire qualsiasi metodo.
- creare interfacce in cui si desidera scambiare un'implementazione, ad esempio per test unitari.
Ci sono molti esempi in cui questa "regola" deve essere infranta, ma di solito ti guida verso una progettazione buona e flessibile ed evita i bug dovuti a cambiamenti involontari nelle classi base (o usi involontari della sottoclasse come istanza della classe base ).
Alcune lingue hanno meccanismi di applicazione più severi:
- Tutti i metodi sono definitivi per impostazione predefinita e devono essere contrassegnati in modo esplicito come
virtual
- Forniscono eredità privata che non eredita l'interfaccia ma solo l'implementazione.
- Richiedono che i metodi della classe base siano contrassegnati come virtuali e che anche tutti gli override siano contrassegnati. Questo evita problemi in cui una sottoclasse ha definito un nuovo metodo, ma un metodo con la stessa firma è stato successivamente aggiunto alla classe base ma non inteso come virtuale.
final
? Molte persone (incluso me) ritengono che sia un buon design realizzare ogni classe non astrattafinal
.