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

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

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

Сперва преподаватель (то есть, я) минимизировал код вот до такого: 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, подсказал ещё более минималистичный код!

Всего 4 строки:

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 года, но прогресса пока нет.

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

@nickolaym
09.06.2021 17:51 UTC
Первоисточник

Комментарии

@
09.06.2021 15:28 UTC
0
НЛО прилетело и опубликовало эту надпись здесь
@nickolaym
09.06.2021 16:59 UTC
+1

Очень похоже.


Вот, если мы поможем компилятору, чтобы он давал разные имена разным перегрузкам, — то всё будет работать.
https://gcc.godbolt.org/z/K8d9vv8oT


namespace a {}
namespace b {}

using namespace a;
using namespace b;

namespace a {
template<class T> void f() { std::cout << __PRETTY_FUNCTION__ << std::endl; }
}

void g() { f<int>(); }  // a::f

namespace b {
template<class T> void f() requires true { std::cout << __PRETTY_FUNCTION__ << std::endl; }
}

void h() { f<int>(); }  // b::f подошло лучше, чем a::f
@nickolaym
09.06.2021 19:30 UTC
0

Кажется, чувака по ссылке удалось переубедить.

@CSharperSuper
09.06.2021 18:36 UTC
+1
В современном С++ это скорее норма, чем исключение. Надо иметь голову как дом советов чтобы всё понимать досконально. «Штудируй стандарт который меняется почти каждый год.»
@nickolaym
09.06.2021 18:44 UTC
0

Не соглашусь. Если не лазить по обочинам языка, то странное поведение — исключение, а не норма. А обочины были с самого рождения. Тут, конечно, С++ показывает кузькину мать.


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


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


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

@Amomum
09.06.2021 18:52 UTC
+4

Интересно, почему даже в 2021 году компилятор не может сделать два прохода по файлу, а не заставлять программиста писать предварительное объявление :(

@nickolaym
09.06.2021 19:34 UTC
0

На самом деле, компилятор делает два прохода, когда ему это нужно по стандарту.
Инлайновые определения внутри объявления класса.

09.06.2021 19:36 UTC
0

Зачем же тогда все еще требуются эти предварительные объявления, если они не всегда требуются? Т_Т

09.06.2021 20:53 UTC
+2

Обратная совместимость. C был однопроходным, C++ старался по максимуму сохранить с ним совместимость. То есть в классах, как принципиально новых сущностях, сделали как удобно, а для свободных функций, которые были и в С сделали как совместимо. И сейчас вряд ли кто-то это будет менять, потому что неизвестно сколько кода это сломает.

09.06.2021 20:55 UTC
0

Не вижу, почему "необязательная" однопроходность может сломать старый код, но да, наверняка может как-нибудь максимально неожиданно. Эх.

09.06.2021 22:21 UTC
+1

Пример, как можно что-нибудь сломать. Да, надуманный, но почему бы и нет.


// some.h
int f(char);

const size_t N = sizeof(f(0));
long arr[N];

// does_not_break.cpp
#include "some.h"
double f(int);

// breaks.cpp
double f(int);
#include "some.h"

В старом коде мы могли договориться, что "давайте будем следовать определённому порядку инклудов, и всё будет хорошо".
Двухпроходность эту договорённость частично отменяет, и "всё будет хорошо" уже не гарантируется.

09.06.2021 22:23 UTC
0

Спасибо за пример :)

21.06.2021 04:12 UTC
0

Но ведь инклуды отрабатываются не компилятором. Если я правильно препроцессировал и скомпилировал в мозгу, то во втором случае получится что-то вроде на входе компилятора:


double f(int);
int f(char);

const size_t N = sizeof(f(0));
long arr[N];

И sizeof(f(0)) выберет double f(int). И как тут может повлиять многопроходность компилятора? А в случае C, оба вариант не должны скомпилироваться ("conflicting types for 'f'; have 'int(char)'").


Меня не покидает ощущение, что я чего-то недопонял, хочу разобраться :)

21.06.2021 11:49 UTC
0

С точки зрения автора клиентского кода, инклуды - это

  • такой импорт для бедных

  • препроцессорная магия

Правила хорошего тона как бы намекают, что порядок импорта не должен влиять, если это не специально оговорено.

Именно поэтому и делают инклуд-гарды и pragma once, - чтобы все, кто надо, спокойно импортировали зависимости в свои хедеры, и чтобы эта этажерка инклудов не мешала друг другу.

Есть известные антипаттерны, как сделать больно на инклудах. Самый очевидный - (пере)определение макросов. Особенно, когда имя макроса конфликтное. Тогда код до и после инклуда будет компилироваться заметно по-разному.

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

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

Естественно, смотреть в сторону Си тут нет смысла - проблема именно в перегрузках, а Си перегрузки не поддерживает.

22.06.2021 02:07 UTC
0
Именно поэтому и делают инклуд-гарды и pragma once, — чтобы все, кто надо, спокойно импортировали зависимости в свои хедеры, и чтобы эта этажерка инклудов не мешала друг другу.

