Чому ігнорується арифметичний перелив?


76

Ви коли-небудь намагалися підбити підсумки всіх чисел від 1 до 2 000 000 улюбленою мовою програмування? Результат легко обчислити вручну: 2 000 000 000 000, що приблизно в 900 разів перевищує максимальне значення непідписаного 32-бітного цілого числа.

C # друкує -1453759936- негативне значення! І я думаю, що Java робить те саме.

Це означає, що є декілька поширених мов програмування, які ігнорують Arithmetic Overflow за замовчуванням (у C # є приховані варіанти зміни цього). Це поведінка, яка для мене виглядає дуже ризикованою, і чи не був крах Ariane 5, спричинений таким переповненням?

Отже: які дизайнерські рішення стоять за такою небезпечною поведінкою?

Редагувати:

Перші відповіді на це питання виражають надмірні витрати на перевірку. Виконаємо коротку програму C # для перевірки цього припущення:

Stopwatch watch = Stopwatch.StartNew();
checked
{
    for (int i = 0; i < 200000; i++)
    {
        int sum = 0;
        for (int j = 1; j < 50000; j++)
        {
            sum += j;
        }
    }
}
watch.Stop();
Console.WriteLine(watch.Elapsed.TotalMilliseconds);

На моїй машині перевірена версія займає 11015 мс, тоді як неперевірена версія займає 4125 мс. Тобто кроки перевірки займають майже вдвічі більше часу, ніж додавання чисел (загалом у 3 рази від початкового часу). Але з 10 000 000 000 повторень час, витрачений на перевірку, все ще менше 1 наносекунди. Може виникнути ситуація, коли це важливо, але для більшості застосунків це не має значення.

Редагувати 2:

Я перекомпілював наш серверний додаток (сервіс Windows, який аналізував дані, отримані від декількох датчиків, задіяно чимало число хрускотів) з /p:CheckForOverflowUnderflow="false"параметром (як правило, я перемикаю перевірку на переповнення) і розгорнув його на пристрої. Моніторинг Nagios показує, що середнє завантаження процесора залишалося на рівні 17%.

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


19
як примітка, для C # ви можете використовувати checked { }розділ для позначення частин коду, які повинні виконувати перевірки арифметичної переповнення. Це пов’язано з виставою
Paweł Łukasik

14
"Ви коли-небудь намагалися підсумувати всі цифри від 1 до 2 000 000 улюбленою мовою програмування?" - Так: (1..2_000_000).sum #=> 2000001000000. Ще один з моїх улюблених мов: sum [1 .. 2000000] --=> 2000001000000. Чи не мій улюблений: Array.from({length: 2000001}, (v, k) => k).reduce((acc, el) => acc + el) //=> 2000001000000. (Справедливо кажучи, останній обманює.)
Jörg W Mittag

27
@BernhardHiller Integerв Haskell є довільною точністю, воно буде містити будь-яке число до тих пір, поки у вас не закінчиться виділена оперативна пам'ять.
Полігном

50
Аварія Ariane 5 була спричинена перевіркою на наявність переливу, який не мав значення - ракета опинилася в частині польоту, де результат розрахунку вже не потрібен. Натомість було виявлено перелив, і це призвело до переривання рейсу.
Саймон Б

9
But with the 10,000,000,000 repetitions, the time taken by a check is still less than 1 nanosecond.це вказівка ​​оптимізації циклу. Також це речення суперечить попереднім цифрам, які здаються мені дуже правильними.
usr

Відповіді:


86

Для цього є 3 причини:

  1. Вартість перевірки на переливи (для кожної арифметичної операції) під час виконання є надмірною.

  2. Складність доведення того, що перевірку на переповнення можна опустити під час компіляції, є надмірною.

  3. У деяких випадках (наприклад, обчислення CRC, велика кількість бібліотек тощо) "програмування на переповнення" зручніше для програмістів.


10
@DmitryGrigoryev unsigned intне повинен прийти в голову, оскільки мова з перевіркою переповнення повинна перевіряти всі цілі типи за замовчуванням. Вам слід було писати wrapping unsigned int.
іммібіс

32
Я не купую аргумент вартості. Процесор перевіряє переповнення на ВСЕ ЄДИННЕ ціле число обчислення і встановлює прапор перенесення в ALU. Це підтримка мови програмування, якої немає. Проста didOverflow()вбудована функція або навіть глобальна змінна, __carryяка дозволяє отримати доступ до прапора, коштуватиме нульовий час процесора, якщо ви не використовуєте його.
slebetman

37
@slebetman: Це x86. АРМ не робить. Наприклад ADD, не встановлюється перенос (потрібно ADDS). Itanium навіть не має прапора. Навіть на x86, AVX не має прапорців.
MSalters

30
@slebetman Він встановлює прапор перенесення, так (на x86, пам'ятайте). Але тоді вам доведеться прочитати прапор перенесення і визначитися з результатом - ось дорога частина. Оскільки арифметичні операції часто використовуються в циклі (і при цьому тісні петлі), це може легко запобігти безлічі безпечних оптимізацій компілятора, які можуть мати дуже великий вплив на продуктивність, навіть якщо вам потрібна була лише одна додаткова інструкція (і вам потрібно набагато більше, ніж це ). Чи означає це, що він повинен бути типовим? Можливо, особливо такою мовою, як C #, де говорити uncheckedдосить просто; але ви, можливо, переоцінюєте, наскільки часто переповнення мають значення.
Луань

12
ARM's adds- це та сама ціна, що і add(це лише інструкція з 1-бітним прапором, яка вибирає, чи буде оновлений прапор оновлений). addІнструкції MIPS пастки переповнення - ви повинні попросити не захоплювати переповнення, використовуючи adduзамість цього!
іммібіс

65

Хто каже, що це поганий компроміс ?!

Я запускаю всі мої виробничі програми з увімкненою перевіркою переповнення. Це варіант компілятора C #. Я насправді це орієнтував, і не зміг визначити різницю. Вартість доступу до бази даних для створення (неіграшного) HTML затьмарює витрати на перевірку переповнення.

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

У випадку, якщо мені потрібна продуктивність, що іноді буває, я відключаю перевірку переповнення, використовуючи unchecked {}детальну основу. Коли я хочу закликати, що я покладаюся на операцію, що не переповнюється, я можу надмірно додати checked {}до коду, щоб задокументувати цей факт. Я пам’ятаю про переливи, але мені не обов’язково потрібно дякувати за перевірку.

Я вважаю, що команда C # зробила неправильний вибір, коли вирішили не перевіряти переповнення за замовчуванням, але цей вибір тепер запечатаний через сильні проблеми сумісності. Зауважимо, що цей вибір був зроблений близько 2000 року. Обладнання було менш спроможним, і .NET ще не мав великої тяги. Можливо .NET хотів звернутися до програмістів Java та C / C ++ таким чином. .NET також має на меті бути близьким до металу. Ось чому він має небезпечний код, структури та чудові здібності для виклику, у яких Java не має.

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

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

Спосіб боротьби із переповненням JavaScript ще гірший. Номери JavaScript є плаваючою комою вдвічі. "Переповнення" проявляється як залишення повністю точного набору цілих чисел. З'являться трохи неправильні результати (наприклад, вимкнення однієї - це може перетворити кінцеві петлі в нескінченні).

Для деяких мов, таких як C / C ++, перевірка переповнення за замовчуванням явно недоречна, оскільки види програм, які записуються цими мовами, потребують чистого виконання. Тим не менш, є зусилля, щоб C / C ++ перетворився на більш безпечну мову, дозволяючи перейти в безпечніший режим. Це похвально, оскільки 90-99% коду, як правило, холодно. Прикладом може служити fwrapvваріант компілятора, який примушує обгортання 2-го доповнення. Це особливість "якості реалізації" компілятором, а не мовою.

У Haskell немає логічного стека викликів і не визначений порядок оцінки. Це робить винятки траплятися в непередбачуваних точках. У a + bце визначено чи aабо bоцінюється першим і є ці вирази припиняється взагалі чи ні. Тому Haskell має сенс використовувати необмежені цілі числа більшу частину часу. Цей вибір підходить до суто функціональної мови, оскільки винятки справді недоречні в більшості кодів Haskell. І поділ на нуль справді є проблематичним моментом у дизайні мови Haskells. Замість необмежених цілих чисел вони також могли використовувати цілі цілі, що обгортають фіксовану ширину, але це не відповідає темі "фокус на правильності", яка є мовою.

Альтернативою виняткам переповнення є значення отрути, які створюються невизначеними операціями та поширюються через операції (наприклад, NaNзначення float ). Це здається набагато дорожчим, ніж перевірка переповнення, і робить усі операції повільнішими, а не лише ті, що можуть вийти з ладу (забороняється апаратне прискорення, яке часто плаває, і вбудови зазвичай не мають - хоча Itanium має NaT, який є "Не річ" ). Я також не дуже бачу сенс змусити програму продовжувати кульгати разом із поганими даними. Це як ON ERROR RESUME NEXT. Він приховує помилки, але не допомагає отримати правильні результати. supercat вказує, що іноді це оптимізація продуктивності для цього.


2
Відмінна відповідь. То яка ваша теорія щодо того, чому вони вирішили зробити це саме так? Просто копіюємо всіх інших, хто скопіював C і, врешті-решт, збірку та двійкові дані?
jpmc26

19
Коли 99% вашої бази користувачів очікують на поведінку, ви схильні надавати їм їх. А щодо "копіювання C" - це насправді не копія C, а її розширення. C гарантує виключну вільну поведінку unsignedлише для цілих чисел. Поведінка підписаного цілого числа переповнення насправді є невизначеною поведінкою в C та C ++. Так, невизначена поведінка . Так буває, що майже кожен реалізує це як переповнення доповнення 2. C # насправді робить це офіційним, а не залишає його UB як C / C ++
Cort Ammon

10
@CortAmmon: Мова, яку розробив Денніс Річі, мала певну поведінку для підписаних цілих чисел, але насправді не підходить для використання на не-двох платформах-доповненнях. Хоча дозволення певних відхилень від точного розгортання двох доповнення може значно сприяти оптимізації (наприклад, дозволяючи компілятору замінити x * y / y на x, може зберегти множення і поділ), автори-компілятори трактували Undefined Behavior не як можливість зробити що має сенс для заданої цільової платформи та поля додатків, а не як можливість викинути сенс у вікно.
supercat

3
@CortAmmon - Перевірте код , згенерований gcc -O2для x + 1 > x(де xє int). Також см gcc.gnu.org/onlinedocs/gcc-6.3.0/gcc / ... . Поведінка 2s-доповнення підписаного переповнення в C необов'язково , навіть у реальних компіляторах, і gccза замовчуванням ігнорує його в нормальних рівнях оптимізації.
Джонатан У ролях

2
@supercat Так, більшість авторів-компіляторів C більше зацікавлені у тому, щоб переконатися, що якийсь нереальний орієнтир працює на 0,5% швидше, ніж намагатися надати програмістам розумну семантику (так, я розумію, чому це вирішити непросту проблему, і є деякі розумні оптимізації, які можуть викликати несподівані результати в поєднанні, yada, yada, але все ж це просто не фокус, і ви помічаєте це, якщо стежити за розмовами). На щастя, є люди, які намагаються зробити краще .
Во

30

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


28
Це якось так, як сказати, що перевірки на переповнення буфера слід опустити, оскільки вони навряд чи трапляться ...
Бернхард Хіллер

73
@BernhardHiller: і саме це і C, і C ++ роблять.
Майкл Боргвардт

12
@DavidBrown: Як і арифметичні переливи. Перші, проте, не компрометують ВМ.
Дедуплікатор

35
@Deduplicator робить чудовий момент. CLR був ретельно розроблений таким чином, що програми, що перевіряються, не можуть порушувати інваріанти часу виконання, навіть коли трапляються погані речі. Безпечні програми, звичайно, можуть порушувати власних інваріантів, коли трапляються погані речі.
Ерік Ліпперт

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

20

які дизайнерські рішення стоять за такою небезпечною поведінкою?

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

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

Нові мови порушують таке ставлення до багатьох інших функцій, як-от перевірка меж масиву. Я не впевнений, чому вони цього не зробили для перевірки переповнення; це може бути просто недогляд.


18
Це точно не є недоглядом у дизайні C #. Дизайнери C # навмисно створили два режими: checkedі unchecked, додавши синтаксис для перемикання між ними локально, а також перемикачі командного рядка (та налаштування проектів у VS), щоб змінити його глобально. Ви можете не погодитись із тим, що робити uncheckedза замовчуванням (я це роблю), але все це явно дуже навмисно.
svick

8
@slebetman - лише для запису: вартість тут - це не вартість перевірки на переповнення (що тривіально), а вартість виконання різного коду залежно від того, чи відбулося переповнення (що дуже дорого). Процесорні процесори не люблять умовні заяви гілки.
Джонатан У ролях

5
@jcast Чи не прогнозування гілок на сучасних процесорах майже не усуне цю умовну штрафну заяву гілки? Зрештою, у звичайному випадку не повинно бути переповнення, тому це дуже передбачувана поведінка розгалуження.
CodeMonkey

4
Погодьтеся з @CodeMonkey. У разі переповнення компілятор може ввести умовний стрибок на сторінку, яка зазвичай не завантажується / холодна. Прогноз за замовчуванням для цього "не береться", і він, ймовірно, не зміниться. Загальні накладні витрати - одна інструкція в процесі роботи. Але це одна інструкція накладну за арифметичною інструкцією.
MSalters

2
@MSalters так, є додаткові накладні інструкції. І вплив може бути великим, якщо у вас є виключно пов'язані з процесором проблеми. У більшості програм із сумішшю важкого коду IO та CPU я вважаю, що вплив мінімальний. Мені подобається спосіб «Іржа», додавання накладних даних лише у налагодженнях налагодження, але видалення його у версіях Release.
CodeMonkey

20

Спадщина

Я б сказав, що це питання, ймовірно, корінням у спадщину. В:

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

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

Приводить до Стату-кво

Той факт, що C (і за допомогою розширення C ++) не потребують виявлення переповнення по черзі, означає, що перевірка переповнення є млявою.

Обладнання здебільшого обслуговує C / C ++ (серйозно, x86 має strcmpінструкцію (він же PCMPISTRI від SSE 4.2)!), І оскільки C не байдуже, звичайні процесори не пропонують ефективних способів виявлення переповнення. У x86 вам потрібно перевірити прапор на одне ядро ​​після кожної потенційно переповненої операції; коли те, що ви насправді хочете, - це "заплямований" прапор на результат (подібно до NaN поширюється). А векторні операції можуть бути ще більш проблематичними. Деякі нові гравці можуть з’явитися на ринку з ефективними режимами переповнення; але наразі x86 та ARM це не байдуже.

Оптимізатори компілятора непогані в оптимізації перевірок на переповнення або навіть оптимізації при наявності переливів. Деякі вчені, такі як Джон Реджер, скаржаться на цей статут-кво , але факт полягає в тому, що коли простий факт створення переповнення "невдач" запобігає оптимізації ще до того, як збірка потрапить до процесора, може бути калікою. Особливо, коли це заважає автоматичній векторизації ...

З каскадними ефектами

Отже, за відсутності ефективних стратегій оптимізації та ефективної підтримки процесора, перевірка переповнення коштує дорого. Набагато дорожче, ніж обгортання.

Додайте в собі деяку дратівливу поведінку, таку як x + y - 1може переповнювати, коли x - 1 + yні, що може законно дратувати користувачів, а перевірка переповнення, як правило, відкидається на користь обгортання (що цей приклад та багато інших витончено обробляє).

Все-таки не вся надія втрачена

Компілятори clang та gcc доклали зусиль для впровадження "дезінфікуючих засобів": способи інструментації бінарних файлів для виявлення випадків не визначеної поведінки. При використанні -fsanitize=undefinedпідписується переповнення виявляється і перериває програму; дуже корисно під час тестування.

Мова програмування Rust увімкнено перевірку переповнення за замовчуванням у режимі налагодження (він використовує обертальну арифметику в режимі випуску з міркувань продуктивності).

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


6
@DmitryGrigoryev - це протилежний ефективний спосіб перевірити наявність переливів, наприклад, на Haswell він зменшує пропускну здатність з 4 нормальних доповнень на цикл до лише 1 перевіреного додавання, і це перед тим, як врахувати вплив непередбачуваних галузей jo, і Більш глобальні наслідки забруднення вони додають до стану галузевого прогноктора та збільшення розміру коду. Якщо цей прапор був липким, він би запропонував реальний потенціал .. і тоді ви все ще не можете зробити це належним чином у векторному коді.

3
Оскільки ви посилаєтесь на допис у блозі, написаний Джоном Регером, я вважав, що було б доречним також посилання на іншу його статтю , написану за кілька місяців до тієї, яку ви пов’язали. Ці статті говорять про різні філософії: У попередній статті цілі числа мають фіксований розмір; ціла арифметика перевіряється (тобто код не може продовжувати її виконання); є або виняток, або пастка. Новіша стаття розповідає про викидання цілих чисел фіксованого розміру, що виключає переливи.
rwong

2
@rwong Цілі числа нескінченного розміру також мають свої проблеми. Якщо ваш переповнення є результатом помилки (якою вона часто є), це може перетворити швидкий збій у тривалу агонію, яка споживає всі ресурси сервера, поки все жахливо не вийде. В основному я шанувальник підходу "невдало рано" - менше шансів отруїти все довкілля. Я б вважав за краще 1..100типи Pascal-ish - будьте чіткими щодо очікуваних діапазонів, а не «змушених» до 2 ^ 31 і т.д. час компіляції, навіть).
Луань

1
@Luaan: Цікаво, що часто проміжні обчислення можуть тимчасово переповнюватись, але результат цього не робить. Наприклад, у діапазоні 1..100 x * 2 - 2може переповнюватися коли x51, хоча результат підходить, змушуючи переставляти свої обчислення (іноді неприродно). На моєму досвіді я виявив, що я, як правило, вважаю за краще запускати обчислення у більшому типі, а потім перевіряти, чи підходить результат чи ні.
Матьє М.

1
@MatthieuM. Так, саме тут ви потрапляєте на територію "досить розумного компілятора". В ідеалі значення 103 має бути дійсним для типу 1..100 до тих пір, поки воно ніколи не використовується в контексті, коли очікується справжній 1..100 (наприклад, x = x * 2 - 2повинен працювати для всіх, xде призначення приводить до дійсного 1. .100 номер). Тобто, операції над числовим типом можуть мати більш високу точність, ніж сам тип, доки присвоєння відповідає. Це було б досить корисно у випадках, (a + b) / 2коли ігнорування (неподписані) переповнення може бути правильним варіантом.
Луаан

10

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

for (int i=0; i<100; i++)
{
  Operation1();
  x+=i;
  Operation2();
}

якщо початкове значення x спричинить переповнення на 47-му проході через цикл, Operation1 виконає 47 разів, а Operation2 виконає 46. За відсутності такої гарантії, якщо нічого іншого в циклі не використовується x, і нічого буде використовувати значення x після викинутого винятку в Operation1 або Operation2, код можна замінити на:

x+=4950;
for (int i=0; i<100; i++)
{
  Operation1();
  Operation2();
}

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

if (x < INT_MAX-4950)
{
  x+=4950;
  for (int i=0; i<100; i++)
  {
    Operation1();
    Operation2();
  }
}
else
{
  for (int i=0; i<100; i++)
  {
    Operation1();
    x+=i;
    Operation2();
  }
}

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

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

(*) Якщо мова обіцяє, що всі переповнення будуть повідомлятися, такий вираз, як подібний, x*y/yне може бути спрощений до xтих пір, якщо не x*yможе бути гарантовано не переповнення. Так само, навіть якщо результат обчислення буде проігноровано, мова, яка обіцяє повідомити про всі переповнення, потребуватиме її все одно, щоб вона могла виконати перевірку переповнення. Оскільки переповнення в таких випадках не може спричинити арифметично-неправильну поведінку, програмі не потрібно буде проводити такі перевірки, щоб гарантувати, що жоден перелив не спричинив потенційно неточні результати.

Між іншим, переливи в С особливо погані. Хоча майже кожна апаратна платформа, яка підтримує C99, використовує дві семантики беззвучного завершення, сучасні компілятори модно створювати код, який може викликати довільні побічні ефекти у разі переповнення. Наприклад, подано щось на зразок:

#include <stdint.h>
uint32_t test(uint16_t x, uint16_t y) { return x*y & 65535u; }
uint32_t test2(uint16_t q, int *p)
{
  uint32_t total=0;
  q|=32768;
  for (int i = 32768; i<=q; i++)
  {
    total+=test(i,65535);
    *p+=1;
  }
  return total;
}

GCC генерує код для test2, який безумовно збільшується (* p) один раз і повертає 32768 незалежно від значення, переданого в q. Своєю міркуванням, обчислення (32769 * 65535) та 65535u спричинило б переповнення, і тому компілятор не потребує розгляду випадків, коли (q | 32768) дасть значення, що перевищує 32768. Навіть якщо немає Оскільки для обчислення (32769 * 65535) та 65535у слід піклуватися про верхні біти результату, gcc використовуватиме підписаний переповнення як виправдання для ігнорування циклу.


2
"це модно для сучасних компіляторів ..." - аналогічно, розробникам певних відомих ядер було коротко модно вибирати не читати документацію щодо використовуваних ними оптимізаційних прапорів, а потім діяти злістю по всьому Інтернету тому що вони були змушені додати ще більше прапорів компілятора, щоб отримати поведінку, яку вони хотіли ;-). У цьому випадку -fwrapvпризводить до визначеної поведінки, хоча і не до поведінки, яку хоче запитуючий. Зрозуміло, що оптимізація gcc перетворює будь-який розвиток C на ретельний іспит на стандарт і поведінку компілятора.
Стів Джессоп

