“Fragile
Base Class” (da ora FBC per brevità) è un potenziale problema
insito nel'uso del meccanismo di ereditarietà (o, più precisamente,
nel suo abuso) e dunque tipico di tutti i linguaggi Object
Oriented.
In letteratura si possono trovare diversi riferimenti a questo
problema, ma con nomi molto diversi e spesso non omogenei.
La frase “inheritance breaks encapsulation” [Snyder86], ossia
l'ereditarietà rompe l'incapsulamento, è un po' forte ma rende
bene l'idea.
In
bibliografia potete trovare un elenco più completo dei vari
riferimenti presenti in letteratura, fra i quali consiglio
[Pescio95] e [Holub03].
Fra l'altro, tanto per complicare ulteriormente le cose, il
termine classe base fragile è usato anche per riferirsi ad
un problema diverso, sebbene concettualmente simile, che a
sua volta è talvolta indicato come “Fragile Language Problem”.
Ma facciamo un po' d'ordine: concettualmente possiamo dire
che FBC, nella sua accezione classica, si ha quando un cambiamento
in un dettaglio implementativo di una superclasse porta alla
“rottura” di una sua sottoclasse, da cui il nome di classe
base “fragile”.
Ma se a rigor di logica è ovvio che un cambiamento nell'interfaccia
pubblica di una superclasse si rifletta in una sua sottoclasse,
come è possibile che anche un cambiamento di un dettaglio
implementativo porti ad una “rottura” della stessa?
La sottoclasse non dovrebbe infatti dipendere dai dettagli
implementativi della superclasse, altrimenti tutto il meccanismo
di riuso tramite ereditarietà (implementation inheritance,
ovverossia ereditarietà di implementazione) verrebbe a cadere
come un castello di carte.
Come
è possibile che l'ereditarietà “rompa” l'incapsulamento?
Sono del parere che un esempio concreto valga mille parole,
perciò passiamo ad analizzare insieme qualche frammento di
codice.
Gli esempi sono tratti da “Effective Java” di Josh Bloch [Bloch01],
con qualche piccola modifica.
Il nostro capo ci chiede di estendere la classe ArrayList,
aggiungendo la capacità di contare i tentativi di inserimento
in essa. Perchè ce lo chieda non è affar nostro - molto probabilmente
per controllarne usi e abusi e poter scegliere in futuro una
struttura dati più adatta - così ci mettiamo subito al lavoro.
Non ci è stato chiesto di contare gli effettivi inserimenti
nella collezione, ma solo i tentativi di inserimento, quindi
non sono necessari controlli di nessun tipo: non dobbiamo
tener conto di eccezioni, elementi null, eventuale comportamento
della collezione con elementi duplicati, ecc. Il problema
sembra perciò risolvibile abbastanza semplicemente e dopo
aver brevemente sfogliato la documentazione Java della classe
ArrayList, osserviamo che i metodi add() e addAll() sono quelli
di cui in qualche modo dobbiamo prendere il controllo. Dopo
aver tracciato un paio di diagrammi UML (siamo freschi freschi
del corso di Ingegneria del software, e non sia mai che qualcuno
ci dica che manchi qualcosa) decidiamo che un possibile design,
molto lineare, è quello di ereditare da ArrayList ed effettuare
l'override dei metodi add() e addAll() affinchè incrementino
opportunamente un contatore. Dopo pochi minuti il codice è
pronto:
public
class InstrumentedArrayList extends ArrayList {
// costruttori omessi
public boolean add(Object o) {
counter++; return super.add(o);
}
public boolean addAll(Collection c) {
counter += c.size();
return super.addAll(c);
}
public String toString() {
return "Added " + counter
+ " elements to " + super.toString(); }
private int counter;
}
Notiamo
soddisfatti che l'uso dell'ereditarietà è in questo caso perfettamente
legittimo e non può essere considerato una forzatura nel design.
Infatti
la classe InstrumentedArrayList, non aggiungendo nessun metodo
all'interfaccia di ArrayList, rispetta il principio di sostituibilità
di Liskov [Liskov88, Martin02], e il rapporto fra le due classi
è ovviamente del tipo IS-A. InstrumentedArrayList è un (IS-A)
ArrayList, implementa List e una sua istanza può essere usata
ovunque serva una istanza di ArrayList. La classe funziona
perfettamente, così scriviamo un po' di test e pure il javadoc:
in fondo abbiamo fatto presto, e vogliamo fare bella figura.
Il capo è contento dei risultati e ci promuove subito a programmatore
senior, diverso biglietto da visita ma stesso stipendio.
Gia che c'è, ci chiede di implementare lo stesso meccanismo
per la classe HashSet. Andiamo a verificare la documentazione
di HashSet e ci rendiamo conto che la vita è bella, il task
è molto semplice e possiamo affrontarlo nella stessa identica
maniera.
Ad ulteriore conferma del momento positivo, la programmatrice
carina che occupa il cubicolo accanto al nostro sorride e
ci chiede di andare a pranzo con lei.
Dopo pochi minuti alla tastiera, la classe InstrumentedHashSet
è pronta per essere verificata.
public
class InstrumentedHashSet extends HashSet {
// costruttori omessi
public boolean add(Object o) {
counter++;
return super.add(o);
}
public boolean addAll(Collection c) {
counter += c.size();
return super.addAll(c);
}
public String toString() {
return "Added " + counter + " elements
to " + super.toString();
}
private int counter;
}
Il
design ed il relativo codice sono identici alla classe InstrumentedArrayList,
e non ci aspettiamo davvero che possano nascondere delle insidie.
Eseguendo però il codice di test, che tenta di aggiungere
tre elementi di una collezione preesistente usando il metodo
addAll(), ci accorgiamo immediatamente che qualcosa non va.
public
class TestFragile {
public static void main(String[] args) {
List simpleList = new InstrumentedArrayList();
simpleList.addAll(Arrays.asList(new
String[]{"uno", "due", "tre"}));
System.out.println(simpleList);
Set simpleSet = new InstrumentedHashSet();
simpleSet.addAll(Arrays.asList(new
String[] {"uno", "due", "tre"}));
System.out.println(simpleSet);
}
}
L'output
sarà infatti simile al seguente:
Added
3 elements to [uno, due, tre]
Added
6 elements to [tre, uno, due]
Cosa
è successo? Il campo counter è stato incrementato due volte
per ogni inserimento, portando ad un risultato palesemente
errato. Lo stesso codice funziona invece perfettamente per
la classe InstrumentedArrayList! Guardiamo disperati il ns
nuovo biglietto da visita, cercando ispirazione, mentre la
programmatrice carina, stufa di aspettare, va a pranzo con
uno del marketing.
Analisi
del problema della FBC
Il comportamento della classe InstrumentedHashSet è presto
spiegato: addAll() in HashSet è implementato tramite add(),
dunque il polimorfismo fa sì che l'incremento di counter avvenga
due volte per inserimento invece di una. Il metodo addAll()
in ArrayList è invece implementato senza ricorrere internamente
al metodo add(), ed il problema non si evidenzia. Per chi
non riesce a “vedere” il flusso del polimorfismo e dunque
il perchè il contatore venga incrementato più del dovuto,
ecco una sorta di “debug” passopasso:
1. viene chiamato il metodo addAll di InstrumentedHashSet
con la lista anonima {“uno”, due”, tre”}
2. il metodo addAll per prima cosa incrementa il contatore
di 3 (la size della lista), poi chiama super.addAll()
3. HashSet.addAll, fra le altre cose, chiama add() in un ciclo,
per 3 volte.
4. add() è un metodo polimorfico, quindi viene chiamato il
metodo add() di InstrumentedHashSet.
5. add() in InstrumentedHashSet incrementa il contatore di
1, poi chiama super.add() ed effettivamente aggiunge l'elemento.
6. L'ultimo step viene ripetuto in tutto per 3 volte: il contatore
arriva a 6.
A
questo punto ci si rende conto che FBC è un problema piuttosto
subdolo: il funzionamento delle due classi “Instrumented”
dipende da un dettaglio implementativo, ossia dal contesto
di chiamata dei metodi polimorfici e non semplicemente dall'interfaccia.
In altre parole affinchè una sottoclasse funzioni correttamente
deve essere a conoscenza del fatto che un metodo polimorfico
della superclasse (in java qualsiasi metodo non private e
non final è soggetto a possibile override in una sottoclasse
e dunque polimorfico) chiami o non chiami un altro metodo
polimorfico.
Questa situazione è comunemente indicata come “self-use”.
Per comodità non ho classificato come self-use l'uso di metodi
final o privati ma solo quello di metodi polimorfici (“self-use
di metodi polimorfici” era troppo lungo!)
Contestualizzando
nel semplice esempio appena visto, poichè nella classe ArrayList
addAll() internamente non chiama add(), per poter funzionare
correttamente InstrumentedArrayList deve incrementare il contatore
sia all'interno di add() che di addAll().
Nella classe HashSet invece c'è self-use, poichè addAll()
internamente chiama add() e i due metodi sono entrambi pubblici
e non final. Perciò per poter funzionare correttamente InstrumentedHashSet
deve incrementare il contatore solo in add(). Ovviamente nessuno
ci garantisce la stabilità nel tempo, e ArrayList potrebbe
in una versione futura essere implementata con self-use. Questo
cambiamento “romperebbe” immediatamente la nostra classe InstrumentedArrayList
e ci costringerebbe a modificarne l'implementazione.
E' quindi evidente che per poter estendere correttamente una
classe tramite ereditarietà, bisogna conoscere qualcosa di
più che l'interfaccia pubblica della classe che vogliamo estendere,
ma anche tutti i casi di self-use dei metodi. Come nota a
margine del self-use, è bene ricordare che richiamare un metodo
polimorfico dall'interno di un costruttore non è semplicemente
un caso di selfuse, ma è proprio errato. Il perchè è lasciato
come esercizio ai lettori (Suggerimento: seguire la catena
dei costruttori).
A questo punto analizziamo il problema da due diversi punti
di vista: quello di chi deve scrivere il codice di una potenziale
superclasse (che indicheremo come programmatore di libreria),
e quello di chi deve usarla (che indicheremo come programmatore
“cliente”).
Notiamo che programmatore di libreria e programmatore “cliente”
sono solo dei ruoli e che possono essere ricopoerti entrambi
dalla stessa persona.
E' addirittura possibile ricoprirli tutti e due nello stesso
momento: il programmatore medio, quando estende una classe,
tipicamente la scrive in modo che sia potenzialmente estendibile.
Il
punto di vista del programmatore di libreria
Mettiamoci per prima cosa nei panni del programmatore che
ha scritto il codice di HashSet e ArrayList (che dai sorgenti
risulta essere proprio Josh Bloch) e analizziamo le diverse
possibilità che aveva per evitare/minimizzare il problema:
- Proibire
l'ereditarietà
- rendere
la classe final, o renderne privati i costruttori. In
questo modo il programmatore “cliente” non potrà ereditare
il codice e dovrà ricorrere ad altre soluzioni per estendere
la classe
- Proibire
l'override di metodi che richiamino altri metodi
- La
soluzione è molto simile a quella di probire del tutto
l'ereditarietà. Rendendo final i metodi e non la classe
però si lascia un minimo di flessibilità in più
Documentare
i casi di self-use
Scrivere
il codice in modo da evitare il self-use, e documentarlo Documentare
i casi di self-use.
Se nella documentazione della classe HashSet fosse documentato
il fatto che addAll() chiama add(), il problema sarebbe a
questo punto imputabile a chi ha esteso HashSet non correttamente.
Una soluzione potrebbe essere dunque quella di documentare,
per ogni classe destinata (o potenzialmente destinata) ad
essere estesa, gli eventuali metodi polimorfici che chiamino
altri metodi polimorfici. (più in generale, tutti i casi di
self-use) In questo modo però il programmatore della libreria
sarà legato al dettaglio implementativo, non potrà mai più
ripensarci e non potrà dunque cambiare l'implementazione di
addAll() senza allo stesso tempo violare il contratto di HashSet.
Questo vincolo è ovviamente abbastanza restrittivo e imporlo
va dunque ben ponderato. Notare che stranamente HashSet non
documenta il self-use di addAll(). Quindi, stando alla documentazione,
è perfettamente possibile che in una futura versione di HashSet
addAll() non chiami più add().
E' anche perfettamente possibile che la cosa sia invece documentatissima
e che io non la trovi, poichè le Collection tipicamente dichiarano
i casi di self-use (tramite la sezione “this implementation...”
nella descrizione dei singoli metodi).
Scrivere
il codice in modo da evitare il self-use, e documentarlo
E' sempre possibile riscrivere del codice in modo da eliminare
i casi di self-use. Lo stesso Bloch accenna brevemente alla
possibilità nell'item 15 del suo libro [Bloch01], e a questo
punto immagino che ci sarà sicuramente un motivo validissimo
per cui non abbia utilizzato questa tecnica per l'implementazione
di HashSet! La tecnica è in effetti semplicissima e consiste
nell'eliminare i casi di self-use utilizzando opportuni metodi
helper con visibilità privata, effettivamente disaccoppiando
i metodi polimorfici. Di seguito uno schema di come potrebbe
essere implementato HashSet senza self-use. I metodi ora fanno
riferimento ad un metodo privato internalAdd() e la classe
non soffre più di FBC, o perlomeno non quanto prima!
public
class MyHashSet implements Set {
// costruttori omessi
public boolean add(Object o) {
return internalAdd(o);
}
public boolean addAll(Collection c) {
for (Iterator i = c.iterator(); i.hasNext();)
{
internalAdd(i.next());
}
return true;
}
private boolean internalAdd(Object o) {
System.out.println(" -- Adding element
" + o);
return true;
}
// altri metodi dell'interfaccia Set
}
Il
punto di vista del programmatore “client”
Mettiamoci ora invece nei panni del programmatore “cliente”
della libreria, che ha molte più possibilità per “gestire”
il problema della FBC.
- Se
la classe è scritta in modo da evitare i casi di self-use
e lo documenta, non c'è ovviamente nessun bisogno di accorgimenti
particolari. Altrettanto ovviamente è molto difficile che
ciò accada, e HashSet ne è un esempio.
- Se
la classe documenta i casi di self use, non serve nessun
accorgimento particolare se non un commento nella sottoclasse
a beneficio di chi manuterrà il codice.
- Se
la classe non documenta i casi di self use, verificarli,
se possibile, dai sorgenti (non è tipicamente un problema
per Java). A questo punto si può decidere se:
- commentare
il codice della sottoclasse segnalando nelle precondizioni
che si è assunto che la superclasse fosse implementata
in un certo modo.
- riscrivere
i metodi incriminati in modo che non riutilizzino l'implementazione
della superclasse (nel caso specifico, riscrivere addAll
()). Non è detto però che sia semplice, poichè potrebbe
essere necessario accedere a membri privati della superclasse.
In questo caso lo sforzo di ricreare localmente il contesto
di chiamata potrebbe essere eccessivo e oltretutto poco
leggibile.
- Un'altra
soluzione, un po' drastica, è quella di eliminare l'ereditarietà
di implementazione, utilizzando il pattern Decorator.
Eliminare
l'ereditarietà di implementazione
Questo è in sostanza il consiglio ”favour composition over
inheritance”. Utilizzando un mix di composizione e ereditarietà
di tipo, si possono “estendere” le funzionalità di una classe
senza andare incontro al problema della FBC. Il pattern in
questione è noto come Decorator, o Wrapper [Gof95] Nel caso
specifico, la classe InstrumentedHashSet potrebbe essere implementata
come segue:
public
class InstrumentedSet implements Set {
public InstrumentedSet(Set s) {
decorated = s; }
public boolean add(Object o) {
counter++;
return decorated.add(o);
}
public boolean addAll(Collection c) {
counter += c.size();
return decorated.addAll(c);
}
public String toString() {
return "Added " + counter + " elements
to " + decorated.toString();
}
public int size() {
return decorated.size();
}
// seguono tutti metodi di Set, implementati con
forward a decorated
private final Set decorated; private int counter;
}
La
soluzione non soffre del problema della classe base fragile,
ed è molto più flessibile. Infatti effettua un monitoring
di qualsiasi implementazione concreta di Set (non solo di
HashSet), e la relazione con l'oggetto contenuto è dinamica.
Si confronti con la soluzione precedente, nella quale la relazione
di ereditarietà, che è statica (compile-time) e non dinamica
(run-time), “scolpisce nella roccia” il contratto fra InstrumentedHashSet
e HashSet. Pagina 10 di 13
Il problema della Fragile Base Class In effetti, con questo
piccolo esempio abbiamo di fatto misurato che il grado di
accoppiamento [Parnas72] fra classi generato dall'ereditarietà
di implementazione è più alto di quello generato dalla “decorazione”.
Non è un caso che il Decorator sia anche noto come “Dynamic
inheritance”
I prezzi da pagare per questa aumentata flessibilità sono
diversi:
- il
codice è quasi sicuramente più lungo da scrivere. Dico quasi
sicuramente poichè dipende in realtà dal numero di costruttori
e di metodi presenti nella “superclasse”. Se infatti è vero
che nella soluzione con composizione si scrivono molti più
metodi, è altrettanto vero che molto probabilmente si scriveranno
meno costruttori.
- c'è
un overhead dovuto al forwarding. Nel caso specifico di
Java, le ultime JVM rendono l'overhead trascurabile se non
inesistente. • la “decorazione” degli oggetti porta a perdere
l'identità di tipo. L'oggetto “contenuto” infatti non sa
di essere stato decorato: se non correttamente incapsulato,
delle callback potrebbero violarne il contratto. Questo
è noto come SELF problem. [Lieberman86, Bloch01]
- può
rendere il codice molto più difficile da capire. Immaginate
molti oggetti che si decorano l'un l'altro, tutti apparentemente
uguali o comunque molto simili. Flessibilissimo, ma capire
quale sia il comportamento della composizione di tutti gli
oggetti potrebbe non essere banale. (è lo stesso problema
che si ha con gli stream di Java)
- se
il programmatore della libreria non avesse previsto un'interfaccia
(Set in questo caso) non sarebbe possibile “decorare” la
classe. La soluzione di sola composizione che ne deriverebbe
non avrebbe la stessa flessibilità, poichè non sostituirebbe
completamente il meccanismo di ereditarietà: InstrumentedHashSet
non potrebbe essere usato al posto di un Set. Per essere
precisi questo è in realtà vero solo per i linguaggi con
tipizzazione forte (Java, C++, C#), poichè per linguaggi
come Objective C o Smalltalk non farebbe praticamente nessuna
differenza aver previsto esplicitamente l'interfaccia!
Pur condividendo in gran parte lo scetticismo sulla possibilità
di riusare codice tramite ereditarietà, non mi sento però
di consigliare la decorazione come alternativa sistematica
ad essa. Lasciamo all'ereditarietà almeno un piccolo spiraglio,
soprattutto tenendo conto che abbiamo visto come sia possibile,
con un po' di sforzo, scrivere delle superclassi che non siano
così fragili!
Conclusioni
Questa lunga carrellata sul problema della Fragile Base Class
dovrebbe perlomeno aver mostrato che le cose non sono mai
semplici come sembrano.
Siamo partiti da una semplice classe di una decina di righe,
e siamo arrivati a scoperchiare un vaso di pandora. Abbiamo
analizzato il problema della Fragile Base Class dal punto
di vista del programmatore di libreria e dal punto di vista
del programmatore “cliente”, mostrando i possibili approcci
al problema in tutti e due i casi. Per quanto riguarda il
programmatore di libreria, il consiglio migliore è sicuramente
quello di progettare le classi per essere estese (non solo
per quanto riguarda il problema del self-use, ovviamente)
o esplicitamente proibire l'ereditarietà [Bloch01]. Il problema
di questo approccio è di ordine pratico, poichè lo sforzo
necessario per rendere una classe “safe” dal punto di vista
dell'ereditarietà non è minimo.
Cosa succede se non si ha abbastanza tempo per rendere una
o più classi “safe”? Renderle final per mancanza di tempo
non sembra infatti una opzione priva di difetti. Per quanto
riguarda il programmatore “client” invece le alternative sono
di più e le soluzioni vanno implementate caso per caso. La
soluzione “estrema”, che abbiamo dettagliato, è sicuramente
quella di evitare l'ereditarietà come meccanismo di riuso
tramite il pattern Decorator.
In
bibliografia trovate ulteriori riferimenti per approfondire
il problema della Fragile Base Class (FBC).
Bibliografia
ed approfondimenti
[Bloch01] Bloch, Josh. Effective Java. Sun Microsystems, Addison-
Wesley, 2001. ISBN:0201310058
[Gof95] Erich Gamma, Richard Helm, Ralph Johnson and John
Vlissides. Design Patterns: Elements of Reusable Object Oriented
Software, Addison-Wesley, Reading, MA, 1995. ISBN:0201633612
[Holub03] Holub, Allen. Why extends is evil, Javaworld, 2003.
http://www.javaworld.com/javaworld/jw-08-2003/jw-0801- toolbox_p.html
[Lieberman86] Lieberman, Henry. Using Prototypical Objects
to Implement Shared Behavior in Object Oriented Systems. Proceedings
of the first ACM Conference on Object- Oriented Programming
Systems, Languages, and Applications, pages 214-223, Portland,
September 1986. ACM Press.
[Liskov88] Liskov, Barbara. Data Abstraction and Hierarchy.
SIGPLAN Notices, 23,5 (May 1988)
[Martin02] Martin, Robert Cecil. Agile Software Development:
principles, patterns and practices, Prentice-Hall, 2002. ISBN:0135974445
[Parnas72] Parnas., David On the criteria to be used in decomposing
systems into modules. Communications of the ACM, December:1053-1058,
December 1972.
[Pescio95] Pescio, Carlo. Il problema della “fragile base
class” in C++, 1995. http://www.eptacom.net/pubblicazioni/pub_it/fragile.html
[Snyder86] Snyder, Alan. Encapsulation and Inheritance in
Object Oriented Programming Languages. In Object Oriented
Programming Systems, Languages, and Applications Conference
Proceedings, 38-45, 1986. ACM Press.
[Various] http://c2.com/cgi/wiki?InheritanceBreaksEncapsulation
http://c2.com/cgi/wiki?FragileBaseClassProblem http://www.ugolandini.net/LowCouplingPattern.html
http://www.ugolandini.net/DecoratorPattern.html
|