C++

Simple name lookup in C++

Apr 22, 202611 min

This is a continuation of the topic started in Do names matter to the compiler, continued in At night all cats are grey, and all using's are alike (in Russian) and further in Compilers get confused by names too. If you haven't read them, it's better to skim through. Now we've gotten to such an interesting topic as qualified and unqualified lookup.

What is simple lookup of a name n in a scope S? It's the compiler mechanism that finds all declarations of n located directly in that scope. Simple? On the face of it, yes, but even this simple mechanism often doesn't work the way a developer expects.

For example, we have a namespace N and a local variable N. Can they coexist? They can, because they live in different scopes. And can a namespace and a global variable with the same name? How did we get to this point — let's figure it out.

The proper term from the standard is unqualified name lookup, but the phrase "simple lookup" is often used too. Before reading on, try to say what this simple piece of code prints (godbolt).

namespace N {
    int value = 10;
}

int main() {
    int N = 5;
    std::cout << N << "\n";
    std::cout << N::value << "\n";
    return 0;
}
Answer and explanation
namespace N {
    int value = 10;
}

int main() {
    int N = 5; // local variable N

    // Access to the local variable
    std::cout << "Local variable N: " << N << "\n";

    // Access to the variable in namespace N
    std::cout << "Variable N::value: " << N::value << "\n";

    return 0;
}

Here we have a namespace N and a local variable N. They can coexist because they live in different scopes, but a namespace and a global variable with the same name cannot. The local variable N and the namespace N coexist without conflict because they are in different scopes: the variable N is in the scope of the function main, while the namespace N is in the global scope.

If we tried to create a global variable int N, the compiler would immediately report an error, because a global variable and a namespace live in the same scope and can't have the same name.

namespace N {}  // namespace N
int N = 42;     // Error! The name is already taken

But what's even more interesting is how a local variable and a namespace with the same name coexist, and exactly how the compiler figures out what is meant. Simple lookup and qualified lookup work by completely different rules, and the name N in the expression N += 1 is resolved quite differently from the same name N in the expression N::x.

Now let's look at another example, the expression N += 1. Here the name N is looked up via simple lookup in the local scope, the compiler finds the local variable, and the lookup ends there. But in the expression N::x the situation is different, because it's a qualified name, and the part before :: is looked up separately.

namespace N {
    int x = 42;
}

int main() {
    int x = 5; // local variable x

    // Unqualified name: simple lookup
    x += 1;
    std::cout << "Local variable x: " << x << "\n";
    // Prints 6

    // Qualified name: the qualifier is looked up separately
    N::x += 1;
    std::cout << "Variable N::x: " << N::x << "\n";
    // Prints 43

    return 0;
}

The key point is that when looking up a name used as a qualifier, the compiler ignores entities that aren't namespaces or classes, and since the local variable N isn't a namespace, it's ignored during such a lookup, and the expression N::x works correctly even if there's a variable N in the current scope.

This situation is called name hiding, when a declaration of a name in an inner scope makes a same-named declaration from an outer scope inaccessible, and during simple lookup the compiler simply stops looking further as soon as it finds the first match.

int x = 42; // global variable

int main() {
    int x = 5; // hides the global x

    std::cout << x << "\n";   // 5 local, the global one is hidden
    std::cout << ::x << "\n"; // 42 explicit qualification removes the hiding
}

The same thing happens in classes with inheritance, and there the consequences are more surprising, because it's not a single variable that's hidden but the whole overload set (godbolt):

struct Base {
    void foo(int x) { std::cout << "int base\n"; }
    void foo(double x) { std::cout << "double\n"; }
    // both overloads are available
};

struct Derived : Base {
    void foo(int x) { std::cout << "int derived\n"; }
    // hides BOTH overloads from Base, not just foo(int)
};

int main() {
    Derived d;
    d.foo(1);      // OK: Derived::foo(int)
    d.foo(1.0);    // ??? Base::foo(double) is hidden
    d.Base::foo(1.0); // OK: explicit qualification removes the hiding
}

>> int derived
>> int derived
>> double

// to avoid hiding, you need using:
struct Derived2 : Base {
    using Base::foo; // bring all overloads of Base::foo into scope
    void foo(int x) {} // now it overloads instead of hiding
};

This brings us to the conclusion that name hiding depends on which kind of lookup is performed: hiding works only for the kind of lookup that considers entities of that category. This is a fundamental feature of C++ that helps avoid conflicts between variables, namespaces, and types when qualifiers are used correctly — which, however, didn't always work the same way across compilers.

GCC: in old versions qualified lookup sometimes "incorrectly" took local variables into account when resolving qualifiers, which led to errors or ambiguities in code with identically named variables and namespaces. In GCC before version 3.4 qualified lookup in some cases incorrectly considered local variables when resolving a namespace qualifier, which could lead to the error "N is not a namespace" even when the local variable and the namespace were supposed to coexist correctly. Starting with GCC 3.4 the behavior was brought into line with the standard.

