Por que o estouro aritmético é ignorado?


76

Já tentou resumir todos os números de 1 a 2.000.000 na sua linguagem de programação favorita? O resultado é fácil de calcular manualmente: 2.000.001.000.000, que são 900 vezes maiores que o valor máximo de um número inteiro de 32 bits não assinado.

C # imprime -1453759936- um valor negativo! E eu acho que Java faz o mesmo.

Isso significa que existem algumas linguagens de programação comuns que ignoram o Arithmetic Overflow por padrão (em C #, existem opções ocultas para alterar isso). Esse é um comportamento que me parece muito arriscado, e a queda do Ariane 5 não foi causada por um excesso tão grande?

Então: quais são as decisões de design por trás de um comportamento tão perigoso?

Editar:

As primeiras respostas a esta pergunta expressam os custos excessivos da verificação. Vamos executar um pequeno programa C # para testar esta suposição:

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

Na minha máquina, a versão marcada leva 11015ms, enquanto a versão desmarcada leva 4125ms. Ou seja, as etapas de verificação levam quase o dobro do tempo de adição dos números (no total, três vezes a hora original). Porém, com as 10.000.000.000 de repetições, o tempo gasto em um cheque ainda é menor que 1 nanossegundo. Pode haver uma situação em que isso é importante, mas para a maioria dos aplicativos isso não importa.

Edição 2:

Eu recompilei nosso aplicativo de servidor (um serviço do Windows analisando dados recebidos de vários sensores, bastante processamento de número envolvido) com o /p:CheckForOverflowUnderflow="false"parâmetro (normalmente, eu ativei a verificação de estouro) e implantei em um dispositivo. O monitoramento do Nagios mostra que a carga média da CPU ficou em 17%.

Isso significa que o desempenho encontrado no exemplo inventado acima é totalmente irrelevante para a nossa aplicação.


19
apenas como uma observação, para C # você pode usar a checked { }seção para marcar as partes do código que devem executar as verificações do Arithmetic Overflow. Isto é devido ao desempenho
Paweł Łukasik 8/17

14
"Já tentou resumir todos os números de 1 a 2.000.000 na sua linguagem de programação favorita?" - Sim: (1..2_000_000).sum #=> 2000001000000. Outra das minhas línguas preferidas: sum [1 .. 2000000] --=> 2000001000000. Não o meu favorito: Array.from({length: 2000001}, (v, k) => k).reduce((acc, el) => acc + el) //=> 2000001000000. (Para ser justo, o último é batota.)
Jörg W Mittag

27
O @BernhardHiller Integerem Haskell é de precisão arbitrária; ele manterá qualquer número enquanto você não ficar sem RAM alocável.
Polygnome

50
O acidente de Ariane 5 foi causado pela verificação de um estouro que não importava - o foguete estava em uma parte do voo em que o resultado de um cálculo não era mais necessário. Em vez disso, o estouro foi detectado e isso causou o cancelamento do voo.
Simon B

9
But with the 10,000,000,000 repetitions, the time taken by a check is still less than 1 nanosecond.isso é uma indicação de que o loop está sendo otimizado. Também essa frase contradiz números anteriores que me parecem muito válidos.
usr

Respostas:


86

Existem três razões para isso:

  1. O custo da verificação de estouros (para cada operação aritmética) em tempo de execução é excessivo.

  2. A complexidade de provar que uma verificação de estouro pode ser omitida no tempo de compilação é excessiva.

  3. Em alguns casos (por exemplo, cálculos CRC, bibliotecas de grande número, etc.), o "wrap on overflow" é mais conveniente para os programadores.


10
@DmitryGrigoryev unsigned intnão deve ser lembrado porque um idioma com verificação de estouro deve estar verificando todos os tipos de números inteiros por padrão. Você deveria ter que escrever wrapping unsigned int.
User253751

32
Eu não compro o argumento do custo. A CPU verifica o estouro no cálculo de cada número inteiro e define o sinalizador de transporte na ALU. Está faltando o suporte à linguagem de programação. Uma didOverflow()função simples em linha ou mesmo uma variável global __carryque permita o acesso ao sinalizador de transporte custaria zero tempo de CPU se você não o usar.
Slebetman

37
@slebetman: Isso é x86. ARM não. Por exemplo ADD, não define o transporte (você precisa ADDS). Itanium nem tem uma bandeira de transporte. E mesmo no x86, o AVX não possui sinalizadores.
MSalters

30
@slebetman Define a bandeira de transporte, sim (em x86, lembre-se). Mas então você precisa ler a bandeira de transporte e decidir o resultado - essa é a parte cara. Como as operações aritméticas são frequentemente usadas em loops (e loops apertados), isso pode impedir facilmente muitas otimizações seguras do compilador que podem ter um impacto muito grande no desempenho, mesmo que você precise apenas de uma instrução extra (e precisa muito mais do que isso) ) Isso significa que deve ser o padrão? Talvez, especialmente em uma linguagem como C #, onde dizer uncheckedé fácil; mas você pode estar superestimando a frequência com que o excesso é importante.
Luaan

12
Os ARMs addstêm o mesmo preço que add(é apenas um sinalizador de instrução de 1 bit que seleciona se o sinalizador de transporte é atualizado). As addinstruções do MIPS interceptam o transbordamento - você precisa pedir para não interceptar o transbordamento usando em seu addulugar!
User253751

65

Quem disse que é uma má troca ?!

