Comportamento indefinido, em princípio

8

Seja em C ou C ++, acho que esse programa ilegal, cujo comportamento de acordo com o padrão C ou C ++ é indefinido, é interessante:

#include <stdio.h>

int foo() {
    int a;
    const int b = a;
    a = 555;
    return b;
}

void bar() {
    int x = 123;
    int y = 456;
}

int main() {
    bar();
    const int n1 = foo();
    const int n2 = foo();
    const int n3 = foo();
    printf("%d %d %d\n", n1, n2, n3);
    return 0;
}

Saída na minha máquina (após compilação sem otimização):

123 555 555

Eu acho que esse programa ilegal é interessante porque ilustra a mecânica de empilhamento, porque o motivo pelo qual se usa C ou C ++ (em vez de, digamos, Java) é programar próximo ao hardware, próximo à mecânica de empilhamento e similares.

No entanto, no StackOverflow, quando o código de um interlocutor lê inadvertidamente a partir de armazenamento não inicializado, as respostas mais fortemente votadas invariavelmente citam o padrão C ou C ++ (especialmente C ++) para o efeito de que o comportamento é indefinido. Isso é verdade, é claro, no que diz respeito ao padrão - o comportamento é realmente indefinido - mas é curioso que respostas alternativas tentem, do ponto de vista do hardware ou da mecânica da pilha, investigar por que um comportamento indefinido específico (como o saída acima) pode ter ocorrido, é raro e tende a ser ignorado.

Até me lembro de uma resposta que sugeria que um comportamento indefinido poderia incluir a reformatação do meu disco rígido. Não me preocupei muito com isso antes de executar o programa acima.

Minha pergunta é a seguinte: por que é mais importante ensinar aos leitores apenas que o comportamento é indefinido em C ou C ++, do que entender o comportamento indefinido? Quero dizer, se o leitor entendeu o comportamento indefinido, ele não teria mais chances de evitá-lo?

Minha formação acontece em engenharia elétrica, e trabalho como engenheiro de construção civil, e a última vez em que trabalhei como programador em si foi em 1994, por isso estou curioso para entender a perspectiva dos usuários de maneiras mais convencionais e mais convencionais. históricos recentes de desenvolvimento de software.

thb
fonte
3
Às vezes, é realmente difícil entender o que o seu programa realmente faz até que você observe o assembly produzido e veja que o compilador subitamente otimizou um bom pedaço de código, tudo devido a um pequeno pedaço de comportamento indefinido.
Chris
7
Comportamento indefinido significa que qualquer coisa pode acontecer. Se a saída faz sentido ou não, não importa ... É apenas sorte aleatória que o compilador é implementado como seria de esperar que ele seja ....
Jaa-c
5
O modo como um compilador escolhe compilar o UB é muito específico para ser uma pergunta útil para o SO: depende do compilador, SO, arquitetura da máquina, níveis de otimização específicos e qual versão exata do compilador você está usando. A série de artigos em blog.llvm.org/2011/05/what-every-c-programmer-should-know.html é uma boa visão geral de por que você deve evitar o UB e algumas das coisas que podem dar errado.
Paul Hankin
4
Um compilador diferente, ou o mesmo compilador em configurações diferentes, diferentes níveis de otimização ou talvez em um sistema diferente, pode compilar o código de maneira diferente. Você não pode saber ao certo quais serão os resultados. Como depende da "magia negra" interna do compilador, e é possivelmente influenciado por opções e outros parâmetros externos, tornando-o possivelmente não reproduzível e, mesmo que fosse, não recomendável. Se você quiser aprender sobre a pilha, existem maneiras melhores de fazê-lo, talvez eu sugira olhar para uma saída de montagem de códigos válida.
Tommy Andersen
2
O problema com esta pergunta está em como você define "indefinido" (ha!). Se você sabe o que o compilador fará, ele não é indefinido : é definido pela implementação (se o padrão ISO C não der permissão explícita à implementação para defini-lo, será definido pela implementação e você também está agora usando GNU C ou qualquer outra coisa, em vez de ISO C). Não é significativo falar sobre "entender" o verdadeiro UB; se pode ser entendido de forma consistente, não é.
Leushenko 15/09/14

Respostas:

5

A análise de valor do Frama-C, um analisador estático cujo objetivo é encontrar todos os comportamentos indefinidos em um programa em C, considera a atribuição const int b = a;como aceitável. Essa é uma decisão deliberada de design para permitir memcpy()(normalmente implementado como um loop sobre unsigned charelementos de uma matriz virtual, e que o padrão C indiscutivelmente permite reimplementar como tal) copiar uma struct(que pode ter membros preenchidos e não inicializados) para outro.

