Неопределённое поведение и правда не определено

https://cryptoservices.github.io/fde/2018/11/30/undefined-behavior.html
  • Перевод
Термином «неопределённое поведение» в языке C и C++ обозначают ситуацию, в которой буквально «чего только не бывает». Исторически, к неопределённому поведению относили случаи, когда прежние компиляторы для C (и архитектуры на нём) вели себя несовместимым образом, и комитет по разработке стандарта, в своей безграничной мудрости, решил ничего не решать по этому поводу (т.е. не отдавать предпочтение какой-то одной из конкурирующих реализаций). Неопределённым поведением также называли возможные ситуации, в которых стандарт, обычно столь исчерпывающий, не предписывал никакого конкретного поведения. У этого термина есть и третье значение, которое в наше время становится всё более актуальным: неопределённое поведение — это возможности для оптимизации. А разработчики на C и C++ обожают оптимизации; они настойчиво требуют, чтобы компиляторы прикладывали все усилия для ускорения работы кода.

Данная статья была впервые опубликована на сайте Cryptography Services. Перевод публикуется с разрешения автора Томаса Порнина (Thomas Pornin).

Вот классический пример:

void
foo(double *src, int *dst)
{
    int i;

    for (i = 0; i < 4; i ++) {
        dst[i] = (int)src[i];
    }
}

Скомпилируем этот код GCC на 64-битной x86-платформе под Linux (я работаю на свежей версии Ubuntu 18.04, версия GCC — 7.3.0). Включим полную оптимизацию, а затем посмотрим на листинг ассемблера, для чего задействуем ключи "-W -Wall -O9 -S" (аргументом "-O9" задаётся максимальный уровень оптимизации GCC, который на практике эквивалентен "-O3", хотя в некоторых форках GCC определены и более высокие уровни). Получаем следующий результат:

        .file   "zap.c"
        .text
        .p2align 4,,15
        .globl  foo
        .type   foo, @function
foo:
.LFB0:
        .cfi_startproc
        movupd  (%rdi), %xmm0
        movupd  16(%rdi), %xmm1
        cvttpd2dq       %xmm0, %xmm0
        cvttpd2dq       %xmm1, %xmm1
        punpcklqdq      %xmm1, %xmm0
        movups  %xmm0, (%rsi)
        ret
        .cfi_endproc
.LFE0:
        .size   foo, .-foo
        .ident  "GCC: (Ubuntu 7.3.0-27ubuntu1~18.04) 7.3.0"
        .section        .note.GNU-stack,"",@progbits

Каждая из первых двух инструкций movupd перемещает два значения типа double в 128-битный регистр SSE2 (double имеет размер 64 бита, поэтому регистр SSE2 может хранить два значения типа double). Другими словами, сначала считываются четыре исходных значения, а уже потом они приводятся к int (операции cvttpd2dq). Операция punpcklqdq перемещает четыре полученных 32-битных целых значения в один регистр SSE2 (%xmm0), содержимое которого затем записывается в оперативную память (movups). А теперь главное: наша C-программа формально требует, чтобы доступ к памяти происходил в следующем порядке:

  • Считать первое значение типа double из src[0].
  • Записать первое значение типа int в dst[0].
  • Считать второе значение типа double из src[1].
  • Записать второе значение типа int в dst[1].
  • Считать третье значение типа double из src[2].
  • Записать третье значение типа int в dst[2].
  • Считать четвёртое значение типа double из src[3].
  • Записать четвёртое значение типа int в dst[3].

Однако все эти требования имеют смысл только в контексте абстрактной машины, которую и определяет стандарт C; порядок действий на реальной машине может отличаться. Компилятор волен переставлять или изменять операции при условии, что их результат не противоречит семантике абстрактной машины (так называемое правило as-if — «как если бы»). В нашем примере порядок действия как раз другой:

  • Считать первое значение типа double из src[0].
  • Считать второе значение типа double из src[1].
  • Считать третье значение типа double из src[2].
  • Считать четвёртое значение типа double из src[3].
  • Записать первое значение типа int в dst[0].
  • Записать второе значение типа int в dst[1].
  • Записать третье значение типа int в dst[2].
  • Записать четвёртое значение типа int в dst[3].

Таков язык C: всё содержимое памяти в конечном счёте есть байты (т.е. слоты со значениями типа unsigned char, а на практике — группы из восьми битов), и разрешены любые произвольные операции с указателями. В частности, указатели src и dst при вызове могут быть использованы для обращения к перекрывающимся участкам памяти (такая ситуация именуется «алиасингом»). Таким образом, порядок чтения и записи может быть важен в том случае, если байты записываются, а затем снова считываются. Чтобы реальное поведение программы соответствовало абстрактному, определённому стандартом C, компилятор должен был бы чередовать операции чтения и записи, обеспечивая полный цикл обращений к памяти на каждой итерации. Получившийся в результате код имел бы больший размер и работал бы гораздо медленнее. Для C-разработчиков это было бы горе.

