Підписане переповнення C ++ та невизначена поведінка (UB)


56

Мені цікаво про використання коду, як описано нижче

int result = 0;
int factor = 1;
for (...) {
    result = ...
    factor *= 10;
}
return result;

Якщо цикл повторюється в nрази, то factorвін множиться на 10точно nрази. Однак factorвикористовується лише коли-небудь, помноживши їх 10на загальну кількість n-1разів. Якщо припустити, що factorніколи не переповнюється, окрім останньої ітерації циклу, але може переповнюватися на останній ітерації циклу, то чи повинен такий код бути прийнятним? У цьому випадку величина factor, ймовірно, ніколи не буде використана після того, як переповнення відбулося.

У мене дебати щодо того, чи слід приймати такий код. Можна було б покласти множення всередині оператора if і просто не робити множення на останній ітерації циклу, коли воно може переповнюватися. Мінус полягає в тому, що він захаращує код і додає зайву гілку, яку потрібно перевірити на всіх попередніх ітераціях циклу. Я також міг би повторити цикл ще один раз і повторити тіло циклу один раз після циклу, і знову це ускладнює код.

Фактичний код, про який йде мова, використовується у вузькому внутрішньому циклі, який вимагає великого куска загального часу процесора в графічному додатку в реальному часі.


5
Я голосую, щоб закрити це питання поза темою, оскільки це питання має бути на codereview.stackexchange.com не тут.
Кевін Андерсон

31
@KevinAnderson, тут це неправда, оскільки приклад коду має бути виправлений, а не просто вдосконалений.
Вірсавія

1
@harold Вони бовтаються близько.
Гонки легкості по орбіті

1
@LightnessRaceswithMonica: Автори Стандарту задумали і очікували, що реалізація, призначена для різних платформ і цілей, розширить семантику, доступну програмістам, осмислено обробляючи різні дії способами, корисними для цих платформ та цілей, незалежно від того, чи вимагає від них Стандарт, а також заявили, що не бажають знімати не портативний код. Таким чином, подібність між питаннями залежить від того, які реалізації потрібно підтримувати.
Supercat

2
@supercat Для поведінки, визначеної реалізацією, впевнений, і якщо ви знаєте, що ваша ланцюжок інструментів має деяке розширення, ви можете використовувати (і вам не байдуже портативність), добре. Для УБ? Сумнівні.
Гонки легкості по орбіті

Відповіді:


51

Компілятори припускають, що дійсна програма C ++ не містить UB. Розглянемо для прикладу:

if (x == nullptr) {
    *x = 3;
} else {
    *x = 5;
}

Якщо x == nullptrпотім його відновлення і присвоєння значення - це UB. Отже, єдиний спосіб, яким це може закінчитися у дійсній програмі, це коли x == nullptrніколи не вийде істинним і компілятор може припустити за правилом як би, вищезгадане еквівалентно:

*x = 5;

Тепер у вашому коді

int result = 0;
int factor = 1;
for (...) {      // Loop until factor overflows but not more
   result = ...
   factor *= 10;
}
return result;

Останнє множення значення factorне може відбутися у дійсній програмі (переповнення підпису не визначено). Звідси також і призначення, яке resultне може відбутися. Оскільки немає можливості поділитися до останньої ітерації, попередня ітерація не може відбутися. Врешті-решт правильна частина коду (тобто, жодного не визначеного поведінки ніколи не відбувається):

// nothing :(

6
"Не визначена поведінка" - це вираз, про який ми багато чуємо у відповідях SO, не чітко пояснюючи, як це може вплинути на програму в цілому. Ця відповідь робить речі набагато зрозумілішими.
Жиль-Філіп Пайле

1
І це навіть може бути "корисною оптимізацією", якщо функція викликається лише на цілях INT_MAX >= 10000000000, з іншою функцією, яка називається у випадку, коли INT_MAXменша.
R .. GitHub СТОП ДОПОМОГА

