Данная статья была впервые опубликована на сайте 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, особенно неопределённое поведение в операциях со знаковыми типами, ведут к дополнительным издержкам при дальнейшем сопровождении кода, которые часто недооцениваются.
Комментарии (39)
Hommit
08.02.2019 15:00-2В первом примере: допустим у нас есть double d[4]; int i[4]; после чего мы их передаем в упомянутую функцию. Разве здесь все равно будет UB, ведь области памяти гарантированно не пересекаются? Да, в d будет фигня, но тут что просили, то и получили.
По последнему примеру — имхо, это просто бред и косяк компилятора. Даже если это выход за границы цикла и мы печатаем чужую память, разве это повод игнорировать счетчик цикла? А если tmp это просто char *, то что тогда?mayorovp
08.02.2019 15:15+1Разве здесь все равно будет UB, ведь области памяти гарантированно не пересекаются?
Нет, не будет, а в чём проблема? Точнее, будет из-за чтения неинициализированной памяти, но "проявится" оно только если функция окажется заинлайнена.
Даже если это выход за границы цикла и мы печатаем чужую память, разве это повод игнорировать счетчик цикла?
Да, повод.
Lol4t0
08.02.2019 21:36+2разве это повод игнорировать счетчик цикла?
Если предположить, что UB нет, то это означает, что внутри printf есть условно exit, который завершает программу раньше, чем счётчик достигает границы, а значит счётчик и не нужен.
thebestzorro
08.02.2019 16:55+1Интересные примеры, статься читается как детектив)
Спасибо за перевод.
b_t
08.02.2019 17:39+1Хорошая статья. Спасибо за перевод!
Может кто-нибудь подсказать правильную реализацию int add(int x, int y, int *z) без использования более длинных типов?4e1
08.02.2019 20:10как вариант можно поиграться с 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
mayorovp
08.02.2019 20:22Вот эта реализация работать будет. Но не уверен что достаточно быстро.
if (y >= 0 && x > INT_MAX - y) return 0; if (y < 0 && x < INT_MIN - y) return 0; *z = x+y; return 1;
maxzhurkin
08.02.2019 20:39if (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;
mayorovp
08.02.2019 20:46А зачем третья и четвертая проверка?
maxzhurkin
08.02.2019 21:04«Фарш невозможно провернуть назад» — да, они дублируют первую и вторую проверки
b_t
08.02.2019 23:20Накидал проверку:
Код//#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; }
Ddnn
09.02.2019 01:05Еще могу порекомендовать почитать вот эту статью, если заинтересовала тема арифметики без UB — не только сложение разбирается, но и другие операции. Да и вообще, весь ресурс — отличный справочник по различным UB и секьюрному кодингу.
GCU
08.02.2019 20:41-1Можно использовать более короткие типы с переносом.
https://ru.wikipedia.org/wiki/Длинная_арифметика
dim2r
09.02.2019 10:38+2Неужели до сих пор в С/С++ нет опции checked arithmetics.
Насколько знаю, такие же проблемы в java. В математических расчетах это создает проблемы, когда число переполняется, а исключение не выкидывается.
В с# попроще — там есть опция на проект или служебное слово, после которого происходит проверка арифметических операций. Подобная опция есть delphi.b_t
09.02.2019 15:18На C++ и Java можно написать классы для знаковых целых и за счет перегрузки операторов ввести проверки. Это может решить проблему, но производительность значительно снизится.
Конечно, было бы здорово получить поддержку проверок как в C#. Там и с производительностью все не плохо, однако по умолчанию проверка операций все равно отключена.dim2r
09.02.2019 20:25+1На самом деле проблема насущная. Я когда-то делал расчеты полета большого количества частиц на джаве. И так и не смог найти, где происходит порча чисел. Если бы был checked arithmetics, то проблем бы не было бы.
qw1
09.02.2019 21:01Вероятно, эту задачу надо было считать в double?
b_t
09.02.2019 21:39+1Не факт, что помогло бы. Переменная любого типа фиксированного размера может переполниться, так что double тоже подвержен этой проблеме. Ведь может быть ошибка в алгоритме, приводящая, например, к бесконтрольному умножению в цикле. Сообщение о переполнении в определенном месте позволит локализовать проблемный участок.
dim2r
10.02.2019 09:46Оно самое! Умножение или накопление ошибки из-за дискретизации. На большом количестве объектов вообще очень трудно это все отладить, так как надо поднимать историю и пытаться понять, какая цепочка событий к этому привела. Спецэффекты довольно редкие и проявляются при большом количестве объектов. И когда взаимодействует сотни или миллионы объектов, то надо еще вообразить, как это могло быть в пространстве. В общем я забросил этот проект изза сложностей диагностики ошибок.
a-tk
10.02.2019 19:49+2В С и С++ проблема другого толка. Если у нас есть проверка в рантайме, то должен быть механизм уведомления о том, что проверку не прошли. В Си исключений нет, в С++ их при определённых условиях может не быть. Может быть, например, сигнал или SEH, а может быть и не быть. Если же формируем какое-то значение и код ошибки, то нет гарантий, что вызывающий код их проверит. И даже если проверит, дальше что?
В C#/Java просто постулируется наличие канала уведомлений об ошибках.dim2r
10.02.2019 21:46насколько помню, в С исключения эмулировались через библиотеку longjump, так что какое-то подобие должно быть
a-tk
10.02.2019 22:34+1C и C++ должны работать на ОЧЕНЬ маленьких компьютерах, где всякие longjump-ы — непозволительная роскошь, не говоря уже про исключения. Посмотрите мелкие Attiny и PIC-и, проникнитесь скудностью ресурсов. А программы на Си и С++ на них работают (пускай и мелкие).
netch80
11.02.2019 00:25В C++ (если реализация намеренно не урезана до состояния, не соответствующего стандарту) есть RTTI, есть структурные исключения с созданием объектов исключений, есть реально немаленькая STL, есть подготовка перед main(), которая включает в себя инфраструктуру хотя бы двух форматов I/O, и многое другое.
И для C этого немало. Фактически, на упомянутых архитектурах уже не C, а нечто, что сохраняет его вид, но обпилено до 1/10 полного вида.
Основная же тема всё-таки неявно, но касалась полных C и C++.
С другой стороны, SJLJ-exceptions не лучший вариант, и я тут (повторю соседний комментарий) «голосую» за переменную-флаг — явно указанную, или в состоянии исполняющей задачи.
netch80
11.02.2019 00:19Пример механизма уже есть для стандартной реализации плавающей точки: все эти fetestexcept() и родственная группа.
Повторить эту логику при соответствующем синтаксически выставленном режиме компиляции — тривиально и даже дешевле, чем будет в случае плавучки. Сложнее таки договориться о необходимости:)
> И даже если проверит, дальше что?
А это уже зависит от того, что код будет с этим делать. Но лучше дать возможность, чем не давать. «Tools, not policy» ©.
qw1
11.02.2019 07:29Чисто теоретически, можно было бы указать пользовательскую ф-цию вызовом std::set_overflow_handler (по аналогии с std::set_terminate). Если она ничего не делает (или не задана), переполнение игнорируется. Но после каждого целочисленного сложения/вычитания/умножения вставлять условный вызов ф-ции — получается медленновато.
netch80
09.02.2019 21:48+21. Ещё можно добавить, что для GCC и Clang работают overflow builtins, и это самое удобное и дешёвое из всех вариантов, пока они доступны — особенно для проверки умножения, которое иначе делается ну очень тяжело в пограничном случае.
Периодически появляются предложения загнать их в C++, но пока ни одно не дошло до реализации. Наверно, ещё лет 10 подождать надо:(
2. Если предложение по форсированию two?s complement для C++ войдёт в C++20, то будет гарантия не-UB при конверсии в unsigned и обратно, а тогда можно смело сравнивать результат с ожиданиями. Да, это возможно дороже overflow builtins, но хорошо сработает, где их нет, и где полезно получать результат, даже если он переполнился.
Temtaime
09.02.2019 11:16+4С завидной частотой появляются статьи по UB в C++ на хабре.
Ненавижу, когда из технического инструмента делают религию.
UB — это костыль, который появился потому, что C/C++ писался как транслятор из текста в машинный код. UB не надо поклоняться, изучать, почему тут оно работает так, а тут — иначе. Его нужно просто обходить. А ещё лучше — обходить плюсы, потому что у них последнее время какие-то не очень правильные приоритеты. Почему нельзя задефайнить UB в каком-нибудь C++2X?Cerberuser
09.02.2019 11:49C/C++ писался как транслятор из текста в машинный код
Рискну полюбопытствовать, а как ещё-то можно?
netch80
09.02.2019 21:53Ну пишут, что наличие таких UB очень хорошо помогает оптимизации, и первый пример из статьи таки это показывает.
С другой стороны, считаю, что правильным было бы определять синтаксические контексты (пример такого есть в C#), в которых подобные оптимизации разрешены, а в остальном коде, которого будет процентов 90 и который на скорость не повлияет, наоборот, делать минимум ожиданий — тут можно ужесточить политику до полного запрета оптимизации по алиасингу, генерирования исключений на переполнение и т.п.
В результате, опасные места будут хорошо огорожены видимыми знаками.
PsyHaSTe
10.02.2019 19:43UB — это костыль, который появился потому, что C/C++ писался как транслятор из текста в машинный код.
Если в двух словах — вы неправы. UB это необходимая техника. Почитайте например статью, зачем оно нужно, причем, внезапно, НЕ в С++.
mapron
Мелкое занудство: в этом примере программа напечает что попало еще потому, что mul возвращает тип short, а print ожидает int.
mayorovp
Тут как раз всё в порядке, ведь short передаётся как int
mikhaelkh
Видимо суть статьи вы так и не поняли. Суть неопределённого поведения в том, что компилятор может сделать всё что угодно, и его опасность в том, что часто неопределённое поведение совпадает с ожидаемым. Но стоит чуть изменить код или компилятор или параметры компиляции, и всё поменяется до неузнаваемости.
mayorovp
Преобразование short в int при передаче variadic-параметра прописано в стандарте. Это не UB.
mikhaelkh
unsigned short не всегда строго меньше int, преобразование не всегда будет в int. Иногда будет преобразование в unsigned int и UB.
qw1
С трудом расшифровал, что вы хотели сказать. Дело не в том, что
А в том, что
mapron
Да, спасибо, я был неправ. Покурил, почитал, посыпал пеплом голову)