Спокойно всё равно не получится, пример, когда два класса друг на друга ссылаются. Да и на практике встречал, когда порядок инклудов влиял на успешность сборки (кто-то не видел банальные uint32_t, поэтому перед подключением требовался cstdint, но это локальные косяки). Ну и делали, как мне кажется не именно из-за правил хорошего тона, а что бы не появлялось два struct foo {};, что вступало бы в конфликт и вызывало бы ошибку компиляции.


И это, кстати, хорошо. Вон, в Linux Kernel для генерации Device Tree тоже используют C Pre-Propcessor, только там не принято обкладываться инклуд-гардами и в сложном случае что-то молча переписать и получить не то, что ожидалось очень легко. Недавно баг с этим связанный разбирал. Ужасно неприятно.


Самый очевидный — (пере)определение макросов.

В курсе, как минимум привет _POSIX_C_SOURCE, как максимум, специфическая "кодогенерация".


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

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

24.06.2021 14:43 UTC
0

Под многопроходностью понимается именно в единице трансляции. А уже потом по всему коду - это компетенция линкера :)

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

---

Если два класса ссылаются друг на друга, - для этого есть предобъявления. И написать такой код, который не будет чувствителен к порядку инклудов, - в общем-то, несложно.

Можно проворонить проблему - например, забыть `#include <cstdint>`, потому что он приехал по зависимостям.

---

Вон, в Linux Kernel для генерации Device Tree тоже используют C Pre-Propcessor, только там не принято обкладываться инклуд-гардами

Это уже не импорты, а препроцессорная магия, это другое.

09.06.2021 22:47 UTC
0

Или, например, трюки со счётчиками времени компиляции.
Общая схема такая


#define SET_TAG(value) ..... // вводит новую перегрузку функции
#define LAST_TAG() ..... // лучше всего подходит к последней перегрузке

SET_TAG(123);
.....
static_assert(LAST_TAG() == 123);
.....
static_assert(LAST_TAG() == 123);
.....
SET_TAG(456);
.....
static_assert(LAST_TAG() == 456);
.....

В какой-нибудь автоматической кодогенерации, макросной магии может пригодиться.


Пример, как это можно реализовать нечувствительно к одно-двух-проходности


template<unsigned N> struct argument : argument<N-1> {};
template<> struct argument<0> {};

#define SET_TAG(V) \
    static constexpr int tag_func(argument<__COUNTER__>*) { return V; }
#define LAST_TAG() \
    tag_func((argument<__COUNTER__>*)nullptr)

https://gcc.godbolt.org/z/3vThTex4P


Тут фокус в том, что argument<N> лучше всего приводится к ближайшей базе argument<M>, где M<N.
И, поскольку __COUNTER__ монотонно растёт, то будет выбрана самая последняя из перегрузок.
static нужно для того, чтобы не нарушить ODR, если в разных единицах трансляции одинаковым __COUNTER__'ам будут соответствовать разные значения тэгов.


Однако, это не единственный способ. Я когда-то встречал реализации, где новая перегрузка утилизировала старую. Без __COUNTER__.
И вот там уже однопроходность была существенна.
Но на ходу не вспомню, как это делается.

09.06.2021 22:52 UTC
+1

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

09.06.2021 23:04 UTC
+2

В пучине разных библиотек, типа того же буста, может и не такой ад скрываться.
И тут прибегает некто с -ftwo-pass...

09.06.2021 23:09 UTC
0

Кажется, процесс обсуждения С++ можно описывать семью стадиями принятия…
Как вы справляетесь с отчаянием?

09.06.2021 23:25 UTC
+8

Мою посуду вот в такой последовательности:


  1. Кружки и стаканы
  2. Вилки, ложки и ножи
  3. Тарелки и контейнеры
  4. Отрицание
  5. Гнев
  6. Торг
  7. Депрессия
  8. Принятие
  9. Кастрюли
  10. Сковородки

11. Противень


Мой фокус в том, что я давно нахожусь в стадии противня. Чтобы вернуться к отчаянию, мне нужно перепачкать слишком много посуды обратно.

09.06.2021 23:59 UTC
0

Я думал в стадии противня только члены Комитета :D

10.06.2021 00:57 UTC
+3

Они этот противень подсовывают!

21.06.2021 04:16 UTC
0

Они включают под ним огонь.

09.06.2021 21:35 UTC
0

А если добавить какой-то флаг вида -ftwo-pass? Очередной способ выстрелить себе в ногу, конечно, но как минимум осознанно.

09.06.2021 22:06 UTC
+1
Например, если нижние инклуды будут влиять на верхние, будет еще более печально, чем сейчас
09.06.2021 22:08 UTC
0

Ну, если два инклуда содержат объявления одних и тех же функций или типов, то это, по-моему, уже сейчас стреляние в ногу.

09.06.2021 23:01 UTC
0

Нет, просто очень хрупкий код. Приглашение к прилёту дятла из известной поговорки.