
Есть старая шутка о том “чем отличается обычный программист на С++ от хорошего программиста на С++”? Первый пишет код, а второй может объяснить, почему он работает.
Это конечно шутка, но сейчас далеко не всякий даже хороший программист может объяснить, как работает тот или иной участок кода или внутренняя логика, которая привела к конечному решению, не прибегая к ультимативными фразам вроде «так написано в стандарте» или «так нафигачил компилятор».
В центре всего, что происходит внутри компилятора, находятся два процесса: поиск имён и разрешение перегрузок, которое мы рассмотрели в предыдущих статьях. И каждый раз, когда компилятор обрабатывает ваш код — он обрабатывает всего два вопроса:
Первый: «Что вообще может означать это имя здесь?»
Второй: «Если вариантов несколько, какой из них правильный?»
Если вы поймёте, как компилятор отвечает на эти вопросы, то вы поймете как работает и всё остальное — шаблоны, концепты, перегрузки — это всё строится на решении этих двух вопросов.
Нескучное программирование: Обобщения (WIP)
Важны ли компилятору имена <= Вы тут
Ночью все кошки серы, а типы одинаковы
Тот, кто путается в именах
Простой поиск имен
Непослушный using
Правила выведения типа
От вывода типов к проблемам с ко[д/т]ом
Выражения в ко[д/т]е
Схлопывание ссылок
Специализация шаблонов
. . .
Большие проблемы маленьких имен
Когда у вас есть несколько перегруженных функций с одним именем, то "в голове" это воспринимается как «одна функция», я обсуждал это с коллегами и даже матерые плюсатые воспримают множество перегрузок как "одну" функцию. Это не случайность - разработчики стандарта специально стремились к такому виду, чтобы скрыть сложность. Намерения как всегда благие: если называется одинаково - значит, оно и делать должно одно и тоже, верно? Почти, дьявол, как обычно в деталях...
void print(int x);
void print(double x);
void print(std::string x);Мы видим функцию print и думаем: «Есть функция print, которая умеет печатать разное» и это полезная абстракция, которую нам навязывают разработчики стандарта, но для компилятора это другая вселенная и для него это три (3) разные функции, которые случайно называются одинаково. И каждый раз, когда вы пишете print(...), компилятор должен разобраться, какую именно функцию вы имели в виду, и именно потому, что мы, люди, захотели упростить себе жизнь, отсюда начинаются все сложности. Откройте любой код, лучше не свой, потому что вы его знаете, а чужой, и найдите что-то вроде `resolve(x);`
Вы можете сказать, что это? Вероятно это вызов функции, а может и нет, на самом деле простой вопрос «что такое resolve()?» для компилятора достаточно сложный, и у компилятора обычно сделано несколько блоков анализа имен, потому что resolve может быть:
// Обычной функцией
void resolve(x) { std::cout << "Function\n"; }
resolve(x); // Вызов функции// Объектом с перегруженным оператором вызова
struct Callable {
void operator()(x) { std::cout << "Functor\n"; }
};
Callable resolve;
resolve(x); // Вызов operator()// Объектом
struct resolve{
resolve(int x) { std::cout << "Constructor\n"; }
};
resolve(x); // Создание временного объектаТеперь компилятор должен сначала собрать все возможные варианты того, чем может быть resolve в данном контексте - этот процесс называется поиск имён, а потом выбрать правильный вариант, - этот процесс называется разрешение перегрузок. А еще есть другой случай, теперь у нас будет несколько конструкторов:
struct resolve {
int x;
static int y;
resolve() : x(1) {} // Конструктор по умолчанию
resolve(int val) { // Конструктор с аргументом
y = val;
}
};
int resolve::y = 0; // Инициализация статического поляИ вот такой код resolve s{}; теперь будет вызовом конструктора по умолчанию, где мы создаём объект s;
Вернемся к случаю, когда resolve() - это обычная функция:
int resolve(int x) { // Обычная функция!
return x + 1;
}Приехали... теперь если компилятор встречает функцию с таким же именем, то это всегда будет вызовом функции, не конструктора объекта или чего то другого, потому что в C++ есть правило (если крякает - значит утка): ес��и что-то может быть интерпретировано как вызов функции, это и будет вызов функции. Если раньше resolve(2) был конструктором, потому что не было других вариантов, то теперь та же самая запись превратилась в вызов функции и одна и та же синтаксическая форма начинает работать с разными реализациями и имеет разный смысл в зависимости от контекста. Добро пожаловать в плюсы, как говорится.
Вы можете просто писать код, который будет работать, сейчас, на данном компиляторе и данных условиях, но проблема в том, что рано или поздно вы напишете код или возьмете чужой, который работать не будет, а компилятор выдает простыню логов в месте ошибки. И без понимания того, как работает поиск имён, как происходит разрешение перегрузок, вы будете тыкать наугад, меняя код до тех пор, пока не скомпилируется. Но без понимания про область поиска имен, вы так и будете очень долго менять код в разных местах, не зная, что компилятор нашёл не ту перегрузку. Это все усугубляется современным C++ с кучей шаблонов, концептов, связками ADL, SFINAE и всего прочего, но это всё это зависит только от двух вопросов из начала статьи: “что это за имя и какой вариант имени правильный”.
Старается ли комитет сделать язык сложнее?
Смысл любого имени в C++ очень сильно зависит от того, что находится вокруг него. Если просто смотреть на имя как изолированный текст, то почти ничего не будет понятно, я называю это проблемой resolve(x), посмотрите любой код и попробуйте ответить - "что такое resolve (условно, можно взять любой текст)" ограничиваясь только двумя строчками выше и ниже этого определения. Скорее всего у вас ничего не выйдет, потому что понять значение имени можно только через контекст, какие функции, переменные и логика стоит выше, ниже и рядом в коде.
Если попробовать повторить то, что делает компилятор для такого кода (классический пример, его разбирают на первом-втором курсе универа, если вам не повезло начать с плюсов, как своего первого языка):
void resolve(); // функция
struct resolve { // структура
resolve(int);
};
...
// bla-bla много кода
...
resolve s{42};то получим примерно следующее:
компилятор находит оба варианта: и функцию resolve , и структуру resolve. Возникает вопрос: resolve{0} это попытка создать объект с аргументом, или как‑то «использовать» функцию? Ситуация в целом для нас человеков очевидная, но компилятор видит две сущности и обе подходят - неоднозначность, поэтому стандарт даёт очень конкретное правило, что делать в таком случае:
Если имя может означать функцию, то в контексте проверки вариантов компилятор сначала обязан рассматривать вариант с функцией и если находит функцию с таким именем, он считается верным.Компилятор видит resolve{42} и знает, что существует функция resolve.
Он пытается разобрать resolve{42} как форму записи, связанную с функцией
-> Либо как начало тела функции (но здесь тело функции писать нельзя)
--> Либо как какое‑то выражение с указателем на функцию (но теперь синтаксис не сходится)
С точки зрения синтаксиса функции - это ошибка
Дальше мы не едем, ибо см. п1 и на рассмотрение struct resolve даже не переключаемся, просто выдавая сообщение об ошибке.
<source>:11:5: error: must use 'struct' tag to refer to type 'resolve' in this scope
11 | resolve s{2};
| ^
| struct
<source>:3:6: note: struct 'resolve' is hidden by a non-type declaration of 'resolve' here
3 | void resolve(); // функция
| ^Даже если у нас есть структура resolve с конструктором от int, который мог бы обработать эту ситуацию, компилятор до неё просто не добирается, потому что его останавливает правило стандарта и неудачная попытка трактовать resolve как функцию.
Проблемы с именами
Теперь давайте посмотрим, какие сущности вообще могут иметь одно и то же имя без конфликта. Тут важно различать две вещи - "cосуществование" имён, когда разные сущности просто могут иметь одно имя (пример выше) и перегрузку, когда несколько сущностей одного «вида» (например, функций) сознательно разделяют одно имя, что часто становится источником путаницы.
Не всегда пересечения имен считаются конфликтом, и компилятор прекрасно различает эти сущности, если они принадлежат к разным категориям: одна — вызываемая, другая — тип (https://godbolt.org/z/WTh1oG6sM)
struct resolve
{
int x;
};
void resolve(int value)
{
// обычная функция
}
int main()
{
struct resolve f{42}; // resolve -- это структура
resolve(10); // resolve -- это функция
}Здесь одно и то же имя resolve используется и как имя типа, и как имя функции, но контекст вокруг этого полностью определяет, что именно имеется в виду и у компилятора нет проблем с разрешением неоднозначности.


Кстати вот так тоже можно:
struct resolve
{
int x;
};
void resolve(int value)
{
struct resolve resolve{42};
}
int resolve()
{
resolve(10); // resovle -- это функция
return 42;
}
int main() {
resolve();
struct resolve resolve{::resolve()};
}Но такая свобода существует не всегда и если попытаться объявить пространство имён с именем, которое уже используется для функции, компилятор выдаст ошибку, потому что пространства имён живут в той же «категории имён», что и перечисления (enum) и функции, и здесь такая неоднозначность будет ошибкой.
void kot()
{
}
namespace kot // ошибка: конфликт имени
{
int x;
}
<source>(8): error C2757: 'kot': a symbol with this name already exists
and therefore this name cannot be used as a namespace nameТеперь компилятор не может решить, является ли kot вызываемой сущностью или контейнером имён, поэтому запрещает такой код. По той же причине пространство имён и enum с одинаковым именем конфликтуют между собой, т.е. имя должно однозначно указывать на одну сущность своего уровня имен.
enum Kot
{
Vaska,
Murzik,
Murka
};
namespace Kot // ошибка: конфликт имени
{
int Afigenariy;
}Здесь Kot уже занято перечислением, и повторно использовать его для namespace нельзя, но повторное объявление пространства имён с тем же именем не создаёт новую сущность, а просто снова открывает уже существующее пространство имён и добавляет в него новые имена.
namespace Kot
{
int Murzik;
}
namespace Kot
{
double Murka;
}
namespace Kot // это не новая сущность
{
void Tuguduk();
}
int main()
{
Kot::Murzik = 0;
Kot::Tuguduk();
}В отличие от функций или переменных, здесь нет ни перегрузки, ни повторного объявления и это будет оставаться всё той же областью имён, стандарт делает в этом месте исключение. Теперь если у нас есть несколько объявлений с одинаковым именем, которые прошли через правила выше и не вызвали ошибок анализатора, то компилятор может считать это перегрузкой, а может повторением объявления.
void log(int value);
void log(double value);
int main()
{
log(10); // вызывает log(int)
log(3.14); // вызывает log(double)
}Здесь компилятор прекрасно разберет такой код, потому что видит два разных набора параметров и формирует набор перегруженных функций, и только если функции будут отличаться возвращаемым типом, то перегрузки не случится. С точки зрения языка это считается повторным объявлением одной и той же функции, потому что возвращаемый тип не участвует в выборе перегрузки, добро пожаловать в плюсы, как говорится.
int size();
double size(); // ошибка: различие только в возвращаемом типеЭто еще не всё, и тот же const на параметре, передаваемом по значению, тоже не создаёт новую перегрузку, а по ссылке создает, но об этом сейчас не буду, чтобы не забираться сильно в дебри.
void f(int x);
void f(const int x); // это то же самое объявление
int main()
{
int x = 42;
f(10);
const int y = 42;
f(y);
}
То же самое относится к параметрам по умолчанию и разные значения по умолчанию не означают разные функции, параметры по умолчанию не участвуют в формировании перегрузок, а лишь подставляются на этапе вызова.
void g(int x = 1);
void g(int x = 2); // повторное объявление той же функции
int main()
{
int x = 42;
g(10);
}
<source>(4): error C2572: 'g': redefinition of default argument: parameter 1
<source>(3): note: see declaration of 'g'Получается вот такой наборчик правил:
Функция + структура
-> Можно: разные категории имён, контекст различается.
Функция + namespace
-> Нельзя: обе сущности участвуют в поиске имён на одном уровне.
namespace + namespace
-> Можно (исключение): Это не конфликт, а повторное открытие.
enum + namespace / function
-> Нельзя: имя должно быть однозначным.Если вы думаете, что это ребята из комитета сидели и выдумывали граничные кейсы, то нет. Причина намного, намного проще и за всем этим стоит древняя идея name mangling прямиком из 80х (как компилятор кодирует типы в символьные имена), т.е. как компилятор превращает имя функции и её сигнатуру в строку для линкера. Линкер условно “тупой” и ничего, кроме “хешей” понимать не обучен, и это тоже было было сделано не просто так, а для скорости работы, иначе бы даже маленькая программа линковалась по несколько минут. Например:
void f(int);
void f(double);могут превратиться во что‑то вроде:
_Z1fi — f(int)
_Z1fd — f(double)
Поэтому, когда мы добираемся до этапа линковки, если два объявления дают одинаковое «замангленное» имя, то компилятор считает, что речь идёт об одной и той же сущности. Именно поэтому void g(int); и void g(const int); дают один и тот же mangled‑идентификатор, и это одно объявление, а не перегрузка, а вот разные параметры (по типу) дают разные mangled‑имена и считаются перегрузкой. Тут мы подошли к правилам поиска имен (name lookup), которые я постараюсь разобрать в следующей статье. Приходите - будет страшно интересно , ну или как минимум нескучно.
