Много лет назад моя рутинная работа заключалась в поддержке большой базы кода на C++. Этот проект был настоящим кормильцем всей компании, и в нём предоставлялся публичный HTTP API, через который принимались онлайн-платежи. Речь шла об обработке платежей в размере миллиардов евро ежегодно.
Тогда меня ещё было не назвать опытным C++-разработчиком. Разумеется, я знал о неопределённом поведении, но как о чём-то абстрактном, о беде, которая приключается только с новичками. Как же я был неправ!
В этой статье везде, где написано «структура», я имею в виду «структура или класс».
Багрепорт
Итак, однажды приходит мне багрепорт. Речь идёт о конечной точке HTTP, возвращающей простой отклик. Отклик должен проинформировать клиента, как прошла операция: успешно или с ошибкой:
{ "error": false, "succeeded": true,}
или
{ "error": true, "succeeded": false,}
По-моему, на практике здесь использовался не JSON, а какой-то формат с привязкой к форме, точно не помню, но в контексте данного бага это не важно.
Эта модель данных неидеальна, но в ней отражено, что именно делала программа. Очевидно, результат может быть равен либо «успех», либо «ошибка», но не оба этих значения вместе, и отсутствовать значение также не может (это исключающее «ИЛИ»).
А в багрепорте сообщалось, что клиент получил следующий отклик:
{ "error": true, "succeeded": true}
Хм, ладно. Это невозможно, значит, здесь действительно баг.
Разбор полётов
Тогда я заглянул в код. Весь он уложен в одну большую функцию, выполняющую множество операций с базой данных, но форма кода как такового очень проста:
struct Response { bool error; bool succeeded; std::string data;};void handle() { Response response; try { // [..] много операций с базой данных, *не* касающихся `отклика`. response.succeeded = true; } catch(...) { response.error = true; } response.write();}
Вот ссылка на godbolt, по которой приведён примерно такой же код.
Значение «успех» устанавливается в нужное поле ровно в одном месте. Значение «ошибка» — тоже. Нигде более код не затрагивает двух этих полей.
Тогда я рот открыл от удивления. Как такое возможно, что оба поля оказываются true? Код без изысков. Каждое поле устанавливается всего один раз и при этом исключительно. Кажется, что невозможно, чтобы оба поля одновременно имели значение true.
Верёвка достаточно длинная, чтобы на ней повеситься
И тут меня стала донимать интуиция бывалого C-разработчика — а вдруг виной всему Response response;? Всё на то указывает, верно? В C чтение полей из response: — верный путь к неопределённому поведению. Ведь структура C не инициализируется.
Но почти сразу же после этого я наталкиваюсь на официальные примеры из C++, где используется именно такой синтаксис. Неразбериха. В конце концов, в C++ действуют иные правила инициализации, нежели в C.
Я засел за стандарт C++ и корпел над ним часами. Хорошо было бы смонтировать этот процесс под саундтрек из 80-х, как в тех эпизодах из старых боевиков, где герой качает железо в спортзале. Если коротко — да, выяснилось, что правила отличаются настолько, что об этом можно было бы написать книгу. Более того, в C++ они варьируются от версии к версии. В некоторых условиях Response response; — совершенно нормально. А в других приводит к неопределённому поведению.
Суть такова: правило инициализации по умолчанию действует, когда переменная объявляется без инициализатора. Это довольно сложно, но здесь я постараюсь объяснить как можно проще.
Инициализация по умолчанию происходит в определённых обстоятельствах при работе с синтаксисом объекта T object; :
-
Если по типу
Tне является ни структурой, ни массивом, например,int a;, то никакой инициализации не происходит. Очевидно, перед нами неопределённое поведение. -
Если
T— это массив, например,std::string a[10];, то всё нормально: каждый элемент инициализируется по умолчанию. Но обратите внимание: для некоторых типов, например,int: int a[10]инициализация по умолчанию не предусмотрена; в таком случае все эти элементы остаются неинициализированными. -
Если
T— это POD (простая структура данных, использовались в языке C++ до C++11. С выходом версии C++11 формулировка в стандарте изменилась, но под термином «Trivially Default Constructible» (тривиально по умолчанию конструируемая) структура понимается то же самое), напр.,Foo foo;то никакой инициализации не производится. Напоминает ситуацию, в которой мы бы выполнилиint a;, а потом прочиталиa. Очевидно, перед нами неопределённое поведение. -
Если
T— это структура, не являющаяся POD, напр.,Bar bar;, то вызывается конструктор по умолчанию, отвечающий за инициализацию всех полей. Легко пропустить какой‑нибудь из них или вообще забыть реализовать конструктор по умолчанию, что, опять же, приведёт к неопределённому поведению.
Важно различать первый и последний случай. В первом случае вызов конструктора по умолчанию пропускает сам компилятор. В последнем случае вызывается конструктор, заданный по умолчанию. Если конструктор по умолчанию в структуре не объявлен, то компилятор сгенерирует его за нас, а затем вызовет. Можете в этом убедиться, просмотрев сгенерированный ассемблер.
При возникновении такого бага имеем дело с последним случаем: тип Response не является простой структурой данных (поскольку содержит поле std::string data), поэтому вызывается конструктор по умолчанию. Response не реализует конструктор по умолчанию. Таким образом, компилятор сгенерирует конструктор по умолчанию за нас. Причём, в этом сгенерированном коде каждое поле структуры инициализируется по умолчанию. Таким образом, конструктор std::string вызывается применительно к полю data — и всё нормально. Кроме того, другие два поля не инициализируются каким-либо образом. Ой.
Кратко обобщим ситуацию:
|
Тип |
Пример |
Результат (Default Init) |
|
Примитив ( |
|
Неопределённое поведение (мусорное значение) |
|
простая / тривиальная структура |
|
Неопределённое поведение (все поля мусорные) |
|
Массив объектов |
|
Безопасно (все строки инициализируются) |
|
Массив примитивов |
|
Неопределённое (везде мусор) |
|
Нетривиальная структура |
|
Вызывает конструктор по умолчанию (со структурами всё нормально, с примитивами получается нормально) |
|
Любой тип (скобки) |
|
Значение инициализировано (безопасно / заполнено нулями) |
Следовательно, единственная возможность исправить структуру без необходимости править все точки вызовов — реализовать такой конструктор по умолчанию, который исправно инициализирует каждое поле:
struct Response { bool error; bool succeeded; std::string data; Response(): error{false}, succeeded{false}, data{} { }};
Вот ссылка на godbolt с разбором этого кода.
Разумеется, согласно правилу 6 (когда я начинал учить C++, речь шла о 3), теперь требуется реализовать и деструктор по умолчанию, конструктор перемещений по умолчанию и т.д, и т.п.
В качестве альтернативы можно определить значения по умолчанию для всех полей прямо в определении структуры, чтобы не определять конструктор по умолчанию:
struct Response { bool error = false; bool succeeded = false; std::string data;}
В таком случае конструктор по умолчанию, сгенерированный компилятором, инициализирует все поля.
Что было потом
На момент описываемых событий я просто изменил точку вызова вот так:
Response response{};
Вот ссылка на godbolt с разбором этого кода.
В таком случае поля error и succeeded принудительно инициализируются в ноль, и data также инициализируется по умолчанию. Причём, менять определение структуры не требуется.
Именно это я и порекомендовал коллегам по команде, обсуждая эту проблему: не дразните лукавого, просто при объявлении переменной всегда инициализируйте её в ноль.
Важно отметить, что в некоторых случаях синтаксис объявления Response response; полностью корректен — при соблюдении следующих условий:
-
По типу значение относится к массивам или к структурам, не являющимся POD и
-
Для каждого поля предусмотрен конструктор по умолчанию или значение по умолчанию, прописываемое прямо в определении структуры
Затем вызывается конструктор по умолчанию для структуры, который, в свою очередь, вызывает конструктор по умолчанию для каждого поля.
Например:
struct Bar { std::string s; std::vector<std::string> vec;};int main() { Bar bar; // Выводит s=`` v.len=0 // Неопределённого поведения нет printf("s=%s v.len=%zu\n", bar.s.c_str(), bar.vec.size());}
Но, чтобы это узнать, необходимо (рекурсивно) проверить каждое поле или исходить из того, что каждый конструктор по умолчанию инициализирует каждое поле.
Наконец, стоит отметить, что при прочтении неинициализированного значения мы в любом случае получим неопределённое поведение. Если у вас просто есть неинициализированные поля — это ещё не неопределённое поведение. Если поля никогда не будут читаться, либо перед чтением в них будет записываться известное значение, то неопределённого поведения не возникнет.
Нам поможет статический анализ?
Компилятор (clang) не улавливает эту проблему, даже когда в нём включены все предупреждения. Это удручает — ещё бы, компилятор с готовностью генерирует и вызывает такой конструктор по умолчанию, который инициализирует не все поля. Таким образом, ожидается, что вызывающая сторона вручную заполнит все неинициализированные поля какими-то значениями? На мой взгляд, это нонсенс.
clang-tidy проблему отлавливает. Но на момент описываемых событий механизм был несовершенным, вот что я тогда записал на этот счёт:
clang-tidy сообщает о данной проблеме при попытке передать такую переменную функции в качестве аргумента — только и всего. Мы же хотим выявить все проблемные точки, даже в тех случаях, когда значение не передаётся функции. Кроме того, clang-tidy сообщает только об одном выявленном случае — и выходит.
Правда, на данный момент ситуация как будто улучшилась, и компилятор сообщает обо всех проблемных точках, а не только о тех, что обнаруживаются в вызовах функций. Это отлично.
Также в моих заметках я писал, что cppcheck отлавливает такое без проблем, но теперь, пытаясь его использовать, обнаруживаю, что он видит не всё даже при --enable=all. То есть, может быть, либо программа немного деградировала, либо я использую её неправильно.
Нужно анализировать код во время выполнения
Вероятно, наиболее опытные специалисты по C или C++ сейчас в сердцах восклицают: да просто используйте санитайзер адресов (или коротко ASan)!
Давайте рассмотрим следующий проблематичный код:
$ clang++ main.cpp -Weverything -std=c++11 -g -fsanitize=address,undefined -Wno-padded$ ./a.outa.out(46953,0x1f7f4a0c0) malloc: nano zone abandoned due to inability to reserve vm space.main.cpp:21:41: runtime error: load of value 8, which is not a valid value for type 'bool'SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior main.cpp:21:41 error=0 success=1
Отлично, мы выявили неопределённое поведение! Даже если сообщение об ошибке сформулировано не слишком понятно, ASan таким образом вам докладывает: «Это булево значение, я ожидал, что здесь будет 0 или 1, но в этой ячейке памяти я нашёл случайную 8».
В качестве альтернативы можно было бы воспользоваться Valgrind и получить тот же результат.
Но, это одновременно означает, что мы можем быть уверены в полном отсутствии неопределённого поведения в нашем коде, лишь обеспечив ему 100% покрытие тестами. Это серьёзное требование.
Кроме того, в рамках тех тестов, что я проводил, санитайзер адресов не всегда сообщал о проблеме. Такова природа этого инструмента: он консервативен и должен не допускать ложноположительных результатов, не утомлять пользователя уведомлениями, но именно поэтому он и не может отловить всех проблем.
Наконец, работа этих инструментов негативно сказывается на производительности и может немного осложнить процесс сборки.
Что насчёт санитайзера памяти?
В некоторых комментариях к оригиналу этой статьи читатели указывали, что есть другой инструмент для выявления именно таких проблем: санитайзер памяти.
Отличная штука, но на момент подготовки оригинала статьи этот инструмент не поддерживался ни под Windows, ни под macOS. Кроме того, как следует из документации, этот инструмент не работает со статическим связыванием.
То есть, в общем, он не очень удобен в использовании.
Что было потом
Тогда я написал плагин libclang, позволяющий выявлять другие случаи возникновения такой проблемы в базе кода во время сборки: https://github.com/gaultier/c/tree/master/libclang-plugin.
Забавно, что во всей базе кода нашёлся ещё всего один случай, который на поверку оказался ложноположительным результатом — просто оказалось, что вызывающая сторона выставляет неинициализированные поля, вот так:
Response response;response.error = false;response.success = true;
Не уверен, что этот плагин libclang до сих пор работает, поскольку слышал, что в API libclang часто вносятся принципиальные изменения.
Некоторые бонусные правила по обращению с C++
Помните все правила, которые мы обсудили выше? Хотите ещё? Что, если мы добавим к ним несколько лакомых специальных случаев?
Есть некоторые типы, которые не провоцируют неопределённого поведения, даже если их значение не инициализировано, если используются определёнными способами:
-
std::byte -
unsigned char -
char, если основополагающее представление является беззнаковым (unsigned)
Например, следующий код совершенно нормален, и неопределённого поведения в нём нет:
unsigned char c; // “c” обладает неопределённым/ошибочным значением unsigned char d = c; // неопределённое/ошибочное поведение отсутствует, // но “d” имеет неопределённое/ошибочное значение assert(c == d); // соблюдается, но в обоих случаях целочисленное повышение приводит к // неопределённому/ошибочному поведению
И при применении ASan этот механизм работает безупречно. Clang может выбросить ряд предупреждений, но нормально справится с работой, и это валидный код (по меркам стандарта C++).
Теперь давайте попробуем воспользоваться (например) bool:
bool c; bool d = c; assert(c == d);
Это неопределённое поведение, сразу же провоцирующее ошибки в ASan! Даже если в коде останутся прежними как размер типов, так и компоновка стека!
Не представляю, почему в стандарте C++ потребовалось дополнительно мутить воду, но определённо резоны были. Верно?
Наскоро проверив эту тему, пришёл к выводу, что эти типы — специальные, и с их помощью можно прямо в коде манипулировать сырыми байтами или управлять буфером, а также не злить при этом компилятор. Пожалуй… это оправданно?
Заключение
На мой взгляд, в этом баге заключена вся суть C++:
-
Синтаксис, на вид напоминающий C, но иногда делающий что‑то совершенно не так, как в C, причём, незаметно. Этот синтаксис может быть абсолютно корректным (например, при работе с массивом, а в некоторых случаях — и с типами, не относящимися к числу POD) или приводить к неопределённому поведению. Напомню, что C и C++ — это разные языки.
-
Компилятор не предупреждает о неопределённом поведении и, чтобы выявить его, приходится полагаться на сторонние инструменты. Такие инструменты всегда в чём‑то ограничены, а, кроме того, обычно работают медленно.
-
Компилятор с готовностью генерирует такой конструктор по умолчанию, который оставляет объект в полуинициализированном состоянии
-
Правила в стандарте сформулированы запутанно и меняются от версии к версии стандарта (или, как минимум, в формулировках). Я просто ещё раз отмечу, что в стандарте C++26 эти правила вновь изменились. Изменилась и формулировка. Уф.
-
В C++ так много способ инициализировать переменную — и большинство из них неправильные.
-
Чтобы код работал правильно, разработчик должен продумывать не только точку вызова, но и полное определение структуры, а также учитывать, не POD ли это.
-
Если добавить или удалить в структуре одно поле (напр., поле
data), то компилятор сгенерирует в точках вызовов совершенно другой код. -
Нужно иметь учёную степень в программерском крючкотворстве, чтобы понимать, что именно понимается в стандарте под неопределённым поведением, и как его можно спровоцировать.
Напротив, мне правда очень нравится подход в стиле «POD», взятый на вооружение во многих языках от C до Go и Rust: структура трактуется как простые данные. Либо компилятор вынуждает вас установить каждое поле в структуре прямо на этапе её создания, либо не принуждает вас — и в таком случае инициализирует в ноль все неупомянутые поля. Это настолько просто, что по определению корректно (но давайте не будем говорить о неинициализированных заполненных нулями участках между полями в C :/ ).
Наконец, я рад, что мне попался этот баг, поскольку благодаря нему я впервые осознал всю реалистичность и опасность неопределённого поведения по одной простой причине: при неопределённом поведении в коде написано одно, а программа ведёт себя совершенно по-другому. Читая код, вы никак не могли бы спрогнозировать, что программа поведёт себя так. Код больше не является источником истины. В программе возникают невозможные значения, словно в ваш компьютер попал космический луч и перещёлкнул несколько битов. Причём, не составляет труда легко и непринуждённо спровоцировать неопределённое поведение, самому того не заметив.
Программисты — тоже люди, и мы по-настоящему усваиваем важность какой-либо проблемы (повреждение или гонка данных, неопределённое поведение, т.д.), лишь когда обожжёмся на ней, и она испортит нам день.
ссылка на оригинал статьи https://habr.com/ru/articles/1043796/