Непослушный using

от автора

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

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

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


std::cout << "hello";

по сути этот код соответствует вызову функции и компилятор видит в этом коде не что иное, как

operator<<(std::cout, "hello");

Но, чтобы это работало так как мы сейчас видим функция operator<< должна быть найдена в пространстве имён std, то есть на на самом деле нам нужен вызов вида, которого в чистом виде нет

std::operator<<(std::cout, "hello");

Но если вы посмотрите на исходный синтаксис std::cout << «hello», то в чистом виде квалификатора std:: там нет, вернее он есть, но только для левого операнда cout  и формально std:: вообще никак не связан с именем оператора. Как тогда компилятор понимает где именно искать правильный оператор?

Ответ на этот вопрос был предложен Эндрю Кёнигом в начале 1990-х годов и сегодня мы знаем его как argument-dependent lookup, или ADL, или поиск по аргументам, заставляя компилятор искать функцию в пространствах имён, связанных с типами аргументов вызова. Но делать такой неквалифицированный поиск он будет только, если не нашёл ничего подходящего в текущей области поиска, что тоже добавляет головной боли и разработчикам компиляторов и разработчикам эти компиляторы использующим. 

namespace N {    struct A {};    void f(A);}int main() {    N::A a;    f(a);}

При вызове f(a) неквалифицированный поиск имени f в текущей области ничего не находит, тем самым разрешая ADL. Тогда компилятор видит, что аргумент a имеет тип N::A, а значит видит и всё связанное с этой переменной  пространство имён N, далее ищет f внутри N, находя наконец N::f. Так вызов f  становится корректным.

Поэтому работает и поиск вывода в потоки, компилятор через аргумент-зависимый поиск (ADL) видит, что левый аргумент std::cout имеет тип, определённый в пространстве имён std, и ищет подходящий operator<< именно там. К сожалению правила у ADL запускаются только в том случае, если неквалифицированный поиск не нашёл вообще ничего, и если неквалифицированный поиск нашёл какую угодно сущность, будь то тип, переменную, шаблон, что угодно, тогда ADL полностью отключается.

typedef int f;namespace N {    struct A {};    void f(A);}int main() {    N::A a;    f(a);}

Здесь неквалифицированный поиск имени f сразу находит typedef int f и считается успешным, т.е. дальше не ищем. Соответственно компилятор интерпретирует f(a) как функциональную форму приведения типа, то есть попытку привести a к int. И это совершенно корректно с точки зрения синтаксиса, но явно не то, что ожидал программист. Именно поэтому взаимодействие обычного неквалифицированного поиска и ADL иногда приводит к крайне неожиданным результатам, когда «очевидная» функция оказывается полностью невидимой для компилятора.

Вот еще один скользкий пример (правда выглядит безопасно?):

namespace std {    struct ostream {        ostream& operator<<(const char*) {            printf("world");            return *this;        }    };    ostream cout;}using namespace std;int main() {    cout << "hello";}

Здесь имя std найдено неквалифицированным поиском и относится вовсе не к стандартной библиотеке, а к нашей локальной подмене, тогда как запись ::std::cout однозначно указывает на глобальное пространство имён и на настоящий std.

Именно поэтому стандартная библиотека всегда использует полностью квалифицированные имена с ::std:: и не полагается на неквалифицированный поиск, как вы видите его слишком легко «перехватить» локальными трюками.

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

И здесь вступает в игру концепция проверки пригодности функции, или function viability, которые мы и обсудим дальше, что это такое и почему она важна при разрешении перегрузок.

Проверка пригодности функции

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

// Функция с двумя параметрамиvoid greet(const std::string& firstName, const std::string& lastName) {   cout << "Hello, " << firstName << " " << lastName << "!";}int main() {    std::string name = "Alice";    // Попытка вызвать функцию с одним аргументом    // greet(name); // Ошибка: нет подходящей перегрузки    // Правильный вызов с двумя аргументами    greet(name, "Smith"); // Работает: Hello, Alice Smith!    return 0;}

Аналогично, приватные методы класса не могут быть выбраны в качестве кандидатов извне класса, а конструкторы с модификатором explicit также учитываются при проверке, поскольку они требуют явного вызова.

class Person {private:    void secretGreet() {        std::cout << "This is a secret greeting!" << std::endl;    }public:    void publicGreet() {        std::cout << "Hello!" << std::endl;    }};int main() {    Person p;    // p.secretGreet(); // Ошибка: метод недоступен    p.publicGreet();     // Работает: метод public    return 0;}

Суть в следующем: конструктор с explicit остаётся пригодным кандидатом для прямой инициализации (явного вызова), но исключается из числа кандидатов при копирующей инициализации и неявных преобразованиях. То есть он не «всегда требует явного вызова» в том смысле, в каком приватный метод «всегда недоступен извне» он именно отфильтровывается в тех контекстах, где требовалось бы неявное преобразование.

class MyString {public:    explicit MyString(const char* s) { /* ... */ }};void print(MyString s) { /* ... */ }int main() {    MyString a("hello");        // OK: прямая инициализация, кандидат пригоден    MyString b = "hello";       // Ошибка: копирующая инициализация,                                // explicit-конструктор исключён                                 // из кандидатов    print("hello");             // Ошибка: неявное преобразование                                 // запрещено    print(MyString("hello"));   // OK: явное преобразование}

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

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

struct S {    S(int x) { std::cout << "S(int) constructor called\n"; }     // пользовательское преобразование};void foo(double d) {    std::cout << "foo(double) called\n";}void foo(S s) {    std::cout << "foo(S) called\n";}void foo(...) {    std::cout << "foo(...) called\n";    // вариадик версия}int main() {    int x = 42;    // Какой foo будет вызван?    foo(x);    return 0;}

https://godbolt.org/z/9djzdheMK

Разберем пример выше подробнее, у нас есть три перегрузки функции foo: 