1
@SteveJessop: C була б набагато здоровішою мовою, якби автори-компілятори визнавали діалект низького рівня, де "невизначена поведінка" означала "робити все, що має сенс на базовій платформі", а потім додала способів програмістам відмовитись від непотрібних гарантій, що маються на увазі, а не припускати, що словосполучення "непереносний або помилковий" у Стандарті просто означає "помилкове". У багатьох випадках оптимальний код, який можна отримати мовою зі слабкими гарантіями поведінки, буде набагато кращим, ніж його можна отримати з більш сильними гарантіями або відсутністю гарантій. Наприклад ...
supercat

1
... якщо програмісту потрібно оцінити x+y > zтаким чином, який ніколи не зробить нічого, окрім урожайності 0 або виходу 1, але будь-який результат був би однаково прийнятним у разі переповнення, компілятор, який пропонує цю гарантію, часто може генерувати кращий код для вираз, x+y > zніж будь-який компілятор міг би генерувати для оборонно написаної версії виразу. Реально кажучи, яка частка корисних оптимізацій, пов’язаних із переповненням, виключатиметься гарантією того, що цілі обчислення, окрім поділки / залишку, виконуватимуться без побічних ефектів?
суперкар

Зізнаюсь, я не повністю вникаю в деталі, але той факт, що ваша кривда в цілому з "авторами-компіляторами", а не конкретно "хтось із gcc, який не прийме мого -fwhatever-makes-senseвиправлення", настійно підказує мені, що є більше до цього, ніж примха з їхнього боку. Звичайні аргументи, про які я чув, полягають у тому, що вбудований код (і навіть розширення макросу) виграють якомога більше виведення щодо конкретного використання конструкції коду, оскільки будь-яка річ зазвичай призводить до вставленого коду, який займається справами, які йому не потрібні. до того, що навколишній код "виявляється" неможливим.
Стів Джессоп

