Undefined behavior (UB) — боль, знакомая каждому разработчику со стажем; эдакий «код Шредингера», когда не знаешь, правильно тот работает или нет. К счастью, стандарты языка С++20/23/26 привнесли относительно неопределенного поведения кое-что новое. И довольно важное, если вы — архитектор ПО, а «плюсы» — ключевой стек вашей компании (подробнее о том, как и почему мы в «Лаборатории Касперского» много используем С++, читайте здесь).

В этой статье я со своих позиций Senior Software Architect и Security Champion в микроядерной операционной системе KasperskyOS рассмотрю кейсы-ловушки, в которые можно попасть практически в любом из стандартов, и покажу, что меняется в С++20/23/26, — уменьшается ли количество кейсов с неопределенным поведением, и становится ли С++ безопаснее.



Что такое undefined behavior


Начнем с основных определений: что такое undefined behavior, сколько их всего, хорошо это или плохо. Тем более что мир неопределенных поведений в «плюсах» широк и разнообразен.

В стандарте есть три базовых определения, которые можно связать с undefined behavior.

  1. Поведение, определяемое реализацией, — implementation-defined behavior, т. е. не определенное в стандарте. С ним все довольно неплохо, потому что полный список есть в специальном разделе в конце стандарта Index of implementation-defined behavior (https://timsong-cpp.github.io/cppwp/n4868/impldefindex). Там довольно много кейсов с примерами, в частности:

    • размер указателей — он везде разный;
    • определение макроса NULL;
    • знаковость типа char (знаковый или беззнаковый — зависит от компилятора);
    • размер базовых типов, кроме char.

  2. Неуточненное поведение — unspecified behavior, когда стандарт определяет несколько вариантов. В целом это можно назвать implementation defined, но стандарт не дает полного списка неспецифицированного поведения, кейсы приходится искать самостоятельно. Примерами могут служить:

    • порядок вычисления аргументов в вызове функции (кроме четко определенных — операторов «и», «или» и тернарного);
    • порядок вычисления операндов операторов +, -, =, *, /, кроме &&, ||, ?:;

  3. Неопределенное поведение — 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 в structured 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 <typename>
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<std::string> 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<class T>
constexpr T add_sat(T x, T y) noexcept;

template<class T>
constexpr T sub_sat(T x, T y) noexcept;

template<class T>
constexpr T mul_sat(T x, T y) noexcept;

template<class T>
constexpr T div_sat(T x, T y) noexcept;

template<class T, class U>
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++ — отнюдь не лучший язык, чтобы выстрелить себе в ногу :)

