Отладка шаблонов C ++ должна быть менее сюрреалистичной и ужасной

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

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

Неявный отказ через реализацию

Читая вечную классику Скотта Мейерса Эффективный C ++, я был поражен следующим шаблоном из правила 45, особенно шаблонным конструктором:

template<typename T>
class SmartPtr {
public:
  explicit SmartPtr(T *realPtr);
  …
  template<typename U>
  SmartPtr(const SmartPtr<U>& other)
  : heldPtr(other.get()) { … }
  T* get() const { return heldPtr; }
  …
private:
  T *heldPtr;
}

Этот фрагмент кода позволит вам инициализировать SmartPtr любым «совместимым типом». Это, например, означает производные типы, как показывает Мейерс:

class Top {…};
class Middle: public Top {…};
class Bottom: public Middle {…};

С помощью обычных указателей вы можете:

Top *pt1 = new Middle;
Top *pt2 = new Bottom;
const Top *pct2 = pt1;

Таким образом, SmartPtr из шаблона выше имитирует поведение обычных указателей, потому что благодаря шаблонному конструктору вы можете делать

SmartPtr<Top> spt1 = SmartPtr<Middle>(new Middle);
SmartPtr<Top> spt2 = SmartPtr<Bottom>(new Bottom);
SmartPtr<const Top> spct2 = spt1;

Хитрость в том, что это будет компилироваться, только если неявное преобразование, например, из Middle указатель на указатель Top разрешен. Это, конечно, так, поскольку указатель Middle является указателем Top (плюс дополнительные Middle вещи).

Однако, если неявное преобразование не разрешено, скажем, если вы попытаетесь инициализировать объект дочернего класса родительским классом следующим образом:

SmartPtr<Bottom> spt1 = SmartPtr<Middle>(new Middle);

компилятор будет жаловаться следующим образом:

compile_fail.cpp:8:5: error: cannot initialize a member subobject of type ‘Bottom *’ with an rvalue of type ‘Middle *’
 : heldPtr(other.get()) {}
 ^ ~~~~~~~~~~~
compile_fail.cpp:22:27: note: in instantiation of function template specialization ‘SmartPtr<Bottom>::SmartPtr<Middle>’ requested here
 SmartPtr<Bottom> spt2 = SmartPtr<Middle>(new Middle); // compileth not
 ^
1 error generated.

Добро пожаловать в шаблоны C ++! Что, черт возьми, я здесь вижу? Байт-код ?!

Сообщения отладки не должны быть настолько трудными для чтения, не так ли? Честно говоря, иногда я истерически смеюсь над такого рода ошибками, особенно когда они повторяются на многих строках, как это часто бывает. Это слишком сюрреалистично.

Честно говоря, как только вам удалось преодолеть беспорядок, это более или менее четкое сообщение:

error: cannot initialize a member subobject of type ‘Bottom *’ with an rvalue of type ‘Middle *’
note: [obtuse hint that the error was caused by the template specialization of the constructor]

Или, чтобы сделать его более понятным для говорящего по-английски:

You cannot construct a SmartPtr<Bottom> using a SmartPtr<Bottom>::SmartPtr<Middle> constructor, because this will lead to an error, namely: cannot initialize a member subobject of type ‘Bottom *’ with an rvalue of type ‘Middle *’.

Исключения во время компиляции: сделайте намерение явным

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

И по определенной причине: они хотят имитировать поведение обычных указателей. Пользователь SmartPtr не должен иметь возможность использовать этот класс другими способами.

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

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

error: cannot initialize an object of type ‘SmartPtr<Bottom>’ with an rvalue of type ‘SmartPtr<Middle>’

Или более явный:

error: cannot initialize an object of type ‘SmartPtr<Bottom>’ with an rvalue of type ‘SmartPtr<Middle>’, because Middle is not implicitly convertible to Bottom

Если компилятор может обнаружить эти ошибки, что, очевидно, может, о чем свидетельствует сообщение об ошибке, то почему бы не позволить программисту использовать это?

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

Этот образ мышления привел меня к моему моменту эврики: C ++ должен иметь исключения времени компиляции! Рассмотрим следующую (выделенную жирным шрифтом) модификацию нашего конструктора:

template<typename T>
class SmartPtr {
 …
  template<typename U>
  compile_except(E2064) {
    compile_cerr << "error: cannot initialize a 'SmartPtr<" << T << ">' with an rvalue of type 'SmartPtr<" << U << ">', because " << U << " is not implicitly convertible to " << T << "\n";
    compile_fail;
  }
  SmartPtr(const SmartPtr<U>& other) …
 
 …
};

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

Хорошо, возможно, нам не нужен специальный выходной поток времени компиляции (он должен быть основан на fmt!), Но вы поняли. Для краткости я распечатываю типы T и U напрямую, имея в виду, что их имена должны быть напечатаны.

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

Итак, почти сразу после того, как я это придумал, я уже наслаждался (в моем уме) славой и богатством, которые, очевидно, принесло мне это предложение. Я никогда раньше не слышал об этой идее, и действительно, некоторый поиск в Google (не далее, чем страница 1, конечно, это все еще просто сообщение в блоге ...) не привел к сопоставимым идеям (я приветствую ваше презрение, насмешки и насмешки, если я пропустил что-то очевидное).

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

Теперь у нас есть Концепции.

Четко определенная неудача: концепции спешат на помощь?

В C ++ 20 такое же поведение класса можно запрограммировать с помощью Концепции, что также делает намерение кода более явным. Это приводит к сбою компиляции на более раннем этапе: в первой строке шаблона, где аргумент шаблона немедленно проверяется на совместимость с классом шаблона, вместо того, чтобы полагаться на реализацию (несовместимое присвоение указателя) для сбоя компиляции.