Eu executo todos os meus aplicativos de produção com a verificação de estouro ativada. Esta é uma opção do compilador C #. Na verdade, comparei isso e não consegui determinar a diferença. O custo de acesso ao banco de dados para gerar HTML (não brinquedo) ofusca os custos de verificação de estouro.

Aprecio o fato de saber que nenhuma operação transborda na produção. Quase todo o código se comportaria de maneira irregular na presença de estouros. Os bugs não seriam benignos. É provável a corrupção de dados, a segurança gera uma possibilidade.

Caso eu precise do desempenho, que às vezes é o caso, desativo a verificação de estouro usando unchecked {}em uma base granular. Quando quero dizer que confio em uma operação que não transborda, devo adicionar redundante checked {}ao código para documentar esse fato. Estou ciente dos estouros, mas não preciso necessariamente agradecer à verificação.

Acredito que a equipe de C # fez a escolha errada quando optou por não verificar o estouro por padrão, mas essa opção está agora selada devido a fortes preocupações de compatibilidade. Observe que essa escolha foi feita por volta do ano 2000. O hardware era menos capaz e o .NET ainda não tinha muita tração. Talvez o .NET desejasse atrair os programadores de Java e C / C ++ dessa maneira. O .NET também deve estar perto do metal. É por isso que ele possui códigos inseguros, estruturas e ótimas habilidades de chamada nativa, todas as quais o Java não possui.

Quanto mais rápido nosso hardware fica e os compiladores mais inteligentes obtêm, por padrão, a verificação de estouro mais atraente.

Também acredito que a verificação de estouro é geralmente melhor que números de tamanho infinito. Números de tamanho infinito têm um custo de desempenho ainda mais alto, mais difícil de otimizar (acredito) e abrem a possibilidade de consumo ilimitado de recursos.

A maneira do JavaScript lidar com o estouro é ainda pior. Os números JavaScript são duplos de ponto flutuante. Um "estouro" se manifesta como deixando o conjunto totalmente preciso de números inteiros. Resultados ligeiramente errados ocorrerão (como desligar um por um - isso pode transformar loops finitos em infinitos).

Para alguns idiomas, como a verificação de estouro de C / C ++ por padrão, é claramente inapropriado, porque os tipos de aplicativos que estão sendo gravados nesses idiomas precisam de desempenho bare metal. Ainda assim, existem esforços para transformar o C / C ++ em uma linguagem mais segura, permitindo a inclusão em um modo mais seguro. É recomendável, pois 90-99% do código tende a ser frio. Um exemplo é a fwrapvopção do compilador que força o agrupamento do complemento de 2. Esse é um recurso de "qualidade de implementação" do compilador, não do idioma.

Haskell não possui uma pilha de chamadas lógica e nenhuma ordem de avaliação especificada. Isso faz as exceções ocorrerem em pontos imprevisíveis. Em a + bque não é especificado se aou bé avaliado primeiro e se essas expressões terminar em tudo ou não. Portanto, faz sentido para Haskell usar números inteiros ilimitados na maioria das vezes. Essa opção é adequada para uma linguagem puramente funcional, porque as exceções são realmente inadequadas na maioria dos códigos Haskell. E a divisão por zero é realmente um ponto problemático no design da linguagem Haskells. Em vez de números inteiros não limitados, eles poderiam ter usado números inteiros de quebra automática de largura fixa, mas isso não se encaixa no tema "foco na correção" que o idioma apresenta.

Uma alternativa às exceções de estouro são os valores de veneno criados por operações indefinidas e propagados por meio de operações (como o NaNvalor flutuante ). Isso parece muito mais caro do que a verificação de estouro e torna todas as operações mais lentas, não apenas as que podem falhar (exceto a aceleração de hardware que os flutuadores geralmente têm e os ints geralmente não têm - embora o Itanium tenha NaT, o que não é uma coisa ). Também não vejo o ponto de fazer o programa continuar mancando junto com dados ruins. É como ON ERROR RESUME NEXT. Oculta erros, mas não ajuda a obter resultados corretos. A supercat ressalta que às vezes é uma otimização de desempenho para fazer isso.


2
Excelente resposta. Então, qual é a sua teoria sobre por que eles decidiram fazer dessa maneira? Apenas copiando todos os outros que copiaram C e, finalmente, montagem e binário?
Jpmc26 8/17

19
Quando 99% da sua base de usuários espera um comportamento, você tende a dar a eles. E quanto a "copiar C", na verdade não é uma cópia de C, mas uma extensão dela. C garante um comportamento livre de exceção unsignedapenas para números inteiros. O comportamento do estouro de número inteiro assinado é realmente um comportamento indefinido em C e C ++. Sim, comportamento indefinido . Acontece que quase todo mundo o implementa como um excesso de complemento do 2. C # realmente torna oficial, ao invés de deixá-lo UB como C / C ++
Cort Ammon

10
@CortAmmon: o idioma projetado por Dennis Ritchie havia definido um comportamento abrangente para números inteiros assinados, mas não era realmente adequado para uso em plataformas que não fossem dois. Embora permitir certos desvios da envolvente precisa do complemento de dois possa ajudar bastante algumas otimizações (por exemplo, permitir que um compilador substitua x * y / y por x possa salvar uma multiplicação e uma divisão), os escritores do compilador interpretaram o comportamento indefinido não como uma oportunidade para fazer o que faz sentido para uma determinada plataforma de destino e campo de aplicativo, mas como uma oportunidade de lançar sentido pela janela.
Supercat

