Опасность устарела: несколько важных нюансов в новых стандартах C++
Undefined behavior (UB) — боль, знакомая каждому разработчику со стажем; эдакий «код Шредингера», когда не знаешь, правильно тот работает или нет. К счастью, стандарты языка С++20/23/26 привнесли относительно неопределенного поведения кое-что новое. И довольно важное, если вы — архитектор ПО, а «плюсы» — ключевой стек вашей компании (подробнее о том, как и почему мы в «Лаборатории Касперского» много используем С++, читайте здесь).
В этой статье я со своих позиций Senior Software Architect и Security Champion в микроядерной операционной системе KasperskyOS рассмотрю кейсы-ловушки, в которые можно попасть практически в любом из стандартов, и покажу, что меняется в С++20/23/26, — уменьшается ли количество кейсов с неопределенным поведением, и становится ли С++ безопаснее.
Что такое undefined behavior
Начнем с основных определений: что такое undefined behavior, сколько их всего, хорошо это или плохо. Тем более что мир неопределенных поведений в «плюсах» широк и разнообразен.
В стандарте есть три базовых определения, которые можно связать с undefined behavior.
- Поведение, определяемое реализацией, — implementation-defined behavior, т. е. не определенное в стандарте. С ним все довольно неплохо, потому что полный список есть в специальном разделе в конце стандарта Index of implementation-defined behavior (https://timsong-cpp.github.io/cppwp/n4868/impldefindex). Там довольно много кейсов с примерами, в частности:
- размер указателей — он везде разный;
- определение макроса NULL;
- знаковость типа char (знаковый или беззнаковый — зависит от компилятора);
- размер базовых типов, кроме char.
- …
- Неуточненное поведение — unspecified behavior, когда стандарт определяет несколько вариантов. В целом это можно назвать implementation defined, но стандарт не дает полного списка неспецифицированного поведения, кейсы приходится искать самостоятельно. Примерами могут служить:
- порядок вычисления аргументов в вызове функции (кроме четко определенных — операторов «и», «или» и тернарного);
- порядок вычисления операндов операторов +, -, =, *, /, кроме &&, ||, ?:;
- …
- Неопределенное поведение — undefined behavior. Если предыдущие два типа изменчивого поведения предполагают, что программа все еще корректна, ошибок нет, то с undefined behavior она уже не валидная. Стандарт не налагает никаких требований, утверждая, что произойти может все что угодно. В нем дано довольно общее определение и список этого undefined behavior весьма широк. В этой статье речь пойдет как раз об undefined behavior. Примеры:
- доступ за пределами массива;
- разыменование нулевого указателя;
- целочисленное деление на ноль;
- целочисленное переполнение;
- использование памяти после освобождения;
- использование неинициализированной переменной;
- бесконечные циклы без сайд-эффектов;
- гонки;
- …
Сколько всего undefined behavior в С++?
Ответ на этот вопрос неутешителен — никто точно не знает.
Если взять стандарт C99, в нем неопределенное поведение прописано в отдельной секции: J.2 Undefined behavior https://port70.net/~nsz/c/c99/n1256.html#J.2 (там 193 кейса). Все это относится и к С++.
Но у С++ есть и свой специфичный undefined behavior. Можно использовать такие списки:
- P1705R1 Enumerating Core Undefined Behavior (36 кейсов) https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2019/p1705r1.html — этот proposal еще не принят, т. е. работа над ним не закончена. Его задача перечислить все официальные для С++ виды undefined behavior и добавить их в стандарт;
- справочники разной степени полноты https://github.com/Nekrolm/ubbook (~60 кейсов). Здесь все расписано довольно подробно;
- правила в санитарах: clang ubsan (~35 правил) https://clang.llvm.org/docs/UndefinedBehaviorSanitizer.html#id4 — можно использовать, если более формально подходить к вопросу.
Но даже все эти списки вместе не дадут полного перечня.
А реально может произойти все что угодно?
Стандарт говорит, что результатом undefined behavior может быть все что угодно. Так ли это? Ответ не очень утешительный — это действительно так.
Обычно приводят отрицательные примеры, типа разлитого кофе или армагеддона. Я же постарался поискать более положительный — оказывается, с undefined behavior можно выиграть в лотерее.
Самый популярный кейс undefined behavior в компаниях, которые занимаются информационной безопасностью, — это переполнение буфера. Им активно пользуются злоумышленники, так как сейчас в основном лотереи электронные. На сервере запускается генератор случайных чисел и выбирается случайный победитель. Мы можем воспользоваться уязвимостью переполнения буфера на сервере, чтобы через нее добавить shell-код, влияющий на генератор, и запустить его. Результат: мы неожиданно стали победителем лотереи и выиграли, например, «АААААвтомобиль»!
По сути, можно сказать, что undefined behavior в исходном его варианте и был причиной положительного исхода. Таких сценариев можно придумать бесконечное количество.
Почему UB — это плохо?
Безопасность
Основная сложность — проблема с безопасностью. Чтобы оценить, сколько уязвимостей возникает из-за undefined behavior, можно взять 25 TOP CWE (https://cwe.mitre.org/top25/archive/2023/2023_top25_list.html — данные за 2023 год) — рейтинг проблем в программных продуктах, которые приводят к уязвимостям. В ТОПе за 2023 год есть пять уязвимостей, непосредственно связанных с undefined behavior:
Первое место традиционно занимает out of bounds write — переполнение буфера, оно же stack overflow. В данном случае «оценка» — это некий интегральный рейтинг, который говорит о серьезности уязвимости, а эксплуатируемость — количество эксплойтов.
В списке перечислены только те уязвимости, которые напрямую связаны с undefined behavior. Но во многих других уязвимостях undefined behavior также может быть косвенной причиной.
Неожиданная оптимизация
Оптимизация — сильная сторона компилятора С++. Но она может преподносить некоторые сюрпризы. Многие наверное слышали, что компилятор может оптимизировать очень интересными способами. Вот хрестоматийный пример, который часто используется для описания проблемы:
Функция ищет в массиве элемент. После оптимизации она может неожиданно упроститься.
Компилятор выкидывает цикл и if, потому что изначально в коде ошибка — мы выходим за границы массива. Компилятор видит undefined behavior и на основе этого оптимизирует код так, как ему удобно (так, чтобы программа выполнялась максимально быстро). Здесь он предполагает, что за границей массива элемент будет в любом случае найден, поэтому возвращает true, а цикл со всем остальным выкидываются за борт.
Здесь стоит отметить, что такая странная оптимизация, изменяющая ход выполнения, возникает, только если изначально программа содержала ошибки. В обычной ситуации, когда в коде нет undefined behavior, оптимизация выполняется корректно.
Кейсов такой странной оптимизации довольно много. Есть источники, которые их собирают:
Почему UB — это хорошо?
Undefined behavior — это не всегда плохо. В большинстве случаев из этого можно взять что-то хорошее.
Скорость
Первое и основное преимущество — это скорость работы откомпилированной программы.
Традиционно компилятор С++ считает программиста достаточно умным, чтобы не допускать undefined behavior. Поэтому по умолчанию он не выполняет:
- нулевую оптимизацию;
- проверки счетчиков и ссылок;
- проверки на границы буфера;
- проверки предусловий и других ограничений.
Все это считается лишним. А отсутствие этих проверок приводит к агрессивной оптимизации, когда можно делать инлайновые функции, выкидывать циклы, менять порядок выполнения инструкций и так далее. Это приводит к большому бусту производительности.
Разнообразие платформ
Код, который компилируется на С и С++, запускается на самом разнообразном железе. И везде он должен работать, несмотря на разную архитектуру, адресацию, работу с памятью, отличия в представлении чисел и т. п. Поэтому компилятор оставляет себе пространство для маневров в виде undefined behaviour.
Да, есть много положительного, но если UB потенциально может навредить, то все прекрасное того не стоит.
UB в современных стандартах
Рассмотрим конкретные примеры undefined behaviour в современных стандартах. И начнем с С++ 20.
Знаковые целые в дополнительном коде в С++20
В proposal P0907R4: Signed Integers are Two’s Complement https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2018/p1236r1.html знаковые числа теперь представляются в дополнительном коде.
Положительное число везде представлено одинаково: первый бит — знаковый, для положительного числа он всегда ноль. Для отрицательного числа появляются отличия в представлениях. Общее здесь только то, что первый знаковый бит становится единицей. В прямом коде значащие биты остаются такими, какими были, в обратном коде — инвертируются, а в дополнительном коде помимо инверсии к числу добавляется единичка. Инверсия нужна для того, чтобы производить операции сложения и вычитания на одном АЛУ. А добавление единицы — это способ предварительно учесть ее при переносе.
Дополнительный код решает проблему двух нулей (отрицательного и положительного), типичную для прямого и обратного кода, — он только один. Плюс немного отличаются диапазоны — в дополнительном коде, если мы говорим про восьмиразрядную сетку, есть еще один отрицательный элемент: −128. В обратном и прямом коде этого числа нет.
До стандарта С++20 никто не говорил о том, каким должно быть представление знакового целого числа. Оно могло быть представлено в любом коде, в том числе и поэтому переполнение знакового числа было undefined behavior. А начиная с 20-го стандарта мы используем дополнительный код, который всегда ведет себя одинаково: при переполнении он осуществляет циклический возврат, т. е. максимальное значение становится минимальным.
К сожалению, в С++ не все так просто. Даже с учетом известного способа представления знакового числа, его переполнение все равно остается undefined behavior — и в proposal это явно отмечено.
Note: Overflow for signed arithmetic yields undefined behavior (7.1 [expr.pre]). — end note
С тем, что это undefined behavior, связано много оптимизаций в компиляторе. Тем не менее в С++ добавились небольшие нововведения в битовых сдвигах (<<, >>). Они будут полезны тем, кто пользуется битовыми операциями.
- Можно сдвигать отрицательные числа (раньше это было undefined behavior).
int x = -1 << 12;
- При сдвиге влево происходит заполнение нулями.
- При сдвиге вправо происходит заполнение знаковым битом.
- Количество сдвигов должно быть положительным.
- Количество сдвигов не должно превышать количество битов числа.
Упомянутые ограничения существовали и раньше для положительных чисел. Их нарушение и сейчас вызовет undefined behavior.
Деприкейт volatile в С++20
В С++20 задеприкейтили ключевое слово volatile: P1152R4: Deprecating volatile https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2019/p1152r4.html.
Это ключевое слово имеет дурную славу и в С, и в С++. Раньше никто не понимал, как им пользоваться, поэтому его использовали неправильно. Кажется, теперь оно наконец-то должно уйти.
Правда, это не совсем так…
The proposed deprecation preserves the useful parts of volatile, and removes the dubious / already broken ones.
Запрещены только некоторые кейсы, которые и раньше были сломаны — они либо ничего не делали (игнорировались), либо демонстрировали некорректное использование volatile, например в качестве atomic. В итоге volatile сохраняется, объявлять его можно.
Задеприкейтили сложное присваивание и инкремент / декремент, потому что это составные операции.
volatile int x = 0;
x += 10; // C++ 20 deprecated
x++; // C++ 20 deprecated
Если кто-то по старой привычке использовал volatile в качестве atomic, ожидая атомарных операций, то это всегда было не атомарно. А сейчас это будет явно подсвечено при компиляции.
Запретили volatile аргументы и возвращаемое значение функции.
volatile int func(volatile int arg); // C++ 20 deprecated
Это и раньше не работало, а просто игнорировалось. Например, если задан volatile для аргумента, можно было бы подумать, что в этом случае какие-то оптимизации будут отключены (аргумент передавался бы не через регистр, а через стек, и это форсилось бы через volatile). Но volatile никогда не менял calling conventions, т. е. в итоге ни на что не влиял. Такая же история с возвращаемым значением функции.
Еще один запрещенный кейс — это volatile в structure buildings. Это некий механизм задания псевдонимов для члена структуры или элемента массива.
struct Foo {int val;} bar;
volatile auto [val] = bar; // C++ 20 deprecated
Но в данном случае псевдоним, объявленный с volatile, никак не влиял на исходный элемент структуры или массива, а только вводил в заблуждение. Т. е. по сути это тоже не работало.
Кейсы, в которых volatile не работал или просто игнорировался, фактически пришли из того, что он всегда шел в паре с const-ом. Но теперь они разделяются и, по моему мнению, это большой плюс.
Тем, кто хочет изучить эту тему подробнее, рекомендую довольно интересное выступление с CppCon 2019: Deprecating volatile — JF Bastien https://www.youtube.com/watch? v=KJW_DLaVXIY&ab_channel=CppCon.
Знаковая функция ssize () в С++20
В С++20 появилась знаковая функция ssize (): P1227: Signed ssize () functions https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2019/p1227r2.html.
Чтобы понять, зачем она нужна, предлагаю пример:
template
bool has_repeated_values(const T& container) {
for (int i = 0; i < container.size() - 1; ++i) {
if (container[i] == container[i + 1])
return true;
}
return false;
}
Здесь задана функция, которая ищет в контейнере дубликаты. Судя по коду, контейнер должен прийти уже отсортированный. Но в функции есть ошибка: чтение за пределами массива, если приходит нулевой размер контейнера. И эта ошибка вызывает undefined behavior — вместо отрицательного значения мы получаем максимальное, делаем перебор всей памяти и в результате происходит непонятно что.
Исправить этот кейс можно с помощью функции ssize (). Она превращает беззнаковый тип std: size_t в знаковый тип std: ptrdiff_t. Если size_t позволяет адресовать всю доступную память для 64-битной системы (и это 64 бита), то ptrdiff_t по своей семантике позволяет представлять разницу адресов памяти. Эта разница может быть отрицательной и здесь получается значение на один бит меньше (63 значащих бита, один знаковый). Правда, здесь все равно остается возможность undefined behavior, которая возникает, если размер контейнера превысил PTRDIFF_MAX, но меньше SIZE_MAX.
Можно парировать, что контейнеры размером с половину всей адресуемой памяти в 64-битной системе попадаются не так уж и часто. Да и вообще такой контейнер вряд ли получится создать, а значит, кейс undefined behavior будет довольно редкий. Т. е. в целом использование ssize () базовые ошибки все равно пофиксит.
Починка ренжового for в С++23
Перейдем к более свежим стандартам. Одно из моих любимых нововведений — в C++23 починили работу ренжового for: P2644R1: Fix for Range-based for Loop https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2022/p2718r0.html. Оказывается, с момента своего появления в С++11 он работал неправильно.
Пояснения про ошибку придется начать издалека.
Предположим, у нас есть функция GetVector, которая возвращает вектор строк.
std::vector GetVector() {
return { "str1", "str2" };
}
В С++ есть несколько механизмов, которые позволяют продлить жизнь временных объектов до конца скоупа. Первый — это константная ссылка на значение, которая живет до конца скоупа. В этом случае мы получим str правильно.
{
const auto& val{ GetVector() };
std::cout << val.front() << std::endl; // str1
}
Второй механизм — это автоссылка, она же форвард референс, она же универсальная ссылка, которая продлевает время жизни временных объектов.
{
auto&& val{ GetVector() };
std::cout << val.front() << std::endl; // str1
}
Ренжовый цикл for тоже умеет продлевать время жизни временных объектов. В инициализации мы можем создать временный вектор, который будет жить до конца for.
for (auto& val : GetVector()) {
std::cout << val << std::endl; // str1 str2
}
Здесь все будет хорошо до того момента, пока мы не захотим проитерироваться не только по вектору строк, но еще и по символам в строке.
for (auto& val : GetVector().front()) {
std::cout << val << std::endl;
}
Здесь мы получаем undefined behavior. Однако стоит оговориться, что undefined behavior может быть в том случае, если GetVector возвращает вектор нулевого размера. Но здесь я хотел сделать акцент на другом.
Если говорить про ренжовый for в контексте продления жизни временных объектов, то эта конструкция не монолитна. Она раскрывается в обычный for:
auto&& rg = GetVector().front(); // !!!
auto pos = rg.begin();
auto end = rg.end();
for ( ; pos != end; ++pos ) {
char c = *pos;
…
}
Сначала здесь происходит инициализация (объявление универсальной ссылки). Она должна была бы продлить время жизни временного объекта, но, к сожалению, здесь доступ к временному объекту происходит по цепочке вызовов. В C++ продление по цепочке не отрабатывало никогда. Получается, что ренжовый for тоже никогда не работал.
Проблема именно в ренжовом for, потому что он скрывает в себе детали реализации. Для обычного программиста — клиента for — непонятно, как он внутри устроен и во что раскрывается. Эта скрытность вызывает сомнения, поскольку разработчику нужно знать хотя бы то, какие временные объекты сохраняются, независимо от того, в цепочке они или нет.
В proposal не указано, как это фиксится. Там приводятся примеры, как это можно сделать. Например, можно заменить простое раскрытие на лямбду и в нее передавать финальный временный объект. Он будет сохраняться до конца выполнения лямбды, что решит проблему. Но это не единственный способ.
Важный вывод: в С++23 все временные объекты, создаваемые в ренжовом for по цепочке (сколько бы их там ни было), будут сохраняться. Это несомненный плюс.
Использование string с нулевым указателем в С++23
С++23 запрещает использовать string с нулевым указателем: P2166R1: A Proposal to Prohibit std: basic_string and std: basic_string_view construction from nullptr https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2020/p2166r1.html
В чем тут суть. Один из самых популярных конструкторов string в С++ — из нуль-терминальной строки. Там явно указано ограничение на то, что нуль-терминальная строка должна быть валидной, иначе проявится undefined behavior.
Если мы говорим про nullptr, то это невалидный интервал. По стандарту здесь должно быть undefined behavior:
std::string str{ nullptr };
Чтобы это пофиксить, применили элегантное решение: явно запретили конструктор из nullptr. Если нет компиляции, то нет undefined behavior. Казалось бы, все неплохо. Но, к сожалению, если использовать переменную с тем же nullptr, то все равно будет undefined behavior.
Пример:
char *ch = nullptr;
std::string str(ch); // UB
На самом деле инициализация с nullptr имеет рантаймовые проверки на многих компиляторах. По крайней мере на clang будет эксепшен. Но не всегда это так. Например, майкрософтовский компилятор себя ведет по-другому, поэтому лучше логику на это не завязывать.
Использование эксклюзивного режима для файловых стримов в С++23
Еще один интересный кейс — использование эксклюзивного режима для файловых стримов: P2467R1: Support exclusive mode for fstreams https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2022/p2467r1.html.
Прежде чем перейдем к его описанию, небольшое пояснение.
Бывают кейсы, когда нужно что-то проверить в файле и только потом его открыть. В данном случае проверяется наличие файла. Файл открывается, только если его нет:
void CheckAndCreate(const std::filesystem::path& p) {
if (!std::filesystem::exists(p)) {
std::fstream f(p.string(), std::ios_base::in | std::ios_base::out);
f << "data" << std::endl;
}
}
Это нужно, например, при создании конфигов. Во время первого запуска приложение проверяет наличие конфига, и если его нет, то создает файл, добавляя в него дефолтные значения (если же конфиг есть, то приложение его читает).
Но у этого кейса есть проблема. Между моментом проверки файла и моментом его использования есть окно для гонки, в которое может вклиниться злоумышленник. К примеру, он может сделать symlink на путь к какому-то системному файлу, а в итоге программа перепишет системный файл. Здесь, конечно, много «если», поскольку, чтобы перезаписать системный файл, программа должна иметь соответствующие права. Кроме того, она должна иметь внешний ввод, чтобы записать в этот файл то, что нужно злоумышленнику. Но если все звезды сойдутся, то действительно есть такая уязвимость.
Решается это специальным флагом, который в С++ называется noreplace. Он задается при создании файлового стрима и атомарно проверяет наличие файла. Если файла нет, то только в этом случае он будет открываться.
Данная проверка атомарна, и этот флаг — далеко не новость. В стандарте С он существует довольно давно. Теперь это приятное нововведение появилось и в С++.
void CheckAndCreateNoRace(const std::filesystem::path& p)
{
std::fstream f(p.string(),
std::ios_base::in |
std::ios_base::out |
std::ios_base::noreplace);
f << "data" << std::endl;
}
К сожалению, это фиксит не все кейсы. Например, если нужно проверить не просто наличие, а какой-то атрибут файла, допустим, его расширение или время записи, это все равно делается по пути и не атомарно, т. е. все равно будет гонка.
Конечно, здесь можно еще поспорить, является ли это гонкой или нет. Будем считать это просто гонкой. Но на самом деле подобные кейсы, которые проверяют что-то на пути, а потом используют файл уже отдельно, все ломаные. Вся библиотека std: filesystem имеет такие проблемы.
void CheckAndUse(const std::filesystem::path& p) {
if (std::filesystem::is_regular_file(p)) {
std::fstream f(p.string(), std::ios_base::in | std::ios_base::out);
f << "data";
}
}
Существует пара proposal, которые должны решать эту проблему радикально — вместо пути они предлагают использовать файловые хэндлы.
В этих пропозалах полностью перерабатывается вся библиотека std: filesystem. Они намечены на С++26, т. е. еще не приняты:
Доступ к контейнерам в С++26
В контейнерах С++ всегда было две возможности доступа — через оператор скобки и метод At. Первый вариант не проверял диапазон, второй — проверял и выкидывал исключение. Оба метода были реализованы во всех контейнерах, кроме span. С самого момента появления std: span в С++20 метода At там не было.
Proposal P2821R4: span.at () https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2023/p2821r4.html имеет мало технических подробностей, но задает много вопросов. Намеренно ли ни в С++20, ни в С++23 нет этого метода? Если это сделано специально, то почему метод не убрали из других контейнеров (или хотя бы не задеприкейтили)? Ответ дан там же, и он банален:
Ultimately, this becomes a stereotypical example of how C++ traditionally handles safety.
Это пример того, как в С++ традиционно обрабатываются кейсы, связанные с безопасностью. Все происходит не быстро и в суматохе. Иногда о каких-то моментах могут просто забыть.
Только лишь в С++26 в span появится at ().
Арифметика с насыщением в С++26
Напоследок — еще один интересный кейс: арифметика с насыщением: C++ 26, P0543R3: Saturation arithmetic https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2023/p0543r3.html.
Арифметика с насыщением — это версия арифметики, в которой все операции, такие как сложение и умножение, ограничены фиксированным диапазоном между минимальным и максимальным значением.
Напомню, что переполнение беззнаковых целых undefined behavior не является, оно всегда было циклическим возвратом или арифметикой по модулю. Переполнение знаковых целых в С++ было undefined behavior. А арифметика с насыщением позволяет решить этот вопрос радикально. С точки зрения арифметики с насыщением все операции с числами должны происходить в определенном диапазоне значений. Если мы выходим за этот диапазон, возвращено будет только максимальное или минимальное число (в зависимости от того, в какую сторону мы вышли за диапазон).
Использование арифметики с насыщением — далеко не новость. Она довольно развита и в некоторых кейсах действительно помогает. Она действительно имеет смысл, например, в графике или 3D моделировании.
Использование этой арифметики очень простое. В С++26 появится всего четыре дополнительных функции, которые дублируют арифметические операции сложения, умножения, деления, вычитания, а также функция Saturate_cast — преобразование к типу, которое учитывает либо максимум, либо минимум.
template
constexpr T add_sat(T x, T y) noexcept;
template
constexpr T sub_sat(T x, T y) noexcept;
template
constexpr T mul_sat(T x, T y) noexcept;
template
constexpr T div_sat(T x, T y) noexcept;
template
constexpr T saturate_cast(U x) noexcept;
Может возникнуть вопрос, насколько быстро это будет работать. В теории мы могли бы добавить эти проверки (if) во все операции вручную — по сути самостоятельно создать такие функции. Но, в соответствии с proposal, функции будут использовать аппаратное ускорение — команды, которые есть во всех современных процессорах.
Most modern hardware architectures have efficient support for saturation arithmetic on SIMD vectors, including SSE2 for x86 and NEON for ARM.
Работать это будет не медленнее, чем обычная арифметика.
Подведем итоги
Отрадно, что вопросы безопасности и, в частности, undefined behavior в С++ поднимаются все чаще. Это не может не радовать, так как proposal и фич, связанных с safety и security, много, и все они требуют должного внимания.
В современном С++ все меньше шансов словить undefined behavior на практике — по невнимательности или незнанию. То есть UB перестает быть скрытым, и это тоже хорошо.
Но, к сожалению, выпиливание undefined behavior происходит не быстро. Все-таки стандарт — это волокита, согласование, обсуждение, много разных нюансов и легаси-код.
Впрочем, в чем точно можно оставаться уверенным, это в том, что полет на ракете под названием С++ становится безопасней и нет предела совершенству (или совершенство — не предел).
А также в том, что C++ — отнюдь не лучший язык, чтобы выстрелить себе в ногу :)