Namespace

 


 

Programmazione modulare e compilazione separata

 

Nel corso degli anni, l'enfasi nella progettazione dei programmi si è spostata dal progetto delle procedure all'organizzazione dei dati, in ragione anche dei problemi di sviluppo e manutenzione del software che sono direttamente correlati all'aumento di dimensione dei programmi. La possibilità di suddividere grossi programmi in porzioni il più possibile ridotte e autosufficienti (detti moduli) è pertanto caratteristica di un modo efficiente di produrre software, in quanto permette di sviluppare programmi più chiari e più facili da mantenere ed aggiornare (specie se i programmatori che lavorano a un stesso progetto sono molti).

Un modulo è costituito da dati logicamente correlati e dalle procedure che li utilizzano. L'idea-base  è quella del "data hiding" (occultamento dei dati), in ragione della quale un programmatore "utente" del modulo non ha bisogno di conoscere i nomi delle variabili, dei tipi, delle funzioni e in generale delle caratteristiche di implementazione del modulo stesso, ma è sufficiente che sappia come utilizzarlo, cioè come mandargli le informazioni e ottenere le risposte. Un modulo è pertanto paragonabile a un dispositivo (il cui meccanismo interno è sconosciuto), con il quale comunicare attraverso operazioni di input-output. Tali operazioni sono a loro  volta raggruppate in un modulo separato, detto interfaccia che rappresenta l'unico canale di comunicazione fra il modulo e i suoi utenti.

La programmazione modulare offre così un duplice vantaggio: quello di separare l'interfaccia dal codice di implementazione del modulo, dando la possibilità al modulo di essere modificato senza che il codice dell'utente ne sia influenzato; e quello di permettere all'utente di definire i nomi delle variabili, dei tipi, delle funzioni ecc.. senza doversi preoccupare di eventuali conflitti con i nomi usati nel modulo e dell'insorgere di errori dovuti a simboli duplicati.