3
@CortAmmon - Verifique o código gerado por gcc -O2para x + 1 > x(onde xé um int). Veja também gcc.gnu.org/onlinedocs/gcc-6.3.0/gcc/… . O comportamento do complemento 2s no estouro assinado em C é opcional , mesmo em compiladores reais, e o gccpadrão é ignorá-lo nos níveis normais de otimização.
Jonathan Cast

2
@supercat Sim, a maioria dos escritores de compiladores C está mais interessada em garantir que algum benchmark irrealista seja 0,5% mais rápido do que tentar fornecer uma semântica razoável aos programadores (sim, eu entendo por que não é um problema fácil de resolver e existem algumas otimizações razoáveis ​​que podem causar resultados inesperados quando combinados, yada, yada, mas ainda assim não há foco e você percebe se seguir as conversas). Felizmente, existem algumas pessoas que tentam fazer melhor .
Voo

30

Porque é um mau trade-off para fazer todos os cálculos muito mais caro, a fim de travar automaticamente o caso raro que um estouro faz ocorrer. É muito melhor sobrecarregar o programador com o reconhecimento dos casos raros em que isso é um problema e adicionar prevenções especiais do que fazer com que todos os programadores paguem o preço pela funcionalidade que não usam.


28
Isso é de alguma forma como dizer que verifica a existência de estouro de buffer deve ser omitida porque eles quase nunca ocorrem ...
Bernhard Hiller

73
@ BernhardHiller: e é exatamente isso que C e C ++ fazem.
Michael Borgwardt

12
@ DavidBrown: assim como estouros aritméticos. O primeiro, porém, não compromete a VM.
Deduplicator

35
O @Deduplicator é um excelente argumento. O CLR foi cuidadosamente projetado para que programas verificáveis ​​não possam violar os invariantes do tempo de execução, mesmo quando coisas ruins acontecem. É claro que programas seguros podem violar seus próprios invariantes quando coisas ruins acontecem.
Eric Lippert

7
@svick As operações aritméticas são provavelmente muito mais comuns que as operações de indexação de array. E a maioria dos tamanhos inteiros é grande o suficiente para ser muito raro executar aritmética que transborda. Portanto, as taxas de custo-benefício são muito diferentes.
Barmar

20

Quais são as decisões de design por trás de um comportamento tão perigoso?

"Não force os usuários a pagar uma penalidade de desempenho por um recurso que talvez não seja necessário".

É um dos princípios mais básicos no design de C e C ++ e decorre de um momento diferente em que você teve que passar por contorções ridículas para obter um desempenho pouco adequado para tarefas que hoje são consideradas triviais.

Os idiomas mais recentes quebram essa atitude para muitos outros recursos, como verificação de limites de array. Não sei por que eles não fizeram isso para verificação de estouro; poderia ser simplesmente uma supervisão.


18
Definitivamente, não é uma supervisão no design do C #. Os designers do C # deliberadamente criaram dois modos: checkede uncheckedadicionaram sintaxe para alternar entre eles localmente e também opções de linha de comando (e configurações do projeto no VS) para alterá-lo globalmente. Você pode discordar de fazer uncheckedo padrão (eu aceito), mas tudo isso é claramente muito deliberado.
Svick

8
@ slebetman - só para constar: o custo aqui não é o custo da verificação do estouro (o que é trivial), mas o custo da execução de código diferente, dependendo se o estouro aconteceu (o que é muito caro). As CPUs não gostam de instruções de ramificação condicional.
Jonathan Cast

5
@jcast A previsão de ramificação nos processadores modernos quase eliminaria a penalidade condicional da declaração de ramificação? Afinal, o caso normal não deve estar sobrecarregado, portanto é um comportamento de ramificação muito previsível.
CodeMonkey

4
Concorde com @CodeMonkey. Um compilador colocaria um salto condicional em caso de estouro, para uma página que normalmente não está carregada / fria. A previsão padrão para isso "não foi aceita" e provavelmente não será alterada. A sobrecarga total é uma instrução no pipeline. Mas essa é uma sobrecarga de instrução por instrução aritmética.
MSalters

2
@MSalters sim, há uma sobrecarga adicional de instruções. E o impacto pode ser grande se você tiver problemas exclusivamente com a CPU. Na maioria dos aplicativos com uma combinação de código pesado de E / S e CPU, eu assumiria que o impacto é mínimo. Eu gosto da maneira Rust, de adicionar a sobrecarga apenas nas versões de depuração, mas removê-la nas versões de versão.
CodeMonkey

20

Legado

Eu diria que o problema provavelmente está enraizado no legado. Em C:

  • estouro assinado é um comportamento indefinido (compiladores suportam sinalizadores para torná-lo encapsulado),
  • estouro não assinado é um comportamento definido (envolve).

Isso foi feito para obter o melhor desempenho possível, seguindo o princípio de que o programador sabe o que está fazendo .

Leva a Statu-Quo

O fato de C (e por extensão C ++) não exigir a detecção de estouro em turnos significa que a verificação de estouro é lenta.

O hardware atende principalmente ao C / C ++ (sério, o x86 tem uma strcmpinstrução (também conhecida como PCMPISTRI a partir do SSE 4.2)!) E, como o C não se importa, as CPUs comuns não oferecem maneiras eficientes de detectar estouros. No x86, você deve verificar um sinalizador por núcleo após cada operação potencialmente excedente; quando o que você realmente deseja é uma bandeira "contaminada" no resultado (bem como o NaN se propaga). E operações vetoriais podem ser ainda mais problemáticas. Alguns novos players podem aparecer no mercado com manipulação eficiente de estouro; mas por enquanto x86 e ARM não se importam.