Вот тут, к счастью, на помощь и приходит неопределённое поведение. Стандарт C гласит, что доступ к значениям «не может» быть осуществлён через указатели, тип которых не соответствует текущим типам этих значений. Проще говоря, если значение записывается в dst[0], где dst -указатель типа int, то соответствующие байты не могут быть считаны через src[1], где src — указатель типа double, так как в этом случае мы попытались бы получить доступ к значению, которое теперь имеет тип int, с помощью указателя несовместимого типа. В этом случае возникло бы неопределённое поведение. Об этом говорится в параграфе 7 раздела 6.5 стандарта ISO 9899:1999 («C99») (в новой редакции 9899:2018, или «C17», формулировка не изменилась). Это предписание называется правилом строгого алиасинга(strict aliasing). Как следствие, компилятору C разрешено действовать, исходя из предположения, что операции доступа к памяти, приводящие к неопределённому поведению из-за нарушения правила строгого алиасинга, не происходят. Таким образом, компилятор может переставлять операции чтения и записи в любом порядке, поскольку они не должны обращаться к перекрывающимся участкам памяти. В этом и состоит оптимизация кода.

Смысл неопределённого поведения, если кратко, заключается в следующем: компилятор может допустить, что неопределённого поведения не будет, и сгенерировать код, исходя из этого допущения. В случае правила строгого алиасинга — при условии, что алиасинг имеет место быть, — неопределённое поведение позволяет проводить важные оптимизации, реализовать которые иначе было бы сложно. Если говорить в целом, каждая инструкция в процедурах генерации кода, используемых компилятором, имеет зависимости, ограничивающие алгоритм планирования операций: инструкция не может быть выполнена раньше тех инструкций, от которых она зависит, или после тех инструкций, которые зависят от неё. В нашем примере неопределённое поведение устраняет зависимости между операциями записи в dst[] и «последующими» операциями чтения из src[]: такая зависимость может существовать только в тех случаях, когда при доступе к памяти возникает неопределённое поведение. Точно также понятие неопределённого поведения позволяет компилятору просто удалять код, который не может быть выполнен без вхождения в состояние неопределённого поведения.

Всё это, конечно, хорошо, но такое поведение иногда воспринимается как вероломное предательство со стороны компилятора. Можно часто услышать такую фразу: «Компилятор использует понятие неопределённого поведения как предлог сломать мне код». Допустим, некто пишет программу, которая складывает целые числа, и опасается переполнения — вспомним случай с Bitcoin. Он может размышлять так: для представления целых чисел процессор использует дополнительный код, а значит если переполнение случится, то случится оно потому, что результат будет усечён до размера типа, т.е. 32 бит. Значит, результат переполнения можно предсказать и проверить тестом.

Наш условный разработчик напишет так:

#include <stdio.h>
#include <stdlib.h>

int
add(int x, int y, int *z)
{
    int r = x + y;
    if (x > 0 && y > 0 && r < x) {
        return 0;
    }
    if (x < 0 && y < 0 && r > x) {
        return 0;
    }
    *z = r;
    return 1;
}

int
main(int argc, char *argv[])
{
    int x, y, z;
    if (argc != 3) {
        return EXIT_FAILURE;
    }
    x = atoi(argv[1]);
    y = atoi(argv[2]);
    if (add(x, y, &z)) {
        printf("%d\n", z);
    } else {
        printf("overflow!\n");
    }
    return 0;
}

Теперь попробуем скомпилировать этот код с помощью GCC:

$ gcc -W -Wall -O9 testadd.c
$ ./a.out 17 42
59
$ ./a.out 2000000000 1500000000
overflow!

Хорошо, вроде работает. Теперь попробуем другой компилятор, например, Clang (у меня версия 6.0.0):

$ clang -W -Wall -O3 testadd.c
$ ./a.out 17 42
59
$ ./a.out 2000000000 1500000000
-794967296

Шта?

Выходит, что, когда операция со знаковыми целыми типами приводит к результату, который не может быть представлен целевым типом, мы вступаем на территорию неопределённого поведения. Но ведь компилятор может допустить, что оно не происходит. В частности, оптимизируя выражение x > 0 && y > 0 && r < x, компилятор делает вывод, что раз значения x и y строго положительные, то третья проверка не может быть истинной (сумма двух значений не может быть меньше любого из них), и всю эту операцию можно пропустить. Другими словами, так как переполнение — это неопределённое поведение, оно «не может произойти» с точки зрения компилятора, и все инструкции, которые зависят от этого состояния, можно удалить. Механизм обнаружения неопределённого поведения попросту исчез.

Стандарт никогда не предписывал допускать, что в вычислениях со знаковыми типами используется «семантика заворачивания» (которая действительно используется в операциях процессора); так сложилось скорее в силу традиции — ещё в те времена, когда компиляторы не были достаточно сообразительны, чтобы оптимизировать код, ориентируясь на диапазон значений. Заставить Clang и GCC применять семантику заворачивания к знаковым типам можно через специальный флаг -fwrapv (в Microsoft Visual C для этого можно использовать -d2UndefIntOverflow-, как описано здесь). Однако этот подход ненадёжен, флаг может исчезнуть при переносе кода в другой проект или на другую архитектуру.

Мало кто знает о том, что переполнение знаковых типов подразумевает неопределённое поведение. Про это сказано в параграфе 5 раздела 6.5 стандартов C99 и C17:

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

Для беззнаковых типов, однако, гарантирована модульная семантика. В параграфе 9 раздела 6.2.5 сказано следующее:

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