Отже, для спрощеного прикладу, якщо я пишу foo(i + INT_MAX + 1), автори-компілятори прагнуть застосувати оптимізацію до вкладеного foo()коду, який покладається на правильність того, що його аргумент є негативним (можливо, шаленими хитрощами). За ваших додаткових обмежень вони можуть застосовувати лише оптимізацію, поведінка щодо негативних вкладів має сенс для платформи. Звичайно, особисто я радий, що це -fваріант, який включається -fwrapvтощо, і, ймовірно, повинен відключити деякі оптимізації, для яких немає прапора. Але це не так, як я можу потрудитися робити все, що працює сама.
Стів Джессоп

9

Не всі мови програмування ігнорують цілі переповнення. Деякі мови забезпечують безпечні цілі операції для всіх чисел (більшість діалектів Lisp, Ruby, Smalltalk, ...) та інші через бібліотеки - наприклад, існують різні класи BigInt для C ++.

Будь-яка мова робить ціле число захищеним від переповнення за замовчуванням чи ні, залежить від її призначення: системні мови, такі як C і C ++, повинні забезпечувати абстрагування з нульовою вартістю, а "велике ціле число" - це не одне. Мови продуктивності, такі як Ruby, можуть і не давати великих цілих чисел поза коробкою. Такі мови, як Java та C #, які знаходяться десь між ними, повинні IMHO виходити з безпечними цілими числами з поля, оскільки вони не мають цього.


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

