Perché l'overflow aritmetico viene ignorato?


76

Hai mai provato a riassumere tutti i numeri da 1 a 2.000.000 nel tuo linguaggio di programmazione preferito? Il risultato è facile da calcolare manualmente: 2.000.001.000.000, che circa 900 volte più grandi del valore massimo di un intero a 32 bit senza segno.

C # stampa -1453759936- un valore negativo! E immagino che Java faccia lo stesso.

Ciò significa che ci sono alcuni linguaggi di programmazione comuni che ignorano l'Arithmetic Overflow per impostazione predefinita (in C #, ci sono opzioni nascoste per modificarlo). È un comportamento che mi sembra molto rischioso, e l'incidente di Ariane 5 non è stato causato da un tale trabocco?

Quindi: quali sono le decisioni di progettazione alla base di un comportamento così pericoloso?

Modificare:

Le prime risposte a questa domanda esprimono i costi eccessivi del controllo. Eseguiamo un breve programma C # per testare questo presupposto:

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);

Sul mio computer, la versione selezionata richiede 11015ms, mentre la versione non selezionata richiede 4125ms. Vale a dire i passaggi di controllo richiedono quasi il doppio dell'aggiunta dei numeri (in totale 3 volte il tempo originale). Ma con le 10.000.000.000 di ripetizioni, il tempo impiegato da un controllo è ancora inferiore a 1 nanosecondo. Potrebbe esserci una situazione in cui ciò è importante, ma per la maggior parte delle applicazioni non ha importanza.

Modifica 2:

Ho ricompilato la nostra applicazione server (un servizio Windows che analizza i dati ricevuti da diversi sensori, con alcuni crunching numerici coinvolti) con il /p:CheckForOverflowUnderflow="false"parametro (normalmente, accendo il controllo di overflow) e l'ho distribuito su un dispositivo. Il monitoraggio di Nagios mostra che il carico medio della CPU è rimasto al 17%.

Ciò significa che l'hit performance riscontrato nell'esempio inventato sopra è totalmente irrilevante per la nostra applicazione.


19
proprio come una nota, per C # è possibile utilizzare la checked { }sezione per contrassegnare le parti del codice che devono eseguire i controlli di overflow aritmetico. Ciò è dovuto all'esibizione
Paweł Łukasik

14
"Hai mai provato a riassumere tutti i numeri da 1 a 2.000.000 nel tuo linguaggio di programmazione preferito?" - Sì: (1..2_000_000).sum #=> 2000001000000. Un altro uno dei miei preferiti lingue: sum [1 .. 2000000] --=> 2000001000000. Non il mio preferito: Array.from({length: 2000001}, (v, k) => k).reduce((acc, el) => acc + el) //=> 2000001000000. (Per essere onesti, l'ultimo è barare.)
Jörg W Mittag

27
@BernhardHiller Integerin Haskell è una precisione arbitraria, conterrà qualsiasi numero finché non si esaurisce la RAM allocabile.
Polygnome,

50
L'incidente di Ariane 5 è stato causato dal controllo di un trabocco che non aveva importanza: il razzo era in una parte del volo in cui il risultato di un calcolo non era nemmeno più necessario. Invece, è stato rilevato l'overflow e ciò ha causato l'interruzione del volo.
Simon B,

9
But with the 10,000,000,000 repetitions, the time taken by a check is still less than 1 nanosecond.questa è un'indicazione dell'ottimizzazione del loop. Anche quella frase contraddice i numeri precedenti che mi sembrano molto validi.
usr

Risposte:


86

Ci sono 3 motivi per questo:

  1. Il costo del controllo degli overflow (per ogni singola operazione aritmetica) in fase di esecuzione è eccessivo.

  2. La complessità di dimostrare che un controllo di overflow può essere omesso in fase di compilazione è eccessiva.

  3. In alcuni casi (ad es. Calcoli CRC, librerie di grandi numeri, ecc.) "Avvolgere su overflow" è più conveniente per i programmatori.


10
@DmitryGrigoryev unsigned intnon dovrebbe venire in mente perché una lingua con controllo di overflow dovrebbe controllare tutti i tipi di numeri interi per impostazione predefinita. Dovresti scrivere wrapping unsigned int.
user253751

32
Non compro l'argomento dei costi. La CPU verifica l'overflow su OGNI SINGOLO calcolo dei numeri interi e imposta il flag di carry nell'ALU. Manca il supporto del linguaggio di programmazione. Una semplice didOverflow()funzione inline o anche una variabile globale __carryche consente l'accesso al flag carry costerebbe zero tempo CPU se non la si utilizza.
Slebetman,

37
@slebetman: questo è x86. ARM no. Ad esempio ADD, non imposta il carry (è necessario ADDS). Itanium non ha nemmeno avere un carry flag. E anche su x86, AVX non ha flag di trasporto.
MSalters

30
@slebetman Imposta il flag carry, sì (su x86, intendiamoci). Ma poi devi leggere la bandiera carry e decidere il risultato: questa è la parte costosa. Poiché le operazioni aritmetiche sono spesso utilizzate nei loop (e loop stretti in questo), questo può facilmente prevenire molte ottimizzazioni del compilatore sicure che possono avere un impatto molto grande sulle prestazioni anche se hai solo bisogno di un'istruzione aggiuntiva (e hai bisogno di molto di più ). Significa che dovrebbe essere l'impostazione predefinita? Forse, specialmente in un linguaggio come C # in cui dire uncheckedè abbastanza facile; ma potresti sopravvalutare la frequenza con cui trabocca.
Luaan,

12
ARM ha addslo stesso prezzo di add(è solo un flag di istruzioni a 1 bit che seleziona se il flag di carry è aggiornato). Le addistruzioni del MIPS intrappolano l'overflow: devi invece chiedere di non intrappolare l'overflow usando adduinvece!
user253751

65

Chi dice che sia un cattivo compromesso ?!

