Por que o C ++ possui 'comportamento indefinido' (UB) e outras linguagens como C # ou Java não?

50

Esta publicação do Stack Overflow lista uma lista bastante abrangente de situações em que a especificação da linguagem C / C ++ declara ser 'comportamento indefinido'. No entanto, quero entender por que outras linguagens modernas, como C # ou Java, não têm o conceito de 'comportamento indefinido'. Isso significa que o designer do compilador pode controlar todos os cenários possíveis (C # e Java) ou não (C e C ++)?

Sisir
fonte
3
e ainda este post SO refere-se a um comportamento indefinido, mesmo na especificação Java!
gbjbaanb 22/09
"Por que o C ++ tem 'comportamento indefinido'" Infelizmente, essa parece ser uma daquelas perguntas difíceis de responder objetivamente, além da declaração "porque, pelas razões X, Y e / ou Z (todas podem ser nullptr) não alguém se preocupou em definir o comportamento escrevendo e / ou adotando uma especificação proposta ". : c
code_dredd 22/09
Eu desafiaria a premissa. Pelo menos o C # possui código "não seguro". A Microsoft escreve "Em certo sentido, escrever código inseguro é muito parecido com escrever código C em um programa C #" e fornece exemplos de razões pelas quais alguém gostaria de fazer isso: para acessar o hardware ou o sistema operacional e obter velocidade. Foi para isso que C foi inventado (diabos, eles escreveram o sistema operacional em C!), Então aí está.
Peter - Restabelecer Monica

Respostas:

72

O comportamento indefinido é uma daquelas coisas que foram reconhecidas como uma péssima idéia apenas em retrospecto.

Os primeiros compiladores foram grandes conquistas e elogiaram com júbilo as melhorias em relação à alternativa - programação em linguagem de máquina ou linguagem de montagem. Os problemas com isso eram bem conhecidos e as linguagens de alto nível foram inventadas especificamente para resolver esses problemas conhecidos. (O entusiasmo na época era tão grande que as HLLs eram às vezes aclamadas como "o fim da programação" - como se de agora em diante tivéssemos que escrever apenas trivialmente o que queríamos e o compilador faria todo o trabalho real.)

Não foi até mais tarde que percebemos os problemas mais recentes que vieram com a abordagem mais recente. Estar distante da máquina real em que o código é executado significa que há mais possibilidade de as coisas silenciosamente não fazerem o que esperávamos que elas fizessem. Por exemplo, alocar uma variável normalmente deixaria o valor inicial indefinido; isso não foi considerado um problema, porque você não alocaria uma variável se não quisesse manter um valor nela, certo? Certamente não era demais esperar que programadores profissionais não esquecessem de atribuir o valor inicial, não é?

Aconteceu que, com as bases de código maiores e as estruturas mais complicadas que se tornaram possíveis com sistemas de programação mais poderosos, sim, muitos programadores de fato cometiam tais omissões de tempos em tempos, e o comportamento indefinido resultante se tornava um grande problema. Ainda hoje, a maioria dos vazamentos de segurança de pequeno a horrível é o resultado de um comportamento indefinido de uma forma ou de outra. (O motivo é que, geralmente, o comportamento indefinido é realmente muito definido pelas coisas do próximo nível inferior na computação, e os atacantes que entendem esse nível podem usar essa sala de manobra para fazer com que um programa não faça apenas coisas não intencionais, mas exatamente as coisas eles pretendem.)

Desde que reconhecemos isso, houve um esforço geral para banir o comportamento indefinido de linguagens de alto nível, e o Java foi particularmente completo sobre isso (o que foi relativamente fácil, pois ele foi projetado para rodar em sua própria máquina virtual especificamente projetada). Idiomas antigos como C não podem ser facilmente adaptados dessa maneira sem perder a compatibilidade com a enorme quantidade de código existente.

Edit: Como apontado, a eficiência é outro motivo. Comportamento indefinido significa que os escritores do compilador têm muita margem de manobra para explorar a arquitetura de destino, de modo que cada implementação consiga a implementação mais rápida possível de cada recurso. Isso foi mais importante nas máquinas com pouca potência de ontem do que com hoje, quando o salário do programador costuma ser o gargalo para o desenvolvimento de software.