Ещё одним примером неопределённого поведения в операциях со знаковыми типами является операция деления. Как всем известно, результат деления на ноль математически не определён, поэтому, согласно стандарту, эта операция влечёт за собой неопределённое поведение. Если в операции idiv на x86-процессоре делитель равен нулю, возникает исключение процессора. Подобно запросам на прерывание, исключения процессора обрабатываются операционной системой. На Unix-подобных системах, например Linux, исключение процессора, спровоцированное операцией idiv, переводится в сигнал SIGFPE, который посылается процессу, и тот завершается обработчиком по умолчанию (не удивляйтесь, что «FPE» расшифровывается как «floating-point exception» (исключение в операции с плавающей запятой), тогда как idiv работает с целыми числами). Но есть ещё одна ситуация, которая приводит к неопределённому поведению. Рассмотрим следующий код:

#include <stdio.h>
#include <stdlib.h>

int
main(int argc, char *argv[])
{
    int x, y;
    if (argc != 3) {
        return EXIT_FAILURE;
    }
    x = atoi(argv[1]);
    y = atoi(argv[2]);
    printf("%d\n", x / y);
    return 0;
}
Запустим его:
$ gcc -W -Wall -O testdiv.c
$ ./a.out 42 17
2
$ ./a.out -2147483648 -1
zsh: floating point exception (core dumped)  ./a.out -2147483648 -1

И правда: на этой машине (всё та же x86 под Linux) тип int представляет диапазон значений от -2 147 483 648 до +2 147 483 647. Если разделить -2 147 483 648 на -1, должно получиться +2 147 483 648. Но это число не входит в диапазон значений типа int. Поэтому поведение не определено. Может случиться всё что угодно. В данном случае процесс принудительно завершается. На другой системе, особенно с небольшим процессором, в котором нет операции деления, результат может отличаться. В таких архитектурах деление выполняется программно — с помощью процедуры, обычно предоставляемой компилятором, и вот она может сделать с неопределённым поведением всё, что ей заблагорассудится, ведь именно в этом оно и заключается.

Замечу, что SIGFPE можно получить при тех же условиях и с помощью оператора деления по модулю (%). И в самом деле: под ним скрывается всё та же операция idiv, которая вычисляет и частное, и остаток, поэтому срабатывает то же самое исключение процессора. Что интересно, стандарт C99 говорит, что выражение INT_MIN % -1 не может приводить к неопределённому поведению, поскольку результат математически определён (ноль) и однозначно входит в диапазон значений целевого типа. В версии C17 текст параграфа 6 раздела 6.5.5 был изменён, и теперь этот случай также учитывается, что приближает стандарт к реальному положению дел на распространённых аппаратных платформах.

Существует множество неочевидных ситуаций, также приводящих к неопределённому поведению. Взгляните на этот код:

#include <stdio.h>
#include <stdlib.h>

unsigned short
mul(unsigned short x, unsigned short y)
{
    return x * y;
}

int
main(int argc, char *argv[])
{
    int x, y;
    if (argc != 3) {
        return EXIT_FAILURE;
    }
    x = atoi(argv[1]);
    y = atoi(argv[2]);
    printf("%d\n", mul(x, y));
    return 0;
}

Как думаете, что программа, следуя стандарту C, должна распечатать, если в функцию передать множители 45 000 и 50 000?

  • 18 048
  • 2 250 000 000
  • Боже, храни Королеву!

Правильный ответ… да всё вышеперечисленное! Вы, возможно, рассуждали так: раз unsigned short — беззнаковый тип, он должен поддерживать семантику заворачивания по модулю 65 536, потому что на x86-процессоре размер этого типа, как правило, составляет именно 16 бит (стандарт допускает и больший размер, но на практике это всё-таки 16-битный тип). Поскольку математически произведение равно 2 250 000 000, оно будет усечено по модулю 65 536, что даёт ответ 18 048. Однако, размышляя так, мы забываем о расширении целых типов. Согласно стандарту C (раздел 6.3.1.1, параграф 2), если операнды имеют тип, чей размер строго меньше размера int, и значения этого типа могут быть представлены типом int без потери разрядов (а у нас как раз такой случай: на моём x86 под Linux размер int — 32 бита, и он явно может хранить значения от 0 до 65 535), то оба операнда приводятся к типу int и операция совершается уже над преобразованными значениями. А именно: произведение вычисляется как значение типа int и уже только при возврате из функции приводится обратно к unsigned short (т.е. именно в этот момент происходит усечение по модулю 65 536). Проблема в том, что математически результат перед обратным преобразованием равен 2 250 000 000, а это значение превышает диапазон int, который является знаковым типом. В итоге получаем неопределённое поведение. После этого случиться может всё что угодно, в том числе и внезапные приступы английского патриотизма.

Однако на практике, с обычными компиляторами, получится значение 18 048, поскольку не известно ещё такой оптимизации, которая смогла бы воспользоваться неопределённым поведением в данной конкретной программе (можно вообразить более искусственные сценарии, где оно действительно натворило бы бед).

Напоследок ещё один пример, теперь уже на C++:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <array>

int
main(int argc, char *argv[])
{
    std::array<char, 16> tmp;
    int i;

    if (argc < 2) {
        return EXIT_FAILURE;
    }
    memset(tmp.data(), 0, 16);
    if (strlen(argv[1]) < 16) {
        strcpy(tmp.data(), argv[1]);
    }
    for (i = 0; i < 17; i ++) {
        printf(" %02x", tmp[i]);
    }
    printf("\n");
}

