Quello che è stato visto fin'ora costituisce sostanzialmente il sottoinsieme C del C++ (salvo l'overloading, i reference e altre piccole aggiunte), è tuttavia sufficiente per poter realizzare un qualsiasi programma.
A questo punto, prima di proseguire, è doveroso soffermarci per esaminare il funzionamento del linker C++ e vedere come organizzare un grosso progetto in più file separati.
Linkage
Abbiamo già visto che ad ogni identificatore è associato uno scope e una lifetime, ma gli identificatori di variabili, costanti e funzioni possiedono anche un linkage.
Per comprendere meglio il concetto è necessario sapere che in C e in C++ l'unità di compilazione è il file, un programma può consistere di più file che vengono compilati separatamente e poi linkati (collegati) per ottenere un file eseguibile. Quest'ultima operazione è svolta dal linker e possiamo pensare al concetto di linkage sostanzialmente come a una sorta di scope dal punto di vista del linker. Facciamo un esempio:
// File a.cpp
int a = 5;
// File b.cpp
extern int a;
int GetVar() {
return a;
}
Il primo file dichiara una variabile intera e la inizializza, il secondo (trascuriamone per ora la prima riga di codice) dichiara una funzione che ne restituisce il valore. La compilazione del primo file non è un problema, ma nel secondo file GetVar() deve utilizzare un nome dichiarato in un altro file; perché la cosa sia possibile bisogna informare il compilatore che tale nome è dichiarato da qualche altra parte e che il riferimento a tale nome non può essere risolto se non quando tutti i file sono stati compilati, solo il linker quindi può risolvere il problema collegando insieme i due file. Il compilatore deve dunque essere informato dell'esistenza della variabile al fine di non generare un messaggio di errore; tale operazione viene effettuata tramite la keyword extern.
In effetti la riga extern int a; non dichiara un nuovo identificatore, ma dice "La variabile intera a è dichiarata da qualche altra parte, lascia solo lo spazio per risolvere il riferimento". Se la keyword extern fosse stata omessa il compilatore avrebbe interpretato la riga come una nuova dichiarazione e avrebbe risolto il riferimento in GetVar() in favore di tale definizione; in fase di linking comunque si sarebbe verificato un errore perché a sarebbe stata definita due volte (una per file), il perché di tale errore sarà chiaro più avanti.
Naturalmente extern si può usare anche con le funzioni (anche se come vedremo è ridondante):
// File a.cpp
int a = 5;
int f(int c) {
return a+c;
}
// File b.cpp
extern int f(int);
int GetVar() {
return f(5);
}
Si noti che è necessario che extern sia seguita dal prototipo completo della funzione, al fine di consentire al compilatore di generare codice corretto e di eseguire i controlli di tipo sui parametri e il valore restituito.
Come già detto, il C++ ha un'alta compatibilità col C, tant'è che è possibile interfacciare codice C++ con codice C; anche in questo caso l'aiuto ci viene dalla keyword extern. Per poter linkare un modulo C con un modulo C++ è necessario indicare al compilatore le nostre intenzioni:
// Contenuto file C++
extern "C" int CFunc(char*);
extern "C" char* CFunc2(int);
// oppure per risparmiare tempo
extern "C" {
void CFunc1(void);
int* CFunc2(int, char);
char* strcpy(char*, const char*);
}
La presenza di "C" serve a indicare che bisogna adottare le convenzioni del C sulla codifica dei nomi (in quanto il compilatore C++ codifica internamente i nomi degli identificatori in modo assai diverso).
Un altro uso di extern è quello di ritardare la definizione di una variabile o di una funzione all'interno dello stesso file, ad esempio per realizzare funzioni mutuamente ricorsive:
extern int Func2(int);
int Func1(int c) {
if (c==0) return 1;
return Func2(c-1);
}
int Func2(int c) {
if (c==0) return 2;
return Func1(c-1);
}
Tuttavia nel caso delle funzioni non è necessario l'uso di extern, il solo prototipo è sufficiente, è invece necessario ad esempio per le variabili:
int Func2(int); // extern non necessaria
extern int a; // extern necessaria
int Func1(int c) {
if (c==0) return 1;
return Func2(c-1);
}
int Func2(int c) {
if (c==0) return a;
return Func1(c-1);
}
int a = 10; // definisce la variabile
// precedentemente dichiarata
I nomi che sono visibili all'esterno di un file sono detti avere linkage esterno; tutte le variabili globali hanno linkage esterno, così come le funzioni globali non inline; le funzioni inline, tutte le costanti e le dichiarazioni fatte in un blocco hanno invece linkage interno (cioè non sono visibili all'esterno del file); i nomi di tipo non hanno alcun linkage, ma devono riferire ad una unica definizione:
// File 1.cpp
enum Color { Red, Green, Blue };
extern void f(Color);
// File2.cpp
enum Color { Red, Green, Blue };
void f(Color c) { /* ... */ }
Una situazione di questo tipo è illecita, ma molti compilatori potrebbero non accorgersi dell'errore.
Per quanto concerne i nomi di tipo, fanno eccezione quelli definiti tramite typedef in quanto non sono veri tipi, ma solo abbreviazioni.
E' possibile forzare un identificatore globale ad avere linkage interno utilizzando la keyword static:
// File a.cpp
static int a = 5; // linkage interno
int f(int c) { // linkage esterno
return a+c;
}
// File b.cpp
extern int f(int);
static int GetVar() { // linkage interno
return f(5);
}
Si faccia attenzione al significato di static: nel caso di variabili locali static serve a modificarne la lifetime (durata), nel caso di nomi globali invece modifica il linkage.
L'importanza di poter restringere il linkage è ovvia; supponete di voler realizzare una libreria di funzioni, alcune serviranno solo a scopi interni alla libreria e non serve (anzi è pericoloso) esportarle, per fare ciò basta dichiarare static i nomi globali che volete incapsulare.
File header
Purtroppo non esiste un meccanismo analogo alla keyword static per forzare un linkage esterno, d'altronde i nomi di tipo non hanno linkage (e devono essere consistenti) e le funzioni inline non possono avere linkage esterno per ragioni pratiche (la compilazione è legata al singolo file sorgente). Esiste tuttavia un modo per aggirare l'ostacolo: racchiudere tali dichiarazioni e/o definizioni in un file header (file solitamente con estensione .h) e poi includere questo nei files che utilizzano tali dichiarazioni; possiamo anche inserire dichiarazioni e/o definizioni comuni in modo da non doverle ripetere.
Vediamo come procedere. Supponiamo di avere un certo numero di file che devono condividere delle costanti, delle definizioni di tipo e delle funzioni inline; quello che dobbiamo fare è creare un file contenente tutte queste definizioni:
// Esempio.h
enum Color { Red, Green, Blue };
struct Point {
float X;
float Y;
};
const int Max = 1000;
inline int Sum(int x, int y) {
return x + y;
}
A questo punto basta utilizzare la direttiva #include "NomeFile" nei moduli che utilizzano le precedenti definizioni:
// Modulo1.cpp
#include "Esempio.h"
/* codice modulo */
La direttiva #include è gestita dal precompilatore che è un programma che esegue delle manipolazioni sul file prima che questo sia compilato; nel nostro caso la direttiva dice di copiare il contenuto del file specificato nel file che vogliamo compilare e passare quindi al compilatore il risultato dell'operazione.
In alcuni esempi abbiamo già utilizzato la direttiva per poter eseguire input/output, in quei casi abbiamo utilizzato le parentesi angolari (< >) al posto dei doppi apici (" "); la differenza è che utilizzando i doppi apici dobbiamo specificare (se necessario) il path in cui si trova il file header, con le parentesi angolari invece il preprocessore cerca il file in un insieme di directory predefinite.
Si noti inoltre che questa volta è stato specificato l'estensione del file (.h), questo non dipende dall'uso degli apici, ma dal fatto che ad essere incluso è l'header di un file di libreria (ad esempio quando si usa la libreria iostream), infatti in teoria tali header potrebbero non essere memorizzati in un normale file.
Un file header può contenere in generale qualsiasi istruzione C/C++ (in particolare anche dichiarazioni extern) da condividere tra più moduli:
// Esempio2.h
// Un header puo` includere un altro header
#include "Header1.h"
// o dichiarazioni extern comuni ai moduli
extern "C" { // Inclusione di un
#include "HeaderC.h" // file header C
}
extern "C" {
int CFunc1(int, float);
void CFunc2(char*);
}
extern int a;
extern double* Ptr;
extern void Func();
Librerie di funzioni
I file header sono molto utili quando si vuole partizionare un programma in più moduli, tuttavia la potenza dei file header si esprime meglio quando si vuole realizzare una libreria di funzioni.
L'idea è quella di separare l'interfaccia della libreria dalla sua implementazione: nel file header vengono dichiarati (ed eventualmente definiti) gli identificatori che devono essere visibili anche a chi usa la libreria (costanti, funzioni, tipi...), tutto ciò che è privato (implementazione di funzioni non inline, variabili...) viene invece messo in un altro file che include l'interfaccia. Vediamo un esempio di semplicissima libreria per gestire date (l'esempio vuole essere solo didattico); ecco il file header:
// Date.h
struct Date {
unsigned short dd; // giorno
unsigned short mm; // mese
unsigned yy; // anno
unsigned short h; // ora
unsigned short m; // minuti
unsigned short s; // secondi
};
void PrintDate(Date);
ed ecco come sarebbe il file che la implementa:
// Date.cpp
#include "Date.h"
#include < iostream >
using namespace std;
void PrintDate(Date dt) {
cout << dt.dd << '/' << dt.mm << '/' << dt.yy;
cout << " " << dt.h << ':' << dt.m;
cout << ':' << dt.s;
}
A questo punto la libreria è pronta, per distribuirla basta compilare il file Date.cpp e fornire il file oggetto ottenuto ed il file header Date.h. Chi deve utilizzare la libreria non dovrà far altro che includere nel proprio programma il file header e linkarlo al file oggetto contenente le funzioni di libreria. Semplicissimo!
Esistono tuttavia due problemi, il primo è illustrato nel seguente esempio:
// Modulo1.h
#include < iostream >
using namespace std;
/* altre dichiarazioni */
// Modulo2.h
#include < iostream >
using namespace std;
/* altre dichiarazioni */
// Main.cpp
#include < iostream >
using namespace std;
#include < Modulo1.h >
#include < Modulo2.h >
int main(int, char* []) {
/* codice funzione */
}
Si tratta cioè di un programma costituito da più moduli, quello principale che contiene la funzione main() e altri che implementano le varie routine necessarie. Più moduli hanno bisogno di una stessa libreria, in particolare hanno bisogno di includere lo stesso file header (nell'esempio iostream) nei rispettivi file header.
Per come funziona il preprocessore, poiché il file principale include (direttamente e/o indirettamente) più volte lo stesso file header, il file che verrà effettivamente compilato conterrà più volte le stesse dichiarazioni (e definizioni) che daranno luogo a errori di definizione ripetuta dello stesso oggetto (funzione, costante, tipo...). Come ovviare al problema?
La soluzione ci è fornita dal precompilatore stesso ed è nota come compilazione condizionale; consiste cioè nello specificare quando includere o meno determinate porzioni di codice. Per far ciò ci si avvale delle direttive #define SIMBOLO, #ifndef SIMBOLO e #endif: la prima ci permette di definire un simbolo, la seconda è come l'istruzione condizionale e serve a testare un simbolo (la risposta è positiva se SIMBOLO non è definito, negativa altrimenti), l'ultima direttiva serve a capire dove finisce l'effetto della direttiva condizionale. Le ultime due direttive sono utilizzate per delimitare porzioni di codice; se #ifndef e verificata il preprocessore lascia passare il codice (ed esegue eventuali direttive) tra l'#ifndef e #endif, altrimenti quella porzione di codice viene nascosta al compilatore.
Ecco come tali direttive sono utilizzate (l'errore era dovuto all'inclusione multipla di iostream):
// Contenuto del file iostream.h
#ifndef __IOSTREAM_H
#define __IOSTREAM_H
/* contenuto file header */
#endif
si verifica cioè se un certo simbolo è stato definito, se non lo è (cioè #ifndef è verificata) si definisce il simbolo e poi si inserisce il codice C/C++, alla fine si inserisce l'#endif. Ritornando all'esempio, ecco ciò che succede quando si compila il file Main.cpp:
- Il preprocessore inizia a elaborare il file per produrre un unico file compilabile;
- Viene incontrata la direttiva #include < iostream > e il file header specificato viene elaborato per produrre codice;
- A seguito delle direttive contenute inizialmente in iostream, viene definito il simbolo __IOSTREAM_H e prodotto il codice contenuto tra #ifndef __IOSTREAM_H e #endif;
- Si ritorna al file Main.cpp e il precompilatore incontra #include < Modulo1.h > e quindi va ad elaborare Modulo1.h;
- La direttiva #include < iostream > contenuta in Modulo1.h porta il precompilatore ad elaborare di nuovo iostream, ma questa volta il simbolo __IOSTREAM_H è definito e quindi #ifndef __IOSTREAM_H fa sì che nessun codice venga prodotto;
- Si prosegue l'elaborazione di Modulo1.h e viene generato l'eventuale codice;
- Finita l'elaborazione di Modulo1.h, la direttiva #include < Modulo2.h > porta all'elaborazione di Modulo2.h che è analoga a quella di Modulo1.h;
- Elaborato anche Modulo2.h, rimane la funzione main() di Main.cpp che produce il corrispondente codice;
- Alla fine il precompilatore ha prodotto un unico file contenete tutto il codice di Modulo1.h, Modulo2.h e Main.cpp senza alcuna duplicazione e contenente tutte le dichiarazioni e le definizioni necessarie;
- Il file prodotto dal precompilatore è passato al compilatore per la produzione di codice oggetto;
Utilizzando il metodo appena previsto in tutti i file header (in particolare quelli di libreria) si può star sicuri che non ci saranno problemi di inclusione multipla. Tutto il meccanismo richiede però che i simboli definiti con la direttiva #define siano unici.
I namespace
Il secondo problema che si verifica con la ripartizione di un progetto in più file è legato alla necessita` di utilizzare identificatori globali unici. Quello che spesso accade è che al progetto lavorino più persone ognuna delle quali si occupa di parti diverse che devono poi essere assemblate. Per quanto possa sembrare difficile, spesso accade che persone che lavorano a file diversi utilizzino gli stessi identificatori per indicare funzioni, variabili, costanti...
Pensate a due persone che devono realizzare due moduli ciascuno dei quali prima di essere utilizzato vada inizializzato, sicuramente entrambi inseriranno nei rispettivi moduli una funzione per l'inizializzazione e molto probabilmente la chiameranno InitModule() (o qualcosa di simile). Nel momento in cui i due moduli saranno linkati insieme (e sempre che non siano sorti problemi prima ancora), inevitabilmente il linker segnalerà errore.
Naturalmente basterebbe che una delle due funzioni avesse un nome diverso, ma modificare tale nome richiederebbe la modifica anche dei sorgenti in cui il modulo è utilizzato. Molto meglio prevenire tale situazione suddividendo lo spazio globale dei nomi in parti più piccole (i namespace) e rendere unicamente distinguibili tali parti, a questo punto poco importa se in due namespace distinti un identificatore appare due volte... Ma vediamo un esempio:
// File MikeLib.h
namespace MikeLib {
typedef float PiType;
PiType Pi = 3.14;
void Init();
}
// File SamLib.h
namespace SamLib {
typedef double PiType;
PiType Pi = 3.141592;
int Sum(int, int);
void Init();
void Close();
}
In una situazione di questo tipo non ci sarebbe più conflitto tra le definizioni dei due file, perché per accedere ad esse è necessario specificare anche l'identificatore del namespace:
#include "MikeLib.h"
#include "SamLib.h"
int main(int, char* []) {
MikeLib::Init();
SamLib::Init();
MikeLib::PiType AReal = MikeLib::Pi * 3.7;
Areal *= Pi; // Errore!
SamLib::Close();
}
L'operatore :: è detto risolutore di scope e indica al compilatore dove cercare l'identificatore seguente. In particolare l'istruzione MikeLib::Init(); dice al compilatore che la Init() cui vogliamo riferirci è quella del namespace MikeLib. Ovviamente perché non ci siano conflitti è necessario che i due namespace abbiano nomi diversi, ma è più facile stabilire pochi nomi diversi tra loro, che molti.
Si noti che il tentativo di riferire ad un nome senza specificarne il namespace viene interpretato come un riferimento ad un nome globale esterno ad ogni namespace e nell'esempio precedente genera un errore perché nello spazio globale non c'è alcun Pi.
I namespace sono dunque dei contenitori di nomi su cui sono definite delle regole ben precise:
- Un namespace può essere creato solo nello scope globale;
- Se nello scope globale di un file esistono due namespace con lo stesso nome (ad esempio i due namespace sono definiti in file header diversi, ma inclusi da uno stesso file), essi vengono fusi in uno solo;
- E' possibile creare un alias di un namespace con la sintassi: namespace < ID1 > = < ID2 >;
- E' possibile avere namespace anonimi, in questo caso gli identificatori contenuti nel namespace sono visibili al file che contiene il namespace anonimo, ma essi hanno tutti automaticamente linkage interno. I namespace anonimi di file diversi non sono mai fusi insieme.
La direttiva using
Qualificare totalmente gli identificatori appartenenti ad un namespace può essere molto noioso, soprattutto se siamo sicuri che non ci sono conflitti con altri namespace. In questi casi ci viene in aiuto la direttiva using, che abbiamo già visto in numerosi esempi:
#include "MikeLib.h"
using namespace MikeLib;
using namespace SamLib;
/* ... */
La direttiva using utilizzata in congiunzione con la keyword importa in un colpo solo tutti gli identificatori del namespace specificato nello scope in cui appare la direttiva (che può anche trovarsi nel corpo di una funzione):
#include "MikeLib.h"
#include "SamLib.h"
using namespace MikeLib;
// Da questo momento in poi non è necessario
// qualificare i nomi del namespace MikeLib
void MyFunc() {
using namespace SamLib;
// Adesso in non bisogna qualificare
// neanche i nomi di SamLib
/* ... */
}
// Ora i nomi di SamLib devono
// essere nuovamente qualificati con ::
/* ... */
Naturalmente se dopo la using ci fosse una nuova definizione di identificatore del namespace importato, quest'ultima nasconderebbe quella del namespace. L'identificatore del namespace sarebbe comunque ancora raggiungibile qualificandolo totalmente:
#include "SamLib.h"
using namespace SamLib;
int Pi = 5; // Nasconde la definizione
// presente in SamLib
int a = Pi; // Riferisce al precedente Pi
double b = SamLib::Pi; // Pi di samLib
Se più direttive using namespace fanno sì che uno stesso nome venga importato da namespace diversi, si viene a creare una potenziale situazione di ambiguità che diviene visibile (genera cioè un errore) solo nel momento in cui ci si riferisce a quel nome. In questi casi per risolvere l'ambiguità basta ricorrere ricorrere al risolutore di scope (::) qualificando totalmente il nome.
E' anche possibile usare la using per importare singoli nomi:
#include "SamLib.h"
#include "MikeLib"
using namespace MikeLib;
using SamLib::Sum(int, int);
void F() {
PiType a = Pi; // Riferisce a MikeLib
int r = Sum(5, 4); // SamLib::Sum(int, int)
}