C++20: удивить линкер четыремя строчками кода

?v=1

Представьте себе, что вы студент, изучающий современные фичи C++. И вам дали задачу по теме concepts/constraints. У преподавателя, конечно, есть референсное решение «как правильно», но для вас оно неочевидно, и вы навертели гору довольно запутанного кода, который всё равно не работает. (И вы дописываете и дописываете всё новые перегрузки и специализации шаблонов, покрывая всё новые и новые претензии компилятора).

А теперь представьте себе, что вы — преподаватель, который увидел эту гору, и захотел помочь студенту. Вы стали упрощать и упрощать его код, и даже тупо комментировать куски юнит-тестов, чтобы оно хоть как-то заработало… А оно всё равно не работает. Причём, в зависимости от порядка юнит-тестов, выдаёт разные результаты или вообще не собирается. Где-то спряталось неопределённое поведение. Но какое?

Сперва преподаватель (то есть, я) минимизировал код вот до такого: https://gcc.godbolt.org/z/TaMTWqc1T

// пусть у нас есть концепты указателя и вектора
template concept Ptr = requires(T t) { *t; };
template concept Vec = requires(T t) { t.begin(); t[0]; };

// и три перегрузки функций, рекурсивно определённые друг через друга
template void f(T t) {  // (1)
  std::cout << "general case " << __PRETTY_FUNCTION__ << std::endl;
}
template void f(T t) {  // (2)
  std::cout << "pointer to ";
  f(*t);  // допустим, указатель не нулевой
}
template void f(T t) {  // (3)
  std::cout << "vector of ";
  f(t[0]);  // допустим, вектор не пустой
}

// и набор тестов (в разных файлах)
int main() {
  std::vector v = {1};
  
  // тест А
  f(v);
  // или тест Б
  f(&v);
  // или тест В
  f(&v);
  f(v);
  // или тест Г
  f(v);
  f(&v);
}

Мы ожидаем, что

  • f (v) выведет «vector of general case void f (T) [T=int]»

  • f (&v) выведет «pointer to vector of general case void f (T) [T=int]»

А вместо это получаем

  • А: «vector of general case void f (T) [T=int]»

  • Б: «pointer of general case void f (T) [T=std: vector]» — ?

  • В: clang выводит
    «pointer to general case void foo (T) [T = std: vector]» — как в случае с Б
    «general case void foo (T) [T = std: vector]», — не так, как А!
    gcc — даёт ошибку линкера

  • Г: clang и gcc дают ошибку линкера

Что здесь не так?!

А не так здесь две вещи. Первая — это то, что из функции (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 void f(T t) {.....}
template void f(T t) requires Ptr {.....}
template void f(T t) requires Vec {.....}

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

Но вот если прибегнем к старому доброму трюку SFINAE, https://gcc.godbolt.org/z/4sar6W6Kq

// добавим второй аргумент char или int - для разрешения неоднозначности
template void f(T t, char) {.....}
template auto f(T t, int) -> std::enable_if_t, void> {.....}
template auto f(T t, int) -> std::enable_if_t, void> {.....}

..... f(v, 0) .....
..... f(&v, 0) .....

или ещё более старому доброму сопоставлению типов аргументов, https://gcc.godbolt.org/z/PsdhsG6Wr

template void f(T t) {.....}
template void f(T* t) {.....}
template void f(std::vector t) {.....}

то всё станет работать. Не так, как нам хотелось бы (рекурсия по-прежнему сломана из-за правил видимости), но ожидаемо (вектор из f (T*) видится как «general case», из main — как «vector»).

Что же ещё с концептами/ограничениями?

Коллективный разум, спасибо RSDN, подсказал ещё более минималистичный код!

Всего 4 строки: https://gcc.godbolt.org/z/qM8xYKfqe

template void f() {}
void g() { f(); }
template void f() requires true {}
void h() { f(); }

Функция с ограничениями считается более предпочтительной, чем функция без них. Поэтому g () по правилам видимости выбирает из единственного варианта, а h () — из двух выбирает второй.

И вот этот код порождает некорректный объектный файл! В нём две функции с одинаковыми декорированными именами.

Оказывается, современные компиляторы (clang ≤ 12.0, gcc ≤ 12.0) не умеют учитывать requires в декорировании имён. Как когда-то старый глупый MSVC6 не учитывал параметры шаблона, если те не влияли на тип функции…

И, судя по ответам разработчиков, не только не умеют, но и не хотят. Отмазка: «если в разных точках программы одинаковые обращения к шаблону резолвятся по-разному, такая программа ill-formed, никакой диагностики при этом не нужно» (однако, ill-formed означает «не скомпилируется», а не «скомпилируется как попало»…)

Проблема известна с 2017 года, но прогресса пока нет.

Так что живите с этим. И не забывайте объявлять взаимно-рекурсивные функции до определений. А если увидите странные ошибки линкера, то хотя бы будете понимать, из-за чего они возникают. (А если компилятор будет инлайнить наобум, — ну, тогда не повезло).

© Habrahabr.ru