Eseguo tutte le mie app di produzione con il controllo di overflow abilitato. Questa è un'opzione del compilatore C #. In realtà l'ho confrontato e non sono stato in grado di determinare la differenza. Il costo di accesso al database per generare HTML (non giocattolo) oscura i costi di verifica dell'overflow.

Apprezzo il fatto di sapere che nessuna operazione trabocca nella produzione. Quasi tutto il codice si comporterebbe in modo irregolare in presenza di overflow. Gli insetti non sarebbero benigni. È probabile la corruzione dei dati, la sicurezza è una possibilità.

Nel caso in cui ho bisogno delle prestazioni, che a volte è il caso, disabilito il controllo di overflow usando unchecked {}su base granulare. Quando desidero dichiarare che faccio affidamento su un'operazione che non trabocca, potrei aggiungere ridondante checked {}al codice per documentare tale fatto. Sono consapevole degli overflow ma non ho necessariamente bisogno di essere grazie al controllo.

Credo che il team C # abbia fatto la scelta sbagliata quando ha scelto di non controllare l'overflow per impostazione predefinita, ma quella scelta è ora sigillata a causa di forti problemi di compatibilità. Si noti che questa scelta è stata fatta intorno all'anno 2000. L'hardware era meno capace e .NET non aveva ancora molta trazione. Forse .NET voleva fare appello ai programmatori Java e C / C ++ in questo modo. .NET è anche pensato per essere vicino al metal. Ecco perché ha un codice non sicuro, strutture e ottime capacità di chiamata nativa che Java non ha.

Più veloce diventa il nostro hardware e più intelligenti i compilatori ottengono il controllo di overflow più attraente per impostazione predefinita.

Credo anche che il controllo di overflow sia spesso migliore di numeri di dimensioni infinite. Numeri di dimensioni infinite hanno un costo di prestazione ancora più elevato, più difficile da ottimizzare (credo) e aprono la possibilità di un consumo illimitato di risorse.

Il modo in cui JavaScript gestisce l'overflow è ancora peggio. I numeri JavaScript sono doppi in virgola mobile. Un "overflow" si manifesta come lasciando l'insieme completo di numeri interi. Si verificheranno risultati leggermente errati (come essere disattivati ​​da uno - questo può trasformare i loop finiti in infiniti).

Per alcuni linguaggi come il controllo del trabocco C / C ++ per impostazione predefinita è chiaramente inappropriato perché i tipi di applicazioni che vengono scritti in queste lingue richiedono prestazioni bare metal. Tuttavia, ci sono sforzi per rendere C / C ++ in un linguaggio più sicuro consentendo di optare per una modalità più sicura. Ciò è encomiabile poiché il 90-99% del codice tende a essere freddo. Un esempio è l' fwrapvopzione del compilatore che forza il wrapping del complemento di 2. Questa è una funzionalità di "qualità di implementazione" da parte del compilatore, non dalla lingua.

Haskell non ha stack di chiamate logiche e nessun ordine di valutazione specificato. Questo fa sì che si verifichino eccezioni in punti imprevedibili. In a + besso è specificato se ao bviene valutata per prima e se tali espressioni terminano a tutti o no. Pertanto, ha senso che Haskell utilizzi numeri interi illimitati per la maggior parte del tempo. Questa scelta è adatta a un linguaggio puramente funzionale perché le eccezioni sono davvero inadeguate nella maggior parte del codice Haskell. E la divisione per zero è davvero un punto problematico nella progettazione del linguaggio Haskells. Invece di numeri interi senza limiti, avrebbero potuto usare anche numeri interi a larghezza fissa, ma ciò non si adatta al tema "focus sulla correttezza" che il linguaggio presenta.

Un'alternativa alle eccezioni di overflow sono i valori di veleno creati da operazioni non definite e che si propagano attraverso operazioni (come il NaNvalore float ). Ciò sembra molto più costoso del controllo di overflow e rende tutte le operazioni più lente, non solo quelle che possono fallire (salvo l'accelerazione hardware che i float hanno comunemente e gli ints comunemente non hanno - sebbene Itanium abbia NaT che è "Not a Thing" ). Inoltre, non vedo bene il punto in cui il programma continui a zoppicare insieme a dati errati. È come ON ERROR RESUME NEXT. Nasconde gli errori ma non aiuta a ottenere risultati corretti. supercat sottolinea che a volte è un'ottimizzazione delle prestazioni per farlo.


2
Risposta eccellente. Allora, qual è la tua teoria sul perché hanno deciso di farlo in quel modo? Stai copiando tutti gli altri che hanno copiato C e infine assembly e binary?
jpmc26,

19
Quando il 99% della tua base di utenti prevede un comportamento, tendi a darglielo. E per quanto riguarda "la copia di C", in realtà non è una copia di C, ma una sua estensione. C garantisce un comportamento senza eccezioni unsignedsolo per numeri interi. Il comportamento dell'overflow di numeri interi con segno è in realtà un comportamento indefinito in C e C ++. Sì, comportamento indefinito . Accade così che quasi tutti lo implementano come overflow del complemento di 2. C # in realtà lo rende ufficiale, piuttosto che lasciarlo UB come C / C ++
Cort Ammon,

10
@CortAmmon: il linguaggio progettato da Dennis Ritchie aveva definito un comportamento avvolgente per gli interi con segno, ma non era davvero adatto per l'uso su piattaforme a complemento a due. Mentre consentire determinate deviazioni da un preciso involucro del complemento a due complementi può aiutare notevolmente alcune ottimizzazioni (ad esempio consentire a un compilatore di sostituire x * y / y con x potrebbe salvare una moltiplicazione e una divisione), gli autori di compilatori hanno interpretato il comportamento indefinito non come un'opportunità da fare ciò che ha senso per una determinata piattaforma di destinazione e campo di applicazione, ma piuttosto come un'opportunità per gettare un senso dalla finestra.
supercat

