C++ Compilation Semantics: Name Lookup, Overload Resolution, and the Hidden Phases of Meaning
Disclaimer: ChatGPT generated document.
What you’re hearing described (“name-lookup phase”, “overload-resolution time”) is part of the front-end semantic pipeline a C++ compiler runs through to turn tokens into “this exact entity/function/template is meant here”.
Below is a fairly complete deep dive, split into:
- Standard translation phases (the “real” phases the standard names)
- Front-end semantic phases (the practical “priorities/timing” you’re asking about: lookup, ADL, overload resolution, template deduction, constraints, instantiation, etc.)
- Priority rules & tie-breakers (especially overload ranking)
- Two-phase lookup and dependent names (the big “timing” gotcha)
- Concrete examples showing what happens when
These are the phases in the C++ standard (often summarized as 8 phases; exact wording varies by edition but the structure is stable):
-
Physical source file mapping / character set translation Source bytes → “universal character set” / implementation character set.
-
Line splicing Backslash-newline sequences are removed (historically important for macros).
-
Tokenization and preprocessing
- The file is broken into preprocessing tokens and whitespace/comments.
- Comments are removed (conceptually replaced by whitespace).
-
Macro expansion + directive handling
#include,#define, conditional compilation, pragma, etc. Result: a single preprocessed token stream (roughly). -
Character literals / string literal processing Escape sequences, concatenation of adjacent string literals, etc.
-
Parsing into grammar structures Tokens → parse tree / AST shape. (Still not “meaning” — e.g.,
T::xmight parse even ifxdoesn’t exist.) -
Semantic analysis This is where your “name lookup”, “overload resolution”, “template instantiation”, constraints, etc. live.
-
Code generation / linking model (not fully specified by the standard) The standard stops being prescriptive, but compilers then do IR generation, optimization, object emission, and linking.
Your question is really about Phase 7, which is huge and internally staged.
Think of compilation as repeatedly answering: “What does this token sequence mean?” The major sub-problems (and the “timing” people refer to) are:
As the compiler parses, it introduces declarations into symbol tables (namespaces, classes, block scopes, etc.).
Given a name like f, the compiler determines the candidate declarations it could refer to.
Key lookup categories:
- Unqualified lookup:
f(...) - Qualified lookup:
N::f,obj.f,T::f - Member lookup: inside classes, base classes, injected-class-names, etc.
- Argument-dependent lookup (ADL): “Koenig lookup” adds candidates based on argument types
- Using-directive effects:
using namespace std;changes lookup sets (but with tricky rules) - Using-declaration effects:
using std::swap;introduces specific overload sets
Lookup does not pick “the best overload”. It produces sets (or reports ambiguity / not found).
For a call expression f(a,b) after lookup:
-
If
fdenotes an overload set (or function template(s)), gather all possible overloads and template candidates. -
Include:
- non-templates
- function templates
- (sometimes) built-in candidates (e.g., operators)
- (sometimes) rewriting candidates (C++20:
<=>/relational rewriting, etc.) - for member calls, candidates might include base class members and hidden members depending on lookup and using declarations.
For each function template candidate:
- Deduce template parameters from call arguments (or explicit
<...>). - Substitute the deduced arguments into the function type.
This step can fail:
- deduction failure
- substitution failure (SFINAE)
- constraints not satisfied (C++20 concepts)
Only successful ones remain viable.
A candidate function is viable if:
- it can be called with the given arguments
- access control / deletion / constraints allow it
- conversions exist for each parameter
- special rules for
..., ref-qualifiers, implicit object parameter, etc.
Among viable candidates:
- compute implicit conversion sequences for each argument
- compare candidates pairwise
- pick the best, or diagnose ambiguity
Even after choosing a function template specialization, the compiler may still defer instantiating the function body until needed (depending on ODR-use, inline, etc.). But the signature must be known to call it.
- access control
- lifetime / temporary materialization rules
- narrowing conversions (list-init)
- constant evaluation
- etc.
For f in f(x):
-
Search the innermost scope outward:
- block scope
- function parameter scope
- class scope (if inside member function)
- enclosing namespaces
- global namespace
-
“Using declarations” inject names into a scope.
-
“Using directives” (
using namespace N;) make names visible for lookup in a more subtle way: they don’t literally inject names; they affect how namespace scopes are searched.
Important “priority” rule:
- A declaration found in an inner scope hides outer declarations of the same name for unqualified lookup (with several class/namespace nuances).
For N::f:
- Lookup is restricted to
Nand whatNmakes visible (including inline namespaces, using directives insideN, etc.).
For T::f where T is a type:
- If
Tis a class, member lookup rules apply, including base classes.
Inside classes:
- A derived class member named
fhides all base classfoverloads unless you dousing Base::f;. - This is lookup-time hiding, before overload resolution.
struct B { void f(int); void f(double); };
struct D : B { void f(char*); }; // hides BOTH B::f overloadsFor an unqualified function call f(x) (and certain operator calls), ADL adds candidates from associated namespaces/classes of the argument types.
Associated entities include:
- the argument’s class type and its base classes
- namespaces where those types are defined
- for template specializations, associated namespaces of template arguments too
ADL is why swap(a,b) can find myns::swap when a is myns::X.
Crucial “priority” fact:
- ADL augments the candidate set after ordinary unqualified lookup finds
f(or fails to find it) — but it only applies in contexts where ADL is permitted (not in::f(x); the leading::suppresses ADL by forcing global qualified lookup).
Lookup answers: “Which declarations named f are considered?”
Overload resolution answers: “Which of those declarations is the one being called?”
A lot of “why didn’t my overload get picked?” problems are actually lookup/hiding problems, not overload-ranking problems.
Overload resolution is essentially a tournament with these main stages:
Candidates may be:
- non-template functions
- function template specializations (after deduction)
- converting constructors / conversion functions (for some contexts)
- built-in operator candidates
- rewritten candidates (some operators)
Eliminate candidates that can’t be called.
For each argument, the compiler classifies the conversion needed:
-
Exact match (best)
- identity
- qualification adjustments
- integral promotions (still “exact match” category-ish vs “promotion” depending on context; compilers/standard separate “Exact Match” and “Promotion” as ranks)
-
Promotion
char → int,float → double, etc.
-
Conversion
int → double, derived-to-base pointer, etc.
-
User-defined conversion
- constructors / conversion operators (with a standard conversion before and/or after)
-
Ellipsis
- match to
...(worst)
- match to
The standard defines comparison rules: “better conversion sequence” wins per parameter; the best viable function is one that is not worse for any argument and better for at least one (with many subtleties).
If conversion sequences tie, further rules may pick a winner, including:
- Non-template vs template: non-template can be preferred if otherwise equal (but not always; partial ordering of templates is its own step).
- More specialized template wins (partial ordering of function templates).
- Ref-qualifiers / cv-qualifiers on member functions can matter via the implicit object parameter.
- Initializer-list constructors are favored in list-initialization.
- Narrowing is forbidden for list-initialization, which can remove candidates entirely.
- Constraints (C++20): a constrained template may be preferred; “more constrained” can win if both viable and ordering rules apply.
When two function template candidates are viable, the compiler decides which is more specialized by trying to deduce one from the other in a formal way.
This is why:
template<class T> void f(T);
template<class T> void f(T*);calling f(p) with int* p prefers the pointer version.
For constrained templates:
- First, constraints must be satisfied to be viable.
- Then, if multiple are viable, the compiler can prefer the more constrained one (roughly: if one implies the other).
This is the #1 reason people talk about “phases” in this context.
In templates, the compiler performs lookup in (at least) two conceptual times:
-
Template definition time (first phase) Non-dependent names are looked up immediately, when the template is parsed/checked.
-
Template instantiation time (second phase) Dependent names are looked up later, when the template is instantiated with concrete template arguments.
This is why the same code can compile or fail depending on whether a name is dependent.
A name is dependent if it depends on a template parameter.
Example:
template<class T>
void g(T t) {
f(t); // dependent: argument type depends on T => ADL may find f later
h(); // non-dependent: looked up now
}h()must be found at template definition time. If there’s nohvisible then, it’s an error (even if later you defineh).f(t)is dependent becauset’s type depends onT; ADL can kick in at instantiation and findfin the namespace ofT.
Because parsing and lookup happen before T is known, C++ needs disambiguators:
typename T::value_typetells the parser “this is a type”.t.template foo<int>()tells it “foois a template”.
These aren’t “style”; they’re required because at definition time the compiler can’t know what T::value_type refers to.
Unqualified lookup in a class template does not automatically consider dependent base classes at definition time.
template<class T>
struct Base { void f(); };
template<class T>
struct Der : Base<T> {
void g() { f(); } // ERROR: f not found in first phase
};Fixes:
this->f();(makes it dependent)Base<T>::f();(qualified, dependent)using Base<T>::f;(bring into scope)
This is purely lookup timing.
People commonly refer to these “semantic phases” (not officially named by the standard as phases, but very real):
- Parsing (grammar)
- Early name binding / non-dependent lookup (template definition time)
- Type formation (build types, resolve declarators)
- Template argument deduction (for call candidates)
- Substitution & SFINAE (remove failing template candidates)
- Constraint checking (C++20)
- Overload resolution (best viable selection)
- Template instantiation (bodies, class members, variable templates)
- Constant evaluation (
constexpr, immediate functions,consteval) - ODR-use decisions (what must be instantiated/emitted)
- Access control / deleted function checks (some are earlier depending on context)
- Codegen
In reality, compilers interleave these, but the rules behave as if they happen in this kind of order.
If a derived class declares f, base overloads named f are not even candidates unless reintroduced.
using std::swap;ensures ordinary lookup findsstd::swap- then
swap(a,b)allows ADL to addmyns::swapas a better match This is a canonical “lookup + ADL” pattern.
T x{arg}; engages initializer-list constructors and bans narrowing, often altering viability.
If you qualify with ::, you’re forcing lookup and typically turning off ADL for that call.
If it’s non-dependent, you can get an error even if some instantiation would have made it valid.
If you want, I can go one level deeper and enumerate overload resolution for operators specifically (built-in candidates, rewritten candidates, user-defined operators, member vs non-member priority, operator<=> rewriting, etc.), because that’s where the rules get the most “phase-y”.
Alright — this is where C++ stops feeling like a language and starts feeling like a logic puzzle designed by committee. I’ll go deep and systematic, because operator overload resolution has more phases, more synthetic candidates, and more rewriting than normal function calls.
We’ll cover:
-
What makes operator resolution special
-
Overall pipeline (the exact order things happen)
-
Lookup rules for operators
-
Candidate construction
- member operators
- non-member operators
- built-in operators
- rewritten candidates
-
Viability and implicit object parameter
-
Ranking and tie-breakers
-
operator<=>and relational rewriting -
Conversion operators and operators
-
Common traps and why they happen
-
Mental model you can actually use
Unlike normal function calls:
f(a, b);An operator expression like:
a + bis not just syntax sugar for operator+(a, b).
Instead, the compiler:
- synthesizes multiple kinds of candidates
- performs lookup differently
- adds built-in operators
- creates rewritten operators (C++20+)
- treats the left operand as an implicit object parameter for member operators
- runs overload resolution after all of this
This makes operators the most phase-heavy part of the language.
For an operator expression like:
a @ bthe compiler does this exact conceptual sequence:
1. Determine operator kind and context
2. Collect operator function candidates
a) Member operator candidates
b) Non-member operator candidates (via lookup + ADL)
c) Built-in operator candidates
d) Rewritten operator candidates (C++20)
3. Filter viable candidates
4. Rank candidates (overload resolution)
5. Select best viable candidate or diagnose ambiguity
Every “why did this overload win?” question maps to one of these steps.
For a + b, the compiler looks for:
a.operator+(b)(member)operator+(a, b)(non-member)
But lookup is not symmetric.
If the operator can be a member (most binary operators except = etc.):
- The left operand’s class type is searched for
operator@ - Base classes are considered
- Access control applies
Important:
Only the left operand participates in member lookup
struct A {
A operator+(const A&) const;
};
A a, b;
a + b; // OK
b + a; // OK (same member)But:
struct B {};
A a;
B b;
a + b; // Member candidate exists
b + a; // No member operator+ in BFor operator+(a, b):
- Unqualified lookup applies
- Argument-dependent lookup is always used
- Associated namespaces of both operands are considered
This is why non-member operators are often preferred for symmetric operators.
::operator+(a, b); // ADL suppressedThis often silently removes user-defined overloads.
For a @ b, member candidates are synthesized as:
a.operator@(b)Conceptually rewritten as a function with an implicit object parameter:
operator@(A const* this, B)Ref-qualifiers and cv-qualifiers apply here.
Found via normal lookup + ADL:
operator@(a, b)These are ordinary functions.
Here’s the big surprise:
Built-in operators participate in overload resolution
Examples:
- arithmetic on fundamental types
- pointer arithmetic
- comparisons
- logical operators
- bitwise operators
These are imaginary functions with fixed signatures.
Example for +:
int operator+(int, int);
double operator+(double, double);
T* operator+(T*, std::ptrdiff_t);They’re only added if no user-defined candidate blocks them via better viability.
This is where things get truly weird.
For <, >, <=, >=, the compiler may rewrite expressions into:
a < bb < a(a <=> b) < 0(b <=> a) > 0
These rewritten forms become additional candidates.
A candidate is viable only if:
- All parameters can be matched
- Access is allowed
- Not deleted
- Constraints satisfied (concepts)
- For members: implicit object parameter binding is valid
For a member operator:
struct X {
int operator+(int) &;
};The implicit object parameter is X&.
Thus:
X{} + 1; // ❌ temporary cannot bind to non-const lvalue refThis affects overload ranking just like a normal parameter.
After viability, normal overload resolution applies — but with extra structure.
Important myth to kill:
Member operators are NOT preferred over non-members
The only difference is:
- member operators use implicit object parameter
- non-members treat both operands equally
Whichever yields better conversions wins.
If a user-defined operator is viable and better than the built-in candidate, it wins.
Example:
struct X {};
X operator+(X, X);
X a, b;
a + b; // user-defined beats built-in (built-in not viable anyway)Rewritten candidates are not “fallbacks” — they fully participate.
This is why <=> can dominate relational operators.
If no direct overload of <, >, <=, >= is viable, the compiler tries:
a <=> bAnd rewrites:
| Expression | Rewritten as |
|---|---|
a < b |
(a <=> b) < 0 |
a > b |
(a <=> b) > 0 |
a <= b |
(a <=> b) <= 0 |
a >= b |
(a <=> b) >= 0 |
These rewritten forms create new operator candidates.
struct X {
auto operator<=>(const X&) const = default;
};
X a, b;
a < b; // works, even without operator<And worse:
struct Y {
int operator<(const Y&) const;
auto operator<=>(const Y&) const;
};Now overload resolution must choose between:
- direct
< - rewritten
<=>
Subtle conversion differences can change which wins.
Conversion operators introduce user-defined conversion sequences, which are ranked worse than standard conversions.
Example:
struct X {
operator int() const;
};
X x;
x + 1; // built-in int + int after conversionBut if there’s also:
X operator+(X, int);That may win because:
- one standard conversion vs
- one user-defined conversion + built-in
Lookup-time hiding:
struct B { friend bool operator==(B, B); };
struct D : B {};
D d1, d2;
d1 == d2; // ❌ ADL doesn't consider B unless conversion happensFix: define operator in namespace, not as hidden friend, or add using declarations.
struct A {};
bool operator==(A, int);
A a;
a == 1; // OK
1 == a; // ❌ different lookup setBecause lookup starts from the operands’ types.
struct X {
bool operator==(const X&) &;
};
X{} == X{}; // ❌ no viable candidateImplicit object parameter cannot bind.
When debugging operator overloads, always ask:
-
Which candidates exist?
- member?
- non-member via ADL?
- built-in?
- rewritten?
-
Which are viable?
- ref qualifiers?
- access?
- constraints?
-
Which conversions are needed?
- standard vs user-defined
- implicit object parameter included
-
Did rewriting introduce a better candidate?
- especially
<=>
- especially
If you answer those four, the compiler’s decision will stop feeling arbitrary.
We can still drill into:
- operator
=vs copy/move generation - assignment vs compound assignment operators
operator[]and multidimensional indexingoperator new/deletelookup and replacement rules- literal operators (
operator"") - spaceship + defaulted comparison edge cases
Just say which rabbit hole you want next.