Parallelo al concetto di programmazione modulare è quello di compilazione separata. Per motivi di efficienza la progettazione di un programma (specie se di grosse dimensioni) dovrebbe prevedere la sistemazione dei moduli in files separati: in questo modo ogni intervento di modifica o di correzione degli errori di un singolo modulo comporterebbe la ricompilazione di un solo file. E' utile che anche l'interfaccia di un modulo risieda in un file separato sia dal codice dell'utente che da quello di implementazione del modulo stesso. Entrambi questi files dovrebbero poi contenere la direttiva #include (file dell'interfaccia) così che il preprocessore possa creare due translation units indipendenti, ma collegate entrambe alla stessa interfaccia (questo approccio è molto più conveniente di quello di creare due soli files entrambi con il codice dell'interfaccia, in quanto permette al progettista del modulo di modificare l'interfaccia senza implicare che la stessa modifica venga eseguita anche nel file dell'utente).

 


 

Definizione di namespace

 

Dal punto di vista sintattico, la definizione di un namespace somiglia molto a quella di una struttura (cambia la parola-chiave e inoltre il punto e virgola in fondo non è obbligatorio). Esempio:

                  namespace Stack
{         
const int max_size = 100;
char v[max_size ];
int top = 0;
void push(char c) {......}
char pop( ) {......}
}

I membri di un namespace sono dichiarazioni o definizioni (con eventuali inizializzazioni) di identificatori di qualunque genere (variabili, funzioni, typedef, strutture, enumeratori, altri tipi astratti qualsiasi ecc...). Anche il nome di un namespace (Stack, nell'esempio) è un identificatore. Pertanto definire un namespace significa dichiarare/definire un gruppo di nomi a sua volta identificato da un nome.

A differenza dalle strutture, Stack non è un tipo (non può essere istanziato da oggetti) ma identifica semplicemente un ambito di visibilità (scope). I membri di Stack sono perciò identificatori locali, visibili soltanto nello scope definito da Stack. Il programmatore è perciò libero di definire gli stessi nomi al di fuori, senza pericolo di conflitti o ambiguità.

Non è ammesso definire un namespace all'interno di un altro scope (per esempio nel block scope di una funzione o una struttura); e quindi il suo nome ha global scope cioè è riconoscibile dappertutto. E' però possibile "annidare" un namespace all'interno di un altro namespace: in questo caso il suo  scope coincide con quello degli altri membri del namespace superiore.

In definitiva, il termine namespace si identifica con quello di "ambito dichiarativo con un nome". In questo senso, anche i blocchi delle funzioni e delle strutture sono dei namespace (con molte funzionalità in più) e tutto ciò che è al di fuori (le variabili globali) è detto appartenere al "namespace globale".

 


 

Risoluzione della visibilità

 

Sorge a questo punto spontanea una domanda: come comunicare fra i namespace? In altre parole, se i membri di un namespace non sono accessibili dall'esterno, come si possono usare nel programma ?

Per accedere a un nome definito in un namespace, bisogna "qualificarlo", associandogli il nome del namespace (che invece è visibile, avendo global scope), tramite l'operatore binario di risoluzione di visibilità :: (doppi due punti).
Seguitando nell'esempio precedente:

        Stack::top  (accede al membro top del namespace Stack)

Notare l'analogia di questo operatore con quello unario di riferimento globale (già visto a proposito dell'accesso alle variabili globali). Infatti, se il left-operand manca, vuol dire che il nome dato dal right-operand deve essere cercato nel namespace globale.

 


 

Membri di un namespace definiti esternamente

 

Abbiamo visto che i membri di un namespace possono essere sia dichiarati che definiti. Sappiamo però che alcune dichiarazioni non sono definizioni e che in generale un identificatore è utilizzabile dal programma se è definito (da qualche parte) ed è dichiarato prima del punto in cui lo si vuole utilizzare.

Possiamo perciò separare, dove è possibile, le dichiarazioni dalle definizioni e includere solo le prime fra i  membri di un namespace, ponendo le seconde al di fuori. Nelle definizioni esterne però, i nomi devono essere qualificati, altrimenti non sarebbero riconoscibili.

La separazione fra dichiarazioni e definizioni è applicata soprattutto alle funzioni. Seguitando con lo stesso esempio:

                  namespace Stack
{        
const int max_size = 100;
char v[max_size ];
int top = 0;
void push(char);
char pop( );
}
void  Stack::push(char c) {......}
char  Stack::pop( ) {......}

Le funzioni push e pop sono soltanto dichiarate nella definizione del namespace Stack, e definite altrove con i nomi qualificati. Non è necessario, invece, qualificare i membri di Stack utilizzati all'interno delle funzioni, in quanto il compilatore, se incontra una variabile locale non definita nell'ambito della funzione, la va a cercare nel namespace a cui la funzione appartiene.

   
Quando viene chiamata una funzione membro di un namespace, con argomenti di cui almeno uno è di tipo astratto membro dello stesso namespace, la qualificazione del nome della funzione non è necessaria. Esempio:

   #include <iostream.h>
   namespace A { struct AS {int k;}; char ff(AS); }
   char A::ff(AS m) { return (char)m.k; }
   int main()
   {
	A::AS m;
	m.k = 65;
	cout << ff(m) << '\n';   // non importa A:: davanti a ff
   }

Infatti il nome di una funzione è cercato, non solo nell'ambito della chiamata (o in ambiti superiori), ma anche in quelli dei namespace in cui sono definiti i tipi di ogni argomento. Non sono prefissati criteri di precedenza: in caso di ambiguità il compilatore dà un messaggio di errore.
NOTA
: si tratta di una funzionalità recente del C++. Infatti il compilatore gcc la accetta, mentre il Visual C++ pretende la qualificazione.

 


 

Namespace annidati

 

Abbiamo detto che i namespace possono essere definiti solo nell'ambito globale (cioè non si possono definire all'interno di altri blocchi, per esempio di funzioni o strutture). E' però possibile definire un namespace all'interno di un altro namespace (namespace "annidati").

Es.:                          void  f( );
namespace  A    {
            void  g( );
            namespace  B    {
                      void  h( );
             }
}

la funzione f è dichiarata nel namespace globale; la funzione g è dichiarata nel namespace A; e infine la funzione h  è dichiarata nel namespace B definito nel namespace A.

