Skip to content
C++
Language
Intermediate

Name Lookup

How the C++ compiler associates every name in source code with the declaration that introduced it, before overload resolution begins.

Name Lookupsince C++98

Name lookup is the compiler procedure that associates every name encountered in a translation unit with the declaration β€” or, for overloaded functions, the set of declarations β€” that introduced it.

Overview

Every identifier in a C++ program must be tied to a declaration before the program can compile. Name lookup performs that binding. It runs before overload resolution and before access checking: candidates are first gathered by lookup, then filtered by overload resolution, and only then are access rules applied. Understanding this ordering is essential because it explains why a private member can still participate in overload resolution (and why the error says "access" rather than "not found").

The lookup procedure divides into two branches depending on whether the name is qualified or unqualified.

  • A qualified name appears to the right of ::: std::cout, Base::reset, ::global_fn.
  • An unqualified name appears without a preceding ::: cout, reset, global_fn.

Qualified Name Lookup

Qualified lookup searches exactly the scope named on the left of ::. It does not consider enclosing namespaces, does not perform ADL, and stops as soon as it finds the name in the designated scope (or fails with an error).

cpp
namespace outer {
    void f(int);

    namespace inner {
        void f(double);

        void g() {
            outer::f(1);         // finds outer::f(int) only β€” inner::f not considered
            outer::inner::f(1);  // finds inner::f(double)
        }
    }
}

For class member access, qualified lookup traverses the class and its base-class hierarchy, respecting declaration order. One important use is deliberate bypass of virtual dispatch:

cpp
struct Base {
    virtual void process();
};

struct Derived : Base {
    void process() override;

    void run() {
        process();           // unqualified β€” virtual dispatch, calls Derived::process
        Base::process();     // qualified β€” calls Base::process directly, no dispatch
    }
};

Calling Base::process() via qualified lookup is the canonical pattern for invoking a base implementation from an override without triggering polymorphism.

Unqualified Name Lookup

Unqualified lookup starts at the point of use and moves outward through enclosing scopes: block β†’ enclosing blocks β†’ function scope β†’ class scope (for member functions) β†’ enclosing namespaces β†’ global namespace. The search halts at the first scope that contains at least one declaration of the name.

cpp
int threshold = 100;

namespace config {
    int threshold = 50;

    void check(int v) {
        // Lookup finds config::threshold first; global threshold is shadowed
        if (v > threshold) { /* uses 50 */ }
        if (v > ::threshold) { /* uses 100 β€” qualified, bypasses shadowing */ }
    }
}

Argument-Dependent Lookup (ADL)

For function names in a call expression, unqualified lookup is extended by ADL (Koenig lookup). The compiler collects all namespaces associated with the types of the function arguments β€” including the namespace of the type itself, its enclosing namespaces, and namespaces of its template arguments β€” and searches those too. This extension applies only to unqualified function calls.

ADL is what makes operator overloading and customization-point idioms work without littering call sites with namespace qualifications:

cpp
#include <iostream>
#include <utility>

namespace geom {
    struct Vec2 { double x, y; };

    std::ostream& operator<<(std::ostream& os, Vec2 v) {
        return os << v.x << ' ' << v.y;
    }

    void swap(Vec2& a, Vec2& b) noexcept {  // noexcept since C++11
        using std::swap;
        swap(a.x, b.x);
        swap(a.y, b.y);
    }
}

void example() {
    geom::Vec2 u{1.0, 2.0}, v{3.0, 4.0};
    std::cout << u << '\n';  // ADL finds geom::operator<<
    swap(u, v);              // ADL finds geom::swap; std::swap available as fallback
}

ADL does not apply to variable names, type names, or namespaces β€” only to function names in call expressions. Qualifying the call (e.g., geom::swap(u, v)) disables ADL for that call.

Function Names vs. All Other Names

The semantics of what lookup returns differ by name category.

Function names: Lookup gathers every visible declaration from each scope it searches β€” it does not stop at the first scope. ADL can add further candidates from associated namespaces. The entire candidate set is then handed to overload resolution.

All other names (variables, types, namespaces, enumerators): Lookup must produce exactly one declaration, or multiple declarations of the same entity (e.g., an extern variable declared in multiple headers). Finding two distinct entities with the same name is ill-formed.

cpp
extern int counter;    // declaration 1
extern int counter;    // declaration 2 β€” same entity, OK

struct Result { int code; };
int Result = 0;        // type/non-type in same scope β€” special rule applies (see below)

Type/Non-Type Hiding (The Struct Hack)

C++ preserves a C compatibility rule: within a single scope, a struct/class/union/enum tag name can coexist with a variable, function, or enumerator of the same name. When both exist, ordinary lookup finds the non-type name; the type name is hidden and can only be accessed with an elaborated type specifier.

cpp
struct status { int code; };

void handle() {
    int status = 200;        // hides the struct name in this scope
    // status s;             // error: 'status' here means the int
    struct status s;         // OK: elaborated-type-specifier reaches the hidden type
    s.code = status;
}

This rule also applies when a function name and a class name collide:

cpp
namespace io {
    struct stream { /* ... */ };
    void stream();           // hides struct stream from plain lookup
    // stream s;            // error: stream names the function
    struct stream s;        // OK
}

Two-Phase Lookup in Templates

Inside a function template body, lookup is split into two phases to handle the distinction between names that depend on template parameters and those that do not.

Phase 1 runs at the point of template definition. Names that are non-dependent (do not involve template parameters) are looked up immediately, using the enclosing context at definition time.

Phase 2 runs at instantiation. Dependent names β€” those that involve template parameters β€” are looked up using both the definition context and the instantiation context (including ADL over the deduced argument types).

cpp
namespace lib {
    struct Token {};
    void validate(Token);   // must be declared before instantiation, not before definition
}

template <typename T>
void pipeline(T t) {
    validate(t);   // dependent call β€” phase-2 lookup, ADL finds lib::validate
}

void run() {
    pipeline(lib::Token{});   // instantiation triggers phase-2 lookup
}

The most common mistake is relying on phase-2 lookup for a function that is not findable via ADL β€” it won't be found even if declared before the instantiation point.

Common Pitfalls

Shadowing without intent: An inner declaration silently shadows an outer one. -Wshadow catches this.

cpp
int count = 0;

void accumulate(const std::vector<int>& items) {
    for (int count : items) {    // shadows outer count β€” outer is never incremented
        // work with local count
    }
    // outer count still 0
}

Inherited names in templates: Unqualified lookup inside a class template does not search dependent base classes (phase-1 lookup; base type unknown at definition). Make names dependent explicitly.

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

template <typename T>
struct Derived : Base<T> {
    void run() {
        // helper();            // error: not found β€” Base<T> not searched in phase 1
        this->helper();         // OK: makes the name dependent, deferred to phase 2
        Base<T>::helper();      // OK: qualified lookup
    }
};

ADL interference: Any name introduced into an associated namespace after a template is defined can be picked up at instantiation. This makes templates sensitive to what third-party code places in the same namespace as their argument types β€” a real concern when writing generic library code.

See Also