3
@CortAmmon - Controlla il codice generato da gcc -O2per x + 1 > x(dove xè un int). Vedi anche gcc.gnu.org/onlinedocs/gcc-6.3.0/gcc/… . Il comportamento del complemento 2s su overflow con segno in C è facoltativo , anche nei compilatori reali, e per gccimpostazione predefinita lo ignora nei normali livelli di ottimizzazione.
Jonathan Cast

2
@supercat Sì, la maggior parte degli autori di compilatori C sono più interessati a assicurarsi che alcuni benchmark non realistici vengano eseguiti lo 0,5% più velocemente rispetto al tentativo di fornire una ragionevole semantica ai programmatori (sì, capisco perché non è un problema facile da risolvere e ci sono alcune ragionevoli ottimizzazioni che possono causare risultati inaspettati se combinati, yada, yada ma non è ancora attivo e se ne accorge se si seguono le conversazioni). Fortunatamente ci sono alcune persone che cercano di fare di meglio .
Voo

30

Perché è un cattivo compromesso rendere tutti i calcoli molto più costosi al fine di cogliere automaticamente il raro caso in cui si verifichi un overflow . È molto meglio caricare il programmatore nel riconoscere i rari casi in cui si tratta di un problema e aggiungere precauzioni speciali piuttosto che far pagare a tutti i programmatori il prezzo per funzionalità che non usano.


28
È in qualche modo come dire che i controlli per Buffer Overflow dovrebbero essere omessi perché non si verificano quasi mai ...
Bernhard Hiller

73
@BernhardHiller: ed è esattamente ciò che fanno C e C ++.
Michael Borgwardt,

12
@DavidBrown: così come gli overflow aritmetici. Il primo però non compromette la VM.
Deduplicatore

35
@Deduplicator rende un punto eccellente. Il CLR è stato progettato con cura in modo che i programmi verificabili non possano violare gli invarianti del runtime anche quando accadono cose brutte. I programmi sicuri possono ovviamente violare i propri invarianti quando accadono cose brutte.
Eric Lippert,

7
Le operazioni aritmetiche di @svick sono probabilmente molto più comuni delle operazioni di indicizzazione di array. E la maggior parte delle dimensioni di interi sono abbastanza grandi che è molto raro eseguire aritmetiche che traboccano. Quindi i rapporti costi-benefici sono molto diversi.
Barmar,

20

quali sono le decisioni di progettazione alla base di un comportamento così pericoloso?

"Non forzare gli utenti a pagare una penalità prestazionale per una funzionalità di cui potrebbero non aver bisogno."

È uno dei principi di base nella progettazione di C e C ++ e nasce da un momento diverso in cui dovevi attraversare ridicole contorsioni per ottenere prestazioni a malapena adeguate per compiti che oggi sono considerati banali.

Le lingue più recenti rompono questo atteggiamento per molte altre funzionalità, come il controllo dei limiti di array. Non sono sicuro del perché non lo abbiano fatto per il controllo di overflow; potrebbe essere semplicemente una svista.


18
Non è sicuramente una svista nel design di C #. I progettisti di C # hanno creato deliberatamente due modalità: checkede unchecked, hanno aggiunto la sintassi per passare da una locale all'altra e anche opzioni della riga di comando (e impostazioni del progetto in VS) per cambiarla a livello globale. Potresti non essere d'accordo con uncheckedl'impostazione predefinita (lo faccio), ma tutto ciò è chiaramente molto deliberato.
svick,

8
@slebetman - solo per la cronaca: qui il costo non è il costo della verifica dell'overflow (che è banale), ma il costo di eseguire codice diverso a seconda che si sia verificato l'overflow (che è molto costoso). Alle CPU non piacciono le istruzioni sui rami condizionali.
Jonathan Cast

5
@jcast La previsione del ramo sui processori moderni non eliminerebbe quasi quella penalità condizionale del ramo? Dopo tutto il caso normale non dovrebbe essere un overflow, quindi è un comportamento di diramazione molto prevedibile.
CodeMonkey

4
Accetto con @CodeMonkey. Un compilatore inserisce un salto condizionale in caso di overflow, a una pagina che normalmente non è caricata / fredda. La previsione predefinita per questo non è "presa" e probabilmente non cambierà. L'overhead totale è un'istruzione in corso. Ma questa è un'istruzione generale per istruzione aritmetica.
MSalters

2
@MSalters sì, c'è un sovraccarico di istruzioni aggiuntivo. E l'impatto potrebbe essere grande se si hanno esclusivamente problemi legati alla CPU. Nella maggior parte delle applicazioni con un mix di codice pesante IO e CPU suppongo che l'impatto sia minimo. Mi piace il modo Rust, di aggiungere l'overhead solo nelle build di debug, ma rimuoverlo nelle build di rilascio.
CodeMonkey

20

eredità

Direi che il problema è probabilmente radicato nell'eredità. In C:

  • l'overflow firmato è un comportamento indefinito (i compilatori supportano i flag per farlo avvolgere),
  • l'overflow senza segno è definito comportamento (va a capo).

Ciò è stato fatto per ottenere le migliori prestazioni possibili, seguendo il principio secondo cui il programmatore sa cosa sta facendo .

Conduce a Statu-Quo

Il fatto che C (e per estensione C ++) non richiedano il rilevamento di overflow a turno significa che il controllo di overflow è lento.

L'hardware si rivolge principalmente a C / C ++ (sul serio, x86 ha strcmpun'istruzione (alias PCMPISTRI a partire da SSE 4.2)!), E poiché C non se ne cura, le CPU comuni non offrono modi efficienti per rilevare overflow. In x86, è necessario controllare un flag per core dopo ogni operazione potenzialmente traboccante; quando quello che vorresti davvero è una bandiera "contaminata" sul risultato (molto simile alla propagazione della NaN). E le operazioni vettoriali possono essere ancora più problematiche. Alcuni nuovi attori potrebbero apparire sul mercato con una gestione efficiente dell'overflow; ma per ora x86 e ARM non se ne curano.

