Мы используем куки, чтобы пользоваться сайтом было удобно.
Хорошо
to the top
>
>
>
Путеводитель C++ программиста по неопре…

Путеводитель C++ программиста по неопределённому поведению: часть 1 из 11

Вашему вниманию предлагается первая часть электронной книги, которая посвящена неопределённому поведению. Книга не является учебным пособием и рассчитана на тех, кто уже хорошо знаком с программированием на C++. Это своего рода путеводитель C++ программиста по неопределённому поведению, причём по самым его тайным и экзотическим местам. Автор книги — Дмитрий Свиридкин, редактор — Андрей Карпов.

1129_book_pt_1_ru/image1.png

Предисловие

Паникуй!

От автора. Коротко о том, зачем и почему

Всё начинается просто и незатейливо: обычный десятиклассник увлекается программированием, знакомится с алгоритмическими задачками, решения которых должны быть быстрыми. Узнаёт о языке 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 начинал запускать игрушечки.

Сегодня, если вы поделите что-то на ноль, подобного наверняка не произойдёт. Однако неприятности всё же бывают разные:

  • Для конкретной платформы и компилятора в документации сказано, что именно произойдёт несмотря на страшные слова "undefined behavior" в стандарте. И всё будет хорошо. Вы знаете, что делаете. Никакой неопределённости. Все классно.
  • UB при работе с памятью чаще всего заканчиваются ошибкой сегментации и получением прекрасного сигнала SIGSEGV от операционной системы. Программа падает.
  • Программа работает и штатно завершается. Но даёт разные (или неадекватные) результаты от запуска к запуску. Также результаты меняются от сборки к сборке при изменении опций компилятора или самого компилятора. Никаких генераторов случайных чисел вы не использовали.
  • Программа ведёт себя неправильно несмотря на то, что в коде наставлено огромное множество проверок, assert'ов, try-catch блоков, каждый из которых "подтверждает", что всё корректно. В отладчике видно, что вычисления идут корректно, но совершенно внезапно всё ломается.
  • Программа выполняет код, который в ней есть, но не вызывался. Отрабатываются ни разу не вызываемые функции.
  • Компилятор "без причины" и без падения отказывается собирать код. Линковщик выдаёт "невозможные и бессмысленные" ошибки.
  • Проверки в коде перестают исполняться. Под отладчиком видно, что исполнение не заходит внутрь веток if или catch, хотя по значениям переменных заход должен быть выполнен.
  • Внезапный необоснованный вызов std::terminate.
  • Бесконечные циклы становятся конечными и наоборот.

С неопределённым поведением часто путают два других понятия.

  • Ещё одна страшная аббревиатура UB — неуточнённое (unspecified) поведение. Стандарт не уточняет, что именно может произойти, но описывает варианты. Так, например, порядок вычисления аргументов функции — поведение неуточнённое.
  • Поведение, определяемое реализацией (implementation-defined) — надо смотреть документацию для вашей платформы и вашего компилятора.

Эта парочка намного лучше неопределённого, хотя и имеет с ним одну общую черту: программа, полагающаяся на любое из них, строго говоря, непереносима.

Также выделяют два класса неопределённого поведения:

  • Неопределённое поведение на уровне библиотеки (Library Undefined Behavior): вы сделали что-то, что не предусматривается конкретной библиотекой (в том числе и стандартной, но не всегда). Например, библиотека gMock под страхом неопределённого поведения не допускает донастраивать mock-объект после начала его использования.
  • Неопределённое поведение на уровне языка (Language Undefined Behavior): вы сделали что-то, что фундаментально не определено спецификацией языка программирования. Например, разыменовали нулевой указатель.

Если вы столкнулись с первым — у вас проблемы. Но если всё работает, то с очень большим шансом и продолжит работать до тех пор, пока вы не обновите библиотеку или не смените платформу. А побочные эффекты часто могут быть лишь локальными. Очень похоже на implementation-defined поведение.

