Pull to refresh

Comments 60

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

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

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

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


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

Да, повод.

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

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

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

Может кто-нибудь подсказать правильную реализацию int add(int x, int y, int *z) без использования более длинных типов?
как вариант можно поиграться с 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
а ведь не так давно можно было просто проверить флаг переноса!

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


if (y >= 0 && x > INT_MAX - y)
    return 0;
if (y < 0 && x < INT_MIN - y)
    return 0;
*z = x+y;
return 1;
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;
А зачем третья и четвертая проверка?
«Фарш невозможно провернуть назад» — да, они дублируют первую и вторую проверки
Накидал проверку:
Код
//#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!



Работает. Спасибо!
Еще могу порекомендовать почитать вот эту статью, если заинтересовала тема арифметики без UB — не только сложение разбирается, но и другие операции. Да и вообще, весь ресурс — отличный справочник по различным UB и секьюрному кодингу.
Можете привести пример кода?
Неужели до сих пор в С/С++ нет опции checked arithmetics.
Насколько знаю, такие же проблемы в java. В математических расчетах это создает проблемы, когда число переполняется, а исключение не выкидывается.

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

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

С другой стороны, SJLJ-exceptions не лучший вариант, и я тут (повторю соседний комментарий) «голосую» за переменную-флаг — явно указанную, или в состоянии исполняющей задачи.
С флагом тоже не всё гладко: в связи с предельной кроссплатформенностью языка нет никаких гарантий относительно целевой машины, а это значит, что нельзя полагаться на аппаратные флаги, потому что они могут быть реализованы по-разному.
Например, я видел одну архитектуру, где в случае переноса флаг сбрасывался, а не устанавливался.
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.
Но и в этом случае, и даже если вы видели реально ту странную платформу — повторюсь, это проблема местного кодогенератора.
Но флаги скорее всего будут извлекаться из аппаратных флагов с их преобразованием в необходимый вид. После каждой операции. Что есть дорого.
В идеале это всё будет преобразовано в один условный переход (за исключением парочки редких платформ), поэтому дороговизна немного преувеличена.
> После каждой операции. Что есть дорого.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Вы путаете со знаковыми.
Вот из 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 в совершенно других целях — а именно, оптимизации — от чего и возник нынешний перекос.
Вы путаете со знаковыми.
Да, конечно, я думал о знаковых целых.

Но, в любом случае, замена UB на определённое поведение связывает руки оптимизатору, потому что в случае UB он может подставить любое поведение, в том числе, доопределённое. А наоборот — нет.
> замена 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 там, где это программист явно указал (это ключевое) — тоже полезно.
Я не против, если в C++ появятся intrinsics, как в rust-е, или новые операторы (с проверкой), но мне нравится текущее поведение существующих операторов, менять его не надо.

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

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

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

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

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

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