domenica 10 febbraio 2008

Programmazione ad oggetti in C

La programmazione ad oggetti (OOP) è un potente paradigma utilizzato nella realizzazione di molti sistemi moderni. L'OOP è solitamente supportata a livello di linguaggio: il linguaggio mette a disposizione del programmatore una serie di astrazioni per realizzare i principi della OOP (incapsulamento, ereditarietà, polimorfismo). Ad esempio, in Java è disponibile il concetto di class, gli specificatori di accesso, l'estensione delle classi in ereditarietà (extends) e il polimorfismo sui metodi. In C++, similarmente, viene messo a disposizione il concetto di class, gli specificatori di accesso, l'ereditarietà (operatore : ) e il polimorfismo tramite le virtual table.

Programmare OOP con un linguaggio che supporta direttamente l'OOP è sicuramente molto comodo, ma questo non significa che solo con il supporto del linguaggio sia possibile programmare OOP. In questo articolo riassumo un'esempio didattico di programmazione OOP in linguaggio C, scritto da me come esercitazione per il corso di Fondamenti di Informatica C (prof. Cabri), Università di Modena e Reggio Emilia.

Considerazioni preliminari
Per ottenere un sistema OOP si deve riuscire ad implementare un tipo di dato astratto (classe), nascondere i dettagli implementativi di tale tipo di dato astratto (incapsulamento), rendere pubblici e accessibili determinati servizi (incapsulamento), consentire l'ereditarietà e rendere possibile il polimorfismo.
Il linguaggio C mette a disposizione le struct, che possono essere utilizzate per la definizione di un tipo di dato astratto (se dichiarate opportunamento con typedef). E' interessante notare come una struct possa contenere sia dati che puntatori a funzioni, e quindi possa contenere sia informazioni che servizi. Questo rende una struct molto simile ad una classe, anche se non consente di usare nessun specificatore di accesso (incapsulamento) e ereditarietà. E' comunque possibile utilizzare le struct per realizzare delle classi, e per farlo è necessario riuscire a nascondere i dettagli implmentativi privati (incapsulamento) e fornire supporto per ereditarietà. Si noti che il supporto per il polimorfismo è naturalmente garantito: siccome le struct possono contenere puntatori a funzioni, è sufficiente variare uno di tali puntatori per farlo puntare ad una funzione (e quindi ad un comportamento) differente.

Descrizione dell'esempio
L'esempio qui proposto è molto semplice: si vuole realizzare un tipo di dato corrispondente ad un conto corrente, quindi con saldo e servizi relativi all'aggiustamento di tale saldo (versamento, prelievo, stampa, riepilogo movimenti). Si consideri una definizione Java relativa ad una classe ContoCorrente:

public class ContoCorrente{
// dato incapsulato: saldo corrente
private float saldo = 0;

// dato incapsulato: array dei movimenti
private float[] movimenti;

// dato incapsulato: numero di conto
private int numeroConto = 0;

// costruttore
public ContoCorrente(int numeroConto);

// restituisce il saldo corrente
public int m_saldo();

// stampa i dati del contocorrente
public void m_stampa();

// versamento
public int m_versamento(int ammontare);

// prelievo
public int m_prelievo(int ammontare);

// stampa dei movimenti
public void m_stampa_movimenti();
}

Come si può notare, dall'esterno della classe non è visibile nessuna informazione circa l'implementazione del conto corrente (ossia come e dove sono memorizzate le informazioni dei movimenti, del saldo e del numero di conto). Gli unici servizi pubblici sono i metodi che consentono di agire sul conto corrente stesso.
Nel seguito si implementerà la classe Java di cui sopra in linguaggio C.

Implementazione tramite struct
E' necessario implementare due struct differenti, una pubblica e una privata. La struct pubblica deve contenere ogni membro pubblico della classe, nell'esempio di cui sopra solo funzioni (puntatori a funzioni). La struct privata conterra' invece i dettagli implementativi, ossia ogni membro non pubblico della classe di cui sopra. Ma come sono legate le due struct? Qui entra in gioco la potenza dei puntatori C: se le due strutture condividono lo stesso layout, allora è possibile effettuare un cast da una struttura all'altra. Ovviamente, essendo differenti, le due strutture non possono avere lo stesso layout, ma possono avere lo stesso layout delle parti sovrapposte.

Si consideri per prima la struttura pubblica, che risulta essere la seguente:

typedef struct CCPUB{
// puntatore alla funzione getSaldo
int (*m_saldo)(struct CCPUB* );

// puntatore alla funzione StampaContoCorrente
void (*m_stampa)(struct CCPUB*);

// puntatore alla funzione versamento
int (*m_versamento)(struct CCPUB*, int);

// puntatore alla funzione prelievo
int (*m_prelievo)(struct CCPUB*, int);

// puntatore alla funzione stampaMovimenti
void (*m_stampa_movimenti)(struct CCPUB*);
} ContoCorrentePub;