Gli ottimizzatori del compilatore non sono bravi nell'ottimizzare i controlli di overflow o addirittura nell'ottimizzare in presenza di overflow. Alcuni accademici come John Regher si lamentano di questa statu-quo , ma il fatto è che quando il semplice fatto di fare "overflow" di overflow impedisce l'ottimizzazione anche prima che l'assembly colpisca la CPU può essere paralizzante. Soprattutto quando impedisce l'auto-vettorializzazione ...

Con effetti a cascata

Pertanto, in assenza di strategie di ottimizzazione efficienti e di supporto efficiente della CPU, il controllo dell'overflow è costoso. Molto più costoso del confezionamento.

Aggiungi un comportamento fastidioso, come x + y - 1potrebbe traboccare quando x - 1 + yno, il che potrebbe infastidire legittimamente gli utenti, e il controllo del trabocco viene generalmente scartato a favore del wrapping (che gestisce questo esempio e molti altri con garbo).

Tuttavia, non tutta la speranza è persa

C'è stato uno sforzo nei compilatori clang e gcc per implementare "disinfettanti": modi per strumentare i binari per rilevare casi di comportamento indefinito. Durante l'utilizzo -fsanitize=undefined, viene rilevato un overflow firmato e si interrompe il programma; molto utile durante i test.

Il linguaggio di programmazione Rust ha il controllo di overflow abilitato per impostazione predefinita in modalità Debug (utilizza l'aritmetica di wrapping in modalità Release per motivi di prestazioni).

Pertanto, vi è una crescente preoccupazione per il controllo dell'overflow e per i pericoli dei risultati fasulli che non vengono individuati, e si spera che questo a sua volta susciterà interesse nella comunità di ricerca, nella comunità dei compilatori e nella comunità dell'hardware.


6
@DmitryGrigoryev è l'opposto di un modo efficace per verificare gli overflow, ad esempio su Haswell riduce il throughput da 4 aggiunte normali per ciclo a solo 1 aggiunta controllata, e questo prima di considerare l'impatto delle previsioni errate del ramo jodegli più effetti globali dell'inquinamento si aggiungono allo stato predittivo della filiale e alla maggiore dimensione del codice. Se quella bandiera fosse appiccicosa, offrirebbe un potenziale reale ... e quindi non puoi ancora farlo correttamente nel codice vettoriale.

3
Dal momento che stai collegando a un post sul blog scritto da John Regehr, ho pensato che sarebbe stato opportuno anche il collegamento a un altro suo articolo , scritto pochi mesi prima di quello che hai collegato. Questi articoli parlano di diverse filosofie: nell'articolo precedente, gli interi sono di dimensioni fisse; l'aritmetica dei numeri interi viene verificata (ovvero il codice non può continuare la sua esecuzione); c'è un'eccezione o una trappola. L'articolo più recente parla dell'abbandono completo di numeri interi di dimensioni fisse, che elimina gli overflow.
dall'8

2
@rwong Anche gli interi di dimensioni infinite hanno i loro problemi. Se il tuo overflow è il risultato di un bug (come spesso accade), potrebbe trasformare un arresto rapido in un'agonia prolungata che consuma tutte le risorse del server fino a quando tutto fallisce in modo orribile. Sono principalmente un fan dell'approccio "fail early" - meno possibilità di avvelenare l'intero ambiente. Preferirei 1..100invece i tipi Pascal-ish - essere esplicito sugli intervalli previsti, piuttosto che essere "forzato" in 2 ^ 31 ecc. Alcune lingue offrono questo, ovviamente, e tendono a fare il controllo di overflow per impostazione predefinita (a volte a tempo di compilazione, anche).
Luaan,

1
@Luaan: Ciò che è interessante è che spesso i calcoli intermedi possono temporaneamente traboccare, ma il risultato no. Ad esempio, nell'intervallo 1..100, x * 2 - 2potrebbe traboccare quando xè 51 anche se il risultato si adatta, costringendoti a riorganizzare il tuo calcolo (a volte in modo innaturale). Nella mia esperienza, ho scoperto che in genere preferisco eseguire il calcolo in un tipo più grande e quindi verificare se il risultato si adatta o meno.
Matthieu M.

1
@MatthieuM. Sì, è qui che entri nel territorio del "compilatore sufficientemente intelligente". Idealmente, un valore di 103 dovrebbe essere valido per un tipo 1..100 purché non sia mai utilizzato in un contesto in cui è previsto un 1..100 vero (ad esempio, x = x * 2 - 2dovrebbe funzionare per tutti i casi in xcui l'assegnazione risulta in un 1 valido. .100 numero). In altre parole, le operazioni sul tipo numerico possono avere una precisione maggiore rispetto al tipo stesso purché l'assegnazione si adatti. Ciò sarebbe abbastanza utile in casi come l' (a + b) / 2ignoranza di overflow (non firmati) potrebbe essere l'opzione corretta.
Luaan,

10

Le lingue che tentano di rilevare overflow hanno storicamente definito la semantica associata in modi che limitavano fortemente quelle che sarebbero state altrimenti utili ottimizzazioni. Tra le altre cose, mentre sarà spesso utile eseguire calcoli in una sequenza diversa da quella specificata nel codice, la maggior parte dei linguaggi che intercettano gli overflow garantiscono quel codice come:

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

se il valore iniziale di x provocherebbe un overflow sul 47 ° passaggio attraverso il loop, Operation1 verrà eseguito 47 volte e Operation2 eseguirà 46. In assenza di tale garanzia, se nient'altro all'interno del loop utilizza x, e niente utilizzerà il valore di x in seguito a un'eccezione generata da Operation1 o Operation2, il codice potrebbe essere sostituito con:

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

Sfortunatamente, eseguire tali ottimizzazioni garantendo al contempo una semantica corretta nei casi in cui si sarebbe verificato un overflow all'interno del loop - richiede essenzialmente qualcosa come:

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();
  }
}