2
@ Gilles-PhilippePaillé Бувають випадки, коли я хочу, щоб ми могли залишити повідомлення про це. Benign Data Races - один з моїх улюбленців, коли я маю уявити, наскільки вони можуть бути неприємними. У MySQL також є чудовий звіт про помилки, який я, здається, не можу знайти знову - перевірка переповнення буфера, яка випадково викликала UB. Конкретна версія конкретного компілятора просто припускала, що UB ніколи не виникає, і оптимізувала всю перевірку переповнення.
Корт Аммон

1
@SolomonSlow: Основними ситуаціями, коли UB є суперечливим, є такі випадки, коли частини Стандарту та документація щодо реалізації описують поведінку певної дії, але якась інша частина стандарту характеризує його як UB. Загальна практика до того, як був написаний Стандарт, полягала в тому, щоб письменники-компілятори осмислювали такі дії осмислено, за винятком випадків, коли їх клієнти отримали б користь від того, щоб вони робили щось інше, і я не думаю, що автори Стандарту ніколи не уявляли, що автори-компілятори навмисно будуть робити щось інше .
Supercat

2
@ Gilles-PhilippePaillé: Що також повинен знати кожен програміст C про не визначене поведінку з блогу LLVM. Це пояснює, як, наприклад, UB-переповнення з підписаним цілим числом може дозволити компіляторам довести, що i <= nпетлі завжди нескінченні, як i<nпетлі. І сприяти int iширині вказівника в циклі, замість того, щоб повторювати знак для можливого індексування обертання масиву до перших елементів масиву 4G.
Пітер Кордес

34

Поведінка intпереповнення не визначена.

Не має значення, чи читаєте ви factorпоза корпусом циклу; якщо він переповнюється, тоді поведінка вашого коду на, після, і дещо парадоксально, перш ніж переповнення не буде визначено.

Одне питання, яке може виникнути при збереженні цього коду, - це те, що компілятори стають все більш агресивними щодо оптимізації. Зокрема, вони розвивають звичку, коли вони припускають, що невизначена поведінка ніколи не буває. Щоб це було так, вони можуть видалити forцикл взагалі.

Хіба ви не можете використовувати unsignedтип для factorхоча тоді вам потрібно турбуватися про небажану конверсії intв unsignedв виразах , що містять як?


12
@nicomp; Чому ні?
Вірсавія

12
@ Gilles-PhilippePaillé: Чи не відповідає моя відповідь, що це проблематично? Моє вступне речення є не обов'язково для ОП, а ширшої спільноти І factor"використовується" у завданні назад до себе.
Вірсавія

8
@ Gilles-PhilippePaillé і ця відповідь пояснює, чому це проблематично
idclev 463035818

1
@Bathsheba Ти маєш рацію, я неправильно зрозумів твою відповідь.
Жиль-Філіп Пайле

4
Як приклад невизначеної поведінки, коли цей код компілюється з увімкненими перевірками виконання, він припиняється замість повернення результату. Код, який вимагає від мене відключити діагностичні функції, щоб працювати, порушено.
Саймон Ріхтер

23

Це може бути проникливим для розгляду оптимізаторів у реальному світі. Розмотування циклу - відома методика. Основна ідея розгортання циклу полягає в тому

for (int i = 0; i != 3; ++i)
    foo()

може бути краще реалізовано за лаштунками як

 foo()
 foo()
 foo()

Це найпростіший випадок із фіксованою обв’язкою. Але сучасні компілятори також можуть це робити для змінних меж:

for (int i = 0; i != N; ++i)
   foo();

стає

__RELATIVE_JUMP(3-N)
foo();
foo();
foo();

Очевидно, це працює лише в тому випадку, якщо компілятор знає, що N <= 3. І ось тут ми повернемося до початкового питання. Оскільки компілятор знає, що підписане переповнення не відбувається , він знає, що цикл може виконати максимум 9 разів у 32 бітній архітектурі. 10^10 > 2^32. Тому він може зробити цикл 9 ітераційних розкручування. Але передбачуваний максимум склав 10 ітерацій! .