Come già detto sopra, la struttura pubblica contiene solo puntatori a funzione, essendo quelli gli unici membri pubblici specificati. La struttura privata deve includere anche i dati relativi a saldo, array dei movimenti, e numero di conto. Se queste informazioni vengono memorizzate in una struttura privata che abbia la stessa parte iniziale di quella pubblica, allora le due possono diventare compatibili. In altre parole, le strutture devono avere la stessa parte pubblica disposta nello stesso modo:

         - +--------------+ -
| | m_saldo | |
| +--------------+ |
| | m_stampa | |
Conto | +--------------+ | Conto
Corrente | | m_versamento | | Corrente
Pub | +--------------+ | Private
| | m_prelievo | |
| +--------------+ |
| |m_stampa_movim| |
- +--------------+ |
| NumeroConto | |
+--------------+ |
| Saldo | |
+--------------+ |
| Movimenti | |
+--------------+ |
| cur_mov | |
+--------------+ -

Avendo definito il layout delle strutture in questo modo, è possibile allocare la memoria necessaria a contenere tutte le informazioni (quindi la struttura privata) e rendere accessibile all'esterno solo un puntatore alla struttura pubblica. Così facendo, la struttura pubblica, o meglio il suo puntatore, impedisce l'accesso ai membri privati, mentre la memoria condivisa garantisce che le strutture siano sempre allineate. Ovviamente la conversione da struttura pubblica a privata non deve essere effettuata all'esterno, ma all'interno dei metodi pubblici del tipo di dato astratto. Infatti, sono solo questi che devono avere accesso alla struttura privata, e quindi come prima operazione di ogni metodo pubblico, occorre convertire il puntatore affinché punti alla propria struttura privata.
Si ipotizzi di aver memorizzato la dichiarazione (si tratta solo di una dichiarazione) della struttura ContoCorrentePub in un file CC_pub.h, che è l'unico header richiesto per poter usare il conto corrente. All'interno di un alto file CC_private.c si inserirà la dichiarazione della struttura completa dei membri privati:

typedef struct{
// puntatore alla funzione getSaldo
int (*m_saldo)(ContoCorrentePub*);

// puntatore alla funzione StampaContoCorrente
void (*m_stampa)(ContoCorrentePub*);

// puntatore alla funzione versamento
int (*m_versamento)(ContoCorrentePub*, int);

// puntatore alla funzione di prelievo
int (*m_prelievo)(ContoCorrentePub*, int);

// puntatore alla funzione di stampa dei movimenti
void (*m_stampa_movimenti)(ContoCorrentePub*);

//------------------------------------------

// dati nascosti
int NumeroConto; // numero del conto corrente
int Saldo; // saldo attuale
int Movimenti[MAX_MOVIMENTI]; // lista degli ultimi movimenti
int cur_mov; // indice del movimento corrente
} ContoCorrentePrivate;

All'interno dello stesso file si definiranno due metodi privati che consentono la conversione da un conto corrente pubblico ad uno privato:

/*
* Servizio privato di conversione dalla struttura pubblica a quella privata.
* Questa funzione accetta il puntatore alla struttura pubblica e lo converte in quello a
* struttura privata.
*/
static ContoCorrentePrivate* Public2Private(ContoCorrentePub* ccpub){


ContoCorrentePrivate* ccpriv = NULL; // puntatore da ritornare

// controllo se il puntatore è valido, e in caso punti realmente
// alla struttura pubblica lo converto (operazione di casting)
// nella struttura privata
if( ccpub != NULL ){
printf("\n\t[Public2Private] conversione della struttura da pub a private");
ccpriv = (ContoCorrentePrivate*) ccpub;
}

return ccpriv;
}



/*
* Servizio privato di conversione della struttura privata in quella pubblica.
* La funzione accetta il puntatore alla struttura privata e, se quest'ultimo è
* valido, lo converte in un puntatore alla struttura pubblica.
*/
static ContoCorrentePub* Private2Public(ContoCorrentePrivate* ccpriv){

ContoCorrentePub* ccpub = NULL; // puntatore da restituire

// controllo se il puntatore è valido, e in caso affermativo
// lo converto in un puntatore alla struttura pubblica
if( ccpriv != NULL ){
printf("\n\t[Private2Pub] conversione della struttura da private a pub");
ccpub = (ContoCorrentePub*) ccpriv;
}

return ccpub;
}

