Programmazione orientata agli oggetti: utilizzare OOP in C
A differenza dei linguaggi OOP C++ e Objective-C, C non è dotato di base di alcuna funzionalità orientata agli oggetti. A causa della diffusione del linguaggio e della popolarità della programmazione orientata agli oggetti, esistono approcci all’uso di OOP in C.
OOP in C: è davvero possibile?
Il linguaggio di programmazione C non è di per sé destinato alla programmazione orientata agli oggetti. Questo linguaggio è un ottimo esempio dello stile di programmazione strutturato all’interno della programmazione imperativa. Ciononostante, è possibile emulare approcci orientati agli oggetti in C, poiché dispone di tutti i componenti necessari a questo scopo. Ad esempio, è servito come base per realizzare la programmazione orientata agli oggetti in Python.
Con OOP, è possibile definire i propri “tipi di dati astratti” (ADT). Un ADT può essere considerato come un insieme di valori possibili e di funzioni che operano su di essi. È importante che l’interfaccia visibile all’esterno e l’implementazione interna siano disaccoppiate tra loro. In questo modo, l’utente può fare affidamento sul fatto che gli oggetti del tipo si comportino secondo la descrizione.
I linguaggi orientati agli oggetti come Python, Java e C++ utilizzano il concetto di “classe” per modellare i tipi di dati astratti. Le classi servono come modello per la creazione di oggetti simili; questa operazione viene anche chiamata istanziazione. C non conosce intrinsecamente le classi, che non possono essere modellate all’interno del linguaggio. Esistono invece diversi approcci per implementare le funzionalità OOP in C.
Come funziona OOP in C?
Per capire come funziona OOP in C, dobbiamo innanzitutto porci la domanda: cos’è esattamente la programmazione orientata agli oggetti (OOP)? Si tratta di uno stile di programmazione comune che è una manifestazione del paradigma di programmazione imperativo. OOP si contrappone quindi alla programmazione dichiarativa e alla sua specializzazione, la programmazione funzionale.
L’idea di base della programmazione orientata agli oggetti consiste nel modellare gli oggetti e farli interagire tra loro. Il flusso del programma risulta dalle interazioni degli oggetti e non viene quindi fissato fino all’esecuzione. In sostanza, OOP comprende solo tre proprietà:
- Gli oggetti incapsulano il loro stato interno.
- Gli oggetti ricevono messaggi attraverso i loro metodi.
- I metodi sono assegnati dinamicamente in fase di esecuzione.
Un oggetto in un linguaggio OOP puro come Java è un’unità autonoma. Comprende una struttura di dati di qualsiasi complessità e metodi (funzioni) che operano su di essa. Lo stato interno dell’oggetto, rappresentato dai dati che contiene, può essere letto e modificato solo attraverso i metodi. Per la gestione della memoria degli oggetti, viene solitamente utilizzata una funzione del linguaggio chiamata “garbage collector”.
In C, non è facile collegare le strutture di dati e le funzioni agli oggetti. Invece, si crea un sistema gestibile di strutture di dati, definizioni di tipi, puntatori e funzioni. Quindi, come di consueto in questo linguaggio, il programmatore o la programmatrice è responsabile della corretta allocazione e abilitazione della memoria.
Il codice C a oggetti che ne risulta non assomiglia molto a quello a cui siamo abituati nei linguaggi OOP, ma funziona. Di seguito vi forniamo un riepilogo dei concetti centrali della programmazione orientata agli oggetti e del loro equivalente in C:
Concetto in OOP | Equivalente in C |
---|---|
Classe | Tipo struct |
Instanza di classi | Instanza struct |
Metodo di istanza | Funzione che accetta i puntatori alla variabile struct |
Variabile this/self | Puntatori alla variabile struct |
Istanziazione | Allocazione e referenza tramite puntatori |
Parola chiave new | Chiamata di malloc |
Modellare gli oggetti come strutture dati
Vediamo innanzitutto come la struttura dei dati di un oggetto può essere modellata in C nello stile dei linguaggi OOP. C è un linguaggio compatto che se la cava con pochi costrutti linguistici. Per creare strutture di dati arbitrariamente complesse, si utilizzano i cosiddetti “struct”, il cui nome deriva dal termine “Data Structure” (struttura dei dati).
Struct in C definisce una struttura di dati che include campi chiamati “members” (membri). In altri linguaggi, un costrutto di questo tipo è chiamato anche “record”, e quindi si può immaginare uno struct come la riga di una tabella di un database: una combinazione di più campi, eventualmente di tipo diverso.
La sintassi della dichiarazione di uno struct in C è molto semplice:
struct struct_name;
COpzionalmente, definiamo anche struct specificando i membri con nome e tipo. A titolo di esempio, consideriamo un punto nello spazio bidimensionale con coordinate x e y. Vi mostriamo la definizione di struct:
struct point {
/*X-coordinate*/
int x;
/*Y-coordinate*/
int y;
};
CNel codice C convenzionale, questa operazione è seguita dall’istanziazione di una variabile struct. Creiamo la variabile e inizializziamo entrambi i campi con 0:
struct point origin = {0, 0};
CIn seguito, i valori dei campi possono essere letti e reimpostati. L’accesso ai membri avviene tramite la sintassi origin.x e origin.y, già nota in altri linguaggi:
/*Read struct member*/
origin.x == 0
/*Assign struct member*/
origin.y = 42
CTuttavia, questo viola il requisito dell’incapsulamento: si può accedere allo stato interno di un oggetto solo tramite metodi definiti a tale scopo. Quindi al nostro approccio manca ancora qualcosa.
Definire i tipi per la creazione di oggetti
Come già menzionato, C non conosce il concetto di classe. Invece, i tipi si possono definire con l’istruzione typedef. Con typedef, diamo a un tipo di dati un nuovo nome:
typedef <old-type-name> <new-type-name>
CQuesto ci permette di definire un corrispondente tipo Point per il nostro struct point:
typedef struct point Point;
CLa combinazione di typedef con una definizione di struct corrisponde all’incirca alla definizione di una classe in Java:
typedef struct point {
/*X-coordinate*/
int x;
/*Y-coordinate*/
int y;
} Point;
CNell’esempio, “point” è il nome di struct, mentre “Point” è il nome del tipo definito.
Di seguito la definizione della classe corrispondente in Java:
class Point {
private int x;
private int y;
};
JavaL’uso di typedef ci permette di creare una variabile Point senza usare la parola chiave struct:
Point origin = {0, 0}
/*Instead of*/
struct point origin = {0, 0}
CCiò che ancora manca è l’incapsulamento dello stato interno.
Incapsulamento dello stato interno
Gli oggetti mappano il loro stato interno nella loro struttura di dati. Nei linguaggi OOP come Java, le parole chiave “private”, “protected”, ecc. vengono utilizzate per limitare l’accesso ai dati degli oggetti. Questo impedisce l’accesso diretto dall’esterno e garantisce la separazione tra interfaccia e implementazione.
Per realizzare OOP in C, si usa un meccanismo diverso. Utilizziamo una cosiddetta dichiarazione forward nel file di intestazione come interfaccia, creando così un “Incomplete type”:
/*In C header file*/
struct point;
/*Incomplete type*/
typedef struct point Point;
CL’implementazione dello struct point segue in un file del codice sorgente in C separato che incorpora l’intestazione tramite la macro include. Questo approccio impedisce la creazione di variabili statiche del tipo Point. È comunque possibile utilizzare puntatori del tipo. Poiché gli oggetti sono strutture di dati create dinamicamente, vengono comunque referenziati con puntatori. I puntatori alle istanze struct corrispondono all’incirca ai riferimenti agli oggetti utilizzati in Java.
Sostituire i metodi con le funzioni
Nei linguaggi OOP come Java e Python, gli oggetti includono funzioni che operano su di essi oltre ai loro dati. Queste funzioni sono chiamate metodi o metodi di istanza. Quando scriviamo codice OOP in C, invece dei metodi usiamo funzioni che accettano un puntatore su un’istanza struct:
/*Pointer to `Point` struct*/
Point * point;
CPoiché C non conosce le classi, non è possibile raggruppare le funzioni appartenenti a un tipo sotto un nome comune. Al contrario, forniamo i nomi delle funzioni con un prefisso contenente il nome del tipo. Le firme delle funzioni corrispondenti vengono prima dichiarate nel file di intestazione in C:
/*In C header file*/
/*Function to move update a point’s coordinates*/
void Point_move(Point * point, int new_x, int new_y);
CSuccessivamente, implementiamo la funzione nel file del codice sorgente in C:
/*In C source file*/
void Point_move(Point * point, int new_x, int new_y) {
point->x = new_x;
point->y = new_y;
};
CL’approccio ricorda i metodi di Python, che sono normali funzioni che accettano self come primo parametro. Inoltre, il puntatore su un’istanza struct corrisponde più o meno alla variabile this in Java o JavaScript. La differenza è che quando la funzione C viene chiamata, il puntatore viene trasmesso esplicitamente:
/*Call function with pointer argument*/
Point_move(point, 42, 51);
CCon la chiamata di funzione equivalente in Java, l’oggetto point è disponibile all’interno del metodo come variabile this:
// Call instance method from outside of class
point.move(42, 51)
// Call instance method from within class
this.move(42, 51)
JavaPython consente di chiamare i metodi come funzioni con un argomento self esplicito:
# Call instance method from outside or from within class
self.move(42, 51)
# Function call from within class
move(self, 42, 51)
PythonIstanziare gli oggetti
Una caratteristica di C è la gestione manuale della memoria: i programmatori e le programmatrici sono responsabili dell’allocazione della memoria per le strutture di dati. I linguaggi dinamici orientati agli oggetti, come Java e Python, li sollevano da questo compito. In Java, per istanziare un oggetto si usa la parola chiave new. Sotto la scocca, la memoria viene allocata automaticamente:
// Create new Point instance
Point point = new Point();
JavaQuando scriviamo codice OOP in C, definiamo una funzione costruttore speciale per l’istanziazione. Questa funzione alloca la memoria per la nostra istanza struct, la inizializza e restituisce un puntatore a essa:
Point * Point_new(int x, int y) {
/*Allocate memory and cast to pointer type*/
Point *point = (Point*) malloc(sizeof(Point));
/*Initialize members*/
Point_init(point, x, y);
// return pointer
return point;
};
CNel nostro esempio, disaccoppiamo l’inizializzazione dei membri struct dall’istanziazione. Anche in questo caso, viene utilizzata una funzione con il prefisso point:
void Point_init(Point * point, int x, int y) {
point->x = x;
point->y = y;
};
CCome si può riscrivere un progetto in C in modo orientato agli oggetti?
Riscrivere un progetto esistente in C utilizzando le tecniche OOP descritte è consigliabile solo in casi eccezionali. I seguenti approcci sono più ragionevoli:
- Riscrivere il progetto in un linguaggio simile a C con caratteristiche OOP e utilizzare la base di codice C esistente come specifica
- Riscrivere parti del progetto in un linguaggio OOP e mantenere componenti C specifici
Se la base di codice C è scritta in modo pulito, il secondo approccio dovrebbe dare buoni risultati. È prassi comune implementare parti di programma critiche per le prestazioni in C e accedervi da altri linguaggi. Probabilmente nessun altro linguaggio è più adatto di C a questo scopo. Ma quali sono i linguaggi adatti per ricostruire un progetto C esistente utilizzando i principi OOP?
Linguaggi C-like orientati agli oggetti
Esiste una ricca selezione di linguaggi C-like con orientamento agli oggetti incorporato. Probabilmente il più noto è C++; tuttavia, data la sua complessità, c’è stato un allontanamento da questo linguaggio negli ultimi anni. Poiché i suoi costrutti di base sono in gran parte gli stessi, il codice C è relativamente facile da incorporare in C++.
Molto più leggero di C++ è Objective-C. Il dialetto C, basato sul linguaggio OOP originale Smalltalk, è stato utilizzato principalmente per la programmazione di applicazioni su Mac e sui primi sistemi operativi iOS. In seguito, è stato seguito dallo sviluppo del linguaggio Swift di Apple. Le funzioni scritte in C possono essere richiamate da entrambi i linguaggi.
Linguaggi orientati agli oggetti basati su C
Anche altri linguaggi di programmazione OOP che non sono legati a C in termini di sintassi sono adatti alla riscrittura di un progetto in C. Per Python, Rust e Java esistono approcci standard per includere il codice C.
In Python, i cosiddetti binding permettono di includere codice C. È possibile che i tipi di dati Python debbano essere tradotti nei corrispondenti ctypes. Esiste anche la C Foreign Function Interface (CFFI), che automatizza in una certa misura la traduzione dei tipi.
Rust supporta anche la chiamata di funzioni C con poco sforzo. La parola chiave extern può essere usata per definire una Foreign Function Interface (FFI). Le funzioni di Rust che accedono a funzioni esterne devono essere dichiarate unsafe (non sicure):
extern "C" {
fn abs(input: i32) -> i32;
}
fn main() {
unsafe {
println!("Absolute value of -3 according to C: {}", abs(-3));
}
}
Rust