@MatthieuM. Абсолютно - і я усвідомлюю, що я не розумію цього в своїй відповіді.
Неманья Трифунович

7

Як ви показали, C # був би в 3 рази повільнішим, якби у нього було включено перевірки на переповнення за замовчуванням (припустимо, що ваш приклад є типовим додатком для цієї мови). Я погоджуюся, що ефективність не завжди є найважливішою особливістю, але мови / компілятори, як правило, порівнюють їх ефективність у типових завданнях. Частково це пояснюється тим, що якість мовних особливостей дещо суб'єктивна, а тест на продуктивність - об'єктивний.

Якби ви запровадили нову мову, схожу на C # у більшості аспектів, але в 3 рази повільніше, отримати частку ринку було б непросто, навіть якщо врешті-решт більшість ваших кінцевих користувачів отримають більше переваг від перевірок на переповнення, ніж вони від більш високої продуктивності.


10
Особливо це стосується C #, який був у перші дні порівняно з Java та C ++ не на показниках продуктивності розробників, які важко виміряти, або на показниках порушення грошових коштів, які не отримують справу з порушенням безпеки, які важко виміряти, але на банальних показниках ефективності.
Ерік Ліпперт

1
І, ймовірно, продуктивність процесора перевіряється за допомогою простого скорочення чисел. Таким чином, оптимізація для виявлення переповнення може дати «погані» результати на цих тестах. Ловити22.
Бернхард Хіллер

