As especificações C \ C ++ deixam de fora um grande número de comportamentos em aberto para os compiladores implementarem de sua própria maneira. Há várias perguntas que sempre são feitas aqui sobre o mesmo e temos excelentes postagens sobre isso:
- https://stackoverflow.com/questions/367633/what-are-all-the-common-undefined-behaviour-that-ac-programmer-should-know-abo
- https://stackoverflow.com/questions/4105120/what-is-undefined-behavior
- https://stackoverflow.com/questions/4176328/undefined-behavior-and-sequence-points
Minha pergunta não é sobre o que é um comportamento indefinido ou é realmente ruim. Conheço os perigos e a maioria das citações de comportamento indefinidas relevantes do padrão. Portanto, evite postar respostas sobre quão ruim é. Esta pergunta é sobre a filosofia por trás de deixar de fora tantos comportamentos em aberto para a implementação do compilador.
Eu li um excelente post no blog que afirma que o desempenho é a principal razão. Fiquei me perguntando se o desempenho é o único critério para permitir isso, ou existem outros fatores que influenciam a decisão de deixar as coisas em aberto para a implementação do compilador?
Se você tiver exemplos para citar sobre como um comportamento indefinido específico oferece espaço suficiente para otimização do compilador, liste-os. Se você souber de outros fatores que não sejam o desempenho, retorne sua resposta com detalhes suficientes.
Se você não entender a pergunta ou não tiver evidências / fontes suficientes para apoiar sua resposta, não poste respostas especulativas gerais.
fonte
Respostas:
Primeiro, observarei que, embora apenas mencione "C" aqui, o mesmo se aplica igualmente ao C ++.
O comentário mencionando Godel estava parcialmente (mas apenas parcialmente) em questão.
Quando você começar a ele, um comportamento indefinido nas normas C é em grande parte apenas apontando a fronteira entre o que as tentativas padrão para definir, e que isso não acontece.
Os teoremas de Godel (existem dois) dizem basicamente que é impossível definir um sistema matemático que possa ser provado (por suas próprias regras) completo e consistente. Você pode fazer suas regras para que sejam completas (o caso com o qual ele lidou foram as regras "normais" para números naturais), ou então você pode tornar possível provar sua consistência, mas não pode ter as duas.
No caso de algo como C, isso não se aplica diretamente - na maioria das vezes, a "provabilidade" da integridade ou consistência do sistema não é uma alta prioridade para a maioria dos designers de idiomas. Ao mesmo tempo, sim, eles provavelmente foram influenciados (pelo menos até certo ponto) por saber que é comprovadamente impossível definir um sistema "perfeito" - que seja comprovadamente completo e consistente. Saber que tal coisa é impossível pode ter tornado um pouco mais fácil dar um passo para trás, respirar um pouco e decidir sobre os limites do que eles tentariam definir.
Correndo o risco de (mais uma vez) ser acusado de arrogância, eu caracterizaria o padrão C como sendo governado (em parte) por duas idéias básicas:
O primeiro significa que, se alguém definir uma nova CPU, deve ser possível fornecer uma implementação boa, sólida e utilizável de C para isso, desde que o design fique pelo menos razoavelmente próximo de algumas diretrizes simples - basicamente, se segue algo na ordem geral do modelo de Von Neumann e fornece pelo menos uma quantidade mínima razoável de memória, que deve ser suficiente para permitir uma implementação em C. Para uma implementação "hospedada" (que é executada em um sistema operacional), é necessário oferecer suporte a alguma noção que corresponda razoavelmente aos arquivos e ter um conjunto de caracteres com um determinado conjunto mínimo de caracteres (91 são necessários).
O segundo significa que deve ser possível escrever um código que manipule o hardware diretamente, para que você possa escrever coisas como gerenciadores de inicialização, sistemas operacionais, software incorporado que é executado sem nenhum SO, etc. Existem alguns limites nesse sentido, quase todos os sistema operacional prático, carregador de inicialização, etc., provavelmente conterá pelo menos um pouco de código escrito em linguagem assembly. Da mesma forma, mesmo um pequeno sistema incorporado provavelmente incluirá pelo menos algum tipo de rotina de biblioteca pré-escrita para dar acesso aos dispositivos no sistema host. Embora seja difícil definir um limite preciso, a intenção é que a dependência desse código seja mantida no mínimo.
O comportamento indefinido no idioma é amplamente motivado pela intenção do idioma de oferecer suporte a esses recursos. Por exemplo, o idioma permite converter um número inteiro arbitrário em um ponteiro e acessar o que quer que esteja nesse endereço. O padrão não tenta dizer o que acontecerá quando você o fizer (por exemplo, mesmo a leitura de alguns endereços pode ter efeitos visíveis externamente). Ao mesmo tempo, não faz nenhuma tentativa de impedi-lo de fazer essas coisas, porque você precisa de alguns tipos de software que deveria poder escrever em C.
Há algum comportamento indefinido direcionado por outros elementos de design. Por exemplo, uma outra intenção de C é oferecer suporte à compilação separada. Isso significa (por exemplo) que se pretende "vincular" partes usando um vinculador que segue aproximadamente o que a maioria de nós vê como o modelo usual de um vinculador. Em particular, deve ser possível combinar módulos compilados separadamente em um programa completo sem o conhecimento da semântica da linguagem.
Há outro tipo de comportamento indefinido (que é muito mais comum em C ++ que C), que está presente simplesmente devido aos limites da tecnologia do compilador - coisas que basicamente sabemos que são erros e que provavelmente o compilador deve diagnosticar como erros, mas, considerando os limites atuais da tecnologia do compilador, é duvidoso que eles possam ser diagnosticados sob todas as circunstâncias. Muitos desses fatores são motivados por outros requisitos, como compilação separada, portanto é uma questão de equilibrar requisitos conflitantes; nesse caso, o comitê geralmente optou por oferecer suporte a recursos maiores, mesmo que isso signifique falta de diagnóstico de possíveis problemas, em vez de limitar os recursos para garantir que todos os problemas possíveis sejam diagnosticados.
Essas diferenças de intenção conduzem a maioria das diferenças entre C e algo como Java ou sistemas baseados em CLI da Microsoft. Estes últimos são explicitamente limitados a trabalhar com um conjunto de hardware muito mais limitado ou exigir que o software imite o hardware mais específico que eles almejam. Eles também pretendem especificamente impedir qualquer manipulação direta de hardware, exigindo que você use algo como JNI ou P / Invoke (e código escrito em algo como C) para fazer essa tentativa.
Voltando aos teoremas de Godel por um momento, podemos traçar um paralelo: Java e CLI optaram pela alternativa "internamente consistente", enquanto C optou pela alternativa "completa". Claro, isso é uma analogia muito grosseira - Eu duvido que alguém está tentando uma prova formal de qualquer consistência interna ou completude em ambos os casos. No entanto, a noção geral se encaixa bastante de perto com as escolhas que fizeram.
fonte
A lógica C explica
Importante também é o benefício para os programas, não apenas o benefício para implementações. Um programa que depende de comportamento indefinido ainda pode estar em conformidade , se for aceito por uma implementação em conformidade. A existência de comportamento indefinido permite que um programa use recursos não portáteis explicitamente marcados como tal ("comportamento indefinido"), sem se tornar não conforme. A justificativa observa:
E em 1.7 nota
Portanto, este pequeno programa sujo que funciona perfeitamente no GCC ainda está em conformidade !
fonte
A questão da velocidade é especialmente um problema quando comparado ao C. Se o C ++ fizesse algumas coisas que poderiam ser sensatas, como inicializar grandes matrizes de tipos primitivos, perderia uma tonelada de benchmarks no código C. Portanto, o C ++ inicializa seus próprios tipos de dados, mas deixa os tipos C do jeito que eram.
Outro comportamento indefinido apenas reflete a realidade. Um exemplo é a troca de bits com uma contagem maior que o tipo. Isso realmente difere entre as gerações de hardware da mesma família. Se você tem um aplicativo de 16 bits, o mesmo binário exato fornecerá resultados diferentes em um 80286 e um 80386. Portanto, o padrão da linguagem diz que não sabemos!
Algumas coisas são mantidas como eram, como a ordem de avaliação das subexpressões não especificada. Originalmente, acreditava-se que isso ajudasse os escritores do compilador a otimizar melhor. Atualmente, os compiladores são bons o suficiente para descobrir isso de qualquer maneira, mas o custo de encontrar todos os locais nos compiladores existentes que tiram vantagem da liberdade é muito alto.
fonte
Como exemplo, os acessos a ponteiros quase precisam ser indefinidos e não necessariamente apenas por razões de desempenho. Por exemplo, em alguns sistemas, carregar registros específicos com um ponteiro gerará uma exceção de hardware. No acesso SPARC a um objeto de memória alinhado incorretamente, ocorrerá um erro de barramento, mas no x86 seria "apenas" lento. É difícil especificar o comportamento nesses casos, já que o hardware subjacente determina o que acontecerá, e o C ++ é portátil para muitos tipos de hardware.
É claro que também oferece ao compilador a liberdade de usar conhecimentos específicos da arquitetura. Para um exemplo de comportamento não especificado, o deslocamento à direita dos valores assinados pode ser lógico ou aritmético, dependendo do hardware subjacente, para permitir o uso de qualquer operação de turno disponível e não forçar a emulação de software.
Acredito também que isso facilita bastante o trabalho do compilador-escritor, mas não me lembro do exemplo agora. Vou adicioná-lo se me lembrar da situação.
fonte
Simples: velocidade e portabilidade. Se o C ++ garantisse que você recebeu uma exceção ao cancelar a referência de um ponteiro inválido, ele não seria portátil para o hardware incorporado. Se o C ++ garantisse algumas outras coisas, como sempre as primitivas sempre inicializadas, seria mais lento e, na época da origem do C ++, mais lento seria uma coisa muito, muito ruim.
fonte
C foi inventado em uma máquina com bytes de 9 bits e sem unidade de ponto flutuante - suponha que ele exigisse que bytes fossem 9 bits, palavras 18 bits e que os flutuadores fossem implementados usando a aritmética anterior à IEEE754?
fonte
Eu não acho que a primeira justificativa para o UB foi deixar espaço para o compilador otimizar, mas apenas a possibilidade de usar a implementação óbvia para os destinos em um momento em que as arquiteturas tinham mais variedade do que agora (lembre-se se C foi projetado em um PDP-11, que tem uma arquitetura um tanto familiar, a primeira porta foi para a Honeywell 635, que é muito menos familiar - endereçável por palavras, usando palavras de 36 bits, 6 ou 9 bits, endereços de 18 bits ... bem, pelo menos, usou 2's complemento). Mas se a otimização pesada não era um destino, a implementação óbvia não inclui a adição de verificações em tempo de execução para estouros, a contagem de turnos sobre o tamanho do registro, que é um alias nas expressões que modificam vários valores.
Outra coisa levada em consideração foi a facilidade de implementação. O compilador de CA, na época, era várias passagens usando vários processos, porque ter um processo gerenciando tudo não teria sido possível (o programa teria sido muito grande). Solicitar uma verificação de coerência pesada estava fora do caminho - especialmente quando envolvia várias UC. (Outro programa que não os compiladores C, o lint, foi usado para isso).
fonte
i
en
, de modo quen < INT_BITS
ei*(1<<n)
não estourariam, eu considerariai<<=n;
mais claro quei=(unsigned)i << n;
; em muitas plataformas, seria mais rápido e menor quei*=(1<<N);
. O que é ganho quando os compiladores o proíbem?Um dos primeiros casos clássicos foi a adição de número inteiro assinado. Em alguns dos processadores em uso, isso causaria uma falha e, em outros, continuaria com um valor (provavelmente o valor modular apropriado). A especificação de ambos os casos significaria que os programas para máquinas com o estilo aritmético desfavorável teriam que ter código extra, incluindo uma ramificação condicional, para algo tão semelhante à adição de número inteiro.
fonte
int
16 bits e turnos estendidos por sinal são caros podem calcular(uchar1*uchar2) >> 4
usando um turno estendido sem sinal. Infelizmente, alguns compiladores estendem inferências não apenas aos resultados, mas também aos operandos.Eu diria que era menos sobre filosofia do que sobre realidade - C sempre foi uma linguagem de plataforma cruzada, e o padrão deve refletir isso e o fato de que, no momento em que um padrão for lançado, haverá um grande número de implementações em muitos hardwares diferentes. Um padrão que proíbe o comportamento necessário seria desconsiderado ou produziria um organismo de padrões concorrente.
fonte
Alguns comportamentos não podem ser definidos por qualquer meio razoável. Quero dizer, acessar um ponteiro excluído. A única maneira de detectá-lo seria banir o valor do ponteiro após a exclusão (memorizar seu valor em algum lugar e não permitir que nenhuma função de alocação o retornasse mais). Não apenas essa memorização seria um exagero, mas por um longo período de programa causaria a falta dos valores permitidos dos ponteiros.
fonte
weak_ptr
e anular todas as referências a um ponteiro que obtémdelete
d ... oh espere, estamos nos aproximando da coleta de lixo: /boost::weak_ptr
A implementação de é um modelo muito bom para começar com esse padrão de uso. Em vez de rastrear e anularweak_ptrs
externamente, aweak_ptr
apenas contribui para ashared_ptr
contagem fraca do contador, e a contagem fraca é basicamente uma contagem de refc para o ponteiro em si. Assim, você pode anular oshared_ptr
sem precisar excluí-lo imediatamente. Não é perfeito (você ainda pode ter muitosweak_ptr
s expirados mantendo o subjacenteshared_count
sem uma boa razão), mas pelo menos é rápido e eficiente.Vou dar um exemplo em que não há praticamente nenhuma escolha sensata que não seja um comportamento indefinido. Em princípio, qualquer ponteiro pode apontar para a memória que contém qualquer variável, com a pequena exceção de variáveis locais que o compilador pode saber que nunca teve seu endereço obtido. No entanto, para obter um desempenho aceitável em uma CPU moderna, um compilador deve copiar valores variáveis em registradores. Operar completamente com memória insuficiente não é de partida.
Isso basicamente oferece duas opções:
1) Limpe tudo dos registros antes de qualquer acesso através de um ponteiro, caso o ponteiro aponte para a memória dessa variável em particular. Em seguida, carregue tudo o que for necessário no registro, caso os valores sejam alterados pelo ponteiro.
2) Tenha um conjunto de regras para quando um ponteiro tem permissão para usar um pseudônimo em uma variável e quando o compilador tem permissão para assumir que um ponteiro não tem um pseudônimo para uma variável.
C opta pela opção 2, porque 1 seria péssimo para o desempenho. Mas então, o que acontece se um ponteiro derramar uma variável de uma maneira que as regras C proíbem? Como o efeito depende se o compilador realmente armazenou a variável em um registro, não há como o padrão C garantir definitivamente resultados específicos.
fonte
foo
para 42 e, em seguida, chama um método que usa um ponteiro ilegitimamente modificado para definirfoo
para 44, posso ver o benefício de dizer que até a próxima gravação "legítima"foo
, as tentativas de lê-lo podem legitimamente produz 42 ou 44, e uma expressão comofoo+foo
poderia até render 86, mas vejo muito menos benefício em permitir que o compilador faça inferências estendidas e até retroativas, mudando o comportamento indefinido cujos comportamentos "naturais" plausíveis seriam benignos em uma licença para gerar código sem sentido.Historicamente, o comportamento indefinido tinha dois objetivos principais:
Para evitar exigir que os autores do compilador gerem código para manipular condições que nunca deveriam ocorrer.
Para permitir a possibilidade de que, na ausência de código, lidar explicitamente com essas condições, as implementações podem ter vários tipos de comportamentos "naturais" que, em alguns casos, seriam úteis.
Como um exemplo simples, em algumas plataformas de hardware, a tentativa de adicionar dois números inteiros positivos assinados cuja soma é muito grande para caber em um número inteiro sinalizado produzirá um número inteiro negativo negativo em particular. Em outras implementações, ele acionará uma armadilha do processador. Para o padrão C exigir que qualquer comportamento exija que os compiladores de plataformas cujo comportamento natural seja diferente do padrão precisem gerar código extra para gerar o comportamento correto - código que pode ser mais caro que o código para a adição real. Pior, isso significaria que os programadores que desejassem o comportamento "natural" teriam que adicionar ainda mais código extra para alcançá-lo (e esse código adicional seria novamente mais caro do que a adição).
Infelizmente, alguns autores de compiladores adotaram a filosofia de que os compiladores deveriam se esforçar para encontrar condições que evocassem o Comportamento indefinido e, presumindo que tais situações nunca ocorram, tiram inferências estendidas disso. Assim, em um sistema com 32 bits
int
, dado código como:o padrão C permitiria ao compilador dizer que se q for 46341 ou maior, a expressão q * q produzirá um resultado muito grande para caber em um
int
, causando consequentemente comportamento indefinido e, como resultado, o compilador teria o direito de assumir que não pode acontecer e, portanto, não seria necessário incrementar*p
se isso acontecer. Se o código de chamada usar*p
como um indicador de que ele deve descartar os resultados da computação, o efeito da otimização pode ser pegar um código que teria produzido resultados sensatos em sistemas que executam quase qualquer maneira imaginável com estouro de número inteiro (o trapping pode ser feio, mas pelo menos sensato), e transformou-o em código que pode se comportar sem sentido.fonte
Eficiência é a desculpa usual, mas qualquer que seja a desculpa, o comportamento indefinido é uma péssima idéia para a portabilidade. Com efeito, comportamentos indefinidos se tornam suposições não verificadas e não declaradas.
fonte