Это вам не типичный «плохой ужасный strcpy()!». Ведь здесь функция strcpy() выполняется, только если размер исходной строки, включая терминальный ноль, достаточно мал. Более того, элементы массива явно инициализируются нулём, поэтому все байты в массиве имеют заданное значение независимо от того, большая или маленькая строка передаётся в функцию. Вместе с тем, цикл в конце некорректен: он считывает на один байт больше, чем положено.

Запустим код:

$ g++ -W -Wall -O9 testvec.c
$ ./a.out foo
 66 6f 6f 00 00 00 00 00 00 00 00 00 00 00 00 00 10 58 ffffffca ff
ffffac ffffffc0 55 00 00 00 ffffff80 71 34 ffffff99 07 ffffffba ff
ffffea ffffffd0 ffffffe5 44 ffffff83 fffffffd 7f 00 00 00 00 00 00
 00 00 00 00 10 58 ffffffca ffffffac ffffffc0 55 00 00 ffffff97 7b
 12 1b ffffffa1 7f 00 00 02 00 00 00 00 00 00 00 ffffffd8 ffffffe5
 44 ffffff83 fffffffd 7f 00 00 00 ffffff80 00 00 02 00 00 00 60 56
(...)
62 64 3d 30 30
zsh: segmentation fault (core dumped)  ./a.out foo
Шта++?

Вы можете наивно возразить: ну хорошо, он считывает лишний байт за границей массива; но это не так уж и страшно, ведь на стеке этот байт всё равно есть, в память отображается, так что единственная проблема тут — это лишний семнадцатый элемент с неизвестным значением. Цикл всё равно напечатает ровно 17 целых чисел (в шестнадцатеричном формате) и завершится без нареканий.

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

Забавно, но если GCC запустить с менее агрессивными настройками для оптимизаций, он выдаст предупреждение:

$ g++ -W -Wall -O1 testvec.c
testvec.c: In function 'int main(int, char**)':
testvec.c:20:15: warning: iteration 16 invokes undefined behavior
[-Waggressive-loop-optimizations]
         printf(" %02x", tmp[i]);
         ~~~~~~^~~~~~~~~~~~~~~~~