5

Крім багатьох відповідей, які виправдовують відсутність перевірки переповнення на основі продуктивності, слід розглянути два різні види арифметики:

  1. індексація обчислень (індексація масиву та / або арифметика вказівника)

  2. інші арифметичні

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

Таким чином, перевірка розподілу пам’яті достатня при роботі з арифметичними вказівниками та вираженнями індексації, що включають виділені структури даних. Наприклад, якщо у вас є 32-розрядний адресний простір і ви використовуєте 32-бітні цілі числа, і дозволено виділити максимум 2 ГБ купи (приблизно половину адресного простору), обчислення індексації / покажчика (в основному) не переповнюватиметься.

Крім того, ви можете здивуватися тому, скільки додавання / віднімання / множення включає індексацію масиву або обчислення вказівника, таким чином потрапляючи до першої категорії. Об'єктні покажчики, доступ до поля та маніпуляції з масивом є операціями індексації, і багато програм не виконують арифметичних обчислень, ніж ці! По суті, це головна причина, що програми працюють так само, як і без цілої перевірки переповнення.

Усі неіндексаційні та неточкові обчислення повинні бути класифіковані як ті, які хочуть / очікують переповнення (наприклад, хеш-обчислення), так і такі, які не (наприклад, ваш приклад підсумовування).