Если вы столкнулись со вторым — у вас большие проблемы. Код может совершенно внезапно перестать корректно работать при малейших изменениях. А также могут быть серьёзные угрозы безопасности для пользователей вашего приложения.

Полезные ссылки

Как искать неопределённое поведение?

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

Ответ на него в общем случае — никак. Это алгоритмически неразрешимая задача, практически ничем не отличающаяся от задачи останова. Но программистов как палками ни гоняй, всё равно будут решать неразрешимые задачи, так что для конкретного кода и для конкретных входных данных иногда есть способы дать ответ.

Можно проверить код до компиляции различными статическими анализаторами:

  • Cppcheck,
  • Clang Static Analyzer,
  • PVS-Studio,
  • и другие.

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

Например, 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

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

  • Тип возвращаемого значения std::accumulate определяется третьим аргументом. В данном случае это целочисленный знаковый ноль — тип по умолчанию для всех числовых литералов.
  • Тип возвращаемого значения операции деления определяется наибольшим из участвующих типов аргументов, а также правилами integer promotion. В примере тип левого аргумента — int, а правого — size_t — достаточно широкое беззнаковое целое. Более широкое, чем int. Потому, по правилам integer promotion, результатом будет size_t.
  • -3 неявно преобразуется к типу size_t — такое преобразование вполне определено. Результатом будет беззнаковое число 2^N - 3.
  • Далее будет произведено деление беззнаковых чисел: (2^N - 3) / 3. Старший бит результата окажется нулевым.
  • Возвращаемым типом функции average объявлен int. Так что нужно выполнить ещё одно неявное преобразование.
  • В общем случае преобразование unsigned -> signed определяется реализацией (implementation defined):
    • если размеры типов int и size_t одинаковые, то, поскольку старший бит нулевой, положительное число укладывается в допустимый диапазон значений для типа int — стандарт гарантирует, что никаких проблем нет;
    • если размеры не совпадают, то произойдёт сужающее преобразование (narrowing conversion), которое как раз-таки отдано на откуп деталям реализации. Так, вместо ожидаемой обрезки старших, не поместившихся битов, на некоторых платформах может произойти замена на std::numeric_limits<int>::max;
    • например, при сборке под 64-битную платформу с помощью GCC сужающее преобразование определено, как и ожидается, через обрезку старших битов. Поэтому итоговым результатом оказывается ((2^64 - 3) / 3 % 2^32).

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

Пример с 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 активности.

Последние статьи:

Опрос:

Попробуйте PVS-Studio бесплатно

Unicorn with gift
Популярные статьи по теме


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

Следующие комментарии next comments
close comment form
close form

Заполните форму в два простых шага ниже:

Ваши контактные данные:

Шаг 1
Поздравляем! У вас есть промокод!

Тип желаемой лицензии:

Шаг 2
Team license
Enterprise license
** Нажимая на кнопку, вы даете согласие на обработку
своих персональных данных. См. Политику конфиденциальности
close form
Запросите информацию о ценах
Новая лицензия
Продление лицензии
--Выберите валюту--
USD
EUR
RUB
* Нажимая на кнопку, вы даете согласие на обработку
своих персональных данных. См. Политику конфиденциальности

close form
Бесплатная лицензия PVS‑Studio для специалистов Microsoft MVP
* Нажимая на кнопку, вы даете согласие на обработку
своих персональных данных. См. Политику конфиденциальности

close form
Для получения лицензии для вашего открытого
проекта заполните, пожалуйста, эту форму
* Нажимая на кнопку, вы даете согласие на обработку
своих персональных данных. См. Политику конфиденциальности

close form
Мне интересно попробовать плагин на:
* Нажимая на кнопку, вы даете согласие на обработку
своих персональных данных. См. Политику конфиденциальности

close form
check circle
Ваше сообщение отправлено.

Мы ответим вам на


Если вы так и не получили ответ, пожалуйста, проверьте, отфильтровано ли письмо в одну из следующих стандартных папок:

  • Промоакции
  • Оповещения
  • Спам