Per accedere (dall'esterno) a un membro del namespace B bisogna ripetere due volte l'operazione di risoluzione di visibilità.

Es.:          void A::B::h( ) {......}       (definizione esterna della funzione h)

Per i namespace "annidati" valgono le normali regole di visibilità e di qualificazione: all'interno della funzione h non occorre qualificare i membri di B (come sempre), ma neppure quelli di A, in quanto i nomi definiti in ambiti superiori sono ancora visibili negli ambiti sottostanti; viceversa, all'interno della funzione g bisogna qualificare i membri di B (perchè i nomi definiti in ambiti inferiori  non solo visibili in quelli superiori), ma non quelli di A, per cui è sufficiente applicare la risoluzione di visibilità a un solo livello.

Es.         void  A::g( ) {.... B::h ( ) ....}  ( funzione h chiamata dalla funzione g )

Infine, dall'interno della funzione globale f bisogna qualificare sia i membri di A (a un livello: A::) che quelli di B (a due livelli: A::B::) in quanto nessun nome definito nei due namespace è visibile nel namespace globale.

 


 

Namespace sinonimi

 

La scelta del nome di un namespace è importante: se è troppo breve, rischia il conflitto con i nomi di altri namespace (per esempio includendo librerie create da altri programmatori); se è molto lungo, può evitare il conflitto con altri nomi, ma diventa scomodo se lo si usa ripetutamente per qualificare esternamente i suoi membri.

Es.:    namespace  creato_appositamente_da_me_medesimo    {... int x ...}

con un nome così lungo (e così "stupido") non c'è pericolo di conflitto, ma è scomodissimo utilizzare in altri ambiti il suo membro x:

         creato_appositamente_da_me_medesimo::x = 20;

Entrambi gli inconvenienti possono essere superati, definendo, in un ambito ristretto (e quindi con scarso pericolo di conflitto), un sinonimo breve di un nome "vero" lungo (i sinonimi possono anche essere definiti localmente, a differrenza dei namespace). Per definire un sinonimo si usa la seguente sintassi (seguitando con l'esempio):

           namespace  STUP = creato_appositamente_da_me_medesimo;

da questo punto in poi (nello stesso ambito in cui è definito il sinonimo STUP) si può ogni volta qualificare un membro del suo namespace utilizzando come left-operand il  sinonimo:

         STUP::x = 20;

 
I namespace sinonimi sono utili non solo per abbreviare nomi lunghi, ma anche per localizzare in un unico punto una modifica che altrimenti si dovrebbe ripetere in molti punti del programma (come nelle definizioni con const, #define e typedef). Per esempio, se il nome di un namespace si riferisce alla versione di una libreria usata dal programma, e questa potrebbe essere successivamente aggiornata, è molto conveniente creare un sinonimo da utilizzare nel programma al posto del nome della libreria: in questo modo, in caso di cambiamento di versione  della libreria, si può modificare solo l'istruzione di definizione del sinonimo, assegnando allo stesso sinonimo il nuovo nome (altrimenti si dovrebbero modificare tutte le istruzioni che utilizzano quel nome nel programma).

 


 

Namespace anonimi

 

Nella definizione di un namespace, il nome non è obbligatorio. Se lo si omette, si crea un namespace anonimo.

Es.:   namespace     { int a = 10;  int b; void c(double); }

I membri a, b e c del namespace anonimo sono visibili in tutto il file (file scope), non devono essere qualificati, ma non possono essere utilizzati in files differenti da quello in cui sono stati definiti (cioè, diversamente dagli oggetti globali, non possono essere collegati dall'esterno tramite lo specificatore extern).

In altre parole i membri di un namespace anonimo hanno le stesse identiche proprietà degli oggetti globali definiti con lo specificatore static. Per questo motivo, e allo scopo di ridurre le ambiguità nel significato delle parole-chiave del linguaggio, il comitato per la definizione dello standard (pur mantenendo, per compatibiltà con i "vecchi" programmi, il doppio significato di static), suggerisce di usare sempre i namespace anonimi per definire oggetti con file scope, e di mantenere l'uso di  static esclusivamente per l'allocazione permanente (cioè con lifetime illimitato) di oggetti con visibilità locale (block scope).

 


 

Estendibilità della definizione di un namespace

 

Al contrario delle strutture, i namespace sono costrutti "aperti", nel senso che possono essere definiti più volte con lo stesso nome. Non si tratta però di diverse definizioni, bensì di estensioni della definizione iniziale. E quindi, pur essendovi blocchi diversi di un namespace con lo stesso nome, l'ambito definito dal namespace con quel nome resta unico.

Ne consegue che, per la ODR (one definition rule), i membri complessivamente definiti in un namespace (anche se frammentato in più blocchi) devono essere tutti diversi (cioè nelle estensioni è consentito aggiungere nuovi membri ma non ridefinire membri definiti precedentemente).

Es.:     namespace  A    {
       int x ;
}
namespace  B    {
       int x ; OK:  A::x e B::x sono definiti in due ambiti diversi
}
void  f( ) {... A::y= ...}     errore: y non ancora dichiarato in A
namespace  A    { OK: estensione del namespace A
       int x ; errore: x è già definito nell'ambito di A
       int y ; OK: y è un nuovo membro di A
}
void  f( ) {... A::y= ...}   adesso è OK

 
La possibilità di suddividere un namespace in blocchi separati consente, da un lato, di racchiudere grandi frammenti di programma in un unico namespace e, dall'altro, di presentare diverse interfacce a diverse categorie di utenti, mostrandone parti differenti.

 


 

Parola-chiave using

 

Quando un membro di un namespace viene usato ripetutamente fuori dal suo ambito, esiste la possibilità, aggiungendo una sola istruzione, di evitare il fastidio di qualificarlo ogni volta.

La parola-chiave using serve a questo scopo e può essere usata in due modi diversi:

Entrambe le istruzioni using possono essere inserite in qualunque ambito e in esso mettono a disposizione sinonimi che a loro volta seguono le normali regole di visibilità. In particolare:

Spesso la  using-directive a livello globale è usata come "strumento di transizione", cioè per trasportare in C++ vecchio codice scritto in C. Esistono infatti centinaia di librerie scritte in C, con centinaia di migliaia di righe di codice, che fanno un uso massiccio ed estensivo di nomi globali. Molte di queste librerie sono ancora utili e costituiscono un "patrimonio" che non va disperso. D'altra parte, "affollare" così pesantemente il namespace globale non fa parte della "logica" del C++. Il problema è stato risolto racchiudendo le librerie in tanti namespace e facendo ricorso alle using-directive per renderle accessibili (quando serve). In questo modo si mantiene la compatibilità con i vecchi programmi, ma i nomi utilizzati dalle librerie non occupano il namespace globale e quindi non rischiano di creare conflitti in altri contesti.

 


 

Precedenze e conflitti fra i nomi

 

Abbiamo visto che le istruzioni using forniscono la possibilità di evitare la qualificazione ripetuta dei nomi definiti in un namespace. D'altra parte, rendendo accessibili delle parti di programma che altrimenti sarebbero nascoste, indeboliscono il "data hiding" e aumentano la probabilità di conflitti fra nomi e di errori non sempre riconoscibili. Si tratta pertanto di operare di volta in volta la scelta più opportuna, bilanciando "comodità" e "sicurezza".

A questo scopo il C++ definisce delle regole precise che, in taluni casi, vietano i conflitti di nomi (nel senso che all'occorrenza il compilatore segnala errore) e, in altri, stabiliscono delle precedenze fra nomi uguali (cioè il nome con precedenza superiore "nasconde" quello con precedenza inferiore). Tali regole sono diverse se si usa una using-declaration o una using-directive :

 


 

Collegamento fra namespace definiti in files diversi

 

Finora abbiamo trattato i namespace intendendo che fossero sempre definiti nello stesso file. Ci chiediamo ora  in che modo è possibile il collegamento fra namespace di file diversi. Prima, però, è opportuno ricordare la differenza che intercorre fra file sorgente e translation unit:

Due namespace con lo stesso nome appartenenti a due diverse translation units non sono in conflitto, ma sono da considerarsi come facenti parte dello stesso unico  namespace (per la proprietà di estendibilità dei namespace). Il conflitto, semmai, può sorgere fra i nomi dei membri del namespace, se viene violata la ODR. D'altra parte ogni translation unit viene compilata separatamente e quindi ogni nome utilizzato in una translation unit deve essere, nella stessa, anche dichiarato. Ne consegue che i membri di uno stesso namespace che vengono utilizzati in entrambe le translation units, devono essere, in una delle due, definiti, e nell'altra dichiarati senza essere definiti (questo discorso vale per gli oggetti e le funzioni non inline, mentre le funzioni inline, i tipi astratti e altre entità del linguaggio che vederemo, come i template, possono anche essere ridefiniti, purchè gli elementi lessicali di ogni definizione siano identici).

Diverso è l'approccio, se si considerano i file sorgente: ogni file (cioè ogni modulo del programma) dovrebbe essere progettato in modo da non contenere duplicazioni e da localizzare questo problema soltanto nelle eventuali interfacce incluse da più moduli. Queste interfacce dovrebbero contenere solo dichiarazioni o definizioni "ripetibili".

Quindi il "trucco"  consiste sostanzialmente nel progettare al meglio le interfacce comuni: una "buona" interfaccia dovrebbe essere tale da minimizzare le dipendenze fra le varie parti del programma, in quanto interfacce con dipendenze minime conducono a sistemi più facili da comprendere, con dettagli implementativi invisibili (data-hiding), più facili da modificare e più veloci da compilare.

Riprendiamo a questo proposito il nostro esempio iniziale del namespace Stack e mettiamoci "nei panni" sia del progettista che dell'utente.

Si deduce pertanto che il progettista dovrà spezzare la definizione del namespace Stack in due (per fortuna ciò è possibile!): nella prima parte metterà solo le dichiarazioni delle funzioni push e pop; nella seconda tutto il resto. Creerà poi due files separati: nel primo (l'interfaccia comune) metterà soltanto la prima definizione del namespace Stack , nel secondo metterà l'estensione di Stack e, esternamente al namespace, le definizioni delle due funzioni. A sua volta l'utente non dovrà fare altro che inserire nel suo file sorgente la direttiva di inclusione dell'interfaccia comune. Così, qualsiasi modifica o miglioramento venga fatto al codice di implementazione dello Stack, i programmi degli utenti non ne verranno minimamente influenzati (al massimo dovrano essere ri-linkati).

[p39][p39] [p39]

 


 

Torna all'Indice