В останньому випадку програмісти часто використовують альтернативні типи даних, такі як doubleчи деякі BigInt. Для багатьох розрахунків потрібен decimalтип даних, а не типdouble , наприклад, фінансові розрахунки. Якщо вони не дотримуються цілих типів, то їм потрібно подбати, щоб перевірити наявність цілого переповнення - інакше, так, програма може досягти невідкритого стану помилки, як ви вказуєте.

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


3

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

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

Ви можете прочитати більше про міркування, що стоять за вибором в RFC для зміни. У цій публікації блогу також є багато цікавої інформації , включаючи список помилок , завдяки яким ця функція допомогла у виловленні.


2
Іржа також пропонує такі методи, як checked_mulперевірка, чи відбулося переповнення та повертається, Noneякщо так, в Someіншому випадку. Це можна використовувати як у виробництві, так і в режимі налагодження: doc.rust-lang.org/std/primitive.i32.html#examples-15
Akavall

3

У Swift за замовчуванням виявляються будь-які цілі числа переливів і миттєво зупиняють програму. У тих випадках, коли вам потрібна поведінка в обертанні, існують різні оператори & +, & - і & *, які цього досягають. І є функції, які виконують операцію і кажуть, чи було переповнення чи ні.

Приємно спостерігати за початківцями, які намагаються оцінити послідовність Collatz і зазнати краху коду :-)