Os otimizadores de compilador não são bons em otimizar as verificações de estouro, nem mesmo na presença de estouros. Alguns acadêmicos como John Regher reclamam desse status , mas o fato é que, quando o simples fato de fazer transbordamentos "falhas" impede otimizações antes mesmo que o assembly atinja a CPU, pode ser prejudicial. Especialmente quando impede a auto-vetorização ...

Com efeitos em cascata

Portanto, na ausência de estratégias eficientes de otimização e suporte eficiente à CPU, a verificação de estouro é cara. Muito mais caro do que embrulhar.

Adicione um comportamento irritante, como x + y - 1pode estourar quando x - 1 + ynão, o que pode legitimamente incomodar os usuários, e a verificação de estocagem geralmente é descartada em favor do empacotamento (que lida com esse exemplo e muitos outros normalmente).

Ainda assim, nem toda esperança está perdida

Houve um esforço nos compiladores clang e gcc para implementar "desinfetantes": maneiras de instrumentar binários para detectar casos de comportamento indefinido. Ao usar -fsanitize=undefined, o estouro assinado é detectado e aborta o programa; muito útil durante o teste.

A linguagem de programação Rust possui a verificação de estouro ativada por padrão no modo Debug (usa aritmética de quebra automática no modo Release por motivos de desempenho).

Portanto, existe uma preocupação crescente com a verificação de estouro e os perigos de resultados falsos não serem detectados, e esperamos que isso por sua vez desperte interesse na comunidade de pesquisa, na comunidade de compiladores e na comunidade de hardware.


6
@DmitryGrigoryev é o oposto de uma maneira eficaz de verificar estouros, por exemplo, em Haswell, reduz o rendimento de 4 adições normais por ciclo para apenas 1 adição verificada, e isso antes de considerar o impacto das previsões errôneas de ramificações joe mais efeitos globais da poluição que eles adicionam ao estado preditor de ramificação e ao aumento do tamanho do código. Se esse sinalizador estivesse pegajoso, ofereceria algum potencial real ... e você ainda não poderá fazê-lo corretamente no código vetorizado.

3
Como você está vinculando a um post de John Regehr, pensei que seria apropriado também vincular a outro artigo dele , escrito alguns meses antes daquele que você vinculou. Estes artigos falam sobre diferentes filosofias: no artigo anterior, números inteiros são de tamanho fixo; aritmética inteira é verificada (ou seja, o código não pode continuar sua execução); há uma exceção ou uma armadilha. O artigo mais recente fala sobre abandonar números inteiros de tamanho fixo, o que elimina estouros.
rwong

2
@rwong Inteiros de tamanho infinito também têm seus problemas. Se o seu estouro for o resultado de um bug (o que geralmente acontece), pode transformar uma falha rápida em uma agonia prolongada que consome todos os recursos do servidor até que tudo falhe terrivelmente. Sou principalmente fã da abordagem "falhar cedo" - menos chance de envenenar todo o ambiente. Prefiro os 1..100tipos Pascal-ish - seja explícito sobre os intervalos esperados, em vez de ser "forçado" a 2 ^ 31 etc. Alguns idiomas oferecem isso, é claro, e eles tendem a fazer a verificação de estouro por padrão (às vezes em tempo de compilação, até).
Luaan

1
@Luaan: O interessante é que muitas vezes os cálculos intermediários podem transbordar temporariamente, mas o resultado não. Por exemplo, no seu intervalo de 1 a 100, x * 2 - 2pode exceder os x51 quando o resultado for adequado, forçando você a reorganizar sua computação (às vezes de maneira não natural). Na minha experiência, descobri que geralmente prefiro executar o cálculo em um tipo maior e depois verificar se o resultado se encaixa ou não.
Matthieu M.

1
@MatthieuM. Sim, é aí que você entra no território do "compilador suficientemente inteligente". Idealmente, um valor de 103 deve ser válido para um tipo 1..100, desde que nunca seja usado em um contexto em que seja esperado um verdadeiro 1..100 (por exemplo, x = x * 2 - 2deve funcionar para todos os locais em xque a atribuição resultar em 1 válido. .100 number). Ou seja, as operações no tipo numérico podem ter uma precisão mais alta que o próprio tipo, desde que a atribuição se ajuste. Isso seria bastante útil em casos como (a + b) / 2onde ignorar estouros (não assinados) pode ser a opção correta.
Luaan

10

Os idiomas que tentam detectar estouros excederam historicamente a semântica associada de maneiras que restringiram severamente o que de outra forma seriam otimizações úteis. Entre outras coisas, embora muitas vezes seja útil executar cálculos em uma sequência diferente da especificada no código, a maioria dos idiomas que capturam estouros garantem que o código fornecido seja:

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

se o valor inicial de x causar um estouro na 47ª passagem pelo loop, a Operação1 executará 47 vezes e a Operação2 executará 46. Na ausência de tal garantia, se nada mais no loop usar x, e nada usará o valor de x após uma exceção lançada pela Operação1 ou Operação2, o código pode ser substituído por:

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

