Одной из самых сложных частей C++ до сих пор считаются правила поиска имён, и ошибки связанные с name lookup проявляются обычно уже в рантайме. Код компилится и даже работает какое-то время, но при свете луны ведёт себя не так как ожидает программист. За простыми идентификаторами скрывается многоуровневая система областей видимости, категорий имён и специальных правил, и очень многое в нашем текущем стандарте растёт прямиком из восьмидесятых, частенько без изменений. Давайте посмотрим как компилятор видит имена в C++, какие области видимости существуют и почему они ведут себя по-разному.

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


Сначала рассмотрим пространство имён, где повторное using-объявление одного и того же имени считается безвредным:

namespace lib {
    int value = 42;
}

namespace demo {
    using lib::value;
    using lib::value; // абсолютно корректно

    void f() {
        value = 10;   // однозначно lib::value
    }
}

Здесь оба using lib::value; вводят одно и то же имя в одну и ту же область видимости пространства имён demo. Компилятор воспринимает второе объявление как безобидное повторение первого и никакой новой сущности не создаётся, и никакой конфликт не возникает. А вот в пространстве блока исторически было иначе на бумаге:

namespace lib {
    int value = 42;
}

void f() {
    using lib::value;
    using lib::value; // ошибка компиляции: повторное объявление (до 99 года)

    value = 10;
}

Забавно, что это как раз тот случай, где практика обогнала формальную формулировку: GCC и майки такое обычно принимали, хотя в старых редакциях стандарта пример описывался строже. Потом комитет это расхождение признал и правило подтянули к тому, как код уже жил в реальных компиляторах. CWG Issue 36 (https://cplusplus.github.io/CWG/issues/36.html) ссылается на С++98 §7.3.3/9, где был такой пример:

namespace A { int i; }

namespace A1 {
    using A::i;
    using A::i;  // OK: double declaration
}

void f() {
    using A::i;
    using A::i;  // error: double declaration
}

И сравнение там было вполне здравое: если повторное объявление функции допустимо, то почему повторный using в аналогичной ситуации должен внезапно ломаться.

void f() {
    void g();
    void g();  // well-formed 
               // повторное объявление функции допустимо
}

Тогда же был размещен Proposed Resolution (04/99), который фактически разрешал игнорировать это правило, то есть комитет согласился, что это была ошибка в стандарте, и в C++03 §7.3.3/9 текст был исправлен и повторная using-декларация одного и того же имени в одном скоупе стала разрешённой. Идем дальше, отдельная история, и уже без спорных трактовок, это область класса:

struct S {
    using lib::value; // error: using-declaration for 
                      // non-member at class scope
};

Здесь ошибка не в том, что value переменная, а в том, что в классе using не является универсальным импортом внешних имен, он работает для имен из базовых классов, а lib базовым классом не является. И самый показательный пример на смешение двух разных механизмов:

namespace B {
    int x = 10;
}

namespace C {
    int x = 20;
}

namespace A {
    using namespace B;  // директива using

    void f() {
        using C::x;      // using-объявление

        A::x = 1;
        x = 2;
    }
}

Давайте разберёмся, что здесь происходит: директива using namespace B; не объявляет никаких имён непосредственно внутри A, а лишь говорит компилятору: «если при поиске имени внутри A понадобится заглянуть наружу, считай, что имена из B доступны». Это важный момент, потому что директива не вводит x в текущую область видимости, а только расширяет правила поиска.

Совсем иначе ведёт себя using C::x; внутри функции f, и такая конструкция уже считается объявлением, которое напрямую вводит имя x в блочную область видимости функции f. Вот с этого момента x внутри f будет локальным именем и синонимом для C::x.

A::x = 1;

Здесь используется квалифицированное имя, поэтому компилятор ищет x именно внутри пространства имён A. В самом A переменной x нет, но из-за директивы using namespace B имя B::x становится видимым как член A для квалифицированного поиска, поэтому эта строка однозначно обращается к B::x. А теперь следующая строка:

x = 2;

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

A::x = 1; // обращение к B::x
x = 2;    // обращение к C::x

И это как раз тот момент, где name lookup делают “кусь” даже опытным разработчикам. А теперь представьте использование using в большом листинге с функциями на пару экранов. Уверены что удержите эти неймспейсы в голове и сможете такое обнаружить при беглом ревью? Вот и я тут тоже бывает плаваю, поэтому юзинги на ревью – это еще один красный флаг, что человек делает что-то не то.

namespace B {
    int x = 10;
}

namespace C {
    int x = 20;
}

namespace A {
    using namespace B;  // директива using

    void f() {
        using C::x;     // using-объявление

        A::x = 1;
        x = 2;
    }
}

int main() {
    A::f();
    printf("A::x: %d\n", A::x);
    printf("B::x: %d\n", B::x);
    printf("C::x: %d\n", C::x);
    return 0;
}

Program stdout
A::x: 1
B::x: 1
C::x: 2

Про то как и сам компилятор может путаться в именах, а компиляторов у нас как минимум три, и поведения, соответсвенно тоже будет три, в следующей статье.

З.Ы. Я периодически мучаю людей этой софистикой и примером выше на собесах, просто чтобы понять сталкивался ли кандидат с этими вещами в проде или нет, к сожалению почти 80% затрудняются с ответом на этот, казалось бы простой, вопрос.