Що може статися, це те, що ви отримаєте відносний перехід до інструкції збірки (9-N) з N = 10, тобто зміщення -1, що є самою інструкцією стрибків. На жаль Це цілком коректна оптимізація циклу для чітко визначеного C ++, але наведений приклад перетворюється на тугий нескінченний цикл.


9

Будь-який підписаний цілий цілий перелив призводить до невизначеної поведінки, незалежно від того, переповнене значення є чи може бути прочитане.

Можливо, у вашому випадку ви можете зняти першу ітерацію з циклу, повернувши цю функцію

int result = 0;
int factor = 1;
for (int n = 0; n < 10; ++n) {
    result += n + factor;
    factor *= 10;
}
// factor "is" 10^10 > INT_MAX, UB

в це

int factor = 1;
int result = 0 + factor; // first iteration
for (int n = 1; n < 10; ++n) {
    factor *= 10;
    result += n + factor;
}
// factor is 10^9 < INT_MAX

Якщо включена оптимізація, компілятор може розкрутити другий цикл вище в один умовний стрибок.


6
Це може бути трохи надтехнічним, але "підписане переповнення не визначене поведінка" є надто спрощеним. Формально поведінка програми з підписаним переповненням не визначена. Тобто стандарт не говорить вам, що робить ця програма. Це не просто те, що щось не в тому, що результат переповнюється; у всій програмі щось не так.
Піт Бекер

Справедливе спостереження, я виправив свою відповідь.
ельбруновський

Або, простіше кажучи, очистіть останню ітерацію та видаліть мертвихfactor *= 10;
Пітер Кордес

9

Це UB; в ISO C ++, вся поведінка всієї програми абсолютно не визначено для виконання, яке врешті-решт потрапить на UB. Класичний приклад, що стосується стандарту C ++, це може змусити демонів вилетіти з вашого носа. (Рекомендую не застосовувати реалізацію, де назальні демони - реальна можливість). Дивіться інші відповіді для отримання більш детальної інформації.

Компілятори можуть «спричинити неприємності» під час компіляції для шляхів виконання, які вони можуть бачити, що ведуть до видимого часу компіляції UB, наприклад, припускають, що ці базові блоки ніколи не будуть досягнуті.

Дивіться також те, що повинен знати кожен програміст на C щодо не визначеної поведінки (блог LLVM). Як пояснено там, підписаний переповнення UB дозволяє компіляторам довести, що for(... i <= n ...)петлі не є нескінченними циклами, навіть для невідомих n. Це також дозволяє їм "просувати" лічильники циклів int до ширини вказівника замість повторного розширення знаків. (Таким чином, наслідком UB у цьому випадку може бути доступ за межами 64k або 4G елементів масиву, якщо ви очікували підписання підписання iв його діапазон значень.)

У деяких випадках компілятори видають незаконну інструкцію, як x86, ud2для блоку, який, ймовірно, викликає UB, якщо він коли-небудь виконується. (Зверніть увагу, що функцію ніколи не можна викликати, тому компілятори взагалі не можуть переглядати та порушувати інші функції, або навіть можливі шляхи через функцію, яка не вражає UB, тобто машинний код, який вона компілює, все одно повинен працювати всі входи, які не призводять до UB.)


Напевно, найефективнішим рішенням є вручну factor*=10зняти останню ітерацію, щоб уникнути непотрібних .

int result = 0;
int factor = 1;
for (... i < n-1) {   // stop 1 iteration early
    result = ...
    factor *= 10;
}
 result = ...      // another copy of the loop body, using the last factor
 //   factor *= 10;    // and optimize away this dead operation.
return result;

Або якщо тіло циклу велике, спробуйте просто використати непідписаний тип для factor. Тоді ви можете дозволити безпідписаному множині переповнюватись, і він просто виконає чітко визначене обгортання до деякої потужності 2 (кількість бітів значення у неподписаному типі).