Infelizmente, é difícil realizar essas otimizações e garantir a semântica correta nos casos em que um estouro ocorreria dentro do loop é difícil - exigindo essencialmente algo como:

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 considerarmos que muitos códigos do mundo real usam loops mais envolvidos, será óbvio que otimizar o código e preservar a semântica de transbordamento é difícil. Além disso, devido a problemas de armazenamento em cache, é perfeitamente possível que o aumento no tamanho do código faça com que o programa geral seja executado mais lentamente, mesmo que haja menos operações no caminho comumente executado.

O que seria necessário para tornar a detecção de transbordamento barata seria um conjunto definido de semânticas de detecção de transbordamento mais soltas, o que tornaria mais fácil para o código relatar se uma computação foi executada sem transbordamentos que possam afetar os resultados (*), mas sem sobrecarregar o compilador com detalhes além disso. Se uma especificação de idioma estivesse focada em reduzir o custo da detecção de estouro ao mínimo necessário para alcançar o que foi mencionado acima, isso poderia ser muito menos oneroso do que nos idiomas existentes. No entanto, não conheço nenhum esforço para facilitar a detecção eficiente de estouro.

(*) Se um idioma promete que todos os estouros serão relatados, uma expressão como x*y/ynão pode ser simplificada, a xmenos x*yque seja garantido que não estourará. Da mesma forma, mesmo que o resultado de uma computação seja ignorado, um idioma que prometa relatar todos os estouros precisará executá-lo de qualquer maneira, para que ele possa executar a verificação do estouro. Como o excesso nesses casos não pode resultar em um comportamento aritmeticamente incorreto, um programa não precisaria executar essas verificações para garantir que nenhum excesso causasse resultados potencialmente imprecisos.

Aliás, estouros em C são especialmente ruins. Embora quase todas as plataformas de hardware que suportam o C99 usem semântica de complemento silencioso de dois, é moda para os compiladores modernos gerar código que pode causar efeitos colaterais arbitrários em caso de estouro. Por exemplo, considerando algo como:

#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;
}

O GCC gerará código para test2 que incrementa incondicionalmente (* p) uma vez e retorna 32768, independentemente do valor passado para q. Por seu raciocínio, o cálculo de (32769 * 65535) e 65535u causaria um estouro e, portanto, não é necessário que o compilador considere os casos em que (q | 32768) produziria um valor maior que 32768. Mesmo que não exista Por que o cálculo de (32769 * 65535) e 65535u deve se preocupar com os bits superiores do resultado, o gcc usará o estouro assinado como justificativa para ignorar o loop.


2
"está na moda para compiladores modernos ..." - da mesma forma, estava na moda brevemente para os desenvolvedores de certos kernels conhecidos optar por não ler a documentação sobre os sinalizadores de otimização que usavam e depois agirem com raiva por toda a Internet porque eles foram forçados a adicionar ainda mais sinalizadores de compilador para obter o comportamento que desejavam ;-). Nesse caso, -fwrapvresulta em comportamento definido, embora não seja o comportamento que o questionador deseja. É verdade que a otimização do gcc transforma qualquer tipo de desenvolvimento C em um exame completo do padrão e do comportamento do compilador.
Steve Jessop

1
@SteveJessop: C seria uma linguagem muito mais saudável se os escritores do compilador reconhecessem um dialeto de baixo nível em que "comportamento indefinido" significava "fazer o que faria sentido na plataforma subjacente" e, em seguida, adicionasse maneiras aos programadores de renunciar a garantias desnecessárias implícitas, ao invés de presumir que a frase "não portátil ou errônea" na Norma significa simplesmente "errônea". Em muitos casos, o código ideal que pode ser obtido em uma linguagem com garantias comportamentais fracas será muito melhor do que pode ser obtido com garantias mais fortes ou sem garantias. Por exemplo ...
supercat 9/17

1
... se um programador precisar avaliar x+y > zde uma maneira que nunca fará nada além de produzir 0 ou 1, mas qualquer um dos resultados seria igualmente aceitável em caso de estouro, um compilador que ofereça essa garantia pode gerar código melhor para o expressão x+y > zque qualquer compilador seria capaz de gerar para uma versão defensiva da expressão. Realisticamente falando, que fração de otimizações úteis relacionadas ao estouro seria impedida por uma garantia de que cálculos inteiros diferentes de divisão / restante serão executados sem efeitos colaterais?
Supercat

Confesso que não entendo completamente os detalhes, mas o fato de seu rancor estar com "escritores de compiladores" em geral, e não especificamente "alguém no gcc que não aceitará meu -fwhatever-makes-sensepatch", sugere fortemente que há mais a isso do que caprichoso da parte deles. Os argumentos usuais que ouvi são que o inlining de código (e até a expansão de macro) se beneficia deduzindo o máximo possível sobre o uso específico de uma construção de código, uma vez que qualquer coisa geralmente resulta em código inserido que lida com casos de que não precisa para, que o código circundante "prova" impossível.
Steve Jessop

Portanto, para um exemplo simplificado, se eu escrever foo(i + INT_MAX + 1), os escritores-compiladores desejam aplicar otimizações ao foo()código embutido , que depende da correção de seu argumento não ser negativo (truques divmod diabólicos, talvez). Sob suas restrições adicionais, eles só poderiam aplicar otimizações cujo comportamento para entradas negativas faz sentido para a plataforma. É claro que, pessoalmente, eu ficaria feliz em ser uma -fopção que liga -fwrapvetc., e provavelmente deve desativar algumas otimizações para as quais não há sinalização. Mas não é como se eu estivesse incomodado em fazer todo esse trabalho sozinho.
Steve Jessop

9