Se si considera che un sacco di codice del mondo reale utilizza loop maggiormente coinvolti, sarà ovvio che l'ottimizzazione del codice preservando la semantica di overflow è difficile. Inoltre, a causa di problemi di memorizzazione nella cache, è del tutto possibile che l'aumento delle dimensioni del codice renda l'esecuzione del programma più lenta anche se ci sono meno operazioni sul percorso eseguito comunemente.

Ciò che sarebbe necessario per rendere poco costoso il rilevamento di overflow sarebbe un insieme definito di semantica più debole di rilevamento di overflow che renderebbe più semplice per il codice segnalare se un calcolo è stato eseguito senza overflow che potrebbero aver influito sui risultati (*), ma senza appesantire il compilatore con dettagli oltre a quello. Se una specifica linguistica fosse focalizzata sulla riduzione del costo del rilevamento di overflow al minimo indispensabile per ottenere quanto sopra, potrebbe essere resa molto meno costosa di quanto non sia nelle lingue esistenti. Non sono a conoscenza di alcuno sforzo per facilitare il rilevamento efficiente di troppo pieno.

(*) Se una lingua promette che verranno segnalati tutti gli overflow, allora un'espressione simile x*y/ynon può essere semplificata a xmeno che non si x*ypossa garantire che non esca. Allo stesso modo, anche se il risultato di un calcolo verrà ignorato, un linguaggio che promette di riportare tutti gli overflow dovrà comunque eseguirlo in modo da poter eseguire il controllo dell'overflow. Poiché l'overflow in questi casi non può comportare un comportamento aritmeticamente errato, un programma non dovrebbe eseguire tali controlli per garantire che nessun overflow abbia causato risultati potenzialmente inaccurati.

Per inciso, gli overflow in C sono particolarmente negativi. Sebbene quasi tutte le piattaforme hardware che supportano C99 utilizzino la semantica avvolgente silenziosa a due complementi, è di moda per i compilatori moderni generare codice che può causare arbitrari effetti collaterali in caso di overflow. Ad esempio, dato qualcosa come:

#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 genererà il codice per test2 che incrementa incondizionatamente (* p) una volta e restituisce 32768 indipendentemente dal valore passato in q. Secondo il suo ragionamento, il calcolo di (32769 * 65535) e 65535u causerebbe un overflow e quindi non è necessario che il compilatore consideri tutti i casi in cui (q | 32768) produrrebbe un valore maggiore di 32768. Anche se non c'è motivo per cui il calcolo di (32769 * 65535) e 65535u dovrebbe interessare ai bit superiori del risultato, gcc utilizzerà l'overflow con segno come giustificazione per ignorare il loop.


2
"è alla moda per i compilatori moderni ..." - allo stesso modo, è stato brevemente alla moda per gli sviluppatori di alcuni kernel noti scegliere di non leggere la documentazione relativa alle bandiere di ottimizzazione che hanno usato, e quindi arrabbiarsi su Internet perché sono stati costretti ad aggiungere ancora più flag di compilazione per ottenere il comportamento desiderato ;-). In questo caso, si -fwrapvottiene un comportamento definito, sebbene non il comportamento desiderato dall'interrogante. Certo, l'ottimizzazione di gcc trasforma qualsiasi tipo di sviluppo in C in un esame approfondito dello standard e del comportamento del compilatore.
Steve Jessop,

1
@SteveJessop: C sarebbe un linguaggio molto più sano se gli autori di compilatori riconoscessero un dialetto di basso livello in cui "comportamento indefinito" significava "fare tutto ciò che avrebbe senso sulla piattaforma sottostante", e quindi aggiunto modi per i programmatori di rinunciare alle garanzie non necessarie implicite, piuttosto che presumere che la frase "non portatile o errata" nello Standard significhi semplicemente "errata". In molti casi, il codice ottimale che può essere ottenuto in una lingua con garanzie comportamentali deboli sarà molto meglio di quanto si possa ottenere con garanzie più forti o senza garanzie. Ad esempio ...
Supercat,

1
... se un programmatore ha bisogno di valutare x+y > zin un modo che non farà mai altro che produrre 0 o 1, ma entrambi i risultati sarebbero ugualmente accettabili in caso di overflow, un compilatore che offre tale garanzia potrebbe spesso generare un codice migliore per il espressione x+y > zche qualsiasi compilatore sarebbe in grado di generare per una versione dell'espressione scritta in modo difensivo. Realisticamente parlando, quale frazione di utili ottimizzazioni legate all'overflow sarebbero precluse da una garanzia che i calcoli interi diversi dalla divisione / resto verranno eseguiti senza effetti collaterali?
supercat,