Kilian Foth
fonte
56
Eu não acho que muitas pessoas da comunidade C concordariam com esta afirmação. Se você atualizasse C e definisse um comportamento indefinido (por exemplo, inicialize tudo padrão, escolha uma ordem de avaliação para o parâmetro de função, etc.), a grande base de código bem comportado continuaria funcionando perfeitamente. Somente o código que não estaria bem definido hoje seria interrompido. Por outro lado, se você deixar indefinido como hoje, os compiladores continuarão livres para explorar novos avanços nas arquiteturas de CPU e na otimização de código.
Christophe
13
A parte principal da resposta realmente não me convence. Quero dizer, é basicamente impossível escrever uma função que adicione com segurança dois números (como em int32_t add(int32_t x, int32_t y)) em C ++. Os argumentos usuais em torno desse são relacionados à eficiência, mas frequentemente intercalados com alguns argumentos de portabilidade (como em "Escreva uma vez, execute ... na plataforma em que você o escreveu ... e em nenhum outro lugar ;-)"). Grosso modo, um argumento poderia, portanto, ser: Algumas coisas são indefinido, porque você não sabe se você estiver em um microcontrolador de 16 bits ou um servidor de 64 bits (um fraco, mas ainda um argumento)
Marco13
12
@ Marco13 Concordou - e se livrar do problema de "comportamento indefinido" criando algo "comportamento definido, mas não necessariamente o que o usuário queria e sem aviso quando isso acontecer", em vez de "comportamento indefinido", é apenas jogar jogos de código de advogado IMO .
alephzero 22/09
9
"Até hoje, a maioria dos vazamentos de segurança, de pequenos a horríveis, são resultado de um comportamento indefinido de uma forma ou de outra". Citação necessária. Eu pensei que a maioria deles era injeção de XYZ agora.
Joshua
34
"O comportamento indefinido é uma daquelas coisas que foram reconhecidas como uma péssima idéia apenas em retrospecto". Essa é a sua opinião. Muitos (inclusive eu) não o compartilham.
Lightness Races com Monica
103

Basicamente, porque os designers de Java e linguagens semelhantes não queriam um comportamento indefinido em sua linguagem. Isso foi uma troca - permitir que comportamentos indefinidos tenham o potencial de melhorar o desempenho, mas os projetistas de idiomas priorizaram mais a segurança e a previsibilidade.

Por exemplo, se você alocar uma matriz em C, os dados são indefinidos. Em Java, todos os bytes devem ser inicializados para 0 (ou algum outro valor especificado). Isso significa que o tempo de execução deve passar sobre a matriz (uma operação O (n)), enquanto C pode executar a alocação em um instante. Portanto, C sempre será mais rápido para essas operações.

Se o código que utiliza a matriz irá preenchê-lo de qualquer maneira antes de ler, isso é basicamente um esforço desperdiçado para Java. Mas no caso em que o código é lido primeiro, você obtém resultados previsíveis em Java, mas resultados imprevisíveis em C.

JacquesB
fonte
19
Excelente apresentação do dilema HLL: segurança e facilidade de uso versus desempenho. Não existe uma bala de prata: existem casos de uso para cada lado.
Christophe
5
@Christophe Para ser justo, existem abordagens muito melhores para um problema do que deixar o UB ficar totalmente incontestado como C e C ++. Você pode ter um idioma gerenciado e seguro, com escotilhas de escape em território inseguro, para aplicar onde for benéfico. TBH, seria muito bom poder compilar meu programa C / C ++ com uma bandeira que diz "insira qualquer equipamento de tempo de execução caro que você precise, não me importo, mas apenas conte-me sobre TODO o UB que ocorre . "
Alexander
4
Um bom exemplo de uma estrutura de dados que lê deliberadamente locais não inicializados é a representação esparsa de conjuntos de Briggs e Torczon (por exemplo, consulte codingplayground.blogspot.com/2009/03/… ). A inicialização de um conjunto desse tipo é O (1) em C, mas O ( n) com a inicialização forçada do Java.
Arch D. Robison
9
Embora seja verdade que forçar a inicialização de dados torne os programas quebrados muito mais previsíveis, ele não garante o comportamento pretendido: se o algoritmo espera ler dados significativos enquanto lê erroneamente o zero inicial implicitamente, isso é um bug tanto quanto se tivesse ocorrido. leia um pouco de lixo. Com um programa C / C ++, esse bug seria visível executando o processo em valgrind, o qual mostraria exatamente onde o valor não inicializado foi usado. Você não pode usar o valgrindcódigo java porque o tempo de execução faz a inicialização, tornando valgrindinúteis as verificações de s.
cmaster 22/09
5
@cmaster É por isso que o compilador C # não permite que você leia a partir de locais não inicializados. Não há necessidade de verificações de tempo de execução, não há necessidade de inicialização, apenas análise em tempo de compilação. No entanto, ainda é uma troca - existem alguns casos em que você não tem uma boa maneira de lidar com ramificações em torno de locais potencialmente não atribuídos. Na prática, não encontrei casos em que esse não fosse um projeto ruim e resolvi melhor repensando o código para evitar a ramificação complicada (que é difícil para os humanos analisarem), mas é pelo menos possível.
Luaan 23/09
42