Nem todas as linguagens de programação ignoram estouros inteiros. Alguns idiomas fornecem operações inteiras seguras para todos os números (a maioria dos dialetos Lisp, Ruby, Smalltalk, ...) e outros via bibliotecas - por exemplo, existem várias classes BigInt para C ++.

Se um idioma torna o número inteiro protegido contra o estouro por padrão ou não, depende de sua finalidade: idiomas do sistema como C e C ++ precisam fornecer abstrações de custo zero e "grande número inteiro" não é um deles. Linguagens de produtividade, como Ruby, podem e fornecem grandes números inteiros prontos para uso. Idiomas como Java e C # que estão em algum lugar no meio devem IMHO ir com os inteiros seguros fora da caixa, por isso não.


Observe que há uma diferença entre detectar o estouro (e, em seguida, ter um sinal, pânico, exceção, ...) e mudar para números grandes. O primeiro deve ser factível muito mais barato que o segundo.
Matthieu M.

@MatthieuM. Absolutamente - e percebo que não sou claro sobre isso na minha resposta.
Nemanja Trifunovic

7

Como você mostrou, o C # teria sido três vezes mais lento se tivesse as verificações de estouro ativadas por padrão (supondo que seu exemplo seja um aplicativo típico para esse idioma). Concordo que o desempenho nem sempre é o recurso mais importante, mas os idiomas / compiladores geralmente são comparados com o desempenho em tarefas típicas. Isso se deve em parte ao fato de a qualidade dos recursos da linguagem ser um pouco subjetiva, enquanto um teste de desempenho é objetivo.

Se você introduzisse um novo idioma semelhante ao C # na maioria dos aspectos, mas três vezes mais lento, obter uma participação no mercado não seria fácil, mesmo que no final a maioria dos seus usuários finais se beneficiasse das verificações de excesso do que gostariam. de maior desempenho.


10
Esse foi particularmente o caso do C #, que em seus primeiros dias, comparado ao Java e C ++, não está relacionado às métricas de produtividade do desenvolvedor, difíceis de medir, ou às métricas de economia de dinheiro por não lidar com violações de segurança, difíceis de medir, mas em benchmarks de desempenho triviais.
Eric Lippert

1
E, provavelmente, o desempenho da CPU é verificado com algumas trocas simples de números. Portanto, otimizações para a detecção de estouro podem gerar resultados "ruins" nesses testes. Catch22.
Bernhard Hiller

5

Além das muitas respostas que justificam a falta de verificação de estouro com base no desempenho, existem dois tipos diferentes de aritmética a serem considerados:

  1. cálculos de indexação (indexação de array e / ou aritmética de ponteiros)

  2. outra aritmética

Se o idioma usar um tamanho inteiro igual ao tamanho do ponteiro, um programa bem construído não transbordará fazendo cálculos de indexação porque precisaria necessariamente ficar sem memória antes que os cálculos de indexação causassem transbordamento.

Portanto, verificar alocações de memória é suficiente ao trabalhar com expressões aritméticas e de indexação de ponteiros envolvendo estruturas de dados alocadas. Por exemplo, se você tiver um espaço de endereço de 32 bits e usar números inteiros de 32 bits, e permitir que um máximo de 2 GB de heap seja alocado (cerca de metade do espaço de endereço), os cálculos de indexação / ponteiro (basicamente) não serão excedidos.

Além disso, você pode se surpreender com o quanto de adição / subtração / multiplicação envolve indexação de matriz ou cálculo de ponteiro, inserindo-se na primeira categoria. Ponteiro de objeto, acesso a campo e manipulação de array são operações de indexação, e muitos programas não fazem mais cálculos aritméticos do que estes! Essencialmente, esse é o principal motivo pelo qual os programas funcionam tão bem quanto sem verificação de estouro inteiro.

Todos os cálculos sem indexação e sem ponteiro devem ser classificados como aqueles que desejam / esperam transbordamento (por exemplo, cálculos de hash) e aqueles que não o fazem (por exemplo, seu exemplo de soma).

No último caso, os programadores geralmente usam tipos de dados alternativos, como doublealguns BigInt. Muitos cálculos requerem um decimaltipo de dados e não double, por exemplo, cálculos financeiros. Se não o fizerem e permanecerem com os tipos inteiros, eles terão que tomar cuidado para verificar se há excesso de números inteiros - ou então, sim, o programa pode alcançar uma condição de erro não detectada conforme você está apontando.

Como programadores, precisamos ser sensíveis às nossas escolhas em tipos de dados numéricos e às consequências deles em termos de possibilidades de transbordamento, sem mencionar a precisão. Em geral (e especialmente ao trabalhar com a família C de idiomas com o desejo de usar os tipos inteiros rápidos), precisamos ser sensíveis e cientes das diferenças entre cálculos de indexação e outros.


3

O idioma Rust fornece um compromisso interessante entre a verificação de estouros e não, adicionando as verificações para a compilação de depuração e removendo-as na versão otimizada. Isso permite que você encontre os erros durante o teste, enquanto ainda obtém desempenho total na versão final.

Como a envolvente do estouro às vezes é um comportamento desejado, também existem versões dos operadores que nunca verificam o estouro.

Você pode ler mais sobre o raciocínio por trás da escolha na RFC para a alteração. Também há muitas informações interessantes nesta postagem do blog , incluindo uma lista de bugs que esse recurso ajudou a capturar.