Confesso che non sono pienamente nei dettagli, ma il fatto che il tuo rancore sia con gli "scrittori di compilatori" in generale, e non specificamente con "qualcuno su gcc che non accetterà la mia -fwhatever-makes-sensepatch", mi suggerisce fortemente che c'è di più ad esso che capriccioso da parte loro. I soliti argomenti che ho sentito sono che l'integrazione del codice (e persino l'espansione delle macro) traggono vantaggio dalla deduzione il più possibile sull'uso specifico di un costrutto di codice, poiché entrambe le cose generano comunemente un codice inserito che si occupa di casi che non richiedono a, che il codice circostante "si rivela" impossibile.
Steve Jessop,

Quindi, per un esempio semplificato, se scrivo foo(i + INT_MAX + 1), gli autori di compilatori sono desiderosi di applicare ottimizzazioni al foo()codice incorporato che si basano sulla correttezza perché il suo argomento non è negativo (trucchi diabolici di divmod, forse). In base alle restrizioni aggiuntive, potrebbero applicare solo ottimizzazioni il cui comportamento per input negativi ha senso per la piattaforma. Certo, personalmente sarei felice che questa sia -fun'opzione che si accende -fwrapvecc., E che probabilmente dovrebbe disabilitare alcune ottimizzazioni per le quali non c'è bandiera. Ma non è come se potessi essere disturbato a fare tutto quel lavoro da solo.
Steve Jessop,

9

Non tutti i linguaggi di programmazione ignorano gli overflow di numeri interi. Alcune lingue forniscono operazioni su numeri interi sicuri per tutti i numeri (la maggior parte dei dialetti di Lisp, Ruby, Smalltalk, ...) e altri tramite librerie, ad esempio ci sono varie classi BigInt per C ++.

Il fatto che una lingua renda l'intero integro sicuro dall'overflow per impostazione predefinita o meno dipende dal suo scopo: linguaggi di sistema come C e C ++ devono fornire astrazioni a costo zero e "grande intero" non è uno. I linguaggi di produttività, come Ruby, possono e forniscono interi di grandi dimensioni pronti all'uso. Linguaggi come Java e C # che sono in qualche punto nel mezzo dovrebbero IMHO andare con gli interi sicuri fuori dalla scatola, non lo fanno.


Si noti che esiste una differenza tra il rilevamento di overflow (e quindi il segnale, il panico, l'eccezione, ...) e il passaggio a grandi numeri. Il primo dovrebbe essere fattibile in modo molto più economico del secondo.
Matthieu M.,

@MatthieuM. Assolutamente - e mi rendo conto di non essere chiaro a riguardo nella mia risposta.
Nemanja Trifunovic

7

Come hai mostrato, C # sarebbe stato 3 volte più lento se avesse abilitato i controlli di overflow per impostazione predefinita (supponendo che il tuo esempio sia un'applicazione tipica per quella lingua). Concordo sul fatto che le prestazioni non sono sempre la caratteristica più importante, ma i linguaggi / compilatori vengono in genere confrontati con le loro prestazioni in compiti tipici. Ciò è in parte dovuto al fatto che la qualità delle caratteristiche del linguaggio è in qualche modo soggettiva, mentre un test delle prestazioni è obiettivo.

Se dovessi introdurre un nuovo linguaggio che è simile al C # nella maggior parte degli aspetti ma 3 volte più lento, ottenere una quota di mercato non sarebbe facile, anche se alla fine la maggior parte dei tuoi utenti finali trarrebbe beneficio dai controlli di overflow più di quanto farebbero dalle prestazioni più elevate.


10
Ciò è stato particolarmente vero per C #, che era ai suoi inizi rispetto a Java e C ++ non sulle metriche di produttività degli sviluppatori, che sono difficili da misurare, o sulle metriche salvate in contanti da non trattare con violazioni della sicurezza, che sono difficili da misurare, ma su benchmark di prestazioni banali.
Eric Lippert,

1
E probabilmente, le prestazioni della CPU vengono verificate con un semplice scricchiolio dei numeri. Pertanto, le ottimizzazioni per il rilevamento di overflow possono produrre risultati "negativi" su tali test. Prendi il 22.
Bernhard Hiller

5

Oltre alle molte risposte che giustificano la mancanza di controlli di overflow in base alle prestazioni, ci sono due diversi tipi di aritmetica da considerare:

  1. calcoli di indicizzazione (indicizzazione di array e / o aritmetica del puntatore)

  2. altra aritmetica

Se il linguaggio utilizza una dimensione intera uguale alla dimensione del puntatore, un programma ben costruito non trabocca eseguendo calcoli di indicizzazione perché dovrebbe necessariamente esaurire la memoria prima che i calcoli di indicizzazione causino un overflow.

Pertanto, il controllo delle allocazioni di memoria è sufficiente quando si lavora con l'aritmetica del puntatore e le espressioni di indicizzazione che coinvolgono strutture di dati allocate. Ad esempio, se si dispone di uno spazio degli indirizzi a 32 bit e si utilizzano numeri interi a 32 bit e si consente di allocare un massimo di 2 GB di heap (circa metà dello spazio degli indirizzi), i calcoli di indicizzazione / puntatore (in pratica) non saranno inondati.

Inoltre, potresti essere sorpreso di quanto di addizione / sottrazione / moltiplicazione implichi l'indicizzazione dell'array o il calcolo del puntatore, rientrando così nella prima categoria. Puntatore di oggetti, accesso ai campi e manipolazioni di array sono operazioni di indicizzazione e molti programmi non eseguono calcoli aritmetici più di questi! In sostanza, questo è il motivo principale per cui i programmi funzionano così come fanno senza il controllo di overflow dell'intero.

Tutti i calcoli non indicizzati e non puntatori devono essere classificati come quelli che vogliono / si aspettano un overflow (ad es. Calcoli di hashing) e quelli che non lo fanno (ad esempio il tuo esempio di somma).

In quest'ultimo caso, i programmatori useranno spesso tipi di dati alternativi, come doubleo alcuni BigInt. Molti calcoli richiedono un decimaltipo di dati anziché double, ad esempio, calcoli finanziari. Se non lo fanno e si attaccano ai tipi interi, allora devono fare attenzione a controllare l'overflow dei numeri interi, oppure sì, il programma può raggiungere una condizione di errore non rilevata mentre stai sottolineando.

Come programmatori, dobbiamo essere sensibili alle nostre scelte in termini di tipi di dati numerici e alle loro conseguenze in termini di possibilità di overflow, per non parlare della precisione. In generale (e specialmente quando si lavora con la famiglia di linguaggi C con il desiderio di usare i tipi di numeri interi rapidi) dobbiamo essere sensibili e consapevoli delle differenze tra i calcoli dell'indicizzazione rispetto ad altri.


3

Il linguaggio Rust fornisce un interessante compromesso tra il controllo degli overflow e non, aggiungendo i controlli per la build di debug e rimuovendoli nella versione di rilascio ottimizzata. Ciò consente di trovare i bug durante i test, pur mantenendo le prestazioni complete nella versione finale.

Poiché il wraparound di overflow è talvolta un comportamento ricercato, esistono anche versioni degli operatori che non verificano mai l'overflow.

Puoi leggere di più sul ragionamento alla base della scelta nell'RFC per la modifica. Ci sono anche molte informazioni interessanti in questo post del blog , incluso un elenco di bug che questa funzione ha aiutato a catturare.


2
Rust fornisce anche metodi simili checked_mulche controllano se si è verificato un overflow e, in Nonecaso Somecontrario , restituiscono . Questo può essere usato sia in produzione che in modalità debug: doc.rust-lang.org/std/primitive.i32.html#examples-15
Akavall

3

In Swift, eventuali overflow di numeri interi vengono rilevati per impostazione predefinita e arrestano immediatamente il programma. Nei casi in cui è necessario un comportamento avvolgente, ci sono diversi operatori & +, & - e & * che raggiungono questo obiettivo. E ci sono funzioni che eseguono un'operazione e dicono se si è verificato un overflow.

È divertente vedere i principianti provare a valutare la sequenza di Collatz e avere il loro crash del codice :-)

Ora i progettisti di Swift sono anche i progettisti di LLVM e Clang, quindi conoscono un po 'o due sull'ottimizzazione e sono abbastanza in grado di evitare inutili controlli di overflow. Con tutte le ottimizzazioni abilitate, il controllo dell'overflow non aggiunge molto alla dimensione del codice e ai tempi di esecuzione. E poiché la maggior parte degli overflow porta a risultati assolutamente errati, la dimensione del codice e il tempo di esecuzione sono ben spesi.

PS. In C, C ++, l'overflow aritmetico con numeri interi con segno Objective-C è un comportamento indefinito. Ciò significa che qualunque cosa faccia il compilatore in caso di overflow di numeri interi con segno è corretto, per definizione. I modi tipici per far fronte all'overflow di numeri interi con segno sono di ignorarlo, prendendo qualsiasi risultato che la CPU ti dà, costruendo ipotesi nel compilatore che tale overflow non accadrà mai (e concludiamo ad esempio che n + 1> n è sempre vero, poiché l'overflow è si presume che non accada mai), e una possibilità che viene usata raramente è di verificare e arrestare in modo anomalo se si verifica un overflow, come Swift.


1
A volte mi chiedevo se le persone che spingono la follia indotta da UB in C stessero segretamente cercando di minarlo a favore di un'altra lingua. Ciò avrebbe senso.
supercat,

Trattare x+1>xcome incondizionatamente vero non richiederebbe a un compilatore di fare "assunzioni" su x se un compilatore è autorizzato a valutare espressioni intere usando tipi arbitrari più grandi (o comportarsi come se lo stesse facendo). Un esempio più cattivo di "assunzioni" basate sull'overflow sarebbe decidere che dato che uint32_t mul(uint16_t x, uint16_t y) { return x*y & 65535u; }un compilatore può usare sum += mul(65535, x)per decidere che xnon può essere maggiore di 32768 [comportamento che probabilmente scioccerebbe le persone che hanno scritto il C89 Rationale, il che suggerisce che uno dei fattori decisivi. ..
supercat,

... nel unsigned shortpromuovere ciò signed intera il fatto che le implementazioni silenziose a complemento a due complementi (cioè la maggior parte delle implementazioni C allora in uso) avrebbero trattato il codice come sopra allo stesso modo se unsigned shortpromosso into unsigned. Lo standard non richiedeva implementazioni su hardware a complemento a due componenti silenzioso per trattare il codice come sopra in modo sano, ma gli autori dello standard sembrano aspettarsi che lo farebbero comunque.
supercat,

2

In realtà, la vera causa di ciò è puramente tecnica / storica: la maggior parte della CPU ignora il segno. In genere esiste una sola istruzione per aggiungere due numeri interi nei registri e alla CPU non importa un po 'se si interpretano questi due numeri interi come con o senza segno. Lo stesso vale per la sottrazione e persino per la moltiplicazione. L'unica operazione aritmetica che deve essere consapevole dei segni è la divisione.

Il motivo per cui funziona, è la rappresentazione del complemento di 2 di numeri interi con segno che viene utilizzata praticamente da tutte le CPU. Ad esempio, nel complemento a 4 bit 2 l'aggiunta di 5 e -3 è simile alla seguente:

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

Osservare come il comportamento avvolgente del lancio del bit di fuoriuscita produca il risultato con segno corretto. Allo stesso modo, le CPU di solito implementano la sottrazione x - ycome x + ~y + 1:

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

Questo implementa la sottrazione come aggiunta nell'hardware, modificando solo in modo banale gli ingressi all'unità aritmetico-logica (ALU). Cosa potrebbe esserci di più semplice?

Poiché la moltiplicazione non è altro che una sequenza di aggiunte, si comporta in modo altrettanto piacevole. Il risultato dell'utilizzo della rappresentazione del complemento di 2 e dell'ignorare l'esecuzione delle operazioni aritmetiche è la circuiteria semplificata e le serie di istruzioni semplificate.

Ovviamente, poiché C è stato progettato per funzionare vicino al metallo, ha adottato esattamente lo stesso comportamento del comportamento standardizzato dell'aritmetica senza segno, consentendo solo all'aritmetica firmata di produrre un comportamento indefinito. E quella scelta è stata trasferita in altre lingue come Java e, ovviamente, C #.


Sono venuto qui per dare anche questa risposta.
Lister,

Sfortunatamente, alcune persone sembrano considerare gravemente irragionevole l'idea che le persone che scrivono codice C di basso livello su una piattaforma dovrebbero avere l'audacia di aspettarsi che un compilatore C adatto a tale scopo si comporti in modo limitato in caso di overflow. Personalmente, ritengo ragionevole che un compilatore si comporti come se i calcoli fossero eseguiti usando precisione arbitrariamente estesa per comodità del compilatore (quindi su un sistema a 32 bit, se x==INT_MAX, quindi, x+1potrebbe comportarsi arbitrariamente come +2147483648 o -2147483648 al compilatore convenienza), ma ...
supercat

alcune persone sembrano pensare che se xe ysono uint16_te il codice su un sistema a 32 bit calcola x*y & 65535uquando yè 65535, un compilatore dovrebbe presumere che il codice non sarà mai raggiunto quando xè maggiore di 32768.
supercat

1

Alcune risposte hanno discusso del costo del controllo e hai modificato la tua risposta per contestare che questa è una giustificazione ragionevole. Proverò ad affrontare quei punti.

In C e C ++ (come esempi), uno dei principi di progettazione linguistica non è quello di fornire funzionalità che non sono state richieste. Questo è comunemente riassunto dalla frase "non pagare per ciò che non usi". Se il programmatore desidera un controllo di overflow, può richiederlo (e pagare la penalità). Questo rende la lingua più pericolosa da usare, ma si sceglie di lavorare con la lingua sapendo che, quindi si accetta il rischio. Se non si desidera quel rischio o se si sta scrivendo un codice in cui la sicurezza è di primaria importanza, è possibile selezionare una lingua più appropriata in cui il compromesso di prestazione / rischio è diverso.

Ma con le 10.000.000.000 di ripetizioni, il tempo impiegato da un controllo è ancora inferiore a 1 nanosecondo.

Ci sono alcune cose che non vanno in questo ragionamento:

  1. Questo è specifico per l'ambiente. In genere non ha molto senso citare figure specifiche come questa, perché il codice è scritto per tutti i tipi di ambienti che variano in base all'ordine di grandezza in termini di prestazioni. Il tuo 1 nanosecondo su una macchina desktop (presumo) potrebbe sembrare incredibilmente veloce a qualcuno che codifica per un ambiente incorporato e insopportabilmente lento a qualcuno che codifica per un super cluster di computer.

  2. 1 nanosecondo potrebbe non sembrare nulla per un segmento di codice che viene eseguito raramente. D'altra parte, se è in un ciclo interno di alcuni calcoli che è la funzione principale del codice, allora ogni singola frazione di tempo che puoi radere può fare una grande differenza. Se stai eseguendo una simulazione su un cluster, quelle frazioni salvate di un nanosecondo nel tuo ciclo interno possono tradursi direttamente in denaro speso per hardware ed elettricità.

  3. Per alcuni algoritmi e contesti, 10.000.000.000 di iterazioni potrebbero essere insignificanti. Ancora una volta, non ha generalmente senso parlare di scenari specifici che si applicano solo in determinati contesti.

Potrebbe esserci una situazione in cui ciò è importante, ma per la maggior parte delle applicazioni non ha importanza.

Forse hai ragione. Ma ancora una volta, si tratta di quali siano gli obiettivi di una determinata lingua. Molte lingue sono infatti progettate per soddisfare le esigenze della "maggior parte" o per favorire la sicurezza rispetto ad altre preoccupazioni. Altri, come C e C ++, danno priorità all'efficienza. In quel contesto, far pagare a tutti una penalità di prestazione semplicemente perché la maggior parte delle persone non sarà disturbata, va contro ciò che la lingua sta cercando di ottenere.


-1

Ci sono buone risposte, ma penso che ci sia un punto mancante qui: gli effetti di un overflow di numeri interi non sono necessariamente una cosa negativa, e dopo tutto è difficile sapere se è ipassato dall'essere MAX_INTall'essere MIN_INTdovuto a un problema di overflow o se è stato fatto intenzionalmente moltiplicando per -1.

Ad esempio, se voglio sommare tutti gli interi rappresentabili maggiori di 0, utilizzerei solo un for(i=0;i>=0;++i){...}ciclo di addizione e quando trabocca interrompe l'aggiunta, che è il comportamento dell'obiettivo (lanciare un errore significherebbe che devo aggirare una protezione arbitraria perché interferisce con l'aritmetica standard). È una cattiva pratica limitare l'aritmetica primitiva, perché:

  • Sono utilizzati in tutto: un rallentamento della matematica primitiva è un rallentamento in ogni programma funzionante
  • Se un programmatore ne ha bisogno, possono sempre aggiungerli
  • Se li hai e il programmatore non ne ha bisogno (ma ha bisogno di tempi di esecuzione più rapidi), non possono rimuoverli facilmente per l'ottimizzazione
  • Se li hai e il programmatore ha bisogno che non ci siano (come nell'esempio sopra), il programmatore sta entrambi subendo il colpo di run-time (che può essere o non essere rilevante) e il programmatore deve comunque investire tempo rimuovendo o aggirare la "protezione".

3
Non è davvero possibile per un programmatore aggiungere un controllo di overflow efficiente se una lingua non lo prevede. Se una funzione calcola un valore che viene ignorato, un compilatore può ottimizzare il calcolo. Se una funzione calcola un valore che viene controllato dall'overflow ma altrimenti ignorato, un compilatore deve eseguire il calcolo e il trap in caso di overflow, anche se un overflow non influirebbe altrimenti sull'output del programma e potrebbe essere ignorato in modo sicuro.
supercat,

1
Non puoi andare da INT_MAXa INT_MINmoltiplicando per -1.
David Conrad,

La soluzione è ovviamente quella di fornire un modo per il programmatore di disattivare i controlli in un determinato blocco di codice o unità di compilazione.
David Conrad,

for(i=0;i>=0;++i){...}è lo stile del codice che cerco di scoraggiare nella mia squadra: si basa su effetti speciali / effetti collaterali e non esprime chiaramente ciò che deve fare. Ma apprezzo ancora la tua risposta in quanto mostra un diverso paradigma di programmazione.
Bernhard Hiller,

1
@Delioth: se iè un tipo a 64 bit, anche su un'implementazione con un comportamento coerente del complemento a due silenzioso, eseguendo un miliardo di iterazioni al secondo, un tale ciclo potrebbe essere garantito per trovare il intvalore più grande solo se è consentito eseguire per centinaia di anni. Su sistemi che non promettono comportamenti coerenti e silenziosi, tali comportamenti non sarebbero garantiti indipendentemente dalla durata del codice.
supercat,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.