O comportamento indefinido permite uma otimização significativa, dando latitude ao compilador para fazer algo estranho ou inesperado (ou mesmo normal) em certos limites ou outras condições.

Consulte http://blog.llvm.org/2011/05/what-every-c-programmer-should-know.html

Uso de uma variável não inicializada: Isso é comumente conhecido como fonte de problemas em programas C e existem muitas ferramentas para capturá-las: de avisos do compilador a analisadores estáticos e dinâmicos. Isso melhora o desempenho ao não exigir que todas as variáveis ​​sejam zero inicializadas quando entram no escopo (como Java faz). Para a maioria das variáveis ​​escalares, isso causaria pouca sobrecarga, mas as matrizes de pilha e a memória malloc incorreriam em um conjunto intermediário de armazenamento, o que poderia ser bastante caro, principalmente porque o armazenamento geralmente é sobrescrito por completo.


Estouro de número inteiro assinado: se a aritmética em um tipo 'int' (por exemplo) estourar, o resultado será indefinido. Um exemplo é que "INT_MAX + 1" não é garantido como INT_MIN. Esse comportamento permite certas classes de otimizações importantes para algum código. Por exemplo, saber que INT_MAX + 1 está indefinido permite otimizar "X + 1> X" para "true". O conhecimento da multiplicação "não pode" exceder (porque isso seria indefinido) permite otimizar "X * 2/2" para "X". Embora isso possa parecer trivial, esse tipo de coisa geralmente é exposta por expansão interna e macro. Uma otimização mais importante que isso permite é "<=" loops como este:

for (i = 0; i <= N; ++i) { ... }

Nesse loop, o compilador pode assumir que o loop irá iterar exatamente N + 1 vezes se "i" for indefinido no estouro, o que permite uma ampla variedade de otimizações de loop. Por outro lado, se a variável estiver definida como encapsular em excesso, o compilador deve assumir que o loop é possivelmente infinito (o que acontece se N for INT_MAX) - que desabilita essas importantes otimizações de loop. Isso afeta particularmente as plataformas de 64 bits, pois muitos códigos usam "int" como variáveis ​​de indução.

Erik Eidt
fonte
27
Obviamente, a verdadeira razão pela qual o estouro de número inteiro assinado é indefinido é que, quando C foi desenvolvido, havia pelo menos três representações diferentes de números inteiros em uso (complemento de alguém, complemento de dois, magnitude de sinal e talvez binário compensado) , e cada um fornece um resultado diferente para INT_MAX + 1. Tornar o estouro indefinido permite a + bser compilado para a add b ainstrução nativa em todas as situações, em vez de exigir que um compilador simule alguma outra forma de aritmética inteira assinada.
Marque
2
Permitir que o excesso de números inteiros se comporte de maneira vagamente definida permite otimizações significativas nos casos em que todos os comportamentos possíveis atendem aos requisitos do aplicativo . A maioria dessas otimizações será perdida, no entanto, se os programadores forem obrigados a evitar estouros de número inteiro a todo custo.
supercat 22/09
5
@supercat Essa é outra razão pela qual evitar comportamentos indefinidos é mais comum em idiomas mais recentes - o tempo do programador é muito mais valor que o tempo da CPU. O tipo de otimizações que C é permitido fazer graças ao UB é essencialmente inútil em computadores desktop modernos e dificulta muito o raciocínio sobre o código (sem mencionar as implicações de segurança). Mesmo em código crítico de desempenho, você pode se beneficiar de otimizações de alto nível que seriam um pouco mais difíceis (ou até mais difíceis) de fazer em C. Eu tenho meu próprio renderizador 3D de software em C #, e poder usar, por exemplo, a HashSeté maravilhoso.
Luaan 23/09
2
@ supercat: Wrt_loosely defined_, a escolha lógica para o excesso de números inteiros seria exigir o comportamento definido pela implementação . Esse é um conceito existente e não é um fardo indevido para implementações. A maioria se daria bem com "é o complemento 2 com invólucro", suspeito. <<pode ser o caso difícil.
MSalters 23/09
@MSalters Existe uma solução simples e bem estudada que não é um comportamento indefinido ou definido como implementação: comportamento não determinístico. Ou seja, você pode dizer " x << yavalia com algum valor válido do tipo, int32_tmas não vamos dizer qual". Isso permite que os implementadores usem a solução rápida, mas não age como uma falsa pré-condição, permitindo otimizações no estilo de viagem no tempo, porque o não determinismo é limitado à saída dessa operação - a especificação garante que memória, variáveis ​​voláteis etc. não sejam visivelmente afetadas pela avaliação da expressão. ...
Mario Carneiro
20