Тепер дизайнери Swift також є конструкторами LLVM та Clang, тому вони знають трохи-два про оптимізацію, і цілком здатні уникати зайвих перевірок на переповнення. Якщо активовано всі оптимізації, перевірка переповнення не додає великого розміру коду та часу його виконання. Оскільки більшість переливів призводять до абсолютно невірних результатів, це розмір коду та час, витрачений на виконання.

PS. У C, C ++, цілочисельному арифметичному переповненні, підписаним Objective-C, є невизначена поведінка. Це означає, що все, що компілятор робить у випадку переписаного цілого числа, є правильним, за визначенням. Типовий спосіб впоратися з підписаним цілим числом переповнення - це ігнорувати його, беручи до уваги будь-який результат, який дає вам процесор, будуючи припущення для компілятора, що таке переповнення ніколи не відбудеться (і зробимо, наприклад, що n + 1> n завжди вірно, оскільки переповнення є припускається, що ніколи не трапляється), а можливість, яка рідко використовується, - це перевірка та збій, якщо відбудеться переповнення, як це робить Swift.


1
Я інколи замислювався, чи люди, які підштовхують до СУ божевілля в С, таємно намагаються підірвати його на користь якоїсь іншої мови. Це мало б сенс.
supercat

Трактування x+1>xяк безумовно істинного не вимагає від компілятора робити будь-яких «припущень» щодо x, якщо компілятору дозволено оцінювати цілі вирази, використовуючи довільні більші типи, як зручні (або поводяться так, ніби це роблять). Найгучнішим прикладом "припущень" на основі переповнення було б вирішити, що даний uint32_t mul(uint16_t x, uint16_t y) { return x*y & 65535u; }компілятор може використовувати sum += mul(65535, x)для вирішення, що xне може перевищувати 32768 [поведінка, яка, ймовірно, шокує людей, які написали Обгрунтування C89, що говорить про те, що один із вирішальних факторів. ..
supercat

... при unsigned shortпросуванні до signed intтого, що два-доповнення беззвучного завершення (тобто більшість реалізацій C, які тоді використовуються) будуть розглядати код, як зазначений вище, таким же чином, чи unsigned shortпросуваються до intабо unsigned. Стандарт не потребував реалізацій на апаратному забезпеченні беззвучного обговорення двох, щоб обробляти такий код, як зазначено вище, але, здається, автори Стандартів очікували, що вони так чи інакше зроблять.
суперкат

2

Власне, справжня причина цього - суто технічна / історична: знак ігнорування процесора здебільшого. Як правило, є лише одна інструкція щодо додавання двох цілих чисел у регістри, і процесор не хвилює трохи, чи ви інтерпретуєте ці два цілі числа як підписані чи непідписані. Те саме стосується віднімання і навіть для множення. Єдина арифметична операція, яку потрібно усвідомлювати знаками, - це поділ.

Причиною, чому це працює, є представлення додатків 2 підписаних цілих чисел, яке використовується практично всіма процесорами. Наприклад, у 4-бітовому додатку додавання 5 та -3 виглядає приблизно так:

  0101   (5)
  1101   (-3)
(11010)  (carry)
  ----
  0010   (2)

Слідкуйте за тим, як поведінка при вивертанні викидаючого долота дає правильний підписаний результат. Так само процесори зазвичай здійснюють віднімання x - yяк x + ~y + 1:

  0101   (5)
  1100   (~3, binary negation!)
(11011)  (carry, we carry in a 1 bit!)
  ----
  0010   (2)

Це реалізує віднімання як доповнення в апаратному забезпеченні, налаштовуючи лише вхідні дані до арифметично-логічної одиниці (ALU) тривіальними способами. Що може бути простішим?

Оскільки множення - це не що інше, як послідовність додавання, воно поводиться так само приємно. Результатом використання представлення комплементу 2 та ігнорування виконання арифметичних операцій є спрощена схема та спрощені набори інструкцій.

Очевидно, оскільки С був розроблений так, щоб він працював близько до металу, він прийняв таку саму поведінку, як і стандартизована поведінка непідписаної арифметики, що дозволяло лише підписаній арифметиці давати невизначене поведінку. І цей вибір переноситься на інші мови, такі як Java, і, очевидно, на C #.


Я прийшов сюди, щоб дати цю відповідь.
Містер Лістер

На жаль, деякі люди, здається, вважають вкрай необгрунтованим уявлення про те, що люди, які пишуть код C низького рівня на платформі, повинні мати нахабність сподіватися, що компілятор C, придатний для таких цілей, буде вести себе обмежено у разі переповнення. Особисто я вважаю, що компілятор доцільно поводитись так, ніби обчислення виконуються з довільно розширеною точністю за зручністю компілятора (наприклад, для 32-бітної системи, якщо x==INT_MAX, то, x+1можливо, довільно поводитись як +2147483648 або -2147483648 у компіляторі зручність), але ...
supercat

Деякі люди, здається, думають, що якщо xі yє, так uint16_tі код на 32-бітній системній обчислювальній машині, x*y & 65535uколи yце 65535, компілятор повинен припустити, що код ніколи не буде досягнутий, коли xвін перевищує 32768.
supercat

1

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

У C та C ++ (як приклади) один із принципів дизайну мов - це не надання функціоналу, про який не вимагали. Зазвичай це підсумовується фразою "не плати за те, що ти не використовуєш". Якщо програміст хоче перевірити переповнення, то він може просити його (і сплачувати пеню). Це робить мову більш небезпечною для використання, але ви вирішите працювати з мовою, знаючи це, тому ви приймаєте ризик. Якщо ви не хочете цього ризику, або якщо ви пишете код, коли безпека є найважливішою, тоді ви можете вибрати більш підходящу мову, де продуктивність / ризик відрізняються.

Але з 10 000 000 000 повторень час, витрачений на перевірку, все ще менше 1 наносекунди.

У цьому міркуванні є кілька помилок:

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

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

  3. Для деяких алгоритмів і контекстів 10 000 000 000 повторень можуть бути незначними. Знову ж таки, загалом немає сенсу говорити про конкретні сценарії, які застосовуються лише у певних контекстах.

Може виникнути ситуація, коли це важливо, але для більшості застосунків це не має значення.

Можливо, ви праві. Але знову ж таки, це питання про те, які цілі конкретної мови. Багато мов насправді призначені для задоволення потреб "більшості" або для забезпечення безпеки перед іншими проблемами. Інші, як C і C ++, надають пріоритет ефективності. У цьому контексті змушення всіх платити за ефективність штрафу просто тому, що більшість людей не буде заважати, суперечить тому, що мова намагається досягти.


-1

Є хороші відповіді, але я думаю, що тут пропущений момент: наслідки цілого переповнення не обов'язково є поганою справою, і після факту важко знати, чи iперейшло від буття MAX_INTдо буття MIN_INTчерез проблему переповнення або якщо це було навмисно зроблено множенням на -1.

Наприклад, якщо я хочу додати всі представлені цілі числа, що перевищують 0 разом, я б просто застосував for(i=0;i>=0;++i){...}цикл додавання - і коли він переповнює, він зупиняє додавання, що є цільовою поведінкою (кидання помилки означає, що я повинен обійти довільний захист, оскільки він заважає стандартній арифметиці). Погана практика обмежувати примітивні арифметики, оскільки:

  • Вони використовуються у всьому - уповільнення примітивної математики - це уповільнення роботи кожної функціонуючої програми
  • Якщо програміст потребує їх, вони завжди можуть їх додати
  • Якщо у вас вони є, і програміст їх не потребує (але для цього потрібні швидші умови виконання), вони не можуть легко їх видалити для оптимізації
  • Якщо у вас їх є, і програмісту потрібно, щоб вони не були там (як у прикладі вище), програміст одночасно сприймає час виконання (що може бути, а може і не бути актуальним), і програмісту все одно потрібно інвестувати час на видалення або обіймати «захист».

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

1
Не можна переходити INT_MAXдо INT_MIN, помноживши на -1.
Девід Конрад

Це рішення, очевидно, полягає в тому, щоб програміст зміг відключити чеки в заданому блоці коду або блоку компіляції.
Девід Конрад

for(i=0;i>=0;++i){...}- це стиль коду, який я намагаюся відмовити в своїй команді: він покладається на спеціальні ефекти / побічні ефекти і не чітко виражає, що він має робити. Але все ж я ціную вашу відповідь, оскільки вона показує іншу парадигму програмування.
Бернхард Хіллер

1
@Delioth: Якщо iце 64-розрядний тип, навіть якщо він реалізується з послідовною мовчазною оберненою поведінкою, що доповнює два, виконує мільярд ітерацій в секунду, такий цикл може бути гарантований лише для знаходження найбільшого intзначення, якщо дозволено запускати сотні років. У системах, які не обіцяють послідовної мовчазної поведінки, така поведінка не була б гарантована незалежно від того, який довгий код буде надано.
supercat
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.