Вашему вниманию предлагается первая часть электронной книги, которая посвящена неопределённому поведению. Книга не является учебным пособием и рассчитана на тех, кто уже хорошо знаком с программированием на C++. Это своего рода путеводитель C++ программиста по неопределённому поведению, причём по самым его тайным и экзотическим местам. Автор книги — Дмитрий Свиридкин, редактор — Андрей Карпов.
Паникуй!
Всё начинается просто и незатейливо: обычный десятиклассник увлекается программированием, знакомится с алгоритмическими задачками, решения которых должны быть быстрыми. Узнаёт о языке C++, учит минимальный синтаксис, основные конструкции, контейнеры, решает задачи с предопределённым и всегда корректным форматом ввода и вывода, и горя не знает...
В это же время где-то в большом мире матерые разработчики каждый день ругают то одни языки программирования, то другие, по самым разным причинам: не удобно, нет какой-то возможности, много лишних букв нужно писать, ошибки в стандартной библиотеке... Но есть язык, который ругают за всё и особенно за такую непонятную и таинственную вещь, как неопределённое поведение (undefined behavior, UB).
Спустя лет пять или шесть наш простой десятиклассник, горя не видавший в море оторванных от реальности программ, внезапно узнаёт, что тем самым горячо нелюбимым языком всегда был, остаётся и будет его C++.
А потом ещё в течение нескольких лет он наткнётся на самые кошмарные и невероятные ужасы, поджидающие программистов на C++ почти на каждом шагу. Так и появится эта серия заметок, собирающая наиболее отвратительные примеры, на которые очень легко наткнуться при решении повседневных задач.
"Преждевременная оптимизация — корень всех зол" (Д. Кнут или Э. Хоар — в зависимости от того, какой источник смотрите).
Язык С++, пожалуй, наиболее яркая тому демонстрация: огромное количество ошибок в C++ программах связаны с неопределённым поведением, заложенным в фундаменте языка просто для того, чтобы дать простор оптимизациям на этапе компиляции.
Если вы собираетесь писать на C++ код, в работоспособности которого хотите быть хоть немного уверенны, стоит знать о существовании различных подводных камней и ловко расставленных мин в стандарте языка и его библиотеке, и всячески их избегать. Иначе ваши программы будут работать правильно только на конкретной машине и только по воле случая.
Важно: этот сборник не является учебным пособием по языку и рассчитан на тех, кто уже знаком с программированием, с C++ и понимает основные его конструкции.
Тема неопределённого поведения мне очень близка. Она пронизывает родной мне проект PVS-Studio, одним из основателей которого я являюсь. PVS-Studio — это инструмент статического анализа кода, перед которым стоит необъятная задача по поиску этого самого неопределённого поведения. У анализатора есть и другие задачи, например, поиск опечаток или недостижимого кода. Однако UB — самый большой и неиссякаемый источник проблем в C++ программах, а соответственно и поводов создавать новые диагностики для их выявления.
Так что, встретив путеводитель Дмитрия Свиридкина по UB на GitHub (ubbook), я с большим любопытством с ним ознакомился. Выписал для себя ряд интересных мыслей, которые со временем станут основой новых диагностических правил. В общем, я получил от чтения и удовольствие, и пользу.
После я задумался. Во-первых, у меня тоже есть кое-что на тему неопределённого поведения. Во-вторых, таким ценным и интересным материалом хочется поделиться с как можно большим количеством программистов, для чего нужно перевести его на английский язык. Впрочем, думал я недолго и решил попробовать реализовать это на практике.
Я связался с Дмитрием и предложил сотрудничество по редактированию, дополнению и переводу его материала. Он согласился, и мы приступили к работе над этой электронной книгой, которую со временем попробуем превратить в печатную. Приглашаю посмотреть, что у нас получилось. Запасайтесь печеньками и вниманием для приятного и вдумчивого чтения.
Неопределённое поведение (undefined behavior, UB) — это удивительная особенность некоторых языков программирования, позволяющая написать синтаксически корректную программу, работающую совершенно непредсказуемо при переносе её с одной платформы на другую, изменении опций компиляции/интерпретации или замене одного компилятора/интерпретатора другим. И главное, помимо синтаксической корректности, программа выглядит корректной семантически.
Состоит эта особенность в том, что в спецификации языка программирования сознательно не определяют поведение программы в каких-то особых условиях. Делается это из соображений производительности: не надо генерировать дополнительные инструкции с проверками, — или из соображений обеспечения гибкости при реализации каких-то фич. В спецификации пишут просто: "Если код делает что-то нехорошее, то поведение не определено". Например:
Важно, что это "поведение не определено" означает, что произойти может что угодно: форматирование диска, ошибка компиляции, исключение, а может всё будет хорошо. Никаких гарантий не даётся. Отсюда и происходят весёлые, неожиданные и очень печальные в production-коде последствия.
И, конечно же, именно C и C++ наиболее печально известны своим неопределённым поведением. Однако надо понимать, что эта особенность присуща и другим языкам. Во многих языках можно найти какой-нибудь редкий особенный пример с неопределённым поведением. Но именно в C и C++ оно встречается при написании почти любой программы: слишком много фич языка содержат пункты с неопределённым поведением.
Итак, по каким же признакам можно заподозрить UB в программе, и насколько неопределённое поведение действительно неопределённое?
Когда-то давно UB в коде могло привести действительно к чему угодно. Например, GCC 1.17 начинал запускать игрушечки.
Сегодня, если вы поделите что-то на ноль, подобного наверняка не произойдёт. Однако неприятности всё же бывают разные:
С неопределённым поведением часто путают два других понятия.
Эта парочка намного лучше неопределённого, хотя и имеет с ним одну общую черту: программа, полагающаяся на любое из них, строго говоря, непереносима.
Также выделяют два класса неопределённого поведения:
Если вы столкнулись с первым — у вас проблемы. Но если всё работает, то с очень большим шансом и продолжит работать до тех пор, пока вы не обновите библиотеку или не смените платформу. А побочные эффекты часто могут быть лишь локальными. Очень похоже на implementation-defined поведение.
Если вы столкнулись со вторым — у вас большие проблемы. Код может совершенно внезапно перестать корректно работать при малейших изменениях. А также могут быть серьёзные угрозы безопасности для пользователей вашего приложения.
Очень частый вопрос, который задавали мне, который я задавал сам себе и другим. Да и каждый C++ разработчик, к сожалению, должен его задавать.
Ответ на него в общем случае — никак. Это алгоритмически неразрешимая задача, практически ничем не отличающаяся от задачи останова. Но программистов как палками ни гоняй, всё равно будут решать неразрешимые задачи, так что для конкретного кода и для конкретных входных данных иногда есть способы дать ответ.
Можно проверить код до компиляции различными статическими анализаторами:
Достаточно умный анализатор, работающий с графом потока выполнения программы и знающий сотни ловушек стандарта, способен найти многие проблемы и привлечь внимание к сомнительному коду. Но не все и не всегда.
Например, GCC пожалуется на этот код
int arr[5] = {1,2,3,4,5};
int main() {
int i = 5;
return arr[i];
}
выдав сообщение:
array subscript 5 is above array bounds of 'int [5]' [-Warray-bounds]
6 | return arr[i];
| ~~~~~^
note: while referencing 'arr'
2 | int arr[5] = {1,2,3,4,5};
Мы можем сами проверять часть кода в compile-time на различных наборах входных данных, используя constexpr. В контексте, вычисляемом на этапе компиляции, UB запрещено:
constexpr int my_div(int a, int b) {
return a / b;
}
namespace test {
template <unsigned int N>
constexpr int div_test(const int (&A)[N], const int (&B)[N]) {
int x = 0;
for (auto i = 0u; i < N; ++i) {
x = ::my_div(A[i], B[i]);
}
return x;
}
constexpr int A[] = {1,2,3,4,5};
constexpr int B[] = {1,2,3,4,0};
static_assert((div_test(A, A), true)); // OK
static_assert((div_test(A, B), true)); // Compilation error, zero division
Но constexpr не везде применим: в зависимости от версии стандарта он налагает ограничения на тело функции, а также неявно применяет inline спецификатор, "запрещая" отрывать определение функции в отдельную единицу трансляции (или, по-простому, определение придётся разместить в заголовочном файле).
Наконец, если мы не смогли найти ошибки статическим анализом (внешними утилитами или компилятором), можно прибегнуть к динамическому анализу.
При сборке компиляторами Clang или GCC можно включить санитайзеры -fsanitize=undefined, -fsanitize=address, -fsanitize=thread, позволяющие отлавливать ошибки в run-time, но ценой значительных накладных расходов. Так что пользоваться этими средствами нужно только на этапе тестирования и разработки.
Также для отладочных сборок код стандартных библиотек иногда инструментирован assert'aми. Так, например, сделано для различных итераторов стандартной библиотеки в поставке MSVC (Visual Studio).
Поскольку неопределённое поведение проявляется в возможностях оптимизации тем или иным компилятором, нужно собирать свой код под разные платформы, с разными уровнями оптимизаций, и сравнивать его поведение. Код без ошибок должен быть переносимым и вести себя одинаково (если, конечно, его задача не в генерации совершенно случайных значений).
Тесты, различные сборки, статический и динамический анализ — способы немного поднять уверенность в том, что в вашем коде нет UB. Дать же точную гарантию может только коллегия экспертов, которые будут сверять каждую строчку кода с буквой стандарта и трижды друг друга перепроверять. И даже этого может быть недостаточно.
Ещё есть путь отключения каких-либо оптимизаций флагами компилятора. Также есть вариант включения флагов для различных нарушений стандарта (знаменитый -fpermissive), превращающих язык C++ во что-то совершенно иное. Но призываю вас никогда не идти этим путём. Ваш код станет непереносимым. Ваш код перестанет быть кодом на C++. Лучше сразу возьмите другой язык программирования.
Неявные преобразования типов запрещены во многих современных языках программирования, особенно новых.
Так в Rust, Haskell, Kotlin нельзя просто так использовать float и int в одном арифметическом выражении без явного указания преобразовать одно в другое. Python не так строг, но все же не даёт смешивать строки, символы и числа.
В С++ запрета неявного преобразования нет, что порождает массу ошибочного кода. Причём в таком коде может быть как определённое, но неожиданное, так и неопределённое поведение.
Пример:
#include <vector>
#include <numeric>
#include <iostream>
int average(const std::vector<int>& v) {
if (v.empty()) {
return 0;
}
return std::accumulate(v.begin(), v.end(), 0) / v.size();
}
int main() {
std::cout << average({-1,-1,-1});
}
Любой, кто мельком бросит взгляд на этот код, будет ожидать, что результатом работы окажется -1. Но, увы, результат будет совершенно другим. Программа, собранная GCC под платформу x86-64, распечатает:
1431655764
В этом коде нет неопределённого поведения (по крайней мере на используемых входных данных). Но есть неявное приведение типов, делающее результат неожиданным.
Неявные приведения типов касаются не только встроенных примитивов, но и более сложных типов. И самое неприятное — они вмешиваются в выбор подходящей перегрузки функции, приводя к различным — часто неприятным — казусам.
Пример с abs:
#include <cmath>
#include <iostream>
int main() {
std::cout << abs(3.5) << "\n"; // функция библиотеки С,
// принимает на вход тип long
// результат — 3
std::cout << std::abs(3.5); // функция библиотеки С++
// перегружена для double
// результат — 3.5
}
Ещё более неприятный пример наблюдается со стандартным типом std::string:
#include <string>
int main() {
std::string s;
s += 48; // неявное приведение к char.
s += 1000; // а тут еще и с переполнением, очень неприятным
// на платформе с signed char.
s += 49.5; // опять-таки неявное приведение к char
}
Этот ужас компилируется!
Казалось бы, этот пример совершенно ужасного использования никогда не может встретиться в нормальном коде. Увы, но может.
Вы можете написать обобщённый код своего std::accumulate с различными проверками шаблонных аргументов и случайно, по ошибке, передать в него string в качестве аккумулятора и контейнер, например, float. И никакой ошибки компиляции не будет. Только странный баг в программе.
#include <string>
#include <vector>
#include <iostream>
template <class Range, class Acc>
auto accumulate(Range&& r, Acc acc)
requires(requires(){
{acc += *std::begin(r) };
})
{
for (auto&& x : r){
acc += x;
}
return acc;
}
int main() {
std::vector<double> v {0.5, 0.7, 0.1};
auto res = accumulate(v, std::string{});
std::cout << '"' << res << '"';
}
Вывод программы:
""
Цепочки неявных преобразований могут быть очень неочевидными:
void f(float&& x) { std::cout << "float " << x << "\n"; }
void f(int&& x) { std::cout << "int " << x << "\n"; }
void g(auto&& v) { f(v); } // C++20
int main() {
g(2);
g(1.f);
}
Самым удивительным образом этот пример выводит:
float 2
int 1
Хотя мы подставляли типы констант совсем наоборот и почти наверняка ожидали:
int 2
float 1
Это не баг компилятора и не неопределённое поведение! Всему виной хитрая цепочка неявных преобразований.
Рассмотрим её на примере первого вызова g(2), подставив параметр шаблона:
void g(int&& v) {
// Несмотря на то что тип v — int&&
// Дальнейшее использование v в выражениях дает int& !
// decltype(v) == int&&
// decltype((v)) == int&
// Функции f принимают только rvalue ссылки
// Неявное преобразование int& к int&& запрещено
// int&& x = 5;
// int&& y = x; // не компилируется!
// Таким образом перегрузка f(int&&) не может быть использована
// Остается f(float&&)
// int умеет неявно приводиться к float
// int& умеет неявно выступать в роли просто int
// неявный static_cast<float>(v) возвращает временное значение float
// временные значения типа T неявно биндятся к T&&
// Имеем цепочку преобразований:
// int& -> int -> float -> float&&
f(v); // будет вызван f(float&&) !
// явно: f(static_cast<float>(v));
}
Конечно, никто никогда (по крайней мере явно) не принимает примитивы по rvalue-ссылкам. Потому что это бессмысленно. Но даже без rvalue-ссылки для примитивов мы можем сотворить нечто ужасное:
struct MyMovableStruct {
operator bool () {
return !data.empty();
}
std::string data;
};
void consume(MyMovableStruct&& x) {
std::cout << "MyStruct: " << x.data << "\n";
}
void consume(bool x) { std::cout << "bool " << x << "\n"; }
void g(auto&& v) { consume(v); }
int main() {
g(MyMovableStruct{"hello"});
}
Той же самой цепочкой преобразований получим в выводе "bool 1". Разве что последний шаг не нужен.
Обязательно включайте предупреждения компилятора обо всех неявных преобразованиях. Очень желательно трактовать их как ошибки.
Не привносите неявные преобразования для своих типов — всегда помечайте однопараметрические конструкторы как explicit.
Если перегружаете операторы приведения (operator T()) для своих типов, также делайте их explicit.
Если ваши функции/методы рассчитаны на работу только с определённым примитивным типом, навешивайте на них ограничения с помощью шаблонов, SFINAE, концептов или, что очень просто, механизма явного удаления перегрузок (= delete):
int only_ints(int x) { return x;}
template <class T>
auto only_ints(T x) = delete;
int main() {
const int& x = 2;
only_ints(2);
only_ints(x);
char c = '1';
only_ints(c); // Compilation Error.
only_ints(2.5); // Explicitly deleted.
}
Автор — Дмитрий Свиридкин
Более восьми лет работает в сфере коммерческой разработки высокопроизводительного программного обеспечения на C и C++. С 2019 по 2021 год преподавал курсы системного программирования под Linux в СПбГУ и практики C++ в ВШЭ. В настоящее время — Software Engineer в AWS (Cloudfront), занимается системной и embedded-разработкой на Rust и C++ для edge-серверов. Основная сфера интересов — безопасность программного обеспечения.
Редактор — Андрей Карпов
Более 15 лет занимается темой статического анализа кода и качества программного обеспечения. Автор большого количества статей, посвящённых написанию качественного кода на языке C++. С 2011 по 2021 год удостаивался награды Microsoft MVP в номинации Developer Technologies. Один из основателей проекта PVS-Studio. Долгое время являлся CTO компании и занимался разработкой С++ ядра анализатора. Основная деятельность на данный момент — управление командами, обучение сотрудников и DevRel активности.
0