Nos primeiros dias de C, havia muito caos. Compiladores diferentes trataram o idioma de maneira diferente. Quando houvesse interesse em escrever uma especificação para a linguagem, essa especificação precisaria ser razoavelmente compatível com o C em que os programadores estavam confiando com seus compiladores. Mas alguns desses detalhes não são portáteis e não fazem sentido em geral, por exemplo, assumindo uma disposição ou disposição de dados específica. O padrão C, portanto, reserva muitos detalhes como comportamento indefinido ou especificado pela implementação, o que deixa muita flexibilidade para os escritores do compilador. O C ++ se baseia no C e também apresenta um comportamento indefinido.

O Java tentou ser uma linguagem muito mais segura e muito mais simples que o C ++. Java define a semântica da linguagem em termos de uma máquina virtual completa. Isso deixa pouco espaço para comportamento indefinido, por outro lado, exige requisitos que podem ser difíceis para uma implementação Java (por exemplo, que as designações de referência devem ser atômicas ou como os números inteiros funcionam). Onde o Java suporta operações potencialmente inseguras, elas geralmente são verificadas pela máquina virtual em tempo de execução (por exemplo, algumas transmissões).

amon
fonte
Você está dizendo que a compatibilidade com versões anteriores é a única razão pela qual C e C ++ não estão saindo de comportamentos indefinidos?
Sisir 21/09
3
É definitivamente um dos maiores, @Sisir. Mesmo entre os programadores experientes, você ficaria surpreso quanto material que não deve quebrar faz pausa quando um compilador muda a forma como ele lida com um comportamento indefinido. (Caso em questão, houve um pouco de caos quando o GCC começou a otimizar "is thisnull?" Verifica um tempo atrás, alegando que o thisser nullptré UB e, portanto, nunca pode realmente acontecer.)
Justin Time 2 Restabelecer Monica
9
@ Siris, outro grande problema é a velocidade. Nos primeiros dias de C, o hardware era muito mais heterogêneo do que é hoje. Simplesmente não especificando o que acontece quando você adiciona 1 ao INT_MAX, você pode permitir que o compilador faça o que for mais rápido para a arquitetura (por exemplo, um sistema de complemento de alguém produzirá -INT_MAX, enquanto um sistema de complemento de dois produzirá INT_MIN). Da mesma forma, ao não especificar o que acontece quando você lê após o final de uma matriz, é possível que um sistema com proteção de memória encerre o programa, enquanto um deles não precisará implementar uma verificação de limites de tempo de execução dispendiosa.
Marque
14

As linguagens JVM e .NET são fáceis:

  1. Eles não precisam trabalhar diretamente com o hardware.
  2. Eles só precisam trabalhar com sistemas modernos de desktop e servidor ou dispositivos razoavelmente semelhantes, ou pelo menos dispositivos projetados para eles.
  3. Eles podem impor a coleta de lixo para toda a memória e a inicialização forçada, obtendo segurança de ponteiro.
  4. Eles foram especificados por um único ator que também forneceu a implementação definitiva única.
  5. Eles escolhem a segurança ao invés do desempenho.

Existem bons pontos para as escolhas:

  1. A programação de sistemas é um jogo totalmente diferente, e a otimização intransigente da programação de aplicativos é razoável.
  2. É certo que há menos hardware exótico o tempo todo, mas pequenos sistemas embarcados estão aqui para ficar.
  3. O GC não é adequado para recursos não fungíveis e comercializa muito mais espaço para um bom desempenho. E a maioria (mas não quase todas) das inicializações forçadas pode ser otimizada.
  4. Existem vantagens para mais concorrência, mas comitês significam comprometimento.
  5. Todos desses limites verificações não se somam, embora a maioria pode ser otimizado de distância. As verificações de ponteiro nulo podem ser feitas bloqueando o acesso para zero sobrecarga, graças ao espaço de endereço virtual, embora a otimização ainda seja inibida.

Onde as hachuras de escape são fornecidas, elas convidam o comportamento indefinido completo a voltar. Mas pelo menos elas geralmente são usadas apenas em alguns trechos muito curtos, que são, portanto, mais fáceis de verificar manualmente.