Come si può notare queste due funzioni sono piuttosto semplici: dichiarano il tipo di puntatore necessario e effettuano un semplice cast. Si noti che le funzioni sono dichiarate static, cosa che garantisce il fatto che esse siano private e confinate a CC_private.c.

A questo punto si consideri l'implementazione di uno dei metodi pubblici del conto corrente, per esempio quello relativo ad un versamento:

/*
* Servizio pubblico di versamento.
* Questo servizio accetta come parametro il puntatore alla struttura pubblica, provvede
* alla sua conversione a privata ed esegue il versamento.
* La funzione ritorna l'effettivo ammontare versato.
*/
int versamento(ContoCorrentePub* ccpub, int ammontare){
ContoCorrentePrivate* ccpriv;


// controllo che il puntatore sia valido e che l'ammontare sia positivo
if( ccpub == NULL || ammontare <= 0 ){ return 0; } // effettuo il versamento ccpriv = Public2Private( ccpub ); ccpriv->Saldo += ammontare;
registraMovimento( ccpriv, ammontare );
return ammontare;
}
Come si nota, il metodo effettua la conversione dalla struttura pubblica a quella privata, dopodiché lavora sui membri privati (es. Saldo). Analogamente, le altre funzioni si comporteranno in modo simile: effettueranno la conversione e lavoreranno sulla parte privata della struttura.

Creazione di un oggetto Conto Corrente
Resta da considerare il costruttore di questo tipo di dato astratto. Il costruttore deve allocare la memoria necessaria, nonché agganciare i puntatori della struttura pubblica alle relative funzioni definite nel file privato CC_private.c:

/*
* Servizio pubblico di generazione di un nuovo conto corrente. Questa
* funzione accetta il numero del nuovo corrente da aprire e crea/inizializza
* una struttura dati privata (ContoCorrentePrivate), per poi restituire il
* puntatore alla struttura pubblica (ContoCorrentePub).
*/
ContoCorrentePub* aperturaContoCorrente(int numero_conto){

ContoCorrentePrivate* ccpriv = NULL;

// alloco memoria per il nuovo conto corrente
ccpriv = (ContoCorrentePrivate*) malloc( sizeof(ContoCorrentePrivate) );

// controllo se la malloc ha allocato effettivamente memoria
if( ccpriv != NULL ){
// il nuovo conto corrente è stato generato, lo inizializzo
printf("\n\t[aperturaContoCorrente] inizializzazione dati conto corrente");
ccpriv->NumeroConto = numero_conto;
ccpriv->Saldo = 0;
ccpriv->cur_mov = 0;

// "aggancio" i puntatori alle relative funzioni
ccpriv->m_saldo = getSaldo;
ccpriv->m_stampa = StampaContoCorrente;
ccpriv->m_versamento = versamento;
ccpriv->m_prelievo = prelievo;
ccpriv->m_stampa_movimenti = stampaMovimenti;

}

// restituisco il conto corrente creato (o NULL se non si è riusciti
// ad allocare memoria
return Private2Public(ccpriv);
}


Come si può notare, viene allocata la memoria necessaria ad una struttura privata, dopodiché si agganciano i puntatori a funzione e si restituisce il puntatore ristretto alla struttura pubblica.

Utilizzo del tipo di dato astratto
Il seguente programma mostra l'utilizzo del conto corrente:

#include 
#include "CC_pub.h" // include che definisce la versione pubblica
// della struttura ContoCorrente


int main(void){
ContoCorrentePub* C1;
ContoCorrentePub* C2;



printf("\nProgramma in esecuzione\n");

// creo i conti corrente
C1 = aperturaContoCorrente( 3663 );
C2 = aperturaContoCorrente( 8026 );


// effettuo dei versamenti
C1->m_versamento( C1, 10000 );
C2->m_versamento( C2, 7000 );

// effettuo un prelievo ed un versamento combinato
C1->m_versamento( C1, C2->m_prelievo(C2, 2000) );

// richiedo la stampa delle informazioni dei conti
C1->m_stampa(C1);
C2->m_stampa(C2);

// stampa dei movimenti
C1->m_stampa_movimenti( C1 );
C1->m_stampa_movimenti( C2 );


}


Si noti che:
  • la struttura privata non è mai visibile, poiché l'apertura di un conto corrente avviene fornendo sempre un puntatore alla struttura pubblica. Se si tenta di accedere ad un campo della struttura pubblica si ottiene un errore di compilazione, poiché il campo non è presente nella struttura pubblica. Se si cerca di convertire manualmente la struttura da pubblica a privata, si ottiene un errore di compilazione (ContoCorrentePrivate non è definito in nessuno degli include del programma).
  • ogni chiamata a metodo deve specificare come argomento la struttura alla quale si riferisce.