Це добре, навіть якщо ви використовуєте його з підписаними типами, особливо якщо ваша непідписана конверсія ніколи не переповнюється.

Перетворення між підписаним та доповненим комплектом 2 є безкоштовним (однаковий біт-шаблон для всіх значень); модульне обгортання для int -> без знака, визначеного стандартом C ++, спрощує просто використання одного і того ж бітового шаблону, на відміну від доповнення чи знаку / величини.

І unsigned-> підписано аналогічно тривіально, хоча воно визначається для значень, більших за INT_MAX. Якщо ви не використовуєте величезний неподписаний результат від останньої ітерації, вам нічого не турбуватися. Але якщо ви є, див. Чи не визначено конверсію з непідписаного до підписаного? . Випадок "значення не відповідає" визначається реалізацією , що означає, що імплементація повинна вибрати певну поведінку; здорові просто обрізають (при необхідності) непідписаний бітовий шаблон і використовують його як підписано, оскільки це працює для значень в діапазоні так само, без зайвої роботи. І це точно не UB. Тож великі неподписані значення можуть стати негативними підписаними цілими числами. наприклад, після того, як int x = u; gcc і clang не оптимізуютьсяx>=0як завжди правда, навіть без -fwrapv, тому що вони визначали поведінку.


2
Я не розумію тут голоси. Я в основному хотів дописувати про лущення останньої ітерації. Але, щоб все-таки відповісти на запитання, я зібрав разом кілька пунктів про те, як вправити UB. Дивіться інші відповіді для отримання більш детальної інформації.
Пітер Кордес

5

Якщо ви можете допустити декілька додаткових інструкцій зі збирання в циклі, а не

int factor = 1;
for (int j = 0; j < n; ++j) {
    ...
    factor *= 10;
}

Ви можете написати:

int factor = 0;
for (...) {
    factor = 10 * factor + !factor;
    ...
}

щоб уникнути останнього множення. !factorне вводить галузь:

    xor     ebx, ebx
L1:                       
    xor     eax, eax              
    test    ebx, ebx              
    lea     edx, [rbx+rbx*4]      
    sete    al    
    add     ebp, 1                
    lea     ebx, [rax+rdx*2]      
    mov     edi, ebx              
    call    consume(int)          
    cmp     r12d, ebp             
    jne     .L1                   

Цей код

int factor = 0;
for (...) {
    factor = factor ? 10 * factor : 1;
    ...
}

також призводить до безгалузевої збірки після оптимізації:

    mov     ebx, 1
    jmp     .L1                   
.L2:                               
    lea     ebx, [rbx+rbx*4]       
    add     ebx, ebx
.L1:
    mov     edi, ebx
    add     ebp, 1
    call    consume(int)
    cmp     r12d, ebp
    jne     .L2

(Укладено з GCC 8.3.0 -O3)


