Aspetti Avanzati di Programmazione ad Oggetti
Introduzione e Vantaggi della OOP e Meta-Programmazione
La programmazione orientata agli oggetti (OOP) fornisce numerosi vantaggi per la costruzione di software complessi, tra cui l'incapsulamento, la decomposizione logica del problema, il riuso del codice, la facilità di manutenzione e l'incremento dell'affidabilità globale del sistema. Tuttavia, per ottenere il massimo livello di riusabilità, il C++ offre uno strumento ancora più potente: la Meta-Programmazione.
La meta-programmazione consente di applicare lo stesso blocco di codice a tipi di dato profondamente diversi tra loro, parametrizzando i tipi utilizzati. Questo garantisce due forme fondamentali di indipendenza:
- Indipendenza degli algoritmi dai dati: Un algoritmo di ordinamento, ricerca o calcolo può essere scritto una sola volta ed essere applicato a prescindere dal tipo dei dati da ordinare (siano essi interi, double o strutture personalizzate).
- Indipendenza dei contenitori dal tipo del loro contenuto: Si possono realizzare strutture dati generiche, come uno heap o uno stack, che possono contenere interi, stringhe, double o oggetti, riutilizzando sempre la medesima implementazione.
Funzioni Modello (Template Functions)
Per comprendere l'esigenza dei template, si consideri la stesura di una funzione per calcolare il massimo tra due numeri. Senza l'uso dei template, il programmatore è costretto a dichiarare esplicitamente i tipi e, di conseguenza, a riscrivere la stessa logica di base per ogni nuovo tipo necessario (overloading):
// Implementazione classica: le due funzioni hanno la stessa definizione con tipi diversi
int i_max(int x, int y) {
return (x > y) ? x : y;
}
double d_max(double x, double y) {
return (x > y) ? x : y;
}
Il costrutto template interviene a risolvere questo limite di astrazione, sostituendo i tipi espliciti con un parametro generico introdotto da class. Il compilatore si farà carico del lavoro ripetitivo.
// Definizione di una funzione modello
template<class tipo>
tipo max(tipo x, tipo y) {
return (x > y) ? x : y;
}
Durante la compilazione, il compilatore analizza le chiamate nel sorgente. Se incontra una chiamata max(3, b), sostituisce mentalmente tipo con int e compila il blocco int max<int>(int, int). Se incontra max(3.6, c), compila il blocco double max<double>(double, double). Il risultato finale è la generazione automatica delle due funzioni necessarie a partire da una singola sorgente generica.
Deduzione Implicita e Argomenti Espliciti
Il sistema di risoluzione dei template si basa fortemente sulla deduzione implicita. I tipi passati alla funzione devono essere inequivocabilmente deducibili dalla chiamata.
max(3.6, c) passando un double (3.6) e un intero (presumendo c sia intero), il compilatore non è in grado di dedurre il tipo in modo univoco poiché non può decidere autonomamente se convertire il parametro in int o in double, sollevando un errore.
Per superare questa restrizione e guidare il compilatore, si utilizzano i parametri espliciti, indicati fra parentesi angolari alla chiamata della funzione:
void main() {
double c = 6.0;
// Errore: 3 è intero, c è double. Non si deduce il tipo.
// double d = max(3, c);
// OK: Parametro esplicito che forza la generazione della funzione per double.
// Il valore intero 3 subisce una conversione implicita del parametro.
double d = max<double>(3, c);
// OK: Parametro esplicito per int.
// Conversione del parametro (6.0 diventa 6).
int b = max<int>(3, 6.0);
}
Funzioni Modello con Parametri Multipli e Parametri Non-Tipo
Le potenzialità dei template aumentano notevolmente grazie alla possibilità di definire più di un parametro generico all'interno della stessa funzione. Questo permette di creare operazioni trasversali tra tipi differenti.
template<class tipo1, class tipo2, class tipo3>
tipo1 nuovoMax(tipo2 x, tipo3 y) {
return (x > y) ? x : y;
}
void main() {
int b;
double c = 6.0;
// Fallimento deduzione implicita: manca il tipo di ritorno (tipo1).
// b = nuovoMax(3, c);
// Uso di parametri espliciti ordinati.
// Assegnazione: tipo1=int, tipo2=int, tipo3=double
b = nuovoMax<int, int, double>(3, c);
}
I Parametri Non-Tipo
A fianco delle keyword class o typename, il costrutto template accetta i cosiddetti parametri non-tipo. Si tratta di valori che devono essere noti e risolti a tempo di compilazione (espressioni costanti). Sono utilissimi per la creazione parametrizzata di array e vettori statici, per i quali la dimensione deve essere allocata senza dipendere da variabili eseguite a runtime.
// I parametri non-tipo sono necessariamente espliciti
template<int n, double m>
void funzione(int x = n) {
int y = m;
int array[n]; // Creazione di array locale parametrizzato
}
void main() {
// n=3, m=2. Le due chiamate generano due chiamate di funzioni oggetto diverse.
funzione<1 + 2, 2>(8);
// n=2, m=2.
funzione<2, 2>(9);
}
Funzioni Modello con Variabili Statiche e Compilazione Separata
L'introduzione di variabili static all'interno delle funzioni template introduce una dinamica particolare che è vitale per il corretto funzionamento dei contatori globali: ogni singola istanza della funzione (generata per un tipo specifico) possiede e aggiorna la propria variabile statica isolata.
template<class tipo>
tipo maxT(tipo x, tipo y) {
static int a = 0;
a++;
cout << a << endl;
return (x > y) ? x : y;
}
void main() {
maxT<int>(101, 102); // Stampa 1 (Prima chiamata della versione intera)
maxT<int>(101, 102); // Stampa 2 (La statica della versione intera si incrementa)
maxT<double>(101, 102); // Stampa 1 (Nuova variabile statica per la versione double)
}
Proprio per questo forte legame tra compilazione e deduzione delle chiamate, una funzione modello non può essere compilata separatamente senza prima conoscere le chiamate ad essa effettuate. Ne consegue che, a differenza delle normali funzioni C++, le definizioni e dichiarazioni dei template devono essere tipicamente raggruppate all'interno dei file di intestazione (es. templ.h).
Classi Modello (Template Classes)
Così come è possibile astrarre singole funzioni, il C++ permette di estendere il paradigma della meta-programmazione all'intera struttura di una classe. Questo meccanismo è alla base della Standard Template Library (STL) ed è indispensabile per la creazione di container. In una classe modello, gli attributi e i metodi vengono parametrizzati, rendendo l'oggetto capace di memorizzare e manipolare qualsiasi tipo di dato gli venga fornito durante l'istanziazione.
A differenza delle funzioni modello, in cui il compilatore può dedurre implicitamente i tipi dai parametri passati, nelle classi modello i parametri devono essere sempre esplicitati tra parentesi angolari al momento della dichiarazione dell'oggetto.
Implementazione di uno Stack Parametrico
Per illustrare la potenza delle classi modello, analizzeremo la progettazione di uno stack (struttura dati LIFO: Last In, First Out). Inizialmente, uno stack non generico sarebbe rigidamente vincolato al contenimento di interi:
// Implementazione NON generica (solo per interi)
class stack {
int size;
public:
int* p;
int top;
stack(int n) {
size = n;
p = new int[n];
top = -1;
}
// ... metodi push, pop, empty, full ...
};
Trasformando la classe in un modello parametrizzato rispetto al tipo degli elementi, il puntatore p non sarà più un int*, ma un tipo*. Di seguito, la definizione all'interno di un ipotetico stack.h:
// file stack.h
template<class tipo>
class stack {
int size;
tipo* p;
int top;
public:
stack(int n);
~stack();
int empty();
int full();
int push(tipo s);
int pop(tipo& s);
};
Definizione esterna dei metodi della Classe Modello
Una prassi formale e ordinata richiede la separazione della dichiarazione della classe dall'implementazione dei suoi metodi. Quando si definiscono i metodi esternamente a una classe modello, è necessario premettere ad ogni metodo il costrutto template e specificare l'appartenenza allo scope della classe parametrizzata.
// Implementazione esterna del costruttore
template<class tipo>
stack<tipo>::stack(int n) {
size = n;
p = new tipo[n];
top = -1;
}
// Implementazione esterna del metodo pop
template<class tipo>
int stack<tipo>::pop(tipo& s) {
if (top == -1) return 0;
s = p[top--];
return 1;
}
La dichiarazione delle variabili nel main avverrà forzando esplicitamente il tipo: stack<int> s1(20); oppure stack<float> s4(20);. È persino possibile istanziare uno stack di oggetti complessi, come stack<persona> pila(10);, a patto che la classe persona disponga di un costruttore di default valido.
Stack parametrico rispetto a Tipo e Dimensione
Un livello di astrazione ulteriore si ottiene parametrizzando non solo il tipo, ma anche la dimensione massima della struttura, convertendola da una variabile d'istanza inizializzata a runtime a un parametro non-tipo risolto a compile-time.
template<class tipo, int size>
class stack {
tipo* p;
int top;
public:
stack() {
p = new tipo[size];
top = -1;
}
~stack() { delete[] p; }
// ... altri metodi ...
};
stack<int, 100> pila1; e stack<int, 300> pila2;, un puntatore stack<int, 100>* ptr = &pila1; sarà perfettamente lecito, ma l'assegnazione ptr = &pila2; genererà un errore critico in compilazione, garantendo la totale sicurezza della memoria allocata.
Classi Modello con Membri Statici
Analogamente a quanto accade per le funzioni, i campi dichiarati come static all'interno di una classe modello possiedono una logica di persistenza frammentata. Il compilatore alloca in memoria un'area indipendente per la variabile statica per ogni specifica variante istanziata del template.
Se creiamo una classe cmod parametrizzata da un intero n avente un campo static int istanze = 0;:
- L'istanziazione
cmod<9> nove_a;farà salire il contatore relativo al template<9>a 1. - Un'ulteriore istanziazione
cmod<9> nove_b;riutilizzerà lo stesso blocco di memoria statica, portando il contatore a 2. - Se istanziamo
cmod<7> sette;, verrà allocato un nuovo spazio isolato per il contatore statico, che ripartirà da 1.
Ereditarietà e Gerarchie (Derivazione Semplice)
L'ereditarietà (o derivazione) rappresenta il fulcro organizzativo della programmazione ad oggetti. Essa consente di trasmettere un set di attributi e comportamenti condivisi da una classe genitrice (Base) verso una o più classi figlie (Derivate) senza causare ridondanze nel codice. Questo meccanismo apre le porte all'adattamento e all'estensione logica delle strutture a casi d'uso sempre più circostanziati.
Navigare l'albero di una gerarchia di classi implica percorrere due direttrici assiomatiche complementari:
- Generalizzazione: Procedendo dal basso verso l'alto (Bottom-Up), si estraggono i tratti comuni da classi distinte per radunarli in un super-tipo di astrazione superiore.
- Specializzazione: Procedendo dall'alto verso il basso (Top-Down), si definisce un nuovo sotto-tipo arricchendo un super-tipo con campi esclusivi, comportamenti più complessi o ridefinizioni specifiche.
Composizione delle Classi Derivate
Per un futuro ingegnere informatico alle prese con la progettazione strutturale, il tipico scenario didattico che esplica la specializzazione è la modellazione delle utenze di un ateneo. Partiamo da una classe base universale persona:
class persona {
public:
char nome[20];
int eta;
};
// Derivazione: 'studente' eredita da 'persona'
class studente : public persona {
public:
int esami;
int matricola;
};
// Ulteriore livello: 'borsista' eredita da 'studente'
class borsista : public studente {
public:
int borsa;
int durata;
};
Quando si istanzia in memoria un oggetto di tipo studente, lo spazio allocato comprende integralmente il layout della classe persona (i campi nome ed eta) seguito sequenzialmente dai campi esami e matricola. Similmente, la classe derivata impiegato erediterà da persona affiancandosi come "sorella" della classe studente nell'albero genealogico.
Compatibilità fra Tipi e Slicing
La natura cumulativa del layout di memoria garantisce l'interoperabilità e la fungibilità dei tipi all'interno della medesima catena di derivazione, abilitando un meccanismo conosciuto come upcasting implicito. Un'operazione come persona pe = st; (dove st è un oggetto studente) è valutata come lecita.
pe = st sia sintatticamente corretta, si verifica un fenomeno di troncamento distruttivo (Object Slicing). Durante la conversione asimmetrica da studente a persona, i campi specifici della classe derivata (esami e matricola) svaniscono nel nulla, dacché l'area di memoria di destinazione di pe possiede un'impronta dimensionale calibrata per ospitare unicamente nome ed eta. Al contrario, l'operazione inversa (es. st = pe), in cui un supertipo viene assegnato a un sottotipo, è formalmente proibita e rigettata dal compilatore.
Compatibilità Tramite Puntatori
Il quadro si evolve nettamente qualora l'upcasting si appoggi su riferimenti tramite puntatori in memoria. Un puntatore a una superclasse ha licenza di mantenere un indirizzo che denota un oggetto di una sottoclasse:
studente st;
borsista bo;
persona* ppe = &st; // Upcasting lecito (conversione implicita)
ppe = &bo; // Upcasting transitivo lecito
In questo frangente, i campi della classe derivata restano vivi e integri nello stack (non si subisce slicing). Malgrado ciò, essi non sono direttamente fruibili dal programmatore: il compilatore vincola l'accesso al set di attributi basandosi strettamente e preventivamente sul tipo dichiarato per il puntatore chiamante (nell'esempio, la prospettiva ristretta della classe persona). Un'eventuale evocazione ppe->esami causerà una repentina eccezione fatale in fase di build.
Regole di Visibilità e Tipi di Derivazione
Il paradigma dell'incapsulamento, in sinergia con l'ereditarietà, impone la definizione di una solida politica di permessi. In C++, l'accessibilità dei campi e dei metodi all'interno di una gerarchia è governata da specificatori di accesso che operano su due livelli distinti: la visibilità intrinseca del membro all'interno della classe che lo definisce, e la visibilità ereditata che esso assume all'interno delle classi derivate.
Specificatori di Accesso: Public, Protected, Private
Le tre parole chiave fondamentali per la visibilità stabiliscono un perimetro d'azione molto rigido, che il compilatore fa rispettare tassativamente in fase di analisi semantica:
- Campi Privati (Private): I campi e i metodi dichiarati come
privatesono strettamente vincolati alla classe di appartenenza. Essi non sono mai accessibili dalle sottoclassi, né tantomeno dall'esterno dell'oggetto. Se una classeBaseha un campo privatox, un metodo della classeDerivatache tenta di eseguirex = 5;genererà un errore irreversibile in compilazione. - Campi Protetti (Protected): Questa visibilità rappresenta il ponte ideale per l'ereditarietà. I membri
protectedsono accessibili direttamente dai metodi delle classi derivate, permettendo loro di manipolare lo stato interno ereditato. Tuttavia, per il codice esterno (es. la funzionemain), i membri protetti rimangono completamente invisibili, garantendo l'incapsulamento verso l'utilizzatore finale dell'oggetto. - Campi Pubblici (Public): I membri
publicdefiniscono l'interfaccia aperta della classe. Sono liberamente accessibili sia dalle sottoclassi che dal mondo esterno.
I Tipi di Derivazione
Un errore concettuale frequente consiste nell'ignorare il tipo di derivazione. Quando si eredita una classe, si deve specificare un modificatore (es. class Borsista : public Studente). Questo modificatore funge da filtro massimo per i membri ereditati, potendo restringere, ma mai allargare, la loro visibilità originale:
| Tipo di Ereditarietà | Membro Originale in Base | Nuova Visibilità in Derivata |
|---|---|---|
| Public | public |
public (invariato) |
protected |
protected (invariato) |
|
| Protected | public |
protected (ristretto) |
protected |
protected (invariato) |
|
| Private | public |
private (fortemente ristretto) |
protected |
private (fortemente ristretto) |
Name Hiding e l'Operatore di Risoluzione di Scope (::)
Come accennato, quando si verificano omonimie tra un campo/metodo della classe derivata e uno della classe base, il C++ non solleva errori, ma applica la regola del Name Hiding. L'elemento della classe derivata oscura formalmente quello della classe base per le normali chiamate a punto (es. oggetto.campo).
Per recuperare l'accesso ai membri nascosti, l'unico strumento lecito è l'operatore di risoluzione di scope (::). Questo costrutto non si applica solo alle variabili, ma si estende funzionalmente anche ai metodi:
class Studente {
public:
void chiSei() { cout << "Sono uno studente"; }
};
class Borsista : public Studente {
public:
void chiSei() { cout << "Sono un borsista"; }
};
void main() {
Borsista b;
b.chiSei(); // Stampa: "Sono un borsista"
// Accesso esplicito al metodo della classe base mascherato dal Name Hiding
b.Studente::chiSei(); // Stampa: "Sono uno studente"[cite: 7]
}
Ciclo di Vita: Costruzione e Distruzione
La natura composita e stratificata degli oggetti in una gerarchia rende critica la fase di allocazione e di deallocazione della memoria. Il compilatore segue una rigorosa sequenza di operazioni per assicurare che le dipendenze semantiche siano rispettate: una classe derivata, infatti, potrebbe basare la propria inizializzazione su attributi ereditati dalla classe base, la quale deve quindi esistere e possedere uno stato valido prima che la derivata inizi a operare.
In sintesi: si costruisce prima la parte BASE e poi la parte DERIVATA. Si distrugge prima la parte DERIVATA e poi la parte BASE.
Ordine di Chiamata dei Costruttori
Espandendo la logica base-derivata ad architetture che includono anche oggetti composti (membri di classe che sono a loro volta oggetti), l'algoritmo di costruzione per una gerarchia a due livelli impone esattamente questo ordine cronologico:
- Membri Base: Invocazione dei costruttori degli eventuali oggetti definiti come attributi membri all'interno della classe base.
- Classe Base: Esecuzione del corpo del costruttore della classe base. Se non viene specificato esplicitamente un costruttore, il compilatore invocherà quello di default (senza argomenti).
- Membri Derivata: Invocazione dei costruttori degli eventuali oggetti definiti come attributi membri all'interno della classe derivata.
- Classe Derivata: Infine, esecuzione del corpo del costruttore della classe derivata, la quale trova ora un ambiente sottostante interamente inizializzato e consistente.
class Uno {
protected:
int a;
public:
// Nessun costruttore di default. Costruttore parametrico obbligatorio.
Uno(int x) { a = x; cout << "nuovo Uno"; }
};
class Due : public Uno {
int b;
public:
// Chiamata obbligatoria a Uno(x) nella lista di inizializzazione
Due(int x) : Uno(x + 1) {
b = x;
cout << "nuovo Due";
}
};
// Nel main, l'istruzione 'Due obj(8);' provocherà:
// 1. Chiamata a Uno::Uno(9) (a causa del x+1)
// 2. Chiamata a Due::Due(8)
L'assenza di tale delega nella lista di inizializzazione (es. scrivendo banalmente Due(int x) { ... }) comporterebbe un errore critico in fase di compilazione, poiché il compilatore non saprebbe come dare vita allo strato base dell'oggetto.
Ordine di Chiamata dei Distruttori
Il processo di distruzione persegue filosoficamente lo snellimento della memoria (pop dello stack o deallocazione nell'heap) procedendo a ritroso rispetto alla costruzione. Quando un oggetto termina il suo ciclo di vita (esce dallo scope o riceve una direttiva delete), si verificano i seguenti step:
- Viene eseguito il corpo del distruttore della classe derivata.
- I campi membri della classe derivata vengono distrutti.
- Viene eseguito il corpo del distruttore della classe base.
- I campi membri della classe base vengono distrutti, liberando l'ultimo footprint di memoria.
Il rigoroso rispetto di questo ordine a matrioska previene scenari catastrofici in cui il distruttore di una classe derivata tenti di operare su risorse allocate dalla classe base che, ipoteticamente, si troverebbero già distrutte anzitempo.
Funzioni Virtuali e Polimorfismo Dinamico
Fino a questo momento abbiamo analizzato sistemi in cui il legame tra la chiamata di un metodo e la sua esecuzione effettiva viene risolto dal compilatore durante la fase di build (Static Binding o Early Binding). Se un puntatore di tipo base punta a un oggetto derivato, invocare un metodo tramite quel puntatore comporta inderogabilmente l'esecuzione della versione definita nella classe base, poiché la scelta del metodo da chiamare avviene a tempo di compilazione basandosi esclusivamente sul tipo formale del puntatore.
Tramite le funzioni virtuali, questo vincolo viene spezzato: in una gerarchia di classi, il metodo da eseguire viene selezionato dinamicamente a tempo di esecuzione (Dynamic Binding o Late Binding), in base al tipo dell'oggetto effettivamente puntato in memoria, indipendentemente dal tipo del puntatore che lo referenzia. Questo principio prende il nome di Polimorfismo Dinamico.
Sintassi e Meccanismo del Late Binding
Per abilitare la risoluzione a runtime, la classe base deve marcare il proprio metodo con la parola chiave virtual. Si osservi il comportamento nel seguente esempio:
class studente {
public:
// Dichiarazione di funzione virtuale
virtual void chiSei() { cout << "sono uno studente"; }
};
class borsista : public studente {
public:
// Ridefinizione (override) della funzione. La keyword 'virtual' qui può mancare.
void chiSei() { cout << "sono un borsista"; }
};
void main() {
borsista* b = new borsista(10, 888888, 500000);
studente* b1 = b; // Upcasting lecito
// L'oggetto puntato è un 'borsista', quindi la risoluzione dinamica sceglie borsista::chiSei()
b1->chiSei(); // Stampa: "sono un borsista"
}
Qualora il metodo chiSei() in studente non fosse stato marcato come virtual, l'istruzione b1->chiSei() avrebbe stampato "sono uno studente", generando un comportamento logicamente errato rispetto alla vera natura dell'oggetto istanziato.
virtual nelle classi derivate.
Il Problema Critico dei Distruttori Virtuali
L'uso del polimorfismo dinamico solleva una criticità gravissima riguardo la deallocazione della memoria. Se gestiamo un oggetto derivato tramite un puntatore alla classe base e invochiamo la direttiva delete, il compilatore (se il binding è statico) invocherà solamente il distruttore della classe base. Tutto lo spazio allocato dai membri della classe derivata rimarrà in memoria, generando un Memory Leak (perdita di memoria).
Per scongiurare tale disastro, è tassativo che il distruttore della classe base venga dichiarato virtuale:
class uno {
public:
uno() {};
virtual ~uno() { cout << "via uno" << endl; } // Distruttore Virtuale!
};
class due : public uno {
public:
due() {};
~due() { cout << "via due" << endl; }
};
void main() {
uno* obj = new due;
delete obj;
// L'output corretto sarà: prima "via due" e poi "via uno"
}
Classi Astratte e Interfacce
Nelle architetture software enterprise, spesso si necessita di definire un "contratto" generico per una famiglia di oggetti, demandando l'implementazione pratica alle singole specializzazioni. Le Classi Astratte assolvono proprio a questo compito: fungono da classe base concettuale nelle derivazioni, offrendo un'interfaccia unica e coerente verso le applicazioni esterne.
Il tratto distintivo fondamentale di una classe astratta è che essa non è completamente definita e non può in alcun modo essere istanziata direttamente. Un tentativo di creare un oggetto da una classe astratta (es. studente s;) provocherà un errore di compilazione. Per contro, è assolutamente legale e incoraggiato creare puntatori a una classe astratta (es. studente* s;) per governare polimorficamente le sottoclassi concrete.
Le Funzioni Virtuali Pure
A livello sintattico, una classe diviene astratta non appena accoglie al suo interno almeno una funzione virtuale pura. Tale funzione si distingue da una funzione virtuale classica per l'assenza totale di corpo (definizione) e per la presenza dell'assegnazione = 0 alla fine del prototipo.
class studente {
int matricola;
int esami;
public:
studente(int m) { esami = 0; matricola = m; }
// Funzione virtuale pura. Rende "studente" una classe astratta.
virtual void chiSei() = 0;
};
Qualsiasi classe derivi da studente (es. studenteIngInf o studenteIngMecc) ha l'obbligo formale di fornire una propria implementazione concreta per il metodo chiSei(). In caso contrario, anch'essa risulterà astratta e inistanziabile.
void main() {
// Array di puntatori polimorfi all'interfaccia base astratta
studente* studenti[3];
// Istanziazione delle specializzazioni concrete
studenti[0] = new studenteIngInf(777777);
studenti[1] = new studenteIngMecc(888888);
studenti[2] = new studenteIngInf(999999);
// Invocazione ciclica con risoluzione al dynamic binding
for (int i = 0; i < 3; i++) {
studenti[i]->chiSei();
}
// Output atteso:
// "studente di ingegneria informatica"
// "studente di ingegneria meccanica"
// "studente di ingegneria informatica"
}
Convergenza tra Classi Modello ed Ereditarietà
Le potenzialità della programmazione C++ permettono di ibridare i template con l'ereditarietà in costrutti estremamente avanzati. È possibile avere una classe base modello e derivare da essa implementazioni che gestiscono i tipi parametrici in maniera differente:
- Classe derivata modello con lo stesso tipo parametrico:
template <class tipo> class due: public uno<tipo>. La classe figlia propaga il medesimo parametro di tipo alla base. - Classe derivata non modello (concreta):
class due: public uno<int>. In questo caso la classe derivata congela (fissa) il tipo della classe base modello, diventando un'entità chiusa e stabile. - Classe derivata modello a più dimensioni:
template <class tipo1, class tipo2> class due: public uno<tipo1>. Qui la classe derivata introduce un ulteriore grado di libertà (tipo2) mentre si relaziona alla classe base tramitetipo1.
Gestione delle Eccezioni in Ambienti Object-Oriented
Le applicazioni software nel mondo reale sono soggette a situazioni anomale a runtime (tempo di esecuzione), come divisioni per zero, tentativi di accesso a indici di array fuori dai limiti previsti (out-of-bounds) o esaurimento della memoria fisica allocabile. Tali errori, non essendo rilevabili dal compilatore a monte, possono causare l'immediato e disastroso crash dell'applicazione.
Il C++ introduce il meccanismo delle eccezioni proprio per offrire la possibilità di individuare queste casistiche critiche e gestirle da programma, evitando la terminazione forzata. Questo sistema implementa un metodo formale e ben definito che garantisce una netta separazione tra il codice che rileva e solleva l'eccezione, e il codice preposto a gestirla.
Sintassi e Costrutto Try-Catch-Throw
La gestione delle eccezioni si avvale di tre parole chiave fondamentali che lavorano in stretta sinergia: try, throw e catch.
- Il blocco TRY: È il blocco di codice "osservato speciale". Solo all'interno di questo perimetro (o nelle funzioni invocate da esso) è possibile sollevare un'eccezione.
- L'istruzione THROW: Qualora si verifichi l'anomalia, l'istruzione
throwlancia letteralmente un segnale (che può essere un intero, un carattere, una stringa o un oggetto complesso). In quel preciso istante, l'esecuzione del blocco try si interrompe immediatamente, ignorando le eventuali istruzioni successive al suo interno. - I blocchi CATCH: Sono i "gestori" dell'errore. Ogni blocco
catchè specializzato per intercettare un tipo specifico di eccezione. Se l'eccezione lanciata non viene catturata da alcuncatchcompatibile, il programma terminerà comunque con un errore irreversibile.
void divisione_sicura(int x, int y) {
try {
if (y == 0) throw "Errore: Divisione per 0"; // Lancia una stringa literal
if (x < 0) throw -1; // Lancia un intero
cout << "Risultato: " << x / y << endl;
}
catch (const char* msg) {
// Intercetta l'errore di divisione
cout << msg << endl;
}
catch (int code) {
// Intercetta il valore negativo
cout << "Operando negativo vietato. Codice: " << code << endl;
}
}
Srotolamento dello Stack e Corrispondenza dei Gestori
Le eccezioni vengono gestite a tempo di esecuzione con regole stringenti: non avvengono conversioni di tipo implicite tra il dato lanciato dal throw e l'argomento atteso dal catch (fatta salva la conversione fisiologica da sottotipo a sopratipo in scenari polimorfi).
I gestori catch vengono esaminati rigorosamente nell'ordine cronologico (dall'alto al basso) in cui compaiono all'interno dello script. Viene scelto per l'esecuzione unicamente il primo gestore il cui tipo coincida con quello dell'eccezione scatenata.
throw non deve necessariamente risiedere all'interno del medesimo scope del catch. Se la funzione f() solleva un'eccezione, ma questa funzione è stata invocata da g() la quale, a sua volta, è stata invocata dal main() protetto da un blocco try, l'eccezione "risalirà" lo stack delle chiamate (Stack Unwinding) attraversando le funzioni a ritroso finché non incappa in un blocco try circondato dal corretto catch.
Il Gestore Universale: Catch(...)
Per prevenire terminazioni impreviste derivanti da eccezioni ignote o sfuggite ai precedenti controlli, il C++ mette a disposizione una clausola generica definita dalla sintassi catch(...). Essa va posta sempre come ultimo baluardo alla fine della catena di catch, in quanto la sua natura fungerà da "acchiappatutto" per qualsiasi tipologia di eccezione.
Architetture Avanzate: Gerarchie di Eccezioni Polimorfe
Nei sistemi informatici complessi, il lancio di interi o stringhe stringate per la gestione degli errori risulta insufficiente e sintatticamente povero. La best practice moderna consiste nel far collimare la gestione delle eccezioni con il polimorfismo puro, innalzando le eccezioni al rango di oggetti scaturiti da una gerarchia di classi.
Rifacendoci all'esempio teorico della struttura Stack visto nel capitolo delle classi modello, immaginiamo di progettare una gerarchia dedicata alla gestione delle sue vulnerabilità, ponendo come radice una classe astratta base nominata StackEcc:
// Classe Astratta Base per le eccezioni dello Stack
class StackEcc {
public:
void msg() { cout << "Attenzione: "; }
virtual void print() = 0; // Interfaccia Polimorfica Obbligatoria
};
// Sottoclasse per la gestione dello Stack Pieno (Overflow)
class StackFull : public StackEcc {
int e; // Valore che ha causato l'overflow
public:
StackFull(int n) { e = n; }
void print() {
msg();
cout << e << " non inserito (Overflow)" << endl;
}
};
// Sottoclasse per la gestione dello Stack Vuoto (Underflow)
class StackEmpty : public StackEcc {
public:
void print() {
msg();
cout << "Stack vuoto (Underflow)" << endl;
}
};
Lancio e Intercettazione Tramite Puntatori Polimorfi
Qualora un'anomalia occorra nei metodi dello Stack, andremo ad allocare in via dinamica (heap) il corrispondente oggetto d'errore (ad esempio, throw new StackFull(s); oppure throw new StackEmpty();).
A questo punto, nel nostro blocco principale (es. main), invece di predisporre dozzine di blocchi catch ridondanti per ogni singola eventualità, potremo definire un singolo blocco catch polimorfo delegato ad accogliere un puntatore alla classe astratta capostipite StackEcc*.
int main() {
stack pila(2);
try {
pila.push(4).push(5).push(6); // Il 6 causerà il throw new StackFull(6)
}
catch (StackEcc* ecc) {
// La risoluzione dinamica (Late Binding) eseguirà il print()
// specifico dell'effettiva natura dell'errore.
ecc->print();
// Rilascio imperativo della memoria allocata dinamicamente nell'istruzione throw
delete ecc;
}
return 1;
}
Questo costrutto architetturale corona le capacità dell'ingegneria del software in C++: delega al Dynamic Binding l'identificazione semantica del comportamento opportuno, alleggerisce il codice e costringe all'implementazione dei medesimi canoni d'interfaccia via funzioni virtuali pure. Una sola premura va considerata: siccome il throw è stato avviato sfruttando la keyword new (istanziazione in memoria dinamica), diviene compito inderogabile del blocco ricevente catch distruggere l'oggetto chiamando formalmente delete ecc; onde arginare catastrofici leak di RAM.