Linkage e file header
Quello che e`stato visto fin'ora costituisce sostanzialmente
il sottoinsieme C del C++ (salvo l'overloading, i reference e altre piccole
aggiunte), e` tuttavia sufficiente per poter realizzare un qualsiasi
programma.
A questo punto, prima di proseguire, e` doveroso soffermarci per esaminare
il funzionamento del linker C++ e vedere come organizzare un grosso
progetto in piu` file separati.
Linkage
Abbiamo gia` visto che ad ogni identificatore
e` associato uno scope e una lifetime,
ma gli identificatori di variabili, costanti e funzioni possiedono anche un
linkage.
Per comprendere meglio il concetto e` necessario sapere che in C e in C++
l'unita` di compilazione e` il file, un programma puo` consistere di piu`
file che vengono compilati separatamente e poi linkati (collegati)
per ottenere un file eseguibile. Quest'ultima operazione e` 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 e` un
problema, ma nel secondo file GetVar() deve utilizzare un
nome dichiarato in un altro file; perche` la cosa sia possibile bisogna
informare il compilatore che tale nome e` dichiarato da qualche altra parte
e che il riferimento a tale nome non puo` essere risolto se non quando
tutti i file sono stati compilati, solo il linker quindi puo` 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 e` 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 perche` a sarebbe stata definita
due volte (una per file), il perche` di tale errore sara` chiaro
piu` avanti.
Naturalmente extern si puo` usare anche con le funzioni
(anche se come vedremo e` 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 e` 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 gia` detto, il C++ ha un'alta compatibilita` col C, tant'e` che e`
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++ e` 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 e` 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 e` necessario l'uso di extern,
il solo prototipo e` sufficiente, e` 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, cosi` come
le funzioni globali non inline; le funzioni inline, tutte
le costanti e le dichiarazioni fatte in un blocco hanno invece linkage
interno (cioe` 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 e` 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 e` ovvia; supponete di voler
realizzare una libreria di funzioni, alcune serviranno solo a scopi interni
alla libreria e non serve (anzi e` pericoloso) esportarle, per fare cio`
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 e` 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 e` 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 e` gestita dal precompilatore che e` 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 gia` utilizzato la direttiva per poter eseguire
input/output, in quei casi abbiamo utilizzato le parentesi angolari (<
>) al posto dei doppi apici (" "); la differenza e` 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 e` stato specificato l'estensione del file
(.h), questo non dipende dall'uso degli apici, ma dal fatto che ad
essere incluso e` 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 puo` contenere in generale qualsiasi istruzione C/C++ (in
particolare anche dichiarazioni extern) da condividere
tra piu` 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 piu` moduli, tuttavia la potenza dei file
header si esprime meglio quando si vuole realizzare una libreria di
funzioni.
L'idea e` 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 cio` che e` 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 e` 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
dovra` 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 e` 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 cioe` di un programma costituito da piu` moduli, quello
principale che contiene la funzione main() e altri che
implementano le varie routine necessarie. Piu` 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, poiche` il file principale include
(direttamente e/o indirettamente) piu` volte lo stesso file header, il file
che verra` effettivamente compilato conterra` piu` 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 e` fornita dal precompilatore stesso ed e` nota come
compilazione condizionale; consiste cioe` nello specificare quando
includere o meno determinate porzioni di codice. Per far cio` ci si avvale
delle direttive #define SIMBOLO,
#ifndef SIMBOLO
e #endif: la prima ci permette di definire un simbolo, la seconda e`
come l'istruzione condizionale e serve a testare un simbolo (la risposta e`
positiva se SIMBOLO non e` 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 cioe` se un certo simbolo e` stato definito, se non lo e`
(cioe` #ifndef e` verificata) si definisce il simbolo e poi si inserisce il
codice C/C++, alla fine si inserisce l'#endif.
Ritornando all'esempio, ecco cio` 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 e` definito e quindi #ifndef __IOSTREAM_H fa si` 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 e` 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 e` 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 puo` star sicuri che non ci saranno
problemi di inclusione multipla.
Tutto il meccanismo richiede pero` 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 piu` file e` legato alla necessita`
di utilizzare identificatori globali unici. Quello che spesso accade e`
che al progetto lavorino piu` 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 segnalera` 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 e` utilizzato. Molto meglio prevenire tale situazione
suddividendo lo spazio globale dei nomi in parti piu` 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 piu` conflitto tra
le definizioni dei due file, perche` per accedere ad esse e` 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 :: e` 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 e` quella del namespace
MikeLib. Ovviamente perche` non ci siano conflitti e` necessario
che i due namespace abbiano nomi diversi, ma e` piu` 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 perche` nello spazio globale non c'e` alcun Pi.
I namespace sono dunque dei contenitori di nomi su cui sono defite delle
regole ben precise:
- Un namespace puo` 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 puo` 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
gia` 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 puo` anche trovarsi nel corpo di una funzione):
|
#include "MikeLib.h"
#include "SamLib.h"
using namespace MikeLib;
// Da questo momento in poi non e` 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 piu` direttive using namespace fanno si` che uno stesso
nome venga importato da namespace diversi, si viene a creare
una potenziale situazione di ambiguita` che diviene visibile (genera cioe`
un errore) solo nel momento in cui ci si riferisce a quel nome. In questi
casi per risolvere l'ambiguita` 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;
void F() {
PiType a = Pi; // Riferisce a MikeLib
int r = Sum(5, 4); // SamLib::Sum(int, int)
}
|
Pagina precedente - Pagina successiva
C++, una panoramica sul linguaggio - seconda edizione © Copyright 1996-1999, Paolo Marotta
|