Este é um exemplo para ilustrar minha pergunta, que envolve um código muito mais complicado que não posso postar aqui.
#include <stdio.h>
int main()
{
int a = 0;
for (int i = 0; i < 3; i++)
{
printf("Hello\n");
a = a + 1000000000;
}
}
Este programa contém comportamento indefinido na minha plataforma porque a
irá estourar no terceiro loop.
Isso faz com que todo o programa tenha um comportamento indefinido ou apenas depois que o estouro realmente acontecer ? O compilador poderia potencialmente descobrir que a
irá estourar para que possa declarar todo o loop indefinido e não se incomodar em executar o printfs, mesmo que todos eles ocorram antes do estouro?
(Etiquetados como C e C ++, embora sejam diferentes, porque eu estaria interessado em respostas para ambas as linguagens, caso fossem diferentes).
c++
c
undefined-behavior
integer-overflow
jcoder
fonte
fonte
a
a
Respostas:
Se você estiver interessado em uma resposta puramente teórica, o padrão C ++ permite um comportamento indefinido para "viagem no tempo":
Dessa forma, se o seu programa contém um comportamento indefinido, o comportamento de todo o programa é indefinido.
fonte
sneeze()
função em si é indefinida em qualquer coisa da classeDemon
(da qual a variedade nasal é uma subclasse), tornando a coisa toda circular de qualquer maneira.printf
não retornar, mas seprintf
for retornar, então o comportamento indefinido pode causar problemas antes deprintf
ser chamado. Conseqüentemente, viagem no tempo.printf("Hello\n");
e a próxima linha é compilada comoundoPrintf(); launchNuclearMissiles();
Primeiro, deixe-me corrigir o título desta pergunta:
Comportamento indefinido não é (especificamente) do reino da execução.
O comportamento indefinido afeta todas as etapas: compilar, vincular, carregar e executar.
Alguns exemplos para cimentar isso, tenha em mente que nenhuma seção é exaustiva:
LD_PRELOAD
truques em UnixesIsso é o que é tão assustador sobre o comportamento indefinido: é quase impossível prever, com antecedência, que comportamento exato ocorrerá, e essa previsão deve ser revisada a cada atualização do conjunto de ferramentas, sistema operacional subjacente, ...
Recomendo assistir a este vídeo de Michael Spencer (Desenvolvedor LLVM): CppCon 2016: My Little Optimizer: Undefined Behavior is Magic .
fonte
argc
como contagem de loop, o casoargc=1
não produziria UB e o compilador seria forçado a lidar com isso.i
não pode ser incrementado mais do queN
vezes e, portanto, que seu valor é limitado.f(good);
faz alguma coisa X ef(bad);
invoca um comportamento indefinido, então um programa que apenas invocaf(good);
garante fazer X, masf(good); f(bad);
não é garantido que fará X.if(foo) f(good); else f(bad);
, um compilador inteligente descartará a comparação e produzirá um incondicionalfoo(good)
.Um agressivamente optimizar compilador C ou C ++ segmentação um pouco 16
int
vai saber que o comportamento em adição1000000000
a umint
tipo é indefinido .É permitido por qualquer um dos padrões fazer qualquer coisa que desejar, que pode incluir a exclusão de todo o programa, e sair
int main(){}
.Mas e quanto a
int
s maiores ? Não conheço um compilador que faça isso ainda (e não sou um especialista em design de compiladores C e C ++ de forma alguma), mas imagino que, em algum momento, um compilador voltado para 32 bitsint
ou superior descobrirá que o loop é infinito (i
não muda) e entãoa
acabará por transbordar. Então, mais uma vez, ele pode otimizar a saída paraint main(){}
. O que estou tentando enfatizar aqui é que, conforme as otimizações do compilador se tornam progressivamente mais agressivas, mais e mais construções de comportamento indefinido estão se manifestando de maneiras inesperadas.O fato de seu loop ser infinito não é em si indefinido, pois você está gravando na saída padrão no corpo do loop.
fonte
int
for de 16 bits, a adição ocorrerá emlong
(porque o operando literal tem tipolong
) onde está bem definido e, em seguida, será convertido por uma conversão definida pela implementação de volta paraint
.printf
é definido pelo padrão para sempre retornarTecnicamente, sob o padrão C ++, se um programa contém um comportamento indefinido, o comportamento de todo o programa, mesmo em tempo de compilação (antes mesmo de o programa ser executado), é indefinido.
Na prática, porque o compilador pode assumir (como parte de uma otimização) que o estouro não ocorrerá, pelo menos o comportamento do programa na terceira iteração do loop (assumindo uma máquina de 32 bits) será indefinido, embora é provável que você obtenha resultados corretos antes da terceira iteração. No entanto, uma vez que o comportamento de todo o programa é tecnicamente indefinido, não há nada que impeça o programa de gerar uma saída completamente incorreta (incluindo nenhuma saída), travando em tempo de execução em qualquer ponto durante a execução ou mesmo falhando totalmente na compilação (já que o comportamento indefinido se estende até tempo de compilação).
O comportamento indefinido fornece ao compilador mais espaço para otimizar porque elimina certas suposições sobre o que o código deve fazer. Ao fazer isso, os programas que dependem de suposições que envolvem comportamento indefinido não têm a garantia de funcionar conforme o esperado. Dessa forma, você não deve confiar em nenhum comportamento específico que seja considerado indefinido pelo padrão C ++.
fonte
if(false) {}
escopo? Isso envenena todo o programa, devido ao compilador assumir que todos os ramos contêm porções bem definidas de lógica e, portanto, operar em suposições erradas?Para entender por que o comportamento indefinido pode 'viajar no tempo', como @TartanLlama colocou adequadamente , vamos dar uma olhada na regra 'como se':
Com isso, podemos ver o programa como uma 'caixa preta' com uma entrada e uma saída. A entrada pode ser entrada do usuário, arquivos e muitas outras coisas. A saída é o 'comportamento observável' mencionado na norma.
O padrão apenas define um mapeamento entre a entrada e a saída, nada mais. Ele faz isso descrevendo um 'exemplo de caixa preta', mas diz explicitamente que qualquer outra caixa preta com o mesmo mapeamento é igualmente válida. Isso significa que o conteúdo da caixa preta é irrelevante.
Com isso em mente, não faria sentido dizer que um comportamento indefinido ocorre em um determinado momento. Na implementação de amostra da caixa preta, poderíamos dizer onde e quando isso acontece, mas a caixa preta real poderia ser algo completamente diferente, então não podemos mais dizer onde e quando isso acontece. Teoricamente, um compilador poderia, por exemplo, decidir enumerar todas as entradas possíveis e pré-computar as saídas resultantes. Então, o comportamento indefinido teria acontecido durante a compilação.
O comportamento indefinido é a inexistência de um mapeamento entre a entrada e a saída. Um programa pode ter comportamento indefinido para alguma entrada, mas comportamento definido para outra. Então, o mapeamento entre entrada e saída é simplesmente incompleto; há entrada para a qual não existe mapeamento para saída.
O programa em questão tem comportamento indefinido para qualquer entrada, portanto, o mapeamento está vazio.
fonte
Supondo que
int
seja de 32 bits, o comportamento indefinido ocorre na terceira iteração. Portanto, se, por exemplo, o loop só fosse alcançável condicionalmente, ou pudesse ser encerrado condicionalmente antes da terceira iteração, não haveria comportamento indefinido a menos que a terceira iteração fosse realmente alcançada. No entanto, no caso de comportamento indefinido, toda a saída do programa é indefinida, incluindo a saída que está "no passado" em relação à invocação de comportamento indefinido. Por exemplo, no seu caso, isso significa que não há garantia de ver 3 mensagens de "Olá" na saída.fonte
A resposta da TartanLlama está correta. O comportamento indefinido pode acontecer a qualquer momento, mesmo durante o tempo de compilação. Isso pode parecer absurdo, mas é um recurso importante para permitir que os compiladores façam o que precisam. Nem sempre é fácil ser um compilador. Você tem que fazer exatamente o que a especificação diz, todas as vezes. No entanto, às vezes pode ser monstruosamente difícil provar que um determinado comportamento está ocorrendo. Se você se lembra do problema da parada, é bastante trivial desenvolver um software para o qual você não pode provar se ele completa ou entra em um loop infinito quando alimentado com uma entrada específica.
Poderíamos fazer os compiladores serem pessimistas e compilar constantemente com medo de que a próxima instrução possa ser um desses problemas de travamento, mas isso não é razoável. Em vez disso, damos uma chance ao compilador: nesses tópicos de "comportamento indefinido", eles ficam livres de qualquer responsabilidade. O comportamento indefinido consiste em todos os comportamentos que são tão sutilmente nefastos que temos dificuldade em separá-los dos problemas de parada realmente desagradáveis e nefastos e outros enfeites.
Há um exemplo que adoro postar, embora admita que perdi a fonte, então tenho que parafrasear. Era de uma versão específica do MySQL. No MySQL, eles tinham um buffer circular que era preenchido com dados fornecidos pelo usuário. Eles, é claro, queriam ter certeza de que os dados não estourassem o buffer, então tiveram que verificar:
Parece sensato o suficiente. No entanto, e se numberOfNewChars for realmente grande e estourar? Em seguida, ele envolve e se torna um ponteiro menor que
endOfBufferPtr
, de modo que a lógica de estouro nunca seria chamada. Então, eles adicionaram uma segunda verificação, antes dessa:Parece que você cuidou do erro de estouro de buffer, certo? No entanto, um bug foi enviado informando que este buffer estourou em uma versão particular do Debian! Uma investigação cuidadosa mostrou que esta versão do Debian foi a primeira a usar uma versão particularmente avançada do gcc. Nesta versão do gcc, o compilador reconheceu que currentPtr + numberOfNewChars nunca pode ser um ponteiro menor que currentPtr porque estouro de ponteiros é um comportamento indefinido! Isso foi suficiente para o gcc otimizar toda a verificação e, de repente, você não estava protegido contra estouros de buffer , embora tenha escrito o código para verificar isso!
Este era um comportamento específico. Tudo estava legal (embora pelo que eu ouvi, o gcc reverteu essa mudança na próxima versão). Não é o que eu consideraria um comportamento intuitivo, mas se você forçar um pouco a imaginação, é fácil ver como uma ligeira variante dessa situação pode se tornar um problema de travamento para o compilador. Por causa disso, os redatores das especificações o tornaram "Comportamento indefinido" e afirmaram que o compilador poderia fazer absolutamente qualquer coisa que quisesse.
fonte
if(numberOfNewChars > endOfBufferPtr - currentPtr)
, desde que numberOfNewChars nunca possa ser negativo e currentPtr sempre aponte para algum lugar dentro do buffer que você nem mesmo precisa da verificação ridícula de "envolvente". (Não acho que o código que você forneceu tenha qualquer esperança de funcionar em um buffer circular - você omitiu o que for necessário para isso na paráfrase, então estou ignorando esse caso também)Além das respostas teóricas, uma observação prática seria que por muito tempo os compiladores aplicaram várias transformações em loops para reduzir a quantidade de trabalho realizado dentro deles. Por exemplo, dado:
um compilador pode transformar isso em:
Salvando assim uma multiplicação com cada iteração do loop. Uma forma adicional de otimização, que os compiladores adaptaram com vários graus de agressividade, transformariam isso em:
Mesmo em máquinas com wraparound silencioso em transbordamento, isso poderia funcionar mal se houvesse algum número menor que n que, quando multiplicado pela escala, resultaria em 0. Também poderia se transformar em um loop infinito se a escala fosse lida da memória mais de uma vez e algo mudou seu valor inesperadamente (em qualquer caso onde "escala" pudesse mudar no meio do loop sem invocar o UB, um compilador não teria permissão para realizar a otimização).
Enquanto a maioria dessas otimizações não teria nenhum problema nos casos em que dois tipos curtos sem sinal são multiplicados para produzir um valor que está entre INT_MAX + 1 e UINT_MAX, o gcc tem alguns casos em que tal multiplicação dentro de um loop pode fazer com que o loop saia antecipadamente . Não notei esses comportamentos decorrentes de instruções de comparação no código gerado, mas é observável nos casos em que o compilador usa o estouro para inferir que um loop pode ser executado no máximo 4 ou menos vezes; por padrão, ele não gera avisos nos casos em que algumas entradas causariam UB e outras não, mesmo que suas inferências façam com que o limite superior do loop seja ignorado.
fonte
O comportamento indefinido é, por definição, uma área cinzenta. Você simplesmente não pode prever o que ele fará ou não - isso é o que significa "comportamento indefinido" .
Desde tempos imemoriais, os programadores sempre tentaram salvar resquícios de definição de uma situação indefinida. Eles têm um código que realmente desejam usar, mas que se revelou indefinido, então tentam argumentar: "Eu sei que é indefinido, mas com certeza irá, na pior das hipóteses, fazer isto ou aquilo; nunca fará aquilo . " E às vezes esses argumentos estão mais ou menos certos - mas freqüentemente, eles estão errados. E à medida que os compiladores ficam cada vez mais espertos (ou, algumas pessoas podem dizer, cada vez mais furtivos), os limites da questão continuam mudando.
Então, realmente, se você quiser escrever um código que funcione garantido, e que continuará funcionando por muito tempo, só há uma escolha: evite o comportamento indefinido a todo custo. Na verdade, se você se envolver com ele, ele voltará para assombrá-lo.
fonte
Uma coisa que seu exemplo não considera é a otimização.
a
é definido no loop, mas nunca usado, e um otimizador poderia resolver isso. Como tal, é legítimo para o otimizador descartara
completamente e, nesse caso, todo comportamento indefinido desaparece como a vítima de um boojum.No entanto, é claro que isso em si é indefinido, porque a otimização é indefinida. :)
fonte
Uma vez que esta questão tem duas tags C e C ++, tentarei abordar ambos. C e C ++ têm abordagens diferentes aqui.
Em C, a implementação deve ser capaz de provar que o comportamento indefinido será invocado para tratar todo o programa como se ele tivesse um comportamento indefinido. No exemplo dos OPs, pareceria trivial para o compilador provar isso e, portanto, é como se todo o programa fosse indefinido.
Podemos ver isso no Relatório de defeito 109, que em seu ponto crucial pergunta:
e a resposta foi:
Em C ++, a abordagem parece mais relaxada e sugere que um programa tem um comportamento indefinido, independentemente de a implementação poder provar isso estaticamente ou não.
Temos [intro.abstrac] p5 que diz:
fonte
A principal resposta é um equívoco errado (mas comum):
O comportamento indefinido é uma propriedade de tempo de execução *. Ele NÃO PODE "viagem no tempo"!
Certas operações são definidas (pelo padrão) para ter efeitos colaterais e não podem ser otimizadas. As operações que fazem E / S ou que acessam
volatile
variáveis se enquadram nesta categoria.No entanto , há uma advertência: UB pode ser qualquer comportamento, incluindo comportamento que desfaça operações anteriores. Isso pode ter consequências semelhantes, em alguns casos, à otimização do código anterior.
Na verdade, isso é consistente com a citação na resposta principal (ênfase minha):
Sim, esta citação faz dizer "nem mesmo no que diz respeito a operações anteriores à primeira operação indefinido" , mas aviso que este é especificamente sobre o código que está sendo executado , não apenas compilado.
Afinal, o comportamento indefinido que não é realmente alcançado não faz nada, e para que a linha que contém o UB seja realmente alcançada, o código que o precede deve ser executado primeiro!
Então, sim, uma vez que o UB é executado , quaisquer efeitos das operações anteriores se tornam indefinidos. Mas até que isso aconteça, a execução do programa está bem definida.
Observe, no entanto, que todas as execuções do programa que resultam nisso podem ser otimizadas para programas equivalentes , incluindo qualquer um que execute operações anteriores, mas depois desfaça seus efeitos. Consequentemente, o código anterior pode ser otimizado sempre que isso for equivalente a seus efeitos serem desfeitos ; caso contrário, não pode. Veja abaixo um exemplo.
* Nota: Isso não é inconsistente com o UB ocorrendo em tempo de compilação . Se o compilador pode realmente provar que o código UB vai sempre ser executadas para todas as entradas, então UB pode estender-se a tempo de compilação. No entanto, isso requer saber que todo o código anterior eventualmente retorna , o que é um requisito forte. Novamente, veja abaixo um exemplo / explicação.
Para tornar isso concreto, observe que o código a seguir deve ser impresso
foo
e aguardar sua entrada, independentemente de qualquer comportamento indefinido que o segue:No entanto, também observe que não há garantia de que
foo
permanecerá na tela após a ocorrência do UB, ou que o caractere digitado não estará mais no buffer de entrada; ambas as operações podem ser "desfeitas", o que tem um efeito semelhante à "viagem no tempo" do UB.Se a
getchar()
linha não estivesse lá, seria legal que as linhas fossem otimizadas se e somente se isso fosse indistinguível da saídafoo
e, em seguida, "desfazendo".Se os dois seriam indistinguíveis ou não, dependeria inteiramente da implementação (ou seja, do seu compilador e da biblioteca padrão). Por exemplo, você pode
printf
bloquear seu thread aqui enquanto espera que outro programa leia a saída? Ou vai voltar imediatamente?Se ele pode bloquear aqui, então outro programa pode se recusar a ler sua saída completa e pode nunca retornar e, conseqüentemente, UB pode nunca realmente ocorrer.
Se ele pode retornar imediatamente aqui, então sabemos que ele deve retornar e, portanto, otimizá-lo é totalmente indistinguível de executá-lo e desfazer seus efeitos.
Obviamente, como o compilador sabe qual comportamento é permitido para sua versão específica do
printf
, ele pode otimizar de acordo e, conseqüentemente,printf
pode ser otimizado em alguns casos e não em outros. Mas, novamente, a justificativa é que isso seria indistinguível das operações anteriores de desfazer do UB, não que o código anterior esteja "envenenado" por causa do UB.fonte