2
O Rust também fornece métodos como checked_mul, que verificam se o estouro ocorreu e retornam None, Somecaso contrário. Isso pode ser usado na produção, bem como o modo de depuração: doc.rust-lang.org/std/primitive.i32.html#examples-15
Akavall

3

No Swift, qualquer excesso de número inteiro é detectado por padrão e para instantaneamente o programa. Nos casos em que você precisa de um comportamento abrangente, existem diferentes operadores & +, & - e & * que alcançam isso. E há funções que executam uma operação e informam se houve um estouro ou não.

É divertido ver iniciantes tentando avaliar a sequência Collatz e ter seu código travado :-)

Agora, os projetistas do Swift também são os projetistas do LLVM e Clang, para que saibam um pouco sobre otimização e sejam capazes de evitar verificações desnecessárias de transbordamento. Com todas as otimizações ativadas, a verificação de estouro não adiciona muito ao tamanho do código e ao tempo de execução. E como a maioria dos estouros leva a resultados absolutamente incorretos, o tamanho do código e o tempo de execução são bem gastos.

PS. Em C, C ++, o excesso aritmético de número inteiro assinado com Objective-C é um comportamento indefinido. Isso significa que o que o compilador faz no caso de estouro de número inteiro assinado está correto, por definição. As formas típicas de lidar com o excesso de número inteiro assinado são ignorá-lo, obtendo o resultado que a CPU fornecer, criando suposições no compilador de que esse excesso nunca acontecerá (e conclua, por exemplo, que n + 1> n é sempre verdadeiro, pois o excesso é presume-se que isso nunca aconteça), e uma possibilidade que raramente é usada é verificar e travar se ocorrer um estouro, como o Swift.


1
Às vezes me pergunto se as pessoas que estão pressionando a insanidade dirigida por UB em C estavam secretamente tentando miná-la em favor de alguma outra linguagem. Isso faria sentido.
Supercat