  • первая принимает double и для вызова с аргументом типа int здесь будет применено стандартное преобразование int → double.

  • Вторая перегрузка принимает объект типа S, где существует конструктор S(int) и в этом случае необходимо пользовательское преобразование из int в S.

  • Третья версия функции  вариадическая перегрузка с …, которая может принять любой аргумент, но имеет наименьший приоритет.

Теперь вызываем foo(x), где x имеет тип int. Компилятор соберет все возможные перегрузки, и на все три перегрузки подходят и каждая из них может быть вызвана с аргументом типа int, пусть и через разные преобразования.

Теперь идем по цепочкам преобразований для каждого кандидата, как делает это копмилятор, который в итоге выбирает логику выполнения с самой низкой сложностью. 

  • Для foo(double) надо применить стандартное преобразование int → double, т.е. надо сделать минимальные преобразования, условно оно имеет сложность 1.

  • Для foo(S) придется использовать пользовательское преобразование через конструктор S(int), оно немного сложнее, и имеет сложность 2.

  • Для вариадической версии foo(…) будет применено вариадическое преобразование, которые задействуют самую сложную логику, и для этого придется делать много работать со сложностью 3.

«Сложность 1, 2, 3» — это мое упрощение для примере. В стандарте это называется conversion rank (ранг преобразования), и ранги не «складываются как сложность». У вас после такого изложения может сложиться впечатление, что компилятор как будто складывает баллы, но на самом деле в реальных компиляторах происходит сравнение последовательностей преобразований, по правилу «лучший по худшему параметру». И у каждого компилятора свои приколы с этими правилами.

Почему выбран именно такой порядок? Исторически это связано с эволюцией самого языка C++ и опытом его использования в ранних проектах на C, где строгие правила типов уже устоялись. Считается, что идея принадлежит Бьярне и команде ранней разработки C++ в Bell Labs, чтобы снизить сложность вычислений на машинах того времени, когда они проектировали C++. Думаю вы в очередной раз убедились, что многие современные решения выросли из 80-90-х, когда язык активно вбирал в себя новые идеи, но был ограничен производительностью железа. Так удалось достичь того, что компилятор выбирает наиболее «естественный» вариант вызова функции, не прибегая к сложным преобразованиям, если это возможно.

И еще немного исторического эксурса…

Early C++ (Cfront, 1980-е): первый компилятор C++ использовал пошаговое исключение кандидатов и сначала проверялись точные совпадения типов, потом применялись стандартные преобразования, затем пользовательские и этот процесс был полностью явным в коде компилятора и основан на ранжировании преобразований в самом копиляторе.

GCC и Clang: современные компиляторы сохраняют эту иерархию, но вместо пошагового исключения строят таблицу кандидатов, помечая для каждого параметра «тип преобразования» и его ранг. Так при выборе лучшего кандидата компилятору надо только взять подходящий с верха таблицы, чтобы однозначно выбрать функцию с минимальными и наименее сложными преобразованиями.

MSVC: аналогично строит цепочки преобразований и учитывает их приоритеты, но использует списки кандидатов (реализация оптимизирована под скорость компиляции и обработку больших наборов перегрузок), используя внутренние “теги” для ранжирования.

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

void foo(int x) {    std::cout << "foo(int) called\n";}void foo(double x) {    std::cout << "foo(double) called\n";}int main() {    char c = 'A';    foo(c); // Какой вариант будет вызван?}

https://godbolt.org/z/x5xznr9G1

В этом примере у нас есть два кандидата: foo(int) и foo(double). Аргумент имеет тип char и компилятор может преобразовать char в int (это небольшое стандартное преобразование), оно предпочтительнее. Но также компилятор может преобразовать char в double (это тоже стандартное преобразование), но с более низким приоритетом, потому что оно требует расширения сначала в int, затем в double. В результате будет вызван foo(int), потому что преобразование char → int более «естественное» и простое, чем char → double.

Опять же для статьи я упростил подранги стандартных преобразований. В стандарте char → int будет integral promotion (продвижение, ранг exact match-ish), а char → double уже floating-integral conversion (полноценное преобразование, рангом ниже). promotion и conversion — это разные категории, и promotion всегда побеждает conversion.

Другой пример с перегрузками и значениями по умолчанию: (https://godbolt.org/z/1dbvsEezE)

void bar(int x, int y = 10) {    std::cout << "bar(int, int) called\n";}void bar(double x) {    std::cout << "bar(double) called\n";}int main() {    int a = 5;    bar(a); // Какой вариант вызовет компилятор?}

Здесь у нас опять есть два кандидата: bar(int, int), но второй параметр имеет значение по умолчанию, поэтому вызов bar(a) подходит, и есть bar(double) где можно преобразовать int → double.

Компилятор считает приоритеты: bar(int, int) не требует преобразования (int → int) и использует значение по умолчанию для второго аргумента, тогда как bar(double) требует неявного стандартного преобразования (int → double), поэтому будет вызван bar(int, int).

=delete

=deleteпросится в статью и удалённые функции участвуют в overload resolution как пригодные кандидаты и могут быть выбраны как лучшие, после чего вызов отвергается. Это самый яркий случай, когда «пригодность» и «доступность» расходятся, и хорошо иллюстрирует, зачем понятие viability вообще нужно.

Когда мы помечаем функцию через = delete, многие воспринимают это интуитивно как «функции больше нет» и думают что-то вроде её удаления из кода. На самом деле всё ровно наоборот и удалённая функция полностью участвует в проверке пригодности и в ранжировании, ведёт себя как обычный кандидат, и компилятор может даже выбрать её как лучшую перегрузку.

И только после того, как выбор сделан, компилятор смотрит на пометку = delete и отвергает вызов с ошибкой. Это иллюстрирует, зачем вообще нужно понятие viability как отдельной стадии: «пригоден для участия в разрешении» и «может быть фактически вызван» — это две разные вещи.

https://godbolt.org/z/137nKavoz

void process(int x)    { std::cout << "int\n"; }void process(double x) = delete;int main() {    process(42);    // OK: вызывается process(int)    process(3.14);  // ошибка: выбран process(double),                    //         но он удалён    process(1.0f);  // ошибка: float → double точнее, чем float → int,                    //         поэтому выбран удалённый process(double)}

На первый взгляд кажется, что раз process(double) «удалён», то при вызове process(3.14) компилятор должен просто его проигнорировать и поискать другую перегрузку, например, преобразовать double в int. Но он этого делать не будет и удалённая перегрузка остаётся пригодным кандидатом, выигрывает разрешение по обычным правилам ранжирования (для double точное совпадение лучше, чем преобразование в int), и только потом упирается в = delete.

Перегрузка process(int), которая могла бы принять double через стандартное преобразование, даже не рассматривается она была бы выбрана только если бы process(double) вообще отсутствовала в списке кандидатов. Это даёт идиоматический приём запрещать вызов функции с определёнными типами, не убирая её из перегрузки, а наоборот, оставляя там специально, чтобы она «перехватывала» нежелательные аргументы

struct Handle {    void set(int id);    void set(void*) = delete;   // запрещаем передавать сырые указатели,                                // включая nullptr и NULL};Handle h;h.set(42);       // OKh.set(nullptr);  // ошибка компиляции, а не неявное приведение к int

Если бы = delete означал «удаление функции из рассмотрения», то h.set(nullptr) вызвал бы set(int) через преобразование nullptr → 0, и мы получили бы трудноуловимый баг в рантайме.

Но поскольку set(void*) остаётся пригодным кандидатом и выигрывает разрешение (для nullptr указатель это будет точное совпадение, а преобразование в int нет), компилятор ловит ошибку на этапе компиляции. Именно поэтому формально различать «пригоден» и «доступен» является отдельным инструмент внутри языка, на котором держится целый класс защитных техник современного C++.

Что это все значит для разработчика

В больших кодовых базах процесс проверки пригодности и выбора лучшего кандидата может быть очень сложным, потому что надо учесть количество параметров, доступность функций, значения по умолчанию, неявные преобразования и приоритеты различных видов преобразований. Именно поэтому одна и та же функция может быть вызвана по-разному в зависимости от того, какие перегрузки доступны в текущем контексте и какие аргументы передаются. Это базовые правила выбора лучшего кандидата при перегрузке функций, и надеюсь я дал понимание, как компилятор оценивает пригодность функций.

Материлом для следующих статей мне видится рассмотрение процессов, которые управляют типами самих аргументов и возвращаемых значений в шаблонах. Именно здесь мы первый раз столкнемся с type deduction  механизмом, с помощью которого компилятор выводит типы шаблонных параметров из переданных аргументов, и template instantiation, который подставляет эти типов в тело шаблона для создания конкретной функции или класса.

Понимание этих механизмов определяет, какие перегрузки шаблонов будут выбраны и как будут разрешаться вызовы функций и об этом будет следующая статья…

З.Ы. Первые 7 глав «Нескучного прогрммирования» выложил на гитхабе в ru/en (https://github.com/dalerank/playful_programming_cpp), хотя вот @OlegSivchenkoпредлагает назвать серию «Pragmatic C++», слишком уж практические и скучные вопросы поднимаются. Если будет желание поправить текст или примеры кода, напишите в личку.

ссылка на оригинал статьи https://habr.com/ru/articles/1036104/