Questa è la continuazione del tema iniziato in I nomi contano per il compilatore, proseguito in Di notte tutti i gatti sono grigi, e tutti gli using si somigliano (in russo) e ancora oltre in Anche i compilatori si confondono con i nomi. Se non li hai letti, è meglio dargli una scorsa. Ora siamo arrivati a un tema così interessante come il lookup qualificato e non qualificato.
Che cos'è il lookup semplice di un nome n in uno scope S? È il meccanismo del compilatore che trova tutte le dichiarazioni di n collocate direttamente in quello scope. Semplice? A prima vista sì, ma anche questo semplice meccanismo spesso non funziona nel modo in cui uno sviluppatore si aspetta.
Per esempio, abbiamo un namespace N e una variabile locale N. Possono convivere? Possono, perché vivono in scope diversi. E possono un namespace e una variabile globale con lo stesso nome? Come siamo arrivati a questo punto — cerchiamo di capirlo.
Il termine corretto dallo standard è unqualified name lookup, ma spesso si usa anche l'espressione «lookup semplice». Prima di proseguire con la lettura, prova a dire cosa stampa questo semplice pezzo di codice (godbolt).
namespace N {
int value = 10;
}
int main() {
int N = 5;
std::cout << N << "\n";
std::cout << N::value << "\n";
return 0;
}
Risposta e spiegazione
namespace N {
int value = 10;
}
int main() {
int N = 5; // variabile locale N
// Accesso alla variabile locale
std::cout << "Variabile locale N: " << N << "\n";
// Accesso alla variabile nel namespace N
std::cout << "Variabile N::value: " << N::value << "\n";
return 0;
}Qui abbiamo un namespace N e una variabile locale N. Possono convivere perché vivono in scope diversi, ma un namespace e una variabile globale con lo stesso nome non lo possono. La variabile locale N e il namespace N convivono senza conflitto perché si trovano in scope diversi: la variabile N è nello scope della funzione main, mentre il namespace N è nello scope globale.
Se provassimo a creare una variabile globale int N, il compilatore segnalerebbe immediatamente un errore, perché una variabile globale e un namespace vivono nello stesso scope e non possono avere lo stesso nome.
namespace N {} // namespace N
int N = 42; // Errore! Il nome è già occupato
Ma ciò che è ancora più interessante è come convivano una variabile locale e un namespace con lo stesso nome, e come esattamente il compilatore capisca cosa si intende. Il lookup semplice e il lookup qualificato funzionano secondo regole del tutto diverse, e il nome N nell'espressione N += 1 viene risolto in modo abbastanza diverso dallo stesso nome N nell'espressione N::x.
Ora guardiamo un altro esempio, l'espressione N += 1. Qui il nome N viene cercato tramite lookup semplice nello scope locale, il compilatore trova la variabile locale, e il lookup finisce lì. Ma nell'espressione N::x la situazione è diversa, perché si tratta di un nome qualificato, e la parte prima di :: viene cercata separatamente.
namespace N {
int x = 42;
}
int main() {
int x = 5; // variabile locale x
// Nome non qualificato: lookup semplice
x += 1;
std::cout << "Variabile locale x: " << x << "\n";
// Stampa 6
// Nome qualificato: il qualificatore viene cercato separatamente
N::x += 1;
std::cout << "Variabile N::x: " << N::x << "\n";
// Stampa 43
return 0;
}
Il punto chiave è che, quando cerca un nome usato come qualificatore, il compilatore ignora le entità che non sono namespace o classi, e poiché la variabile locale N non è un namespace, viene ignorata durante tale lookup, e l'espressione N::x funziona correttamente anche se nello scope corrente c'è una variabile N.
Questa situazione si chiama nascondimento dei nomi (name hiding), quando la dichiarazione di un nome in uno scope interno rende inaccessibile una dichiarazione omonima da uno scope esterno, e durante il lookup semplice il compilatore semplicemente smette di cercare oltre non appena trova la prima corrispondenza.
int x = 42; // variabile globale
int main() {
int x = 5; // nasconde la x globale
std::cout << x << "\n"; // 5 locale, quella globale è nascosta
std::cout << ::x << "\n"; // 42 la qualificazione esplicita rimuove il nascondimento
}
La stessa cosa accade nelle classi con l'ereditarietà, e lì le conseguenze sono più sorprendenti, perché non è una singola variabile a essere nascosta, ma l'intero overload set (godbolt):
struct Base {
void foo(int x) { std::cout << "int base\n"; }
void foo(double x) { std::cout << "double\n"; }
// entrambi gli overload sono disponibili
};
struct Derived : Base {
void foo(int x) { std::cout << "int derived\n"; }
// nasconde ENTRAMBI gli overload di Base, non solo foo(int)
};
int main() {
Derived d;
d.foo(1); // OK: Derived::foo(int)
d.foo(1.0); // ??? Base::foo(double) è nascosta
d.Base::foo(1.0); // OK: la qualificazione esplicita rimuove il nascondimento
}
>> int derived
>> int derived
>> double
// per evitare il nascondimento, serve using:
struct Derived2 : Base {
using Base::foo; // porta nello scope tutti gli overload di Base::foo
void foo(int x) {} // ora fa overloading invece di nascondere
};
Questo ci porta alla conclusione che il nascondimento dei nomi dipende da quale tipo di lookup viene eseguito: il nascondimento funziona solo per il tipo di lookup che considera entità di quella categoria. È una caratteristica fondamentale del C++ che aiuta a evitare conflitti tra variabili, namespace e tipi quando i qualificatori vengono usati correttamente — cosa che, tuttavia, non ha sempre funzionato allo stesso modo nei vari compilatori.
GCC: nelle vecchie versioni il lookup qualificato talvolta teneva conto «erroneamente» delle variabili locali nella risoluzione dei qualificatori, il che portava a errori o ambiguità nel codice con variabili e namespace omonimi. In GCC prima della versione 3.4 il lookup qualificato in alcuni casi considerava erroneamente le variabili locali nella risoluzione di un qualificatore di namespace, il che poteva portare all'errore «N is not a namespace» anche quando la variabile locale e il namespace avrebbero dovuto convivere correttamente. A partire da GCC 3.4 il comportamento è stato allineato allo standard.
namespace N {
int value = 10;
}
int main() {
int N = 5;
// Una variabile locale con lo stesso nome
// del namespace
// L'accesso non qualificato funziona
// con la variabile locale
N += 1;
std::cout << "Variabile locale N: " << N << "\n";
// Stampa 6
// Nome qualificato
// Nelle vecchie versioni di GCC questo poteva causare un errore
std::cout << "Namespace N::value: " << N::value << "\n";
// Il compilatore poteva interpretare N come variabile locale
// e segnalare "N is not a namespace" o "ambiguous"
return 0;
}
Clang: fin dalle sue prime versioni Clang ha implementato rigorosamente la regola secondo cui i nomi delle classi base dipendenti non partecipano alla prima fase del two-phase lookup non qualificato. Il codice con ereditarietà da template che compilava su GCC senza this-> non compilava su Clang, cosa che emergeva al cambio di toolchain.
// Le classi base dipendenti
// non partecipavano al lookup non qualificato di fase-1
template<typename T>
struct Base {
void foo() {}
};
template<typename T>
struct Derived : Base<T> {
void bar() {
foo();
// Clang (rigorosamente secondo lo standard): ERRORE
// vecchio GCC: OK, lo cercava nella base all'istanziazione
this->foo(); // il modo che funziona ovunque
}
};
Il compilatore poteva «non accorgersi» del qualificatore e usare il nome nascosto Derived::foo invece di Base::foo, il che portava a errori o comportamenti inattesi quando si usava using per portare i nomi della classe base nello scope corrente.
MSVC: prima di VS2015 MSVC in diversi casi non separava correttamente namespace e variabili locali nella risoluzione di un qualificatore, a causa di ciò la variabile locale N poteva interferire con il lookup del namespace N nell'espressione qualificata N::f(), e il compilatore segnalava l'errore «N is not a namespace» dove, secondo lo standard, avrebbe dovuto trovare il namespace.
namespace N {
void f() { std::cout << "Namespace N::f\n"; }
}
void f() { std::cout << "Global f\n"; }
int main() {
int N = 42;
// Una variabile locale con un nome
// che coincide con il namespace
// Chiamata non qualificata
f(); // chiama la f() globale
// Chiamata qualificata attraverso il namespace
// Nelle vecchie versioni di MSVC la variabile locale N poteva interferire
// e il compilatore tentava erroneamente di usare N come qualificatore
N::f();
// ci si aspettava che venisse chiamata N::f() dal namespace
// ma il vecchio MSVC poteva segnalare "N is not a namespace"
return 0;
}
E il lookup, dopo tutto, non è così semplice
La situazione si complica ancora di più quando dobbiamo raccogliere un insieme di nomi dallo scope della classe corrente e da tutte le sue classi base, e poi eseguire il lookup all'interno di quell'insieme. Quando usiamo un nome non qualificato dentro una classe o un template di classe, il compilatore non percorre semplicemente le classi base una a una — costruisce un insieme di subobject.
Consideriamo l'ormai classico esempio in cui una classe C eredita da A e B. Se cerchiamo il nome x, il compilatore troverà x in A e x in B, ma entrambi sono ritrovamenti validi, e il risultato è un'ambiguità che rende il programma ill-formed.
struct A {
int x = 1;
};
struct B {
int x = 2;
};
struct C : A, B {
void printX() {
// Il nome non qualificato x compare in
// entrambe le classi base
// Il compilatore non può scegliere in modo univoco
// a quale x riferirsi
// Questo porta a un errore di ambiguità
std::cout << x << "\n"; // Errore: ambiguità
}
};
int main() {
C c;
c.printX();
return 0;
}
Il lookup qualificato dei nomi in C++ è organizzato diversamente, e questo lo rende più prevedibile e resistente agli effetti inattesi. Quando il compilatore vede un nome qualificato (C::x), esegue un insieme di regole rigorosamente definite con un ordine di passi precisamente fissato.
Prima di tutto, il compilatore cerca i nomi esplicitamente dichiarati direttamente nello scope indicato, e se scriviamo C::x, allora considera per prime tutte le dichiarazioni di x che appartengono effettivamente a C. Possono essere membri statici della classe, tipi annidati, enumerazioni o funzioni dichiarate direttamente dentro C. Se tale dichiarazione viene trovata, il lookup è considerato completo, e nessun meccanismo esterno di estensione della visibilità gioca alcun ruolo in questa fase.
// Una funzione globale con lo stesso nome non deve interferire
void x() { printf("global x()\n"); }
namespace N {
void x() { printf("N::x()\n"); }
}
struct C {
static int x;
};
int C::x = 42;
int main() {
// Lookup qualificato: guardiamo solo dentro C
printf("%d\n", C::x); // guarda solo in C, senza tirare x() e N::x() nell'analisi
}
Per C::x il compilatore guarda solo in C e trova static int x = 42. La funzione globale x() e N::x() non vengono affatto considerate, anche se con il lookup non qualificato sarebbero anch'esse candidate. Guardiamo un altro esempio:
struct A {
int x = 42;
};
struct B : A {}; // B eredita x da A
struct C : A {}; // C eredita x da A
struct E : B, C { // E eredita sia da B sia da C
void foo() {
// x = 1; // errore di compilazione!
// error: member 'x' found in multiple base classes of different types
}
};
A prima vista sembra che non ci debba essere alcun problema. In fondo il membro x è dichiarato solo in A, e non c'è una seconda x in B o C. Tuttavia, quando il compilatore cerca il nome non qualificato x dentro E, non si limita a «cercare una dichiarazione» — costruisce un insieme di subobject delle classi base. Quell'insieme contiene:
A, come classe base diretta diCA, come classe base diB, che è a sua volta una classe base contenenteA
Sì, è una sola e medesima dichiarazione A::x, ma i percorsi lungo i quali la si può raggiungere sono diversi. Per il compilatore questo significa che il lookup è riuscito lungo due vie indipendenti, e nessuna di esse è preferita. Il risultato è un'ambiguità, e il programma è considerato ill-formed, il che ci porta a comprendere che il lookup nelle classi base ha due livelli: conta non solo quale dichiarazione viene trovata, ma anche per quale percorso il compilatore vi è arrivato (godbolt).
// Il compilatore costruisce un grafo di oggetti:
//
// A::x A::x
// | |
// B C
// \ /
// E
//
// Due percorsi verso la stessa dichiarazione A::x:
// percorso 1: E -> B -> A::x
// percorso 2: E -> C -> A::x
//
// Il compilatore vede due vie e non può sceglierne una preferita
int main() {
E e;
// la qualificazione esplicita rimuove l'ambiguità:
e.B::x = 1; // OK: specifica esplicitamente il percorso attraverso B
e.C::x = 2; // OK: specifica esplicitamente il percorso attraverso C
// questi sono due subobject A DIVERSI in memoria:
std::cout << e.B::x << "\n"; // 1
std::cout << e.C::x << "\n"; // 2
}
Poi, se nessun nome adatto viene trovato nello scope C stesso, il compilatore passa al passo successivo e comincia a tenere conto dei nomi resi visibili in quello scope tramite le direttive using namespace. Qui vale la pena ricordare che stiamo parlando specificamente di direttive, non di using-declaration, quindi i nomi possono essere trovati dal lookup qualificato, ma esclusivamente come ripiego, quando in quello scope non ci sono dichiarazioni proprie. Per questa regola non esiste un termine consolidato nello standard, e spesso viene descritta attraverso il meccanismo piuttosto che con un nome a sé. Nello standard C++ il paragrafo corrispondente è [namespace.qual], e la regola stessa è formulata così:
Se il lookup qualificato in uno scopeXnon trova dichiarazioni proprie, allora vengono considerati i nomi provenienti dai namespace introdotti tramite le direttiveusing namespaceinX, ottenendo il «set of associated namespaces».
Questa, di fatto, è la principale differenza tra lookup qualificato e non qualificato: con il lookup non qualificato, using namespace partecipa attivamente alla formazione dell'insieme di candidati fin dall'inizio, il che porta spesso a conflitti e ambiguità inattesi. Il lookup qualificato, al contrario, dà rigorosamente priorità alle dichiarazioni esplicite ed estende lo scope del lookup tramite using namespace solo in loro assenza, il che lo rende notevolmente più stabile e protetto contro i cambiamenti accidentali di contesto.
Una piccola digressione per rendere completo l'esempio. Ricordiamo l'ereditarietà virtuale, che elimina questo comportamento (ma ne porta con sé molti altri problemi), perché ora c'è un unico subobject A (godbolt):
struct A {
int x = 42;
};
struct B : virtual A {}; // ereditarietà virtuale
struct C : virtual A {}; // ereditarietà virtuale
struct E : B, C {
void foo() {
x = 1; // OK: c'è una sola A nella gerarchia
}
};
// il grafo dei subobject ora ha un aspetto diverso:
//
// B C
// \ /
// A <-- una singola istanza, un singolo percorso
//
// nessuna ambiguità
int main() {
E e;
e.x = 1; // OK
e.B::x = 1; // OK, la stessa x
e.C::x = 1; // OK, la stessa x
// tutti e tre si riferiscono allo stesso subobject
}
Senza ereditarietà virtuale, E contiene due subobject A fisicamente diversi in memoria, e il compilatore non può indovinare a quale dei due accedere durante il lookup non qualificato di x, anche se entrambi contengono la stessa dichiarazione.
Ora immagina di avere due namespace. In uno di essi è dichiarato un nome che «mescoleremo» tramite using namespace, e anche l'altro ha una propria dichiarazione con lo stesso nome.
namespace N {
int x = 42;
}
namespace C {
using namespace N;
int x = 10;
}
Potrebbe sembrare che dentro C ora ci siano due possibili x: una propria (C::x), e una seconda arrivata da N tramite using namespace — ma il lookup qualificato mette subito tutto in ordine. Ora, se scriviamo:
int a = C::x;
allora il compilatore esegue il lookup qualificato per il nome x solo nello scope C e cerca per primi i nomi esplicitamente dichiarati in C, vi trova int x = 10;, e il lookup si ferma lì. Il secondo nome N::x, nonostante la direttiva using namespace N, non viene nemmeno considerato. Di conseguenza, a assume il valore 10. Ora guardiamo un controesempio in cui il lookup non qualificato viene eseguito dentro lo scope C stesso (godbolt):
namespace N {
int x = 42;
}
namespace C {
using namespace N;
int x = 10;
}
int f() {
using namespace C;
return x;
}
int main() {
std::cout << f();
}
<source>:15:12: error: reference to 'x' is ambiguous
15 | return x;
| ^
<source>:10:9: note: candidate found by name lookup is 'C::x'
10 | int x = 10;
| ^
<source>:5:9: note: candidate found by name lookup is 'N::x'
5 | int x = 42;
| ^
Qui il nome x è usato senza qualificatore, quindi si applica il lookup non qualificato, che raccoglie i candidati dallo scope corrente e dai namespace introdotti tramite using namespace. In questo caso sia C::x sia N::x finiscono nell'insieme dei candidati, e poiché entrambe le dichiarazioni hanno «peso» uguale, il programma diventa ambiguo e il compilatore segnala un errore.
Ora torniamo al lookup qualificato e modifichiamo leggermente l'esempio (godbolt):
namespace N {
int x = 42;
}
namespace C {
using namespace N;
}
int main() {
int a = C::x; // Ma C non ha una propria x!
std::cout << "Variabile N::x: " << C::x << "\n";
// stampa 42
}
Qui la situazione è diversa: ora non c'è alcuna x esplicitamente dichiarata nel namespace C, quindi il lookup qualificato cerca prima x direttamente in C, fallisce, e dopo di ciò considera i nomi arrivati tramite using namespace N, e vi trova N::x.
Cosa viene dopo…
Uno degli esempi qui sopra ha mostrato che il nascondimento dei nomi nelle classi non funziona nel modo in cui te lo aspetteresti intuitivamente. Una dichiarazione in Derived nasconde tutti gli overload di Base, non solo quello corrispondente, e questo comportamento è una conseguenza diretta di come funziona il lookup dei nomi lungo la gerarchia delle classi base.
È proprio per casi del genere che esiste la using-declaration, che permette di portare esplicitamente i nomi da una classe base nello scope derivato senza infrangere le regole del lookup e senza creare conflitti. Ma using non riguarda solo l'ereditarietà: con C++14 è diventato uno strumento a tutti gli effetti per la gestione della visibilità, che funziona secondo regole proprie e interagisce con il lookup dei nomi in modo prevedibile e rigorosamente definito — e ne parlerò nel prossimo articolo...
Ho già accumulato materiale per altri due capitoli (la storia dei concepts e il lookup dei nomi); li pubblicherò presto su GitHub — playful_programming_cpp.
← Tutti gli articoli