Desduplicador
fonte
3
De fato. Eu programa em c # para o meu trabalho. De vez em quando eu chegar para um dos inseguras-martelos ( unsafepalavra-chave ou atributos em System.Runtime.InteropServices). Mantendo essas coisas para os poucos programadores que sabem como depurar coisas não gerenciadas e novamente o mínimo possível, mantemos os problemas em baixa. Faz mais de dez anos desde o último martelo inseguro relacionado ao desempenho, mas às vezes você precisa fazê-lo porque não há literalmente outra solução.
Joshua
19
Eu freqüentemente trabalho em uma plataforma a partir de dispositivos analógicos em que sizeof (char) == sizeof (short) == sizeof (int) == sizeof (float) == 1. Ele também faz adição saturante (então INT_MAX + 1 == INT_MAX) , e o interessante de C é que posso ter um compilador em conformidade que gere um código razoável. Se o idioma exigido disser que dois complementam o wrap around, cada adição terminaria com um teste e uma ramificação, algo como um iniciante em uma parte focada no DSP. Esta é uma peça de produção atual.
Dan Mills
5
@BenVoigt Alguns de nós vivem em um mundo onde um computador pequeno possui talvez 4k de espaço de código, uma pilha fixa de chamada / retorno de 8 níveis, 64 bytes de RAM, um relógio de 1MHz e custa <$ 0,20 em quantidade 1.000. Um telefone celular moderno é um pequeno PC com armazenamento praticamente ilimitado para todos os efeitos e pode ser praticamente tratado como um PC. Nem todo o mundo é multicore e carece de restrições em tempo real.
Dan Mills
2
@ DanMills: Não estou falando de telefones celulares modernos aqui com os processadores Arm Cortex A, falando de "feature phones" por volta de 2002. Sim 192kB de SRAM são muito mais que 64 bytes (o que não é "pequeno", mas "minúsculo"), mas Os 192kB também não são chamados com precisão de desktop ou servidor "moderno" há 30 anos. Hoje em dia, 20 centavos oferecem um MSP430 com muito mais que 64 bytes de SRAM.
Ben Voigt
2
O @BenVoigt 192kB pode não ser uma área de trabalho nos últimos 30 anos, mas posso garantir que é totalmente suficiente servir as páginas da Web, o que eu diria que torna esse servidor um servidor pela própria definição da palavra. O fato é que essa é uma quantidade de RAM totalmente razoável (generosa e uniforme) para muitos aplicativos incorporados que geralmente incluem servidores da Web de configuração. Claro, eu provavelmente não estou executando a Amazon nele, mas eu posso estar executando uma geladeira completa com IOT crapware nesse núcleo (com tempo e espaço de sobra). Ninguém precisa de linguagens interpretadas ou JIT para isso!
Dan Mills
8

Java e C # são caracterizados por um fornecedor dominante, pelo menos no início de seu desenvolvimento. (Sun e Microsoft, respectivamente). C e C ++ são diferentes; eles tiveram várias implementações concorrentes desde o início. C também rodou em plataformas de hardware exóticas. Como resultado, houve variação entre as implementações. Os comitês da ISO que padronizaram C e C ++ poderiam concordar com um grande denominador comum, mas nos limites em que as implementações diferem, os padrões deixam espaço para a implementação.

Isso ocorre também porque a escolha de um comportamento pode ser cara em arquiteturas de hardware tendenciosas em relação a outra opção - endianness é a escolha óbvia.

MSalters
fonte
O que significa um "grande denominador comum" literalmente ? Você está falando de subconjuntos ou superconjuntos? Você realmente quer dizer fatores em comum? É o múltiplo menos comum ou o maior fator comum? Isso é muito confuso para nós, robôs que não falam linguagem de rua, apenas matemática. :)
tchrist 23/09
@ tchrist: O comportamento comum é um subconjunto, mas esse subconjunto é bastante abstrato. Em muitas áreas não especificadas pelo padrão comum, implementações reais devem fazer uma escolha. Agora, algumas dessas opções são bastante claras e, portanto, definidas pela implementação, mas outras são mais vagas. O layout da memória em tempo de execução é um exemplo: deve haver uma opção, mas não está claro como você o documentaria.
MSalters 23/09
2
O C original foi feito por um cara. Ele já tinha bastante UB, por design. As coisas certamente pioraram quando o C se tornou popular, mas o UB estava lá desde o início. Pascal e Smalltalk tinham muito menos UB e foram desenvolvidos praticamente ao mesmo tempo. A principal vantagem de C era que era extremamente fácil de portar - todos os problemas de portabilidade foram delegados ao programador de aplicativos: P Eu até mesmo portei um compilador C simples para minha CPU (virtual); fazer algo como LISP ou Smalltalk teria sido um esforço muito maior (embora eu tivesse um protótipo limitado para um tempo de execução do .NET :).
Luaan 23/09
@Luaan: Seria Kernighan ou Ritchie? E não, não tinha comportamento indefinido. Eu sei, eu tenho a documentação original do compilador gravado na AT&T na minha mesa. A implementação fez o que fez. Não houve distinção entre comportamento não especificado e indefinido.
MSalters
4
@MSalters Ritchie foi o primeiro cara. Kernighan só entrou (não muito) depois. Bem, ele não tinha "Comportamento indefinido", porque esse termo ainda não existia. Mas tinha o mesmo comportamento que hoje seria chamado de indefinido. Como o C não tinha uma especificação, até mesmo "não especificado" é um trecho :) Era apenas algo que o compilador não se importava, e os detalhes dependiam dos programadores de aplicativos. Ele não foi projetado para produzir aplicativos portáteis , apenas o compilador deveria ser fácil de transportar.
Luaan 23/09
6

