Oggetti ed Ereditarietà
Uno degli elementi che distinguono un linguaggio ad oggetti da quelli strutturati è sicuramente la
possibilità di ereditare da
ancestor delle caratteristiche e comportamenti. L'ereditarietà è un
meccanismo che ha decretato un grande passo in avanti nel modo di modellare i problemi, derivarne
un design e promuovere il riuso. Questo concetto, infatti, ha condotto ad uno stile di programmazione
indicato come
programmazione per differenza dato che si può far evolvere un sistema con la
crescita di una gerarchia di classi che sono in un rapporto di generalizzazione/specializzazione.
Uno degli esempi classici è quello legato all'idea di automobile; in Java possiamo codificare una
classe
Auto che descrive gli aspetti più generici di un automobile, le caratteristiche assolutamente
comuni a tutti i modelli. Si può pensare ad esempio al fatto che un'automobile sia munita di motore.
Il concetto di automobile sportiva può concretizzarsi come specializzazione della classe
Auto, visto
che anche un'auto sportiva possiede un motore. Nella nuova classe
AutoSportiva non sarà
necessario riscrivere codice per la gestione del concetto
Motore in quanto già presente nella classe
Auto ed ereditato dalla stessa. Applicando questo modo di vedere anche alla classe Motore abbiamo
la possibilità di esprimere nella classe automobile le logiche di base per la gestione del motore e
nelle classi specializzate l'adozione di motori differenti; tutto questo senza dover cambiare una riga
di codice.
Figura 1
Nella pratica di ogni giorno ereditarietà e polimorfismo sono spesso in simbiosi e forniscono una
coppia potentissima di tool per il design ad oggetti. Questi elementi, però, inducono a problemi
sensibili specie se non si segue un'attenta disciplina o si eccede nel loro uso.
Tipi di Ereditarietà
I linguaggi ad oggetti hanno un supporto differente per l'ereditarietà: in alcuni è attuabile
l'
ereditarietà multipla in altri, come Java, è possibile solo l'
ereditarietà singola. Nel primo caso è
possibile avere più classi
ancestor da cui ereditare mentre nel secondo vi può essere una sola
root-
class. È presente anche un altro tipo di classificazione per la quale è possibile distinguere tra
ereditarietà d'interfaccia, o
subtyping, ed
ereditarietà implementativa, o
subclassing. La differenza
tra le due tipologie di ereditarietà nasce dalla differenza che esiste tra il concetto di tipo e quello di
classe. I due elementi sembrerebbero coincidere ma in realtà non è così: un tipo esprime un
protocollo che può essere realizzato in modi differenti; una classe esprime invece un
comportamento ben preciso. In Java in concetto di tipo è espresso dall'idioma
interface che
permette di esprimere un'astrazione senza che sia definita una data implementazione. Il concetto di
classe, invece, combina i due elementi, tipo ed implementazione, dato che codificare una nuova
classe implica esprimere un nuovo tipo che ha già implementato il relativo comportamento.
L'ereditarietà d'interfaccia intende esprimere la possibilità di trasmette alle specializzazioni gli
elementi del protocollo dettato dall'
ancestor a differenza dell'ereditarietà implementativa dove ciò
che viene ereditato è il comportamento.
Class Fragility
Il
subtyping è considerata una caratteristica abbastanza tranquilla, senza molte contro-indicazioni a
differenza delle problematiche introdotte dal
subclassing che è il motivo della nostra discussione.
Sappiamo che uno dei cardini dell'object orientation è l'
Incapsulamento che permette di abbinare
strutture dati ed operazioni negli oggetti e che insieme all'
Information Hiding permette di rendere
privato ciò che è specifico di una data astrazione. L'ereditarietà implementativa viene a rompere
questa idea d'incapsulamento dato che, nella gerarchia, nel rapporto tra generalizzazione-
specializzazione, vengono ad essere esposti elementi privati dell'elemento da cui si eredita. Questo
è il presupposto per il problema della fragilità della classe base. Tale problematica emerge, infatti,
quando si attuano cambiamenti nelle funzionalità di una superclasse interessando a catena anche il
comportamento delle sottoclassi, le quali terminano di operare correttamente.
Riprendiamo l'esempio della classe Auto; possiamo pensare a del codice nella forma seguente:
public class Auto
{
protected Motore motore = new Motore();
protected boolean motoreAcceso;
.
.
.
public void accensione()
{
if (!motoreAcceso) {
motore.attiva();
motoreAcceso = true;
}
}
.
.
.
}
Possiamo considerare che la sottoclasse AutoSportiva erediti dalla superclasse la funzionalità di
accendere il motore e che ne presenti una versione specializzata:
public class AutoSportiva extends Auto
{
.
.
.
public void accensione()
{
if (controlloImpiantoElettrico() && controlloFluidi())
super.accensione();
}
.
.
.
}
La problematica in discussione può presentarsi allorché le sottoclassi di Auto vedano alterata la
funzionalità della superclasse; nel nostro esempio se il metodo accensione cambiasse, non è più
garantito il corretto funzionamento della classe AutoSportiva. Potremmo, ancora, pensare ad una
classe Ferrari che specializza la classe AutoSportiva: se il metodo controllaImpiantoElettrico() non
restituisse più un booleano l'errore sarebbe ancora più evidente, specie in fase di compilazione! Un
ulteriore esempio della problematica in esame si evidenzierebbe se la variabile motore divenisse di
un'altro tipo, differente da
Motore, rendendo le assunzioni delle sottoclassi a riguardo non più
valide.
Evitare la fragilità della classe base
Evitare questa problematica non è semplicissimo e richiede comunque molta attenzione; linee guida
possono essere le seguenti:
-
Usare librerie stabili: potrebbe sembrare banale ma, l'uso di elementi standard è un primo
passo verso la stabilità in modo che il problema non sorga;
-
Usare sempre attributi privati e fornire getter e setter per accedervi: in questo modo si
rafforza l'incapsulamento anche in presenza di ereditarietà e si ottiene un design più pulito;
-
Usare programmazione difensiva nel caso di attributi complessi; questo come corollario al
precedente punto;
-
Programmare per interfacce non per classi. Un classico adagio della programmazione ad
oggetti in questo caso quanto mai utile: le interfacce non forniscono implementazione,
quindi, non presentano il problema;
-
Rendere i metodi ereditabili final: in questo modo non può verificarsi l'overriding di
metodi, azione che potrebbe essere ulteriore causa di fragilità;
-
Evitare l'ereditarietà. Il riuso del codice è importante ma, non deve spingere alla creazioni di
gerarchie dalle dimensioni sensibili o volute per il solo riuso di implementazioni;
-
Non eliminare bensì deprecare. Gli sviluppatori Java spesso incontrano il concetto di
deprecato e in questa sede ne vediamo l'utilità. Eliminare un pezzo di classe vuol dire, in
modo ricorsivo, alterare l'equilibrio funzionale nella gerarchia che vede la data classe come
root-class. Indicando come non più usabile (deprecato) un dato elemento si fornisce
l'opportuno mezzo per non alterare rapporti preesistenti tra generalizzazioni-specializzazioni
e, grazie ad una giusta documentazione, si può condurre lo sviluppatore verso funzionalità
nuove o sostitutive.