namespace N {
    int value = 10;
}

int main() {
    int N = 5;
    // A local variable with the same name
    // as the namespace

    // The unqualified access works
    // with the local variable
    N += 1;
    std::cout << "Local variable N: " << N << "\n";
    // Prints 6

    // Qualified name
    // In old GCC versions this could cause an error

    std::cout << "Namespace N::value: " << N::value << "\n";

    // The compiler could interpret N as a local variable
    // and report "N is not a namespace" or "ambiguous"

    return 0;
}

Clang: from its early versions Clang strictly implemented the rule that names from dependent base classes don't participate in the first phase of unqualified two-phase lookup. Code with template inheritance that compiled on GCC without this-> didn't compile on Clang, which surfaced when switching toolchains.

// Dependent base classes
// didn't participate in phase-1 unqualified lookup

template<typename T>
struct Base {
    void foo() {}
};

template<typename T>
struct Derived : Base<T> {
    void bar() {
        foo();
        // Clang (strictly per the standard): ERROR
        // old GCC: OK, looked it up in the base at instantiation

        this->foo();  // the way that works everywhere
    }
};

The compiler could "fail to notice" the qualifier and use the hidden name Derived::foo instead of Base::foo, which led to errors or unexpected behavior when using using to bring base-class names into the current scope.

MSVC: before VS2015 MSVC in a number of cases didn't correctly separate namespaces and local variables when resolving a qualifier, because of which the local variable N could interfere with the lookup of the namespace N in the qualified expression N::f(), and the compiler reported the error "N is not a namespace" where, by the standard, it should have found the namespace.

namespace N {
    void f() { std::cout << "Namespace N::f\n"; }
}

void f() { std::cout << "Global f\n"; }

int main() {
    int N = 42;
    // A local variable with a name
    // matching the namespace

    // Unqualified call
    f(); // calls the global f()

    // Qualified call through the namespace
    // In old MSVC versions the local variable N could interfere
    // and the compiler mistakenly tried to use N as a qualifier

    N::f();
    // it was expected that N::f() from the namespace would be called
    // but old MSVC could report "N is not a namespace"

    return 0;
}

And the lookup is not so simple after all

The situation gets even more complicated when we need to gather a set of names from the scope of the current class and all of its base classes and then perform the lookup within that set. When we use an unqualified name inside a class or a class template, the compiler doesn't just walk the base classes one by one — it builds a set of subobjects.

Consider the by-now classic example where a class C inherits from A and B. If we look up the name x, the compiler will find x in A and x in B, but both are valid finds, and the result is an ambiguity that makes the program ill-formed.

struct A {
    int x = 1;
};

struct B {
    int x = 2;
};

struct C : A, B {
    void printX() {
        // The unqualified name x occurs in
        // both base classes
        // The compiler can't unambiguously choose
        // which x to refer to
        // This leads to an ambiguity error
        std::cout << x << "\n"; // Error: ambiguity
    }
};

int main() {
    C c;
    c.printX();
    return 0;
}

Qualified name lookup in C++ is arranged differently, and that makes it more predictable and resistant to unexpected effects. When the compiler sees a qualified name (C::x), it runs a strictly defined set of rules with a precisely fixed order of steps.

First of all, the compiler looks for explicitly declared names directly in the specified scope, and if we write C::x, then it first considers all declarations of x that actually belong to C. These can be static class members, nested types, enumerations, or functions declared directly inside C. If such a declaration is found, the lookup is considered complete, and no external visibility-extension mechanisms play any role at this stage.

// A global function with the same name shouldn't interfere
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() {
    // Qualified lookup: we look only inside C
    printf("%d\n", C::x);         // looks only in C, not pulling x() and N::x() into the analysis
}

For C::x the compiler looks only in C and finds static int x = 42. The global function x() and N::x() aren't considered at all, even though under unqualified lookup they would also be candidates. Let's look at another example:

struct A {
    int x = 42;
};

struct B : A {};  // B inherits x from A
struct C : A {};  // C inherits x from A

struct E : B, C { // E inherits from both B and C
    void foo() {
        // x = 1; // compilation error!
        // error: member 'x' found in multiple base classes of different types
    }
};

At first glance it seems there shouldn't be any problem. After all, the member x is declared only in A, and there's no second x in B or C. However, when the compiler looks up the unqualified name x inside E, it doesn't just "look for a declaration" — it builds a set of base-class subobjects. That set contains:

Yes, it's one and the same declaration A::x, but the paths by which it can be reached are different. For the compiler this means the lookup succeeded along two independent routes, and neither of them is preferred. The result is an ambiguity, and the program is considered ill-formed, which brings us to the understanding that lookup in base classes has two levels: what matters is not only which declaration is found, but also by which path the compiler arrived at it (godbolt).