A verdadeira razão se resume a uma diferença fundamental na intenção entre C e C ++, por um lado, e Java e C # (por apenas alguns exemplos), por outro. Por razões históricas, grande parte da discussão aqui fala sobre C e não sobre C ++, mas (como você provavelmente já sabe) C ++ é um descendente bastante direto de C, então o que diz sobre C se aplica igualmente a C ++.

Embora eles sejam amplamente esquecidos (e sua existência às vezes até negada), as primeiras versões do UNIX foram escritas em linguagem assembly. Grande parte (se não apenas) do objetivo original de C era a porta UNIX da linguagem assembly para uma linguagem de nível superior. Parte da intenção era escrever o máximo possível do sistema operacional em um idioma de nível superior - ou examiná-lo de outra direção, para minimizar a quantidade que precisava ser escrita em linguagem assembly.

Para conseguir isso, C precisava fornecer quase o mesmo nível de acesso ao hardware que a linguagem assembly. O PDP-11 (por exemplo) mapeou os registros de E / S para endereços específicos. Por exemplo, você leu um local de memória para verificar se uma tecla foi pressionada no console do sistema. Um bit foi definido nesse local quando havia dados aguardando para serem lidos. Você leu um byte de outro local especificado para recuperar o código ASCII da tecla que foi pressionada.

Da mesma forma, se você quiser imprimir alguns dados, verifique outro local especificado e, quando o dispositivo de saída estiver pronto, escreva seus dados em outro local especificado.

Para oferecer suporte à gravação de drivers para esses dispositivos, C permitiu especificar um local arbitrário usando algum tipo de número inteiro, convertê-lo em um ponteiro e ler ou gravar esse local na memória.

Obviamente, isso tem um problema muito sério: nem todas as máquinas na Terra têm sua memória distribuída de forma idêntica a um PDP-11 do início dos anos 70. Portanto, quando você pega esse número inteiro, converte-o em um ponteiro e, em seguida, lê ou escreve através desse ponteiro, ninguém pode fornecer nenhuma garantia razoável sobre o que você obterá. Apenas para um exemplo óbvio, a leitura e a gravação podem mapear para separar os registros no hardware; portanto, você (ao contrário da memória normal) se escreve algo e tenta lê-lo novamente, o que lê pode não corresponder ao que escreveu.

Eu posso ver algumas possibilidades que restam:

  1. Defina uma interface para todo o hardware possível - especifique os endereços absolutos de todos os locais que você pode querer ler ou gravar para interagir com o hardware de qualquer forma.
  2. Proibir esse nível de acesso e decretar que quem quiser fazer essas coisas precisa usar a linguagem assembly.
  3. Permita que as pessoas façam isso, mas deixe que leiam (por exemplo) os manuais do hardware que eles estão alvejando e escreva o código para se ajustar ao hardware que estão usando.

Destes, 1 parece suficientemente absurdo que dificilmente vale mais discussão. 2 é basicamente jogar fora a intenção básica da linguagem. Isso deixa a terceira opção como essencialmente a única que eles poderiam razoavelmente considerar.

Outro ponto que aparece com bastante frequência é o tamanho dos tipos inteiros. C assume a "posição" que intdeve ter o tamanho natural sugerido pela arquitetura. Portanto, se estou programando um VAX de int32 bits, provavelmente deve ter 32 bits, mas se estou programando um Univac de 36 bits, intprovavelmente deve ter 36 bits (e assim por diante). Provavelmente não é razoável (e talvez nem seja possível) gravar um sistema operacional para um computador de 36 bits usando apenas tipos que garantem múltiplos de 8 bits. Talvez eu esteja apenas sendo superficial, mas parece-me que se eu estivesse escrevendo um sistema operacional para uma máquina de 36 bits, provavelmente desejaria usar uma linguagem que suporte um tipo de 36 bits.

Do ponto de vista da linguagem, isso leva a um comportamento ainda mais indefinido. Se eu pegar o maior valor que caberá em 32 bits, o que acontecerá quando eu adicionar 1? No hardware típico de 32 bits, ele será rolado (ou possivelmente causará algum tipo de falha no hardware). Por outro lado, se estiver rodando em hardware de 36 bits, apenas adicionará um. Se o idioma oferecer suporte à escrita de sistemas operacionais, você não poderá garantir nenhum dos dois comportamentos - basta permitir que o tamanho dos tipos e o comportamento do estouro variem de um para outro.