Например, в нашем примере можно добавить Производную концепцию, как ее можно найти на странице cppreference в Concepts:

template <typename T, typename U>
concept Derived = std::is_base_of<U, T>::value;

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

template<Derived<T> U>
SmartPtr(const SmartPtr<U>& other):
: heldPtr(other.get()) { … }

Действительно красивый, выразительный синтаксис. Мне не нужны старые типы U, мне нужны только те, которые являются производными от T.

Шаблон теперь компилируется только для типов U, которые являются производными от T (как это было раньше, но теперь потому, что я так говорю, а не потому, что язык так случается). Когда вы попытаетесь поступить иначе, вы получите сообщение об ошибке, которое должно прояснить это для вас! И все будет хорошо.

Экспериментальный лязг с поддержкой Concepts в Compiler Explorer выводит это:

<source>:27:20: error: no viable conversion from ‘SmartPtr<Middle>’ to ‘SmartPtr<Bottom>’
 SmartPtr<Bottom> spt2 = SmartPtr<Middle>(new Middle); // compileth not
 ^ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
<source>:7:7: note: candidate constructor (the implicit copy constructor) not viable: no known conversion from ‘SmartPtr<Middle>’ to ‘const SmartPtr<Bottom> &’ for 1st argument
class SmartPtr {
 ^
<source>:7:7: note: candidate constructor (the implicit move constructor) not viable: no known conversion from ‘SmartPtr<Middle>’ to ‘SmartPtr<Bottom> &&’ for 1st argument
class SmartPtr {
 ^
<source>:12:3: note: candidate template ignored: constraints not satisfied [with U = Middle]
 SmartPtr(const SmartPtr<U>& other) // initialize this held ptr
 ^
<source>:11:12: note: because ‘Derived<Middle, Bottom>’ evaluated to false
 template<Derived<T> U>
 ^
<source>:4:19: note: because ‘std::is_base_of<Bottom, Middle>::value’ evaluated to false
concept Derived = std::is_base_of<U, T>::value;
 ^
1 error generated.
Compiler returned: 1

Погодите ... это совсем не кристально ясно. Понятия, почему ты так меня мучаешь ?!

Конечно, это служит приятным тонким напоминанием о том, что я забыл добавить шаблонный конструктор перемещения и что есть также не шаблонный конструктор неявного копирования (о котором, кстати, Скотт Мейерс также напоминает вам в правиле 45).

Но, честно говоря, это была не та обратная связь, на которую я надеялся как пользователь. Эта обратная связь приходит только в четвертой ноте… из пяти!

<source>:11:12: note: because ‘Derived<Middle, Bottom>’ evaluated to false
 template<Derived<T> U>
 ^

Мне действительно нравится это предложение, особенно то, что оно начинается с «потому что» (хотя «оценено как ложно» немного неудобно, но я придираюсь к ним). Но здесь по-прежнему слишком много шума, по крайней мере, для конечного пользователя.

А что насчет static_assert?

Лоуренс Вин напомнил мне, что static_assert не следует здесь не упоминать. В самом деле, этот конкретный случай также можно сделать более явным, используя static_assert:

template<typename U>
SmartPtr(const SmartPtr<U>& other)
: heldPtr(other.get()) {
  static_assert(std::is_base_of<T, U>::value, "U is not derived from T!");
}

Однако у этого подхода есть ряд проблем:

  1. Он по-прежнему выдает нам два сообщения об ошибке (см. Эту реализацию в Compiler Explorer): сначала сообщение об указателе, которое запускается при инициализации heldPtr, а затем сообщение static_assert.
  2. Я не знаю, как это можно совместить с Concepts. Если я правильно понимаю, ни Концепции, ни связанные с ними Ограничения не имеют тела, в котором может находиться static_assert.
  3. Мы по-прежнему не можем использовать его точно так, как хотим, поскольку сообщение static_assert должно быть строковым литералом, поэтому мы не можем динамически отображать типы, которые передаются как U и T.

Что еще более важно, в целом: не все ошибки могут быть представлены логическим условием, которое необходимо для static_assert. Например, синтаксические ошибки по определению нельзя использовать, поскольку мы не можем определить для них правильный синтаксис! Если бы мы могли, они бы больше не были синтаксическими ошибками ...

Случай для исключений времени компиляции

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

Кроме того, вероятно, название не прижится. Я предполагаю, что то, что я здесь описываю, на самом деле не исключения (например, вы не можете разумно исправить их), а скорее настраиваемые сообщения об ошибках. Однако их запуск - это как перехват исключения. Возможно, у этой гибридной штуки есть другое название.

Тем не менее, я знаю, что отладка C ++, особенно шаблонная, может быть адом. В общем случае слишком много шума, чтобы пробираться сквозь него.

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

Почему я не могу получить сообщение об ошибке компиляции просто "Error: argument to copy ctor for SmartPtr<T> can only be SmartPtr<U> when U is derived from T, but it’s not"? Или что-то подобное.

Чтобы это работало, ошибки компилятора должны быть правильно классифицированы, названы, обнаружены без особых усилий и т. Д. Я понятия не имею, так ли это сейчас. Итак, еще один вопрос к вам (особенно если вы эксперт по компиляторам): определены ли ошибки компиляции таким образом (то есть достаточно строгими, в самом языке), чтобы они могли быть реализованы как особые исключения? Можно ли их так определить?

Обязательно оставляйте свои комментарии ниже или на Reddit или Twitter!