L'ultimo punto potrebbe risultare oscuro, ma segue la prassi della programmazione OOP. Infatti, quando ad esempio in Java si effettua una chiamata

C1.m_versamento(200);

il compilatore trasforma la chiamata silenziosamente in

C1.m_versamento(C1, 200);
ciò è necessario per il funzionamento di this. In sostanza, ogni metodo di istanza viene modificato dal compilatore per includere come primo parametro l'istanza stessa sulla quale operare. Tuttavia, mentre in un linguaggio che supporta OOP questo avviene trasparentemente, in una simile soluzione è necessario specificarlo esplicitamente. Questo porta anche alla possibilità di errori quali:

 C1->m_versamento( C2, 10000 );
ove si pensa di agire su C1 ma per errore si agisce su C2. E' evidente che errori del genere sono dovuti alla assenza di supporto OOP nel linguaggio, ma come si è visto da questo esempio, è comunque possibile implementare sistemi OOP.

Ereditarietà
L'ereditarietà viene implementata in modo simile a quello visto per la conversione da struttura pubblica a privata: è possibile estendere una classe aggiungendo nuovi membri in fondo alla struttura, lasciando inalterate le parti sovrapposte alla superclasse. Ad esempio:

typedef struct{
// puntatore alla funzione getSaldo
int (*m_saldo)(ContoCorrentePub*);

// puntatore alla funzione StampaContoCorrente
void (*m_stampa)(ContoCorrentePub*);

// puntatore alla funzione versamento
int (*m_versamento)(ContoCorrentePub*, int);

// puntatore alla funzione di prelievo
int (*m_prelievo)(ContoCorrentePub*, int);

// puntatore alla funzione di stampa dei movimenti
void (*m_stampa_movimenti)(ContoCorrentePub*);

//------------------------------------------

// dati nascosti
int NumeroConto; // numero del conto corrente
int Saldo; // saldo attuale
int Movimenti[MAX_MOVIMENTI]; // lista degli ultimi movimenti
int cur_mov; // indice del movimento corrente

// variabili aggiunte nella sottoclasse
char* intestatario; // nome dell'intestario del conto corrente
int numeroCartaCredito; // numero di carta di credito
} ContoCorrentePrivateExt;
In questo caso, si aggiungono altri dati (ma potevano essere aggiunte altre funzioni) al conto corrente. Si noti che, essendo rimaste identiche le parti sovrapposte, il ContoCorrentePrivateExt è compatibile con ContoCorrentePrivate e quindi ne è una classe derivata.

Si noti che nell'esempio di cui sopra, l'ereditarietà non mostra tutti i vantaggi: è necessario ricopiare tutti i campi della struttura base. Si può ovviare a questo inserendo come primo campo della struttura la struttura base stessa:

typedef struct{
ContoCorrentePrivate* superclasse; // puntatore alla classe base

// variabili aggiunte nella sottoclasse
char* intestatario; // nome dell'intestario del conto corrente
int numeroCartaCredito; // numero di carta di credito
} ContoCorrentePrivateExt;


Polimorfismo
Ottenere il polimorfismo è abbastanza semplice: basta definire nuovi costruttori (metodi aperturaContoCorrente()) che aggancino i puntatori di funzioni a nuove implementazioni.

Considerazioni
Come si è visto è possibile implementare sistemi OOP anche in linguaggi non OOP, chiaramente con qualche difficoltà e possibilità di errori. Diversi sistemi OOP sono realizzati con linguaggi non OOP per diverse ragioni, le principali sono:
  • assenza di compilatori OOP;
  • maggior controllo sul tipo run-time rispetto a quello fornito dal compilatore;
  • adattamento ed evoluzione di un sistema originariamente non OOP.
Alcune implementazioni, come GTK+, mantengono due strutture separate: quella di classe e quella di istanza. Questo simula meglio ciò che avviene ad esempio in Java, ove una sola classe viene caricata (con la definizione dei metodi) e la memoria allocata per le istanze contiene solo i dati di ciascun oggetto.

5 commenti:

Arnaldo Crescente ha detto...

Davvero molto utile ed interessante, ho letto anche il secondo articolo sull'ereditarietà ed è molto bello.

Complimenti

Arnaldo Crescente ha detto...
Questo commento è stato eliminato dall'autore.
Luca Ferrari ha detto...

Mi fa piacere che questo articolo, che in realtà ricalca una lezione introduttiva che facevo all'università, risulti ancora interessante.

Arnaldo Crescente ha detto...

Mi è servito per un esame universitario e in più per una consulenza ad un'azienda di Roma che lavorava ad un software in C per una compagnia telefonica. Mi è stato davvero utile.

Luca Ferrari ha detto...

@Arnaldo
sono molto contento che ti sia servito, spero di poterti essere utile anche in futuro.