Java e C # podem ignorar tudo isso. Eles não pretendem oferecer suporte a sistemas operacionais de gravação. Com eles, você tem algumas opções. Uma é fazer com que o hardware suporte o que eles exigem - já que eles exigem tipos de 8, 16, 32 e 64 bits, apenas construa um hardware que suporte esses tamanhos. A outra possibilidade óbvia é que o idioma seja executado apenas em cima de outro software que forneça o ambiente desejado, independentemente do hardware subjacente.

Na maioria dos casos, isso não é realmente uma opção de escolha. Em vez disso, muitas implementações fazem um pouco de ambos. Você normalmente executa o Java em uma JVM em execução no sistema operacional. Na maioria das vezes, o sistema operacional é escrito em C e a JVM em C ++. Se a JVM estiver sendo executada em uma CPU ARM, é bem provável que a CPU inclua as extensões Jazelle da ARM, para adaptar o hardware mais de perto às necessidades de Java, portanto, menos precisa ser feito em software e o código Java é executado mais rapidamente (ou menos de qualquer maneira).

Sumário

C e C ++ têm comportamento indefinido, porque ninguém definiu uma alternativa aceitável que lhes permita fazer o que pretendem fazer. C # e Java adotam uma abordagem diferente, mas essa abordagem se encaixa mal (se é que existe) com os objetivos de C e C ++. Em particular, nenhum deles parece fornecer uma maneira razoável de escrever software de sistema (como um sistema operacional) na maioria dos hardwares escolhidos arbitrariamente. Ambos geralmente dependem das instalações fornecidas pelo software do sistema existente (geralmente escrito em C ou C ++) para realizar seus trabalhos.

Jerry Coffin
fonte
4

Os autores do Padrão C esperavam que seus leitores reconhecessem algo que consideravam óbvio e aludido na justificativa publicada, mas não disseram abertamente: o Comitê não deveria precisar solicitar que os redatores de compiladores atendessem às necessidades de seus clientes, já que os clientes devem conhecer melhor que o Comitê quais são suas necessidades. Se é óbvio que se espera que os compiladores para certos tipos de plataformas processem uma construção de uma certa maneira, ninguém deve se importar se o Padrão diz que essa construção invoca o Comportamento Indefinido. O fracasso da Norma em exigir que os compiladores em conformidade processem um pedaço de código útil de forma alguma implica que os programadores estejam dispostos a comprar compiladores que não o fazem.

Essa abordagem para o design de idiomas funciona muito bem em um mundo onde os escritores de compiladores precisam vender seus produtos a clientes pagantes. Ele se desfaz completamente em um mundo onde os escritores de compiladores são isolados dos efeitos do mercado. É duvidoso que existam condições adequadas de mercado para orientar um idioma da maneira como haviam dirigido o que se tornou popular na década de 1990, e ainda mais duvidoso que qualquer designer de linguagem sã desejasse confiar nessas condições de mercado.

supercat
fonte
Sinto que você descreveu algo importante aqui, mas isso me escapa. Você poderia esclarecer sua resposta? Especialmente o segundo parágrafo: diz que as condições agora e as condições anteriores são diferentes, mas eu não entendo; o que exatamente mudou? Além disso, o "caminho" agora é diferente do anterior; talvez explique isso também?
anatolyg 22/09
4
Parece que sua campanha substitui todo comportamento indefinido por comportamento não especificado ou algo mais restrito ainda está forte.
Deduplicator
11
@ anatolyg: Se você ainda não o leu, leia o documento C Rationale publicado (digite C99 Rationale no Google). As linhas 23-29 falam sobre o "mercado" e as páginas 5-8 falam sobre o que se pretende com relação à portabilidade. Como você acha que um chefe de uma empresa comercial de compiladores reagiria se um escritor de compilador dissesse aos programadores que reclamaram que o otimizador quebrou o código que todos os outros compiladores trataram de maneira útil que seu código estava "quebrado" porque ele executa ações não definidas pelo Padrão, e recusou-se a apoiá-lo porque isso promoveria a continuação ...
supercat 23/09
11
... uso de tais construções? Esse ponto de vista é facilmente aparente nas placas de suporte do clang e do gcc e serviu para impedir o desenvolvimento de intrínsecos que poderiam facilitar a otimização com muito mais facilidade e segurança do que os idiomas quebrados que o gcc e o clang desejam oferecer.
supercat 23/09
11
@ supercat: Você está perdendo o fôlego reclamando com os fornecedores do compilador. Por que não encaminhar suas preocupações para os comitês de idiomas? Se eles concordarem com você, será emitida uma errata, que você poderá usar para superar as equipes do compilador. E esse processo é muito mais rápido que o desenvolvimento de uma nova versão da linguagem. Mas se eles discordarem, você terá pelo menos razões reais, enquanto os escritores do compilador apenas repetirão (repetidamente) "Não designamos esse código quebrado, essa decisão foi tomada pelo comitê de idiomas e nós siga a decisão deles ".
Ben Voigt
3