A "exceção" é apenas para lvalue = lvalue;atribuições sem uma conversão intermediária, ou seja, para uma atribuição que equivale a uma cópia de uma fatia de memória de um local de memória para outro.

Eu (como um dos autores da análise de valor de Frama-C) discuti isso com Xavier Leroy em um momento em que ele próprio se perguntava sobre a definição a escolher no compilador C verificado CompCert, para que ele pudesse ter acabado usando a mesma definição. Na minha opinião, é mais limpo do que o que o padrão C tenta fazer com valores indeterminados que podem ser representações de interceptações e o tipo unsigned charque é garantido não ter representações de interceptações, mas CompCert e Frama-C assumem metas relativamente não exóticas, e talvez o comitê de padronização estivesse tentando acomodar plataformas nas quais a leitura de um não inicializado intpode realmente abortar o programa.

Retornando b, ou passar n1, n2ou n3para printf, no final, pelo menos, pode ser considerado um comportamento indefinido, porque copiar uma fatia não inicializado da memória não tornando-inicializado. Com uma versão antiga do Frama-C:

$ frama-c -val t.c

t.c:19:… accessing uninitialized left-value: assert \initialized(&n1);

E em uma versão antiga do CompCert, após pequenas modificações para tornar o programa aceitável:

$ ccomp -interp t.c
Time 33: in function foo, expression <loc> = <undef>
ERROR: Undefined behavior
Complicado ver biografia
fonte
8

Comportamento indefinido significa, em última análise, que o comportamento é não determinístico. Os programadores que não sabem que estão escrevendo código não determinístico são apenas programadores ignorantes. Este site tem como objetivo tornar os programadores melhores (e menos ignorantes).

Escrever um programa correto em face do comportamento não determinístico não é impossível. No entanto, é um ambiente de programação especializado e requer um tipo diferente de disciplina de programação.

Mesmo no seu exemplo, se o programa receber um sinal gerado externamente, os valores na "pilha" podem mudar de forma que você não obtenha os valores esperados. Além disso, se a máquina tiver valores de interceptação, a leitura de valores aleatórios pode muito bem causar algo estranho.

jxh
fonte
4
@ jxh Não tenho certeza se não é determinístico . Um programa pode ser indefinido, mas completamente repetível em uma determinada plataforma, certo?
quant
3
@ Arman: Pode ou não ser repetível em uma determinada plataforma, esse é o ponto.
JXH
1
@ Giorgio: O outro ponto é que o comportamento indefinido não precisa ser determinístico, mesmo para a mesma plataforma e implementação.
Jxh # 14/14
1
C e C ++ usam dois termos diferentes: comportamento indefinido e comportamento não especificado. Também há sequências indeterminadas. E a distinção é importante. É possível, embora difícil, escrever um programa correto na presença de comportamento não especificado. Mas nenhuma codificação cuidadosa pode garantir a correção na presença de comportamento indefinido. O comportamento indefinido remove o significado semântico de todo o programa. Por outro lado, o comportamento deixado indefinido pelo idioma pode ser definido pela plataforma.
Ben Voigt
1
@jxh: Sistemas tolerantes a falhas são realmente bastante interessantes. Mas eles não são tolerantes a comportamentos indefinidos. Cópias em execução no modo de bloqueio que encontram comportamento indefinido podem fazer a escolha errada, e a votação não ajudará.
Ben Voigt
6

Por que é mais importante ensinar aos leitores apenas que o comportamento é indefinido em C ou C ++, do que entender o comportamento indefinido?

Porque o comportamento específico pode não ser repetível, mesmo de execução para execução sem reconstrução.

Perseguir exatamente o que aconteceu pode ser um exercício acadêmico útil para entender melhor as peculiaridades de sua plataforma específica, mas, de uma perspectiva de codificação , a única lição relevante é "não faça isso". Uma expressão como a++ * a++é um erro de codificação, ponto final. Isso é realmente tudo o que alguém precisa saber.

John Bode
fonte
5

"Comportamento indefinido" é uma abreviação de "Esse comportamento não é determinístico; provavelmente não só se comportará de maneira diferente em diferentes compiladores ou plataformas de hardware, mas também poderá se comportar de maneira diferente em versões diferentes do mesmo compilador".

A maioria dos programadores consideraria isso uma característica indesejável, especialmente porque C e C ++ são linguagens baseadas em padrões ; ou seja, você os utiliza, em parte, porque a especificação do idioma garante certas formas de comportamento do idioma, se você estiver usando um compilador compatível com os padrões.

Como na maioria das coisas em programação, você deve ponderar as vantagens e desvantagens. Se o benefício de alguma operação que é UB exceder a dificuldade de fazer com que ela se comporte de maneira estável e independente de plataforma, use, por todos os meios, o comportamento indefinido. A maioria dos programadores acha que não vale a pena, na maioria das vezes.