testvec.c:19:19: note: within this loop
     for (i = 0; i < 17; i ++) {
                 ~~^~~~

На уровне -O9 это предупреждение почему-то исчезает. Возможно, дело в том, что на высоких уровнях оптимизации компилятор более агрессивно навязывает развёртывание цикла. Возможно (но неточно), что это баг GCC (в смысле пропажа предупреждения; так-то действия GCC в любом случае не противоречат стандарту, ведь тот не требует выдачи «диагностик» в такой ситуации).

Вывод: если вы пишете код на C или C++, будьте предельно внимательны и избегайте ситуаций, приводящих к неопределённому поведению, даже когда кажется, что «ничего страшного».

Беззнаковые целочисленные типы — хороший помощник в арифметических вычислениях, поскольку для них гарантирована модульная семантика (но всё равно можно получить проблемы, связанные с расширением целых типов). Ещё один вариант — почему-то непопулярный — вовсе не писать на C и C++. По ряду причин это решение не всегда подходит. Но если есть возможность выбрать, на каком языке писать программу, т.е. когда вы только приступаете к новому проекту на платформе с поддержкой Go, Rust, Java или других языков, может быть выгоднее отказаться от использования C в качестве «языка по умолчанию». Выбор инструментов, в том числе языка программирования, — это всегда компромисс. Подводные камни C, особенно неопределённое поведение в операциях со знаковыми типами, ведут к дополнительным издержкам при дальнейшем сопровождении кода, которые часто недооцениваются.
PVS-Studio
558,00
Static Code Analysis for C, C++, C# and Java
Поделиться публикацией

Комментарии 60

    –3
      printf("%d\n", mul(x, y));

    Мелкое занудство: в этом примере программа напечает что попало еще потому, что mul возвращает тип short, а print ожидает int.
      +14
      Тут как раз всё в порядке, ведь short передаётся как int
        –6
        Видимо суть статьи вы так и не поняли. Суть неопределённого поведения в том, что компилятор может сделать всё что угодно, и его опасность в том, что часто неопределённое поведение совпадает с ожидаемым. Но стоит чуть изменить код или компилятор или параметры компиляции, и всё поменяется до неузнаваемости.
          +14
          Преобразование short в int при передаче variadic-параметра прописано в стандарте. Это не UB.
            +1
            unsigned short не всегда строго меньше int, преобразование не всегда будет в int. Иногда будет преобразование в unsigned int и UB.
              +3
              С трудом расшифровал, что вы хотели сказать. Дело не в том, что
              программа напечает что попало еще потому, что mul возвращает тип short, а print ожидает int

              А в том, что
              программа напечает что попало еще потому, что mul возвращает тип unsigned short, а printf("%d", x); ожидает signed
                0
                Не совсем так. Если sizeof(unsigned short) < sizeof(int), то преобразование будет в signed int и всё в порядке. Но если sizeof(unsigned short) == sizeof(int), то преобразование будет в unsigned int, и будет undefined behavior.
          +2
          Да, спасибо, я был неправ. Покурил, почитал, посыпал пеплом голову)
        –4
        В первом примере: допустим у нас есть double d[4]; int i[4]; после чего мы их передаем в упомянутую функцию. Разве здесь все равно будет UB, ведь области памяти гарантированно не пересекаются? Да, в d будет фигня, но тут что просили, то и получили.
        По последнему примеру — имхо, это просто бред и косяк компилятора. Даже если это выход за границы цикла и мы печатаем чужую память, разве это повод игнорировать счетчик цикла? А если tmp это просто char *, то что тогда?
          +2
          Разве здесь все равно будет UB, ведь области памяти гарантированно не пересекаются?

          Нет, не будет, а в чём проблема? Точнее, будет из-за чтения неинициализированной памяти, но "проявится" оно только если функция окажется заинлайнена.


          Даже если это выход за границы цикла и мы печатаем чужую память, разве это повод игнорировать счетчик цикла?

          Да, повод.

            +2
            разве это повод игнорировать счетчик цикла?

            Если предположить, что UB нет, то это означает, что внутри printf есть условно exit, который завершает программу раньше, чем счётчик достигает границы, а значит счётчик и не нужен.

            +3
            Интересные примеры, статься читается как детектив)
            Спасибо за перевод.
              +4
              Хорошая статья. Спасибо за перевод!

              Может кто-нибудь подсказать правильную реализацию int add(int x, int y, int *z) без использования более длинных типов?
                0
                как вариант можно поиграться с unsigned int — сумма двух положительных int не может быть выше максимума unsigned int
                1. если x и y имеют разные знаки или один из них равен нулю — всё ок, просто суммируем
                2. частный случай — x == INT_MIN, то если y == 0 то *z = INT_MIN, иначе — переполнение, проверяем и для y == INT_MIN
                3. если x и y оба отрицательные — домножаем на -1 (тут INT_MIN всё бы сломал, поэтому его рассматриваем в п. 2), кастим их в unsigned int, суммируем, смотрим не превышает ли результат INT_MAX, домножаем его на -1
                4. если x и y оба положительные — кастим их в unsigned int, суммируем, смотрим не превышает ли результат INT_MAX
                  +3
                  а ведь не так давно можно было просто проверить флаг переноса!
                +1

                Вот эта реализация работать будет. Но не уверен что достаточно быстро.


                if (y >= 0 && x > INT_MAX - y)
                    return 0;
                if (y < 0 && x < INT_MIN - y)
                    return 0;
                *z = x+y;
                return 1;
                  0
                  if (y >= 0 && x > INT_MAX - y)
                      return 0;
                  if (y < 0 && x < INT_MIN - y)
                      return 0;
                  if (x >= 0 && y > INT_MAX - x)
                      return 0;
                  if (x < 0 && y < INT_MIN - x)
                      return 0;
                  *z = x+y;
                  return 1;
                    0
                    А зачем третья и четвертая проверка?
                      0
                      «Фарш невозможно провернуть назад» — да, они дублируют первую и вторую проверки
                    0
                    Накидал проверку:
                    Код
                    //#include <iostream> //для С++ компилятора
                    #include  <stdio.h> //для C компилятора
                    #include <limits.h>
                    
                    
                    int
                    add_orig(int x, int y, int *z)
                    {
                        int r = x + y;
                        if (x > 0 && y > 0 && r < x) {
                            return 0;
                    	}
                        if (x < 0 && y < 0 && r > x) {
                            return 0;
                    	}
                        *z = r;
                        return 1;
                    }
                    
                    int add_new1(int x, int y, int *z)
                    {
                    	if (y >= 0 && x > INT_MAX - y)
                        return 0;
                    	if (y < 0 && x < INT_MIN - y)
                        return 0;
                    	*z = x+y;
                    	return 1;
                    }
                    
                    int add_new2(int x, int y, int *z)
                    {
                    	if (y >= 0 && x > INT_MAX - y)
                        return 0;
                    	if (y < 0 && x < INT_MIN - y)
                        return 0;
                    	if (x >= 0 && y > INT_MAX - x)
                        return 0;
                    	if (x < 0 && y < INT_MIN - x)
                        return 0;
                    	*z = x+y;
                    	return 1;
                    }
                    
                    void test(int a, int b)
                    {
                        int x, y, z;
                        x=a;
                        y=b;
                        printf("Orig: ");
                        if (add_orig(x, y, &z)) {
                            printf("%d\n", z);
                    		} else {
                            printf("overflow!\n");
                    	}
                        printf("New1: ");
                    	if (add_new1(x, y, &z)) {
                            printf("%d\n", z);
                    		} else {
                            printf("overflow!\n");
                    	}
                    	printf("New2: ");
                    	if (add_new2(x, y, &z)) {
                            printf("%d\n", z);
                    		} else {
                            printf("overflow!\n");
                    	}
                    }
                    
                    int main(int argc, char *argv[])
                    {
                        printf("Test1 17,42\n");
                        test(17,42);
                        printf("\nTest2 2000000000,1500000000\n");
                        test(2000000000, 1500000000);
                        return 0;
                    }
                    

                    Результаты
                    Результаты для clang 3.8.0:
                    Test1 17,42
                    Orig: 59
                    New1: 59
                    New2: 59

                    Test2 2000000000,1500000000
                    Orig: -794967296
                    New1: overflow!
                    New2: overflow!


                    Результат для Microsoft ® C/C++ Optimizing Compiler Version 19.00.23506 for x64:
                    Test1 17,42
                    Orig: 59
                    New1: 59
                    New2: 59

                    Test2 2000000000,1500000000
                    Orig: overflow!
                    New1: overflow!
                    New2: overflow!


                    Результат для gcc версия 6.4.0 (GCC) под сигвином:
                    Test1 17,42
                    Orig: 59
                    New1: 59
                    New2: 59

                    Test2 2000000000,1500000000
                    Orig: overflow!
                    New1: overflow!
                    New2: overflow!


                    Результат для gcc версия 5.4.0:
                    Test1 17,42
                    Orig: 59
                    New1: 59
                    New2: 59

                    Test2 2000000000,1500000000
                    Orig: overflow!
                    New1: overflow!
                    New2: overflow!



                    Работает. Спасибо!
                      0
                      Еще могу порекомендовать почитать вот эту статью, если заинтересовала тема арифметики без UB — не только сложение разбирается, но и другие операции. Да и вообще, весь ресурс — отличный справочник по различным UB и секьюрному кодингу.
                    –1

                    Можно использовать более короткие типы с переносом.
                    https://ru.wikipedia.org/wiki/Длинная_арифметика

                      0
                      Можете привести пример кода?
                      +2
                      Неужели до сих пор в С/С++ нет опции checked arithmetics.
                      Насколько знаю, такие же проблемы в java. В математических расчетах это создает проблемы, когда число переполняется, а исключение не выкидывается.

                      В с# попроще — там есть опция на проект или служебное слово, после которого происходит проверка арифметических операций. Подобная опция есть delphi.
                        0
                        На C++ и Java можно написать классы для знаковых целых и за счет перегрузки операторов ввести проверки. Это может решить проблему, но производительность значительно снизится.

                        Конечно, было бы здорово получить поддержку проверок как в C#. Там и с производительностью все не плохо, однако по умолчанию проверка операций все равно отключена.
                          +1
                          На самом деле проблема насущная. Я когда-то делал расчеты полета большого количества частиц на джаве. И так и не смог найти, где происходит порча чисел. Если бы был checked arithmetics, то проблем бы не было бы.
                            0
                            Вероятно, эту задачу надо было считать в double?
                              +1
                              Не факт, что помогло бы. Переменная любого типа фиксированного размера может переполниться, так что double тоже подвержен этой проблеме. Ведь может быть ошибка в алгоритме, приводящая, например, к бесконтрольному умножению в цикле. Сообщение о переполнении в определенном месте позволит локализовать проблемный участок.
                                0
                                Оно самое! Умножение или накопление ошибки из-за дискретизации. На большом количестве объектов вообще очень трудно это все отладить, так как надо поднимать историю и пытаться понять, какая цепочка событий к этому привела. Спецэффекты довольно редкие и проявляются при большом количестве объектов. И когда взаимодействует сотни или миллионы объектов, то надо еще вообразить, как это могло быть в пространстве. В общем я забросил этот проект изза сложностей диагностики ошибок.
                              +2
                              В С и С++ проблема другого толка. Если у нас есть проверка в рантайме, то должен быть механизм уведомления о том, что проверку не прошли. В Си исключений нет, в С++ их при определённых условиях может не быть. Может быть, например, сигнал или SEH, а может быть и не быть. Если же формируем какое-то значение и код ошибки, то нет гарантий, что вызывающий код их проверит. И даже если проверит, дальше что?
                              В C#/Java просто постулируется наличие канала уведомлений об ошибках.
                                0
                                насколько помню, в С исключения эмулировались через библиотеку longjump, так что какое-то подобие должно быть
                                  +1
                                  C и C++ должны работать на ОЧЕНЬ маленьких компьютерах, где всякие longjump-ы — непозволительная роскошь, не говоря уже про исключения. Посмотрите мелкие Attiny и PIC-и, проникнитесь скудностью ресурсов. А программы на Си и С++ на них работают (пускай и мелкие).
                                    0
                                    В C++ (если реализация намеренно не урезана до состояния, не соответствующего стандарту) есть RTTI, есть структурные исключения с созданием объектов исключений, есть реально немаленькая STL, есть подготовка перед main(), которая включает в себя инфраструктуру хотя бы двух форматов I/O, и многое другое.
                                    И для C этого немало. Фактически, на упомянутых архитектурах уже не C, а нечто, что сохраняет его вид, но обпилено до 1/10 полного вида.
                                    Основная же тема всё-таки неявно, но касалась полных C и C++.

                                    С другой стороны, SJLJ-exceptions не лучший вариант, и я тут (повторю соседний комментарий) «голосую» за переменную-флаг — явно указанную, или в состоянии исполняющей задачи.
                                      0
                                      С флагом тоже не всё гладко: в связи с предельной кроссплатформенностью языка нет никаких гарантий относительно целевой машины, а это значит, что нельзя полагаться на аппаратные флаги, потому что они могут быть реализованы по-разному.
                                      Например, я видел одну архитектуру, где в случае переноса флаг сбрасывался, а не устанавливался.
                                        0
                                        1. Простите, а где вы вычитали у меня про аппаратные флаги? Я прямо и однозначно говорил про флаговую переменную рантайма. Для данной задачи тут совершенно без разницы, как оно реализовано аппаратно, главное, чтобы код сводился к
                                        c = a + b;
                                        если было переполнение {
                                          seen_overflow := 1;
                                        }
                                        

                                        а будет эта проверка «если было переполнение» по Flags.OF (x86, полные слова), CC.V (аналогично там, где NZVC так и зовутся), (c<a)!=(b<0) (стиль для RISC без CC, как MIPS, Alpha, RISC-V), CC=3 (S/360...zSeries) — это уже целиком и полностью дело местной реализации. В GCC overflow builtins это всё уже сделано, надо только применить (импортировать, если компилятор свой).

                                        2. Подозреваю, вы слегка попутали и вспомнили 6502 или ARM. В обоих при вычитании C=0 означает, что произошёл заём при знаковой интерпретации, а C=1 — что его не было (и SBC отражает это в логике для следующих разрядов). Для сложения же логика такая же, как почти везде — перенос при C=1.
                                        Но и в этом случае, и даже если вы видели реально ту странную платформу — повторюсь, это проблема местного кодогенератора.
                                          0
                                          Но флаги скорее всего будут извлекаться из аппаратных флагов с их преобразованием в необходимый вид. После каждой операции. Что есть дорого.
                                            0
                                            В идеале это всё будет преобразовано в один условный переход (за исключением парочки редких платформ), поэтому дороговизна немного преувеличена.
                                              +1
                                              > После каждой операции. Что есть дорого.

                                              Уже писал рядом и повторюсь: для 90-95% кода даже на C/C++ эта цена будет крошечной, в отличие от пользы самого контроля. А для тех мест, где это важно, следует сделать синтаксические контексты других режимов.

                                              На уровне компиляторов механизм таких контекстов уже отработан — как для файла целиком (-fwrapv), так и для отдельных функций (pragma optimize). Осталось самое тяжёлое — продавить их до стандарта.
                                                0
                                                Осталось самое тяжёлое — продавить их до стандарта.
                                                Всё legacy сразу сломается. Можно сохранить совместимость, если по умолчанию проверка выключена и нужно специально включать её (но и толку будет немного, т.к. никто не предскажет, где бы включить проверку).

                                                Как мне кажется, идеальный вариант — компилятор делает assert-ы только в DEBUG Build, можно найти случайно вылезшие баги, не потребуется менять стандарт или жертвовать производительностью.
                                                  0
                                                  > но и толку будет немного, т.к. никто не предскажет, где бы включить проверку

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

                                                  > Как мне кажется, идеальный вариант — компилятор делает assert-ы только в DEBUG Build,

                                                  Этот подход известен, но сам по себе имеет заметные недостатки.
                                                  И в подавляющем большинстве кода проверки и в релизе не испортят его.
                                                  Но как переходной метод — да, подходит. Разрешил для модуля/функции/etc., получил грабли — пофиксил — разрешил и для release.
                                              0
                                              > что произошёл заём при знаковой интерпретации

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

                                      > И даже если проверит, дальше что?

                                      А это уже зависит от того, что код будет с этим делать. Но лучше дать возможность, чем не давать. «Tools, not policy» ©.
                                        0
                                        Чисто теоретически, можно было бы указать пользовательскую ф-цию вызовом std::set_overflow_handler (по аналогии с std::set_terminate). Если она ничего не делает (или не задана), переполнение игнорируется. Но после каждого целочисленного сложения/вычитания/умножения вставлять условный вызов ф-ции — получается медленновато.
                                          0
                                          Собственно, об этом и речь: у любой плюшки есть цена.
                                            +1
                                            Для 90-95% кода, который пишется на C/C++, эта потеря скорости будет совершенно незаметна — тем более, что если её будут ставить в unlikely ветки, современный компилятор сделает так, что путь по умолчанию для процессора будет без переполнения.
                                            А для отдельных участков, где это важно, и код вылизан, можно применить и указание свободы компилятору синтаксическим контекстом.
                                            Для embedded доля кода, где проверки каждого результата не влияет, может быть меньше, но и там обычно ненулевая, и вылизывают его в разы тщательнее.
                                              0
                                              Как по мне, это будет уже не C++
                                                0
                                                Это будет лучше, чем известный C++ :)

                                                Конечно, это дай бог чтобы 1/20 от того, в чём надо править C++, но лучше с чего-то таки начать.
                                                0
                                                Для 90-95% кода, который пишется на C/C++, эта потеря скорости будет совершенно незаметна
                                                Вся векторизация коту под хвост. А компиляторы всё более и более охотно занимают SSE-регистры.
                                                  0
                                                  > Вся векторизация коту под хвост.

                                                  Так векторизация и нужна в тех 5-10%, не больше. Где она нужна — сменят checked на relaxed.

                                                  Заодно тут ещё одна польза может получиться. Сейчас в C, C++ из-за исторического конфуза для знаковых режим «программист не ошибается» (я называю его тут relaxed), а для беззнаковых — строго truncating по модулю. В результате оптимизация операций с беззнаковыми резко ограничена.
                                                  А если допустить контексты, то можно и её разрешить там, где программист уверен.
                                                    0
                                                    В результате оптимизация операций с беззнаковыми резко ограничена.
                                                    Почему? Сейчас компилятор предполагает, что программист не ошибся и переполнения точно нет.
                                                      0
                                                      > Почему?

                                                      Согласно стандарту.

                                                      > Сейчас компилятор предполагает, что программист не ошибся и переполнения точно нет.

                                                      Вы путаете со знаковыми.
                                                      Вот из C++14 final draft:

                                                      > Unsigned integers shall obey the laws of arithmetic modulo 2n where n is the number of bits in the value representation of that particular size of integer.

                                                      И примечание к этому пункту:

                                                      > 48) This implies that unsigned arithmetic does not overflow because a result that cannot be represented by the resulting unsigned integer type is reduced modulo the number that is one greater than the largest value that can be represented by the resulting unsigned integer type.

                                                      Во всех других версиях стандартов то же самое, даже если другими словами.

                                                      А теперь — хохма: до момента, когда компиляторы начали извлекать пользу из UdB, такая же практика была и для знаковых. Но в стандарте было UdB, потому что не хотели фиксировать в стандарте представление дополнительным кодом. Потом же нашли, как применить это UdB в совершенно других целях — а именно, оптимизации — от чего и возник нынешний перекос.
                                                        0
                                                        Вы путаете со знаковыми.
                                                        Да, конечно, я думал о знаковых целых.

                                                        Но, в любом случае, замена UB на определённое поведение связывает руки оптимизатору, потому что в случае UB он может подставить любое поведение, в том числе, доопределённое. А наоборот — нет.
                                                          0
                                                          > замена UB на определённое поведение связывает руки оптимизатору

                                                          И развязывает — программисту, потому что ему больше не нужно бояться непредсказуемого, подлого удара из-за угла.

                                                          Именно поэтому уже когда данные возможности оптимизатора были давно известны всем, кто всерьёз занимается тематикой — появляется Go, в котором

                                                          >> For signed integers, the operations +, -, *, /, and << may legally overflow and the resulting value exists and is deterministically defined by the signed integer representation, the operation, and its operands. No exception is raised as a result of overflow. A compiler may not optimize code under the assumption that overflow does not occur. For instance, it may not assume that x < x + 1 is always true.

                                                          Или Rust, в котором сделаны раздельно checked_mul(), saturating_mul(), wrapping_mul() и overflowing_mul() (хотя первая и последняя это фактически два представления одного результата).

                                                          Или Swift, в котором умолчание — всегда checked (исключение при переполнении), а если хочешь — есть всякие &+, &-, &*, которые wrapping.

                                                          (Я тут намеренно исключаю Java и C#, которые, возможно, были разработаны раньше того, как индустрия в целом осознала плюсы и минусы всех подходов.)

                                                          Хотя я безусловно согласен, что они перегибают в противоположную сторону, и разрешать UdB там, где это программист явно указал (это ключевое) — тоже полезно.
                                                            0
                                                            Я не против, если в C++ появятся intrinsics, как в rust-е, или новые операторы (с проверкой), но мне нравится текущее поведение существующих операторов, менять его не надо.

                                                            Хотя, по большому счёту, погоды они не сделают (все будут пользоваться привычными +/−/*).
                                      +2
                                      1. Ещё можно добавить, что для GCC и Clang работают overflow builtins, и это самое удобное и дешёвое из всех вариантов, пока они доступны — особенно для проверки умножения, которое иначе делается ну очень тяжело в пограничном случае.

                                      Периодически появляются предложения загнать их в C++, но пока ни одно не дошло до реализации. Наверно, ещё лет 10 подождать надо:(

                                      2. Если предложение по форсированию twoʼs complement для C++ войдёт в C++20, то будет гарантия не-UB при конверсии в unsigned и обратно, а тогда можно смело сравнивать результат с ожиданиями. Да, это возможно дороже overflow builtins, но хорошо сработает, где их нет, и где полезно получать результат, даже если он переполнился.
                                      +7
                                      С завидной частотой появляются статьи по UB в C++ на хабре.
                                      Ненавижу, когда из технического инструмента делают религию.
                                      UB — это костыль, который появился потому, что C/C++ писался как транслятор из текста в машинный код. UB не надо поклоняться, изучать, почему тут оно работает так, а тут — иначе. Его нужно просто обходить. А ещё лучше — обходить плюсы, потому что у них последнее время какие-то не очень правильные приоритеты. Почему нельзя задефайнить UB в каком-нибудь C++2X?
                                        0
                                        C/C++ писался как транслятор из текста в машинный код

                                        Рискну полюбопытствовать, а как ещё-то можно?
                                          0
                                          Ну пишут, что наличие таких UB очень хорошо помогает оптимизации, и первый пример из статьи таки это показывает.

                                          С другой стороны, считаю, что правильным было бы определять синтаксические контексты (пример такого есть в C#), в которых подобные оптимизации разрешены, а в остальном коде, которого будет процентов 90 и который на скорость не повлияет, наоборот, делать минимум ожиданий — тут можно ужесточить политику до полного запрета оптимизации по алиасингу, генерирования исключений на переполнение и т.п.
                                          В результате, опасные места будут хорошо огорожены видимыми знаками.
                                            0
                                            UB — это костыль, который появился потому, что C/C++ писался как транслятор из текста в машинный код.

                                            Если в двух словах — вы неправы. UB это необходимая техника. Почитайте например статью, зачем оно нужно, причем, внезапно, НЕ в С++.

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

                                          Самое читаемое