Комментарии (44)


  1. TheProgger
    27.06.2024 15:04
    +22

    Здесь он предполагает, что за границей массива элемент будет в любом случае найден, поэтому возвращает true

    Нет, компилятор предполагает, что программа корректна и выхода за границы массива не будет. Т.е. выполнение никогда не дойдёт до момента, когда i=4, а значит в какой-то предыдущей итерации выполнится return true.


    1. pda0
      27.06.2024 15:04
      +10

      В этом и есть главная проблема UB в плюсах и вообще плюсов. Язык спроектирован по принципу: "-- Доктор, я сломал ногу в нескольких местах. -- Ну так не ходите больше в те места."


    1. Deosis
      27.06.2024 15:04
      +7

      Компилятор видит, что функция либо возвращает true, либо происходит UB.

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


  1. lazy_val
    27.06.2024 15:04
    +9

    x += 10; // C++ 20 deprecated
    x++; // C++ 20 deprecated
    

    Увидел - вздрогнул ... ))


    1. bakhtiyarov
      27.06.2024 15:04

      C++; //deprecated


  1. truthfinder
    27.06.2024 15:04
    +10

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


    1. pda0
      27.06.2024 15:04
      +24

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

      Т.е. задача "отлавливать ub" не решаема как побочная задача компиляции, только как отдельный статический анализатор, который будет анализировать ast не преобразовывая его, а именно выискивая косяки.

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

      Ну или пользоваться сторонними решениями. (Кивок в сторону мирно пасущегося единорога...)


    1. IvanPetrof
      27.06.2024 15:04
      +7

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


    1. ZirakZigil
      27.06.2024 15:04
      +3

      Злые языки говорят, что теорема Райса (вроде) не позволяет для общего случая разрешать любые нетривиальные свойства, к которым, в том числе, относится "наличие UB вот в этом месте". В таком случае и заморачиваться не стоит.

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


  1. mbait
    27.06.2024 15:04
    +6

    Я прочитал за всё время уже 10-20 статей, где приводятся примеры того, как компилятор С++ видит UB и решает выбросить стол в окно выполнить странную оптимизацию. А можно хотя бы один пример, где такая оптимизация приносит пользу, а не ломает программу? Если нет, то почему она вообще существует? Я понимаю, что есть случаи, когда действительно понятно на этапе компиляции, что, например, ветка if никодга не будет исполнена. Но вот пример с поиском элемента за границей массива это какая-то хрень. С чего бы это элемент должен там найтись? Почему вообще компилятор, заметив выход за границу массива, не выдал предупреждение? Тут либо peephole-оптимизация IR, которая может терять высокоуровневый конекст, либо уже никто толком не понимает, как работают GCC/LLVM, и начинают оправдывать работу оптимизатора заботой о скорости - этакий вариант стокгольмского синдрома.


    1. Deosis
      27.06.2024 15:04
      +1

      Компилятор видит, что функция либо возвращает true, либо происходит UB.

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


      1. mbait
        27.06.2024 15:04
        +3

        Вопрос не в этом, а в том, бывает ли какая-то польза от такой оптимизации? Если программист позаботился о том, что UB нет, значит по логике нужно продолжать выполнять код как есть, а не заменять функцию заглушкой, которая всегда true возвращает.


        1. haqreu
          27.06.2024 15:04
          +4

          Зачем выполнять код, который заведомо не нужен? Это же и есть оптимизация.


          1. leotsarev
            27.06.2024 15:04

            Пример приведите.


            1. haqreu
              27.06.2024 15:04

              #include <climits>
              int main() {
                for (int i=0; i<INT_MAX; i++);
                return 0;
              }

              Цикл вполне можно выкинуть.


        1. pda0
          27.06.2024 15:04

          Польза от самой оптимизации. Я там выше написал. Оптимизирующий компилятор трансформирует ast, так что к моменту когда по UB-коду генерируется машинный, функция может быть уже не функцией (встроена), а цикл - не циклом (развёрнут).


    1. mayorovp
      27.06.2024 15:04
      +1

      Потому что без UB невозможна вообще никакая оптимизация. Даже вы сами не сможете ничего оптимизировать в коде не используя понятие UB.

      Рассмотрим что-нибудь простое и очевидное. Ну вот, например, FizzBuzz:

      for (int i=1; i<=3; i++)
      {
             printf("%d\n", i);
      }
      

      А теперь сделаем loop unrolling:

             printf("%d\n", 1);
             printf("%d\n", 2);;
             printf("%d\n", 3);;
      

      Является ли эти две программы эквивалентными? С одной стороны, да, это вроде бы очевидно.
      С другой стороны, если в языке определено поведение в любой ситуации, то у нас нет никаких гарантий что функция printf не перепишет значение переменной i, поэтому программы не эквивалентны.


      1. haqreu
        27.06.2024 15:04
        +2

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

        Конкретно в вашем примере: если компилятор докажет отсутствие сайд-эффектов на i при вызове printf (а туда передаётся копия i, не адрес), то вполне можно раскрыть.


        1. mayorovp
          27.06.2024 15:04
          +1

          Передаётся-то копия, но нет никакой гарантии что где-то в глобальной переменной не записан адрес i в качестве, к примеру, адреса буфера вывода.

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

          Это либо языки без сырых указателей, либо их авторы лишь притворяются что у них нет UB.


          1. haqreu
            27.06.2024 15:04
            +1

            Передаётся-то копия, но нет никакой гарантии что где-то в глобальной переменной не записан адрес i в качестве, к примеру, адреса буфера вывода.

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


            1. mayorovp
              27.06.2024 15:04

              Разве что при анализе программы целиком, и то не факт. Любой непроанализированный кусок программы может переписать адрес буфера вывода.


              1. haqreu
                27.06.2024 15:04
                +1

                Ещё раз, я с мыслью согласен, а вот формулировка понятна наверняка не всем :)


          1. unC0Rr
            27.06.2024 15:04

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


            1. mayorovp
              27.06.2024 15:04

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


              1. haqreu
                27.06.2024 15:04
                +1

                Такое поведение может быть и без UB...


              1. unC0Rr
                27.06.2024 15:04

                При отсутствии UB у вас не может где-то заранее быть записан указатель на участок стека с новообъявленной переменной.


                1. haqreu
                  27.06.2024 15:04

                  Ну, достаточно добавить одну инструкцию в тело цикла перед вызовом printf. UB нет, адрес есть. Доказательство отсутствия сайд-эффектов вызова printf провалено, loop unrolling не происходит.


                1. mayorovp
                  27.06.2024 15:04
                  +1

                  При отсутствии UB у вас не может где-то заранее быть записан указатель на участок стека с новообъявленной переменной.

                  Почему не может?

                  int* shared_ptr;
                  
                  void foo() {
                      int i;
                      shared_ptr = &i;
                  }
                  
                  void baz() {
                      int i = 1;
                      *shared_ptr = 2; // упс, i изменилась
                  }
                  
                  void main() {
                      foo();
                      bar();
                  }
                  


                  1. unC0Rr
                    27.06.2024 15:04

                    s/упс, i изменилась/UB/


                    1. mayorovp
                      27.06.2024 15:04
                      +1

                      Так UB же отсутствует в языке.


                      1. haqreu
                        27.06.2024 15:04

                        Тут явно путаница с отсутствием ситуации UB конкретно в си и с отсутствием механизма UB в гипотетическом языке.


            1. haqreu
              27.06.2024 15:04
              +1

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


          1. leotsarev
            27.06.2024 15:04

            Гарантия есть. Адрес переменной i никогда не брали, она вообще может его не иметь. И кстати и не будет.

            Но все ещё непонятно, как это связано с понятием UB и его наличием в языке.


            1. ZirakZigil
              27.06.2024 15:04
              +2

              Смотрите на пример выше. Адрес переменной i внутри baz никто не брал, тем не менее её значение изменится. В языках с UB такие приколы, собственно, UB, которого по предположениям компилятора нет, поэтому он может их игнорировать и выполнять оптимизации. В языках без UB такие приколы не будут UB, а значит их результаты будут одним из возможных состояний, в которых программа может оказаться по мере работы цикла. В таком случае игнорировать это нельзя, ведь от этого зависит наблюдаемое поведение этой программы, т.е. возможностей для оптимизации стало меньше.


  1. boldape
    27.06.2024 15:04

    А разве в примере с фором по знаковому индексу с применением ссайз не будет ворнинга на преобразование знакового индекса в сайзт в операторе сабскрипт?

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

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

    for (auto i = arr.empty()?-1:0u; i < arr.size()-1; i++)

    Но да когда завезут где то в с++32 знаковые сабскрипты вот тогда и начнем использовать ссайз как в вашем примере без лишних приседаний.


  1. Browning
    27.06.2024 15:04
    +1

    Видимо, P1705 и не будет принят, поскольку superseded by P3075.


  1. haqreu
    27.06.2024 15:04
    +6

    out of bounds write — переполнение буфера, оно же stack overflow.

    эээ...


    1. TheProgger
      27.06.2024 15:04

      Видимо имеется ввиду, что stack overflow тоже приводит к out of bounds write


    1. gmc Автор
      27.06.2024 15:04

      Stack overflow это частный случай out of bounds write, наиболее известный большинству читателей.


      1. haqreu
        27.06.2024 15:04
        +2

        Ну, если уж вы так вольно трактуете, то тогда "stack overflow, оно же out of bouds write", а не как вы написали :)


  1. sv91
    27.06.2024 15:04

    Кажется, функция

    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;
      }
    }

    не эквивалентна функции

    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;
    }

    Нужна еще проверка, что файла действительно не было. Такой метод в классе есть?


    1. mayorovp
      27.06.2024 15:04

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


  1. ahabreader
    27.06.2024 15:04
    +3

    Деприкейт volatile в С++20.

    Задеприкейтили сложное присваивание и инкремент / декремент

    Сложное присваивание вернули в C++23.

    С его deprecation в C++20 была глупая история. С одной стороны, известно, что volatile не даёт атомарности и в основном нужен для MMIO. С другой стороны, volatile ошибочно используют вместо atomic, что в сознании масс сократилось до "volatile - зло" и похожую ошибку, видимо, сам комитет допустил.

    Дискуссию о возвращении вели на реддите и возвращение встречали критично: эмбеддеры с возу - кобыле легче.

    Кстати, ортогональность volatile и atomic наводит на мысль, что компилятор имеет право оптимизировать atomic (об этом же написал автор "Deprecating volatile"), но судя по godbolt никто из компиляторов не рискует применять оптимизации.


    1. gmc Автор
      27.06.2024 15:04

      Спасибо за комментарий. Про возвращение присваивания не знал, интересная получилась история))