// The compiler builds a graph of objects:
//
//    A::x     A::x
//      |       |
//      B       C
//       \     /
//          E
//
// Two paths to the same declaration A::x:
// path 1: E -> B -> A::x
// path 2: E -> C -> A::x
//
// The compiler sees two routes and can't choose a preferred one

int main() {
    E e;

    // explicit qualification removes the ambiguity:
    e.B::x = 1;  // OK: explicitly specify the path through B
    e.C::x = 2;  // OK: explicitly specify the path through C

    // these are two DIFFERENT A subobjects in memory:
    std::cout << e.B::x << "\n"; // 1
    std::cout << e.C::x << "\n"; // 2
}

Next, if no suitable name is found in the scope C itself, the compiler moves on to the next step and starts taking into account names that became visible in that scope through using namespace directives. Here it's worth recalling that we're talking specifically about directives, not using-declarations, so names can be found by qualified lookup, but solely as a fallback, when there are no own declarations in that scope. There's no established term for this rule in the standard, and it's often described through the mechanism rather than a separate name. In the C++ standard the corresponding paragraph is [namespace.qual], and the rule itself is formulated as follows:

If qualified lookup in a scope X finds no own declarations, then names from the namespaces introduced via using namespace directives in X are considered, yielding the "set of associated namespaces".

This, in fact, is the main difference between qualified and unqualified lookup: under unqualified lookup, using namespace actively participates in forming the candidate set from the very start, which often leads to unexpected conflicts and ambiguities. Qualified lookup, by contrast, strictly prioritizes explicit declarations and only extends the lookup scope via using namespace in their absence, which makes it considerably more stable and protected against accidental changes of context.

A small digression to make the example complete. Let's recall virtual inheritance, which eliminates this behavior (but brings along many other problems), because now there's only a single subobject A (godbolt):

struct A {
    int x = 42;
};

struct B : virtual A {};  // virtual inheritance
struct C : virtual A {};  // virtual inheritance

struct E : B, C {
    void foo() {
        x = 1;  // OK: only one A in the hierarchy
    }
};

// the subobject graph now looks different:
//
//      B     C
//       \   /
//         A      <-- a single instance, a single path
//
// no ambiguity

int main() {
    E e;
    e.x = 1;       // OK
    e.B::x = 1;    // OK, the same x
    e.C::x = 1;    // OK, the same x
    // all three refer to the same subobject
}

Without virtual inheritance, E contains two physically different subobjects A in memory, and the compiler can't guess which of them to access during unqualified lookup of x, even if both contain the same declaration.

Now imagine we have two namespaces. In one of them a name is declared which we'll "mix in" via using namespace, and the other also has its own declaration with the same name.

namespace N {
    int x = 42;
}

namespace C {
    using namespace N;
    int x = 10;
}

It might seem that inside C there are now two possible x: one of its own (C::x), and a second one that came from N via using namespace — but qualified lookup immediately sorts everything out. Now, if we write:

int a = C::x;

then the compiler performs qualified lookup for the name x only in the scope C and first looks for explicitly declared names in C, finds int x = 10; there, and the lookup stops at that. The second name N::x, despite the using namespace N directive, isn't even considered. As a result, a gets the value 10. Now let's look at a counterexample where unqualified lookup is performed inside the scope C itself (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;
      |         ^

Here the name x is used without a qualifier, so unqualified lookup applies, which gathers candidates from the current scope and from the namespaces brought in via using namespace. In this case both C::x and N::x end up in the candidate set, and since both declarations have equal "weight", the program becomes ambiguous and the compiler reports an error.

Now let's return to qualified lookup and modify the example slightly (godbolt):

namespace N {
    int x = 42;
}

namespace C {
    using namespace N;
}

int main() {
    int a = C::x; // But C has no x of its own!
     std::cout << "Variable N::x: " << C::x << "\n";
    // prints 42
}

Here the situation is different: now there's no explicitly declared x in the namespace C, so qualified lookup first tries to find x directly in C, fails, and after that considers the names that came in via using namespace N, and finds N::x there.

What's next…

One of the examples above showed that name hiding in classes doesn't work the way you'd intuitively expect. A declaration in Derived hides all overloads from Base, not just the matching one, and this behavior is a direct consequence of how name lookup works over the base-class hierarchy.

It's precisely for such cases that the using-declaration exists, allowing you to explicitly bring names from a base class into the derived scope without breaking the lookup rules and without creating conflicts. But using isn't only about inheritance: by C++14 it became a full-fledged visibility-management tool that works by its own rules and interacts with name lookup in a predictable and strictly defined way — and I'll talk about that in the next article...

I've already accumulated material for two more chapters (the history of concepts and name lookup); I'll publish them on GitHub soon — playful_programming_cpp.

← All articles