Представьте себе, что вы студент, изучающий современные фичи C++. И вам дали задачу по теме concepts/constraints. У преподавателя, конечно, есть референсное решение "как правильно", но для вас оно неочевидно, и вы навертели гору довольно запутанного кода, который всё равно не работает. (И вы дописываете и дописываете всё новые перегрузки и специализации шаблонов, покрывая всё новые и новые претензии компилятора).
Мы опубликовали и перевели эту статью с разрешения правообладателя. Автор статьи – Николай Меркин. Оригинал опубликован на сайте Habr.
А теперь представьте себе, что вы — преподаватель, который увидел эту гору, и захотел помочь студенту. Вы стали упрощать и упрощать его код, и даже тупо комментировать куски юнит-тестов, чтобы оно хоть как-то заработало... А оно всё равно не работает. Причём, в зависимости от порядка юнит-тестов, выдаёт разные результаты или вообще не собирается. Где-то спряталось неопределённое поведение. Но какое?
Сперва преподаватель (то есть, я) минимизировал код вот до такого: https://gcc.godbolt.org/z/TaMTWqc1T
// пусть у нас есть концепты указателя и вектора
template<class T> concept Ptr = requires(T t) { *t; };
template<class T> concept Vec = requires(T t) { t.begin(); t[0]; };
// и три перегрузки функций, рекурсивно определённые друг через друга
template<class T> void f(T t) { // (1)
std::cout << "general case " << __PRETTY_FUNCTION__ << std::endl;
}
template<Ptr T> void f(T t) { // (2)
std::cout << "pointer to ";
f(*t); // допустим, указатель не нулевой
}
template<Vec T> void f(T t) { // (3)
std::cout << "vector of ";
f(t[0]); // допустим, вектор не пустой
}
// и набор тестов (в разных файлах)
int main() {
std::vector<int> v = {1};
// тест А
f(v);
// или тест Б
f(&v);
// или тест В
f(&v);
f(v);
// или тест Г
f(v);
f(&v);
}
Мы ожидаем, что
А вместо это получаем
Что здесь не так?!
А не так здесь две вещи. Первая — это то, что из функции (2) видны объявления только (1) и (2), поэтому результат разыменования указателя вызывается как (1).
Без концептов и шаблонов это тоже прекрасно воспроизводится: https://gcc.godbolt.org/z/47qhYv6q4
void f(int x) { std::cout << "int" << std::endl; }
void g(char* p) { std::cout << "char* -> "; f(*p); } // f(int)
void f(char x) { std::cout << "char" << std::endl; }
void g(char** p) { std::cout << "char** -> "; f(**p); } // f(char)
int main() {
char x;
char* p = &x;
f(x); // char
g(p); // char* -> int
g(&p); // char** -> char
}
В отличие от инлайн-определений функций-членов в классе, где все объявления видны всем, — определение свободной функции видит только то, что находится выше по файлу.
Из-за этого, кстати, для взаимно-рекурсивных функций приходится отдельно писать объявления, отдельно (ниже) от определения.
Ладно, с этим разобрались. Вернёмся к шаблонам. Почему в тестах В и Г мы получили нечто, похожее на нарушение ODR?
Если мы перепишем код вот так:
template<class T> void f(T t) {.....}
template<class T> void f(T t) requires Ptr<T> {.....}
template<class T> void f(T t) requires Vec<T> {.....}
то ничего не изменится. Это просто другая форма записи. Требование соответствия концепту можно записать и так, и этак.
Но вот если прибегнем к старому доброму трюку SFINAE, https://gcc.godbolt.org/z/4sar6W6Kq
// добавим второй аргумент char или int - для разрешения неоднозначности
template<class T, class = void> void f(T t, char) {.....}
template<class T> auto f(T t, int) -> std::enable_if_t<Ptr<T>, void> {.....}
template<class T> auto f(T t, int) -> std::enable_if_t<Vec<T>, void> {.....}
..... f(v, 0) .....
..... f(&v, 0) .....
или ещё более старому доброму сопоставлению типов аргументов, https://gcc.godbolt.org/z/PsdhsG6Wr
template<class T> void f(T t) {.....}
template<class T> void f(T* t) {.....}
template<class T> void f(std::vector<T> t) {.....}
то всё станет работать. Не так, как нам хотелось бы (рекурсия по-прежнему сломана из-за правил видимости), но ожидаемо (вектор из f(T*) видится как "general case", из main – как "vector").
Что же ещё с концептами/ограничениями?
Коллективный разум, спасибо RSDN, подсказал ещё более минималистичный код!
template<class T> void f() {}
void g() { f<int>(); }
template<class T> void f() requires true {}
void h() { f<int>(); }
Функция с ограничениями считается более предпочтительной, чем функция без них. Поэтому g() по правилам видимости выбирает из единственного варианта, а h() – из двух выбирает второй.
И вот этот код порождает некорректный объектный файл! В нём две функции с одинаковыми декорированными именами.
Оказывается, современные компиляторы (clang ≤ 12.0, gcc ≤ 12.0) не умеют учитывать requires в декорировании имён. Как когда-то старый глупый MSVC6 не учитывал параметры шаблона, если те не влияли на тип функции...
И, судя по ответам разработчиков, не только не умеют, но и не хотят. Отмазка: "если в разных точках программы одинаковые обращения к шаблону резолвятся по-разному, такая программа ill-formed, никакой диагностики при этом не нужно" (однако, ill-formed означает "не скомпилируется", а не "скомпилируется как попало"...)
Проблема известна с 2017 года, но прогресса пока нет.
Так что живите с этим. И не забывайте объявлять взаимно-рекурсивные функции до определений. А если увидите странные ошибки линкера, то хотя бы будете понимать, из-за чего они возникают. (А если компилятор будет инлайнить наобум, — ну, тогда не повезло).