C ++ e c têm padrões descritivos (as versões ISO, pelo menos).

Que existem apenas para explicar como os idiomas funcionam e para fornecer uma única referência sobre o que é o idioma. Normalmente, fornecedores de compiladores e escritores de bibliotecas lideram o caminho e algumas sugestões são incluídas no principal padrão ISO.

Java e C # (ou Visual C #, que suponho que você queira dizer) têm padrões prescritivos . Eles informam o que há no idioma definitivamente antes do tempo, como ele funciona e o que é considerado comportamento permitido.

Mais importante que isso, o Java realmente tem uma "implementação de referência" no Open-JDK. (Acho que Roslyn conta como a implementação de referência do Visual C #, mas não conseguiu encontrar uma fonte para isso.)

No caso de Java, se houver alguma ambiguidade no padrão, e o Open-JDK faz isso de uma certa maneira. A maneira como o Open-JDK faz isso é o padrão.

bobsburner
fonte
A situação é pior do que isso: acho que o Comitê nunca chegou a um consenso sobre se deveria ser descritivo ou prescritivo.
supercat 24/10
1

O comportamento indefinido permite ao compilador gerar código muito eficiente em uma variedade de arquiteturas. A resposta de Erik menciona otimização, mas vai além disso.

Por exemplo, transbordamentos assinados são um comportamento indefinido em C. Na prática, esperava-se que o compilador gerasse um código de operação simples de adição assinada para a CPU executar, e o comportamento seria o que essa CPU específica fizesse.

Isso permitiu que C tivesse um desempenho muito bom e produzisse um código muito compacto na maioria das arquiteturas. Se o padrão tivesse especificado que números inteiros assinados tinham que transbordar de uma certa maneira, as CPUs que se comportassem de maneira diferente precisariam de muito mais código gerado para uma simples adição assinada.

Essa é a razão para grande parte do comportamento indefinido em C e por que coisas como o tamanho de intvariam entre sistemas. Intdepende da arquitetura e geralmente é selecionado para ser o tipo de dados mais rápido e eficiente que é maior que a char.

Quando C era novo, essas considerações eram importantes. Os computadores eram menos potentes, geralmente com velocidade e memória limitadas de processamento. C foi usado onde o desempenho realmente importava, e os desenvolvedores deveriam entender como os computadores funcionavam bem o suficiente para saber quais seriam esses comportamentos indefinidos em seus sistemas específicos.

Linguagens posteriores, como Java e C #, preferiram eliminar o comportamento indefinido do que o desempenho bruto.

do utilizador
fonte
-5

Em certo sentido, o Java também o possui. Suponha que você forneceu um comparador incorreto para Arrays.sort. Pode lançar exceção do que detecta. Caso contrário, ele classificará uma matriz de alguma maneira que não é garantida como particular.

Da mesma forma, se você modificar variáveis ​​de vários threads, os resultados também serão imprevisíveis.

O C ++ foi além para tornar mais indefinidas situações (ou melhor, o java decidiu definir mais operações) e ter um nome para ela.

RiaD
fonte
4
Esse não é um comportamento indefinido do tipo que estamos falando aqui. "Comparadores incorretos" vêm em dois tipos: os que definem uma ordem total e os que não. Se você fornecer um comparador que defina consistentemente a ordem relativa dos itens, o comportamento será bem definido, mas não é o comportamento que o programador desejava. Se você fornecer um comparador que não seja consistente com a ordem relativa, o comportamento ainda será bem definido: a função de classificação gerará uma exceção (que provavelmente também não é o comportamento que o programador desejava).
Marque
2
Quanto à modificação de variáveis, as condições de corrida geralmente não são consideradas comportamento indefinido. Não sei os detalhes de como o Java lida com atribuições a dados compartilhados, mas conhecendo a filosofia geral da linguagem, tenho certeza de que é necessário que seja atômico. A atribuição simultânea de 53 e 71 aseria um comportamento indefinido se você pudesse obter 51 ou 73, mas se você pode obter apenas 53 ou 71, está bem definido.
Mark
@ Mark Com pedaços de dados maiores que o tamanho de palavra nativo do sistema (por exemplo, uma variável de 32 bits em um sistema de tamanho de palavra de 16 bits), é possível ter uma arquitetura que exija o armazenamento de cada parte de 16 bits separadamente. (O SIMD é outra situação em potencial.) Nesse caso, mesmo uma simples atribuição no nível do código-fonte não é necessariamente atômica, a menos que o compilador tome cuidado especial para garantir que seja executado atomicamente.
um CVn