MokaByte 86 - Giugno 2004 
Il problema della “Fragile Base Class”
L'ereditarietà rompe l'incapsulamento?
di
Ugo Landini

In questo paper approfondiremo un problema “nascosto” nel meccanismo di ereditarietà e polimorfismo, il problema della “Fragile Base Class”. Fragile Base Class affligge i più popolari linguaggi OO ed è spesso causa di bug inaspettati
e difficili da scovare. Nonostante gli esempi di questo articolo siano in Java, il tutto è facilmente adattabile ad altri linguaggi OO.

“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


MokaByte® è un marchio registrato da MokaByte s.r.l. 
Java®, Jini® e tutti i nomi derivati sono marchi registrati da Sun Microsystems.
Tutti i diritti riservati. E' vietata la riproduzione anche parziale.
Per comunicazioni inviare una mail a info@mokabyte.it