O remédio para qualquer comportamento indefinido é examinar o comportamento que você realmente obtém, considerando uma plataforma e compilador específicos. Esse tipo de exame não é o que um programador especialista provavelmente irá explorar para você em um ambiente de perguntas e respostas.

Robert Harvey
fonte
+1 Como o @aschepler explicou melhor do que eu, as especificidades detalhadas de comportamento indefinido tendem a ser interessantes durante a depuração. Se minha unidade testar segfaults, e eu entender a mecânica de gerenciamento de memória que produz segfaults, poderei depurar meu programa mais rapidamente. Claro que você está certo: é difícil pensar em um caso em que alguém invocaria propositalmente UB no código final!
thb
1
Você sente falta "com diferentes opções de compilação". Sempre divertido quando as versões Develop / Test / Release se comportam de maneira diferente.
Henk Holterman
1
Ou ainda "pode ​​produzir resultados diferentes em execuções consecutivas do mesmo binário, resultantes de uma única compilação".
Vatine 4/11
Comportamento indefinido às vezes pretendia significar isso e às vezes pretendia significar "Esse comportamento de ação deve funcionar de maneira idêntica em todas as implementações para plataformas que conhecemos, mas pode se comportar de maneira diferente em plataformas onde isso seria problemático; não há necessidade de exigir o comportamento normal em plataformas comuns, porque os escritores de compiladores que não estão sendo obtusos deliberadamente processarão as coisas dessa maneira, independentemente de o Padrão exigir ou não ". Um exemplo deste último seria (-1)<<1que C89 definido como -2 em plataformas que usam não-acolchoada complemento de dois ...
supercat
... tipos inteiros, mas C99 considera como comportamento indefinido sem fornecer nenhum motivo para a alteração. Se alguém interpreta o significado pretendido como acima, não seria uma mudança de última hora, exceto nas plataformas em que o comportamento do C89 era impraticável, mas de algum modo o código dependia dele.
Supercat
1

Se a documentação de um compilador em particular disser o que fará quando o código fizer algo que é considerado "Comportamento Indefinido" pelo padrão, o código que se baseia nesse comportamento funcionará corretamente quando compilado com esse compilador , mas poderá se comportar de maneira arbitrária quando compilado usando outro compilador cuja documentação não especifica o comportamento.

Se a documentação de um compilador não especificar como ele irá lidar com algum "comportamento indefinido" específico, o fato de o comportamento de um programa parecer obedecer a certas regras não diz nada sobre como os programas semelhantes se comportarão. Qualquer variedade de fatores pode fazer com que um compilador emita código que lida com situações inesperadas de maneira diferente - às vezes de maneira aparentemente bizarra.

Considere, por exemplo, em uma máquina em que intum número inteiro de 32 bits:

int undef_behavior_example(uint16_t size1, uint16_t size2)
{
  int flag = 0;
  if ((uint32_t)size1 * size2 > 2147483647u)
    flag += 1;
  if (((size1*size2) & 127) != 0) // Test whether product is a multiple of 128
    flag += 2;
  return flag;
}

Se size1esize2ambos eram iguais a 46341 (seu produto é 2147488281), pode-se esperar que a função retorne 3, mas um compilador poderia legitimamente pular o primeiro teste completamente; ou o produto seria pequeno o suficiente para que a condição fosse falsa ou a multiplicação futura estouraria e aliviaria o compilador de qualquer requisito para fazer ou ter feito qualquer coisa. Embora esse comportamento possa parecer bizarro, alguns autores de compiladores parecem se orgulhar das habilidades dos compiladores de eliminar esses testes "desnecessários". Algumas pessoas podem esperar que um estouro na segunda multiplicação, na pior das hipóteses, faça com que todos os bits desse produto em particular sejam arbitrariamente corrompidos; de fato, no entanto,

supercat
fonte
A multiplicação não seria feita no módulo UINT16_MAX?
precisa
@curiousguy: Se intfor um número inteiro de 32 bits, os valores do tipo uint16_tserão promovidos para intantes de qualquer cálculo que os envolva. Uma regra que geralmente seria adequada se as implementações tratassem apenas a aritmética assinada como diferente da não assinada nos casos em que teriam comportamentos definidos diferentes.
Supercat 03/07
Acredito que qualquer operando do tipo não assinado fez com que a operação não fosse assinada.
curiousguy
@curiousguy: Alguns compiladores trabalharam dessa maneira nos dias anteriores ao Padrão, mas o Padrão especifica que tipos não assinados, classificados abaixo unsignede com uma gama de valores que se encaixam totalmente dentro dele int, são promovidos a assinados int.
Supercat