1
Простіше просто зняти останню ітерацію, якщо тіло петлі не велике. Це спритний злом, але factorнезначно збільшує затримку ланцюга залежності, що переноситься циклом . Або ні: коли він компілюється в 2x LEA, це так само ефективно, як LEA + ADD, f *= 10як f*5*2і з testзатримкою, прихованою першою LEA. Але це коштує додаткових зусиль всередині циклу, так що можливий зворотний пробіг (або принаймні проблема з
Пітер Кордес

4

Ви не показали, що в дужках forзаяви, але я припускаю, що це щось подібне:

for (int n = 0; n < 10; ++n) {
    result = ...
    factor *= 10;
}

Ви можете просто перемістити перевірку приросту лічильника та завершення циклу в тіло:

for (int n = 0; ; ) {
    result = ...
    if (++n >= 10) break;
    factor *= 10;
}

Кількість інструкцій по монтажу в циклі залишиться незмінною.

Натхненна презентацією Андрія Олександреску "Швидкість виявляється у думці людей".


2

Розглянемо функцію:

unsigned mul_mod_65536(unsigned short a, unsigned short b)
{
  return (a*b) & 0xFFFFu;
}

Відповідно до опублікованого обґрунтування, автори Стандарту могли б очікувати, що якщо ця функція буде застосована на (наприклад) звичайному 32-бітному комп'ютері з аргументами 0xC000 та 0xC000, просування операндів *до signed intвикликає обчислення до -0x10000000 , що при перетворенні unsignedдасть 0x90000000uтакий самий відповідь, як якщо б вони зробили unsigned shortрекламу unsigned. Тим не менш, gcc іноді оптимізує цю функцію способами, які б поводилися безглуздо, якщо виникає переповнення. Будь-який код, де деяка комбінація входів може спричинити переповнення, повинен бути оброблений з -fwrapvможливістю, якщо це не було б дозволено дозволити творцям навмисно неправильно сформульованого вводу виконувати довільний код на свій вибір.


1

Чому б не це:

int result = 0;
int factor = 10;
for (...) {
    factor *= 10;
    result = ...
}
return result;

Це не запускає ...тіло циклу для factor = 1або factor = 10лише 100 і вище. Вам доведеться зняти першу ітерацію і все-таки розпочати, factor = 1якщо ви хочете, щоб це спрацювало.
Пітер Кордес

1

Існує багато різних облич не визначеного поведінки, і те, що прийнятно, залежить від використання.

щільний внутрішній цикл, який вимагає великого куска загального часу процесора в графічному додатку в реальному часі

Це само по собі є дещо незвичною річчю, але як би там не було ... якщо це дійсно так, то UB, швидше за все, в царині "допустимо, прийнятно" . Графічне програмування мало відоме для хак і негарних речей. Поки це "працює" і для отримання кадру потрібно не більше 16,6 мс, зазвичай, нікого не цікавить. Але все ж, будьте в курсі того, що означає викликати UB.

По-перше, є стандарт. З цієї точки зору, нічого обговорювати і немає можливості виправдати, ваш код просто недійсний. Немає ifs чи whens, це просто недійсний код. Ви також можете сказати, що це з середньої точки пальця з вашої точки зору, і 95-99% часу вам все одно буде добре.

Далі, є сторона обладнання. Є деякі незвичайні, дивні архітектури, де це проблема. Я кажу "нечасто, дивно", тому що на одній архітектурі, яка становить 80% усіх комп'ютерів (або двох архітектур, які разом складають 95% усіх комп'ютерів) переповнення - "так, що б там не було, байдуже" річ на апаратному рівні. Ви впевнені, що отримаєте сміттєвий (хоча все ще передбачуваний) результат, але нічого поганого не відбудеться.
Це не такщо стосується кожної архітектури, ви можете дуже добре потрапити в пастку переповнення (хоча, бачачи, як ви говорите про графічний додаток, шанси опинитися на такій дивній архітектурі досить малі). Чи проблема з портативністю? Якщо це так, можливо, ви захочете утриматися.

Останнє, є сторона компілятора / оптимізатора. Однією з причин того, що переповнення не визначене, є те, що просто залишити його на цьому найлегше було впоратися з обладнанням один раз. Але ще одна причина полягає в тому, що, наприклад x+1, гарантовано завжди буде більше, ніж xкомпілятор / оптимізатор може використовувати ці знання. Тепер, для згаданого раніше випадку, компілятори, як відомо, діють таким чином і просто викреслюють цілі блоки (існував експлойт Linux кілька років тому, який базувався на тому, що компілятор мав тупий код перевірки через саме це).
У вашому випадку я б серйозно сумнівався, що компілятор робить якісь особливі, дивні, оптимізації. Однак, що ти знаєш, що я знаю. Коли ви сумніваєтесь, спробуйте це. Якщо це працює, ви добре піти.

(І, нарешті, є звичайний аудит коду, можливо, вам доведеться витрачати час на обговорення цього питання з аудитором, якщо вам не пощастить.)

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.