
Одной из самых сложных частей 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% затрудняются с ответом на этот, казалось бы простой, вопрос.