Tratar x+1>xcomo incondicionalmente verdadeiro não exigiria que um compilador fizesse "suposições" sobre x se um compilador pudesse avaliar expressões inteiras usando tipos maiores arbitrários como conveniente (ou se comportasse como se estivesse fazendo isso). Um exemplo mais desagradável de "suposições" baseadas em estouro seria decidir que, dado que uint32_t mul(uint16_t x, uint16_t y) { return x*y & 65535u; }um compilador pode usar sum += mul(65535, x)para decidir que xnão pode ser maior que 32768 [comportamento que provavelmente chocaria as pessoas que escreveram o C89 Rationale, o que sugere esse um dos fatores decisivos. ..
supercat

... ao unsigned shortpromover signed into fato de que as implementações silenciosas e completas de dois complementos (ou seja, a maioria das implementações C em uso) tratariam código como o descrito acima da mesma maneira, unsigned shortpromovido a intou unsigned. O Standard não exigia implementações em hardware de complemento silencioso para tratar dois códigos como acima, mas os autores do Standard esperavam que o fizessem de qualquer maneira.
Supercat

2

Na verdade, a verdadeira causa disso é puramente técnica / histórica: a CPU ignora a maior parte do sinal. Geralmente, existe apenas uma única instrução para adicionar dois números inteiros nos registros, e a CPU não se importa um pouco se você interpreta esses dois números inteiros como assinados ou não. O mesmo vale para subtração e até para multiplicação. A única operação aritmética que precisa ser sensível ao sinal é a divisão.

A razão pela qual isso funciona é a representação complementar de 2 de números inteiros assinados que é usada por praticamente todas as CPUs. Por exemplo, no complemento de 4 bits 2, a adição de 5 e -3 é assim:

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

Observe como o comportamento geral de jogar fora o bit de execução produz o resultado assinado correto. Da mesma forma, as CPUs geralmente implementam a subtração x - ycomo x + ~y + 1:

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

Isso implementa a subtração como uma adição ao hardware, ajustando apenas as entradas para a unidade aritmética-lógica (ALU) de maneiras triviais. O que poderia ser mais simples?

Como a multiplicação nada mais é do que uma sequência de adições, ela se comporta de uma maneira igualmente agradável. O resultado do uso da representação de complemento de 2 e ignorando a execução de operações aritméticas é um circuito simplificado e conjuntos de instruções simplificados.

Obviamente, como C foi projetado para trabalhar próximo ao metal, adotou exatamente esse mesmo comportamento que o comportamento padronizado da aritmética não assinada, permitindo que apenas a aritmética assinada produzisse um comportamento indefinido. E essa escolha foi transferida para outras linguagens como Java e, obviamente, C #.


Eu vim aqui para dar essa resposta também.
Sr. Lister

Infelizmente, algumas pessoas parecem considerar grosseiramente irracional a noção de que as pessoas que escrevem código C de baixo nível em uma plataforma devem ter a audácia de esperar que um compilador C adequado para esse fim se comporte de maneira restrita em caso de estouro. Pessoalmente, acho razoável que um compilador se comporte como se os cálculos fossem executados usando precisão estendida arbitrariamente para a conveniência do compilador (por exemplo, em um sistema de 32 bits, se x==INT_MAX, então, x+1arbitrariamente possa se comportar como +2147483648 ou -2147483648 no compilador conveniência), mas ...
supercat 11/17

algumas pessoas parecem pensar que, se xe ysão uint16_teo código em um sistema 32-bit calcula x*y & 65535uquando yé 65535, um compilador deve assumir que o código nunca será alcançado quando xé maior que 32768.
supercat

1

Algumas respostas discutiram o custo da verificação e você editou sua resposta para contestar que essa é uma justificativa razoável. Vou tentar abordar esses pontos.

Em C e C ++ (como exemplos), um dos princípios de design de linguagens é não fornecer funcionalidade que não foi solicitada. Isso geralmente é resumido pela frase "não pague pelo que você não usa". Se o programador desejar verificar o estouro, ele poderá solicitá-lo (e pagar a penalidade). Isso torna o idioma mais perigoso de usar, mas você escolhe trabalhar com o idioma sabendo disso e aceita o risco. Se você não deseja esse risco ou está escrevendo um código em que a segurança é de desempenho primordial, pode selecionar um idioma mais apropriado em que a troca de desempenho / risco seja diferente.

Porém, com as 10.000.000.000 de repetições, o tempo gasto em um cheque ainda é menor que 1 nanossegundo.

Há algumas coisas erradas nesse raciocínio:

  1. Isso é específico do ambiente. Geralmente, faz muito pouco sentido citar números específicos como esse, porque o código é escrito para todos os tipos de ambientes que variam em ordem de magnitude em termos de desempenho. Seu 1 nanossegundo em uma máquina de desktop (presumo) pode parecer incrivelmente rápido para alguém que codifica para um ambiente incorporado e insuportavelmente lento para alguém que codifica para um cluster de supercomputador.

  2. 1 nanossegundo pode parecer nada para um segmento de código que é executado com pouca frequência. Por outro lado, se estiver em um loop interno de algum cálculo que é a principal função do código, cada fração de tempo que você pode economizar pode fazer uma grande diferença. Se você estiver executando uma simulação em um cluster, as frações salvas de um nanossegundo em seu loop interno podem se traduzir diretamente no dinheiro gasto em hardware e eletricidade.

  3. Para alguns algoritmos e contextos, 10.000.000.000 de iterações podem ser insignificantes. Novamente, geralmente não faz sentido falar sobre cenários específicos que se aplicam apenas em determinados contextos.

Pode haver uma situação em que isso é importante, mas para a maioria dos aplicativos isso não importa.

Talvez você esteja certo. Mas, novamente, é uma questão de quais são os objetivos de um idioma específico. De fato, muitos idiomas são projetados para acomodar as necessidades da "maioria" ou favorecer a segurança em detrimento de outras preocupações. Outros, como C e C ++, priorizam a eficiência. Nesse contexto, fazer com que todos paguem uma penalidade de desempenho simplesmente porque a maioria das pessoas não será incomodada, vai contra o que a linguagem está tentando alcançar.


-1

Há boas respostas, mas acho que há um ponto perdido aqui: os efeitos de um estouro inteiro não são necessariamente uma coisa ruim e, depois do fato, é difícil saber se o fato de ipassar de ser MAX_INTa ser MIN_INTocorreu devido a um problema de estouro. ou se foi intencionalmente multiplicado por -1.

Por exemplo, se eu quiser adicionar todos os números inteiros representáveis ​​maiores que 0, eu apenas usaria um for(i=0;i>=0;++i){...}loop de adição - e, quando estourar, interrompe a adição, que é o comportamento do objetivo (gerar um erro significaria que eu teria que contornar uma proteção arbitrária porque interfere na aritmética padrão). É uma prática recomendada restringir a aritmética primitiva, porque:

  • Eles são usados ​​em tudo - uma desaceleração na matemática primitiva é uma desaceleração em todos os programas em funcionamento
  • Se um programador precisar deles, sempre poderá adicioná-los
  • Se você os tiver e o programador não precisar deles (mas precisar de tempos de execução mais rápidos), eles não poderão removê-los facilmente para otimização
  • Se você os tiver e o programador precisar que eles não estejam lá (como no exemplo acima), o programador receberá o hit em tempo de execução (que pode ou não ser relevante) e o programador ainda precisará investir tempo removendo ou contornar a 'proteção'.

3
Não é realmente possível para um programador adicionar uma verificação eficiente de estouro, se um idioma não o fornecer. Se uma função calcula um valor que é ignorado, um compilador pode otimizar a computação. Se uma função calcula um valor que é o check-estouro, mas de outra forma ignoradas, um compilador deve executar o cálculo e armadilha se ele transborda, mesmo se um estouro de outra forma não afeta a saída do programa e pode ser ignorada com segurança.
Supercat

1
Você não pode ir a partir INT_MAXde INT_MINmultiplicando por -1.
David Conrad

Obviamente, a solução é fornecer uma maneira de o programador desativar as verificações em um determinado bloco de código ou unidade de compilação.
David Conrad

for(i=0;i>=0;++i){...}é o estilo de código que tento desencorajar em minha equipe: ele se baseia em efeitos especiais / efeitos colaterais e não expressa claramente o que deve fazer. Ainda assim, agradeço sua resposta, pois mostra um paradigma de programação diferente.
Bernhard Hiller

1
@Delioth: se ifor do tipo de 64 bits, mesmo em uma implementação com comportamento consistente de complemento de dois em silêncio, envolvendo um bilhão de iterações por segundo, esse loop só poderá garantir o maior intvalor se for permitido executar por centenas de anos. Em sistemas que não prometem um comportamento consistente e silencioso, tais comportamentos não seriam garantidos, não importa quanto tempo o código seja fornecido.
Supercat 10/17 /
Ao utilizar nosso site, você reconhece que leu e compreendeu nossa Política de Cookies e nossa Política de Privacidade.
Licensed under cc by-sa 3.0 with attribution required.