Это продолжение темы начатой в статье Важны ли компилятору имена, и продолженной в Ночью все кошки серы, а using'и одинаковы, и далее в Компиляторы тоже путаются в именах. Если не читали, то лучше будет пробежаться по диагонали. Теперь вот мы подобрались к такой интересной теме, как квалифицированный и неквалифицированный поиск.

Что такое простой поиск имени n в области S? Это механизм компилятора, который находит все объявления n, находящиеся непосредственно в этой области. Просто? С виду да, но даже этот простой механизм часто работает не так, как ожидает разработчик.

Например, у нас есть пространство имён N и локальная переменная N. Они могут сосуществовать вместе? Могут, потому что находятся в разных областях видимости. А пространство имён и глобальная переменная с тем же именем могут? Как же мы докатились до жизни такой, давайте разбираться.


Правильный термин из стандарта будет unqualified name lookup или неквалифицированный поиск имён, но часто используется также и "простой поиск". Прежде чем читать дальше, попробуйте сказать что будет выведено этим простым кодом. (https://godbolt.org/z/3sjjY5zbd)

namespace N {
    int value = 10;
}

int main() {
    int N = 5;
    std::cout << N << "\n";
    std::cout << N::value << "\n";
    return 0;
}
Ответ и объяснение
namespace N {
    int value = 10;
}

int main() {
    int N = 5; // локальная переменная n

    // Доступ к локальной переменной
    std::cout << "Локальная переменная N: " << N << "\n";

    // Доступ к переменной в пространстве имён N
    std::cout << "Переменная N::value: " << N::value << "\n";

    return 0;
}

Тут у нас есть пространство имён N и локальная переменная N. Они могут сосуществовать, потому что находятся в разных областях видимости, но пространство имён и глобальная переменная с тем же именем сосуществовать не могут. Локальная переменная N и пространство имён N сосуществуют без конфликта, потому что они находятся в разных областях видимости: переменная N находится в области функции main, а пространство N — в глобальной области видимости.

Если бы мы попробовали создать глобальную переменную int N, то компилятор сразу бы выдал ошибку, потому что глобальная переменная и пространство имён живут в одной области видимости и не могут иметь одно и то же имя.

namespace N {}  // пространство имён N
int N = 42;     // Ошибка! Имя уже занято

Но интереснее даже не это, а как локальная переменная и пространство имён с одним именем сосуществуют, и как именно компилятор разбирается в том, что имеется в виду. Простой поиск и квалифицированный поиск работают по совершенно разным правилам, и имя N в выражении N += 1 разрешается совсем не так как то же имя N в выражении N::x.

Теперь посмотрим другой пример, на выражение N += 1. Здесь имя N ищется с помощью простого поиска в локальной области видимости, и компилятор находит локальную переменную, и на этом поиск заканчивается. Но в выражении N::x ситуация иная, потому что оно является квалифицированным именем, и часть до :: ищется отдельно.

namespace N {
    int x = 42;
}

int main() {
    int x = 5; // локальная переменная x

    // Неквалифицированное имя: простой поиск
    x += 1;  
    std::cout << "Локальная переменная x: " << x << "\n"; 
    // Выведет 6

    // Квалифицированное имя: поиск квалификатора отдельно
    N::x += 1;
    std::cout << "Переменная N::x: " << N::x << "\n"; 
    // Выведет 43

    return 0;
}

Ключевой момент заключается в том, что при поиске имени, используемого как квалификатор, компилятор игнорирует сущности, которые не являются пространствами имён или классами, а поскольку локальная переменная N не является пространством имён, она игнорируется при таком поиске и выражение N::x  работает корректно, даже если в текущей области есть переменная N.

Такая ситуация называется скрытие имени (name hiding), когда объявление имени во внутренней области видимости делает недоступным одноимённое объявление из внешней области, и компилятор при простом поиске просто перестаёт смотреть дальше как только находит первое совпадение.

int x = 42; // глобальная переменная

int main() {
    int x = 5; // скрывает глобальную x

    std::cout << x << "\n";   // 5 локальная, глобальная скрыта
    std::cout << ::x << "\n"; // 42 явная квалификация снимает скрытие
}

То же самое происходит в классах при наследовании, и там последствия неожиданнее, потому что скрывается не одна переменная а вся перегрузка: (https://godbolt.org/z/KeM61oqe3)

struct Base {
    void foo(int x) { std::cout << "int base\n"; }
    void foo(double x) { std::cout << "double\n"; }  
    // обе перегрузки доступны
};

struct Derived : Base {
    void foo(int x) { std::cout << "int derived\n"; }  
    // скрывает ОБЕ перегрузки из Base, не только foo(int)
};

int main() {
    Derived d;
    d.foo(1);      // OK: Derived::foo(int)
    d.foo(1.0);    // ??? Base::foo(double) скрыта
    d.Base::foo(1.0); // OK: явная квалификация снимает скрытие
}

>> int derived
>> int derived
>> double

// чтобы не скрывать, нужен using:
struct Derived2 : Base {
    using Base::foo; // вводим все перегрузки Base::foo в область видимости
    void foo(int x) {} // теперь перегружает, а не скрывает
};

Это подводит нас к выводу, что скрытие имени зависит от того, какой вид поиска выполняется, скрытие работает только для того вида поиска, который рассматривает сущности данного типа. Это фундаментальная особенность C++, которая помогает избежать конфликтов между переменными, пространствами имён и типами при правильном использовании квалификаторов, что однако не всегда работало для разных компиляторов.

GCC: В старых версиях квалифицированный поиск иногда "неправильно" учитывал локальные переменные при разрешении квалификаторов, что приводило к ошибкам или неоднозначностям в коде с одинаковыми именами переменных и пространств имён. В GCC до версии 3.4 квалифицированный поиск в некоторых случаях некорректно учитывал локальные переменные при разрешении квалификатора пространства имён, что могло приводить к ошибке "N is not a namespace" даже когда локальная переменная и пространство имён должны были сосуществовать корректно. Начиная с GCC 3.4 поведение было приведено в соответствие со стандартом. 

namespace N {
    int value = 10;
}

int main() {
    int N = 5;  
    // Локальная переменная с тем же именем, 
    // что и пространство имён

    // Неквалифицированное обращение работает 
    // с локальной переменной
    N += 1;  
    std::cout << "Локальная переменная N: " << N << "\n"; 
    // Выведет 6

    // Квалифицированное имя
    // В старых версиях GCC это могло вызывать ошибку

    std::cout << "Пространство имён N::value: " << N::value << "\n";

    // Компилятор мог интерпретировать N как локальную переменную
    // и выдать ошибку "N is not a namespace" или "ambiguous"

    return 0;
}

Clang: Clang с ранних версий строго реализовал правило, что имена из зависимых базовых классов не участвуют в неквалифицированном поиске первой фазы two-phase lookup. Код с шаблонным наследованием который компилировался на GCC без this-> не компилировался на Clang, что обнаруживалось при смене тулчейна. 

// Зависимые базовые классы
// в шаблонах не участвовали в неквалифицированном поиске фазы 1

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

template<typename T>
struct Derived : Base<T> {
    void bar() {
        foo();        
        // Clang (строго по стандарту): ERROR
        // GCC старых версий: OK искал в базе при инстанциации
        
        this->foo();  // работающий способ везде
    }
};

Компилятор мог «не заметить» квалификатор и использовать скрытое имя Derived::foo вместо Base::foo, что приводило к ошибкам или неожиданному поведению при использовании using для приведения имен базового класса в текущую область видимости.

MSVC: До VS2015 MSVC в ряде случаев не разделял корректно пространства имён и локальные переменные при разрешении квалификатора, из-за чего локальная переменная N могла мешать поиску пространства имён N в квалифицированном выражении N::f(), и компилятор выдавал ошибку "N is not a namespace" там где по стандарту должен был найти пространство имён. 

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

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

int main() {
    int N = 42; 
    // Локальная переменная с именем
    // совпадающим с пространством имён

    // Неквалифицированный вызов
    f(); // вызывает глобальную f()

    // Квалифицированный вызов через пространство имён
    // В старых версиях MSVC локальная переменная N могла мешать
    // и компилятор ошибочно пытался использовать N как квалификатор
    
    N::f(); 
    // ожидалось, что вызовется N::f() из пространства имён
    // но старый MSVC мог выдать ошибку "N is not a namespace"

    return 0;
}

А поиск то совсем непростой

Ситуация становится ещё сложнее, если надо собирать множество имён из области текущего класса и всех его базовых классов, а затем выполнять поиск внутри этого множества. Когда мы используем неквалифицированное имя внутри класса или шаблона класса, компилятор не просто просматривает базовые классы по очереди, а строит множество подобъектов.

Рассмотрим уже ставший классическим пример, когда класс C наследуется от A и B. Если мы ищем имя x, компилятор найдёт x в A и x в B, но оба варианта являются допустимыми находками, и в результате возникает неоднозначность, делающая программу некорректной.

struct A {
    int x = 1;
};

struct B {
    int x = 2;
};

struct C : A, B {
    void printX() {
        // Неквалифицированное имя x встречается в 
        // обоих базовых классах
        // Компилятор не может однозначно выбрать
        // к какому x обращаться
        // Это приведёт к ошибке неоднозначности
        std::cout << x << "\n"; // Ошибка: неоднозначность
    }
};

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

Квалифицированный поиск имён в C++ устроен иначе, и это делает его более предсказуемым и устойчивым к неожиданным эффектам. Когда компилятор видит имя с квалификатором (C::x), то он запускает строго определенный набор правил, в котором точно задан порядок шагов.

Прежде всего, компилятор ищет явно объявленные имена непосредственно в указанной области и если мы пишем C::x, то в первую очередь рассматриваются все объявления x, которые действительно принадлежат C. Это могут быть статические члены класса, вложенные типы, перечисления или функции, объявленные прямо внутри C. Если такое объявление найдено, поиск считается завершённым и никакие внешние механизмы расширения видимости на этом этапе уже не играют роли.

// Глобальная функция с тем же именем не должна мешать
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() {
    // Квалифицированный поиск: смотрим только внутри C
    printf("%d\n", C::x);         // ищет только в C, не привлекая в анализ x() и N:x()
}

Для C::x  компилятор смотрит только в C и находит static int x = 42. Глобальная функция x() и N::x() не рассматриваются вообще, хотя при неквалифицированном поиске они тоже были бы кандидатами. Рассмотрим другой пример:

struct A {
    int x = 42;
};

struct B : A {};  // B наследует x из A
struct C : A {};  // C наследует x из A

struct E : B, C { // E наследует и от B и от C
    void foo() {
        // x = 1; // ошибка компиляции!
        // error: member 'x' found in multiple base classes of different types
    }
};

На первый взгляд кажется, что никакой проблемы быть не должно. В конечном итоге член x объявлен только в A, и никакого второго x в B или C нет. Однако когда компилятор ищет неквалифицированное имя x внутри E, он не просто «ищет объявление», а строит множество подобъектов базовых классов. В этом множестве оказываются:

  • A, как прямой базовый класс C

  • A, как базовый класс B, который сам является базовым классом A

Да, это одно и то же объявление A::x, но пути, по которым к нему можно прийти, различны. Для компилятора это означает, что поиск был успешен двумя независимыми маршрутами, и ни один из них не является предпочтительным. В результате возникает неоднозначность, и программа считается некорректной и приводит нас к пониманию, что поиск в базовых классах имеет два уровня: важно не только то, какое объявление найдено, но и то, каким путём к нему пришёл компилятор. (https://godbolt.org/z/qEsrET84v)

// Компилятор строит граф объектов:
//
//    A::x     A::x
//      |       |
//      B       C
//       \     /
//          E
//
// Два пути к одному и тому же объявлению A::x:
// путь 1: E -> B -> A::x
// путь 2: E -> C -> A::x
//
// Компилятор видит два маршрута и не может выбрать предпочтительный

int main() {
    E e;

    // явная квалификация устраняет неоднозначность:
    e.B::x = 1;  // OK: явно указываем путь через B
    e.C::x = 2;  // OK: явно указываем путь через C

    // это два РАЗНЫХ подобъекта A в памяти:
    std::cout << e.B::x << "\n"; // 1
    std::cout << e.C::x << "\n"; // 2
}

Далее, если в самой области C подходящего имени не обнаружено, то компилятор переходит к следующему шагу и начинает учитывать имена, которые стали видимыми в этой области через директивы using namespace. Тут надо напомнить, что речь идёт именно о директивах, а не о using-объявлениях, “поэтому имена могут быть найдены квалифицированным поиском, но исключительно как запасной вариант, когда собственных объявлений в этой области нет” - "Правило N-объявлений" (пример чуть ниже). Устоявшегося термина для этого правила в стандарте нет, и часто оно описывается через механизм, а не через отдельное название. В стандарте C++ соответствующий параграф будет [namespace.qual], а само правило формулируется так:

Если при квалифицированном поиске в области X собственных объявлений не найдено, то рассматриваются имена из пространств имён, введённых через using namespace директивы в X, получая таким образом «набор связанных пространств имён» (associated namespaces).

Собственно это и есть основное отличие квалифицированного поиска от неквалифицированного, когда при неквалифицированном поиске using namespace активно участвуют в формировании множества кандидатов с самого начала, что часто приводит к неожиданным конфликтам и неоднозначностям. Квалифицированный поиск, напротив, жёстко приоритизирует явные объявления, и только при их отсутствии расширяет область поиска за счёт using namespace, что делает его значительно более стабильным и защищённым от случайных изменений контекста.

Небольшое отступление, чтобы пример был полным. Вспомним про виртуальное наследование, которое устраняет это поведение (но несет с собой много других проблем), потому что теперь существует только один подобъект A: (https://godbolt.org/z/P9qbqaGPs)

struct A {
    int x = 42;
};

struct B : virtual A {};  // виртуальное наследование
struct C : virtual A {};  // виртуальное наследование

struct E : B, C {
    void foo() {
        x = 1;  // OK: только один A в иерархии
    }
};

// граф подобъектов теперь выглядит иначе:
//
//      B     C
//       \   /
//         A      <-- один экземпляр, один путь
//
// нет неоднозначности

int main() {
    E e;
    e.x = 1;       // OK
    e.B::x = 1;    // OK, то же самое x
    e.C::x = 1;    // OK, то же самое x
    // все три обращаются к одному и тому же подобъекту
}

Без виртуального наследования E содержит два физически разных подобъекта A в памяти, и компилятор не может угадать к которому из них обращаться при неквалифицированном поиске x, даже если оба содержат одно и то же объявление.

Представим теперь, что у нас есть два пространства имён. В одном из них объявлено имя, которое мы будем «подмешивать» с помощью using namespace, а в другом тоже собственное объявление с тем же именем.

namespace N {
    int x = 42;
}

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

Может показаться, что внутри C теперь существует два возможных x: один собственный (C::x), а второй пришёл из N через using namespace, но квалифицированный поиск сразу расставляет всё по местам. Теперь, если мы напишем:

int a = C::x;

то компилятор выполняет квалифицированный поиск для имени x только в области C и сначала ищет явно объявленные имена в C,  там находит int x = 10;, и на этом поиск останавливается. Второе имя N::x, несмотря на директиву using namespace N, даже не рассматривается. В результате a получает значение 10. Теперь посмотрим на контрпример, когда выполняется неквалифицированный поиск внутри самой области C: (https://godbolt.org/z/q1bK5cEG1)

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;
      |         ^

Здесь имя x используется без квалификатора, поэтому применяется неквалифицированный поиск, который собирает кандидатов из текущей области и из пространств имён, подключённых через using namespace. В этом случае в множестве кандидатов окажутся и C::x, и N::x, а так как оба объявления имеют одинаковую «силу», то программа становится неоднозначной, и компилятор выдает ошибку.

Теперь вернёмся к квалифицированному поиску и немного изменим пример: (https://godbolt.org/z/P9qbqaGPs)

namespace N {
    int x = 42;
}

namespace C {
    using namespace N;
}

int main() {
    int a = C::x; // Но в C нет собственного x!
     std::cout << "Переменная N::x: " << C::x << "\n";
    // выведет 42
}

Здесь ситуация иная, теперь в пространстве имён C нет явно объявленного x, поэтому квалифицированный поиск сначала пытается найти x непосредственно в C, но терпит неудачу и после этого рассматривает имена, пришедшие через using namespace N, и находит там N::x. 

Что дальше...

Один из примеров выше показал, что скрытие имён в классах работает не так, как это интуитивно ожидается. Объявление в Derived скрывает все перегрузки из Base, а не только совпадающую и такое поведение является прямым следствием того, как работает поиск имён по иерархии базовых классов.

Именно для таких случаев существует using-объявление, позволяя явно ввести имена из базового класса в область производного, не нарушая правил поиска и не создавая конфликтов. Но using это не только про наследование, к С++14 он стал полноценным инструментом управления видимостью, который работает по своим правилам и взаимодействует с поиском имён предсказуемым и строго определённым образом, и об этом я расскажу в следующей статье...

Уже набралось материала еще для двух глав (история концептов и поиск имен), выложу их на гитхабе в ближайшее время https://github.com/dalerank/playful_programming_cpp