Por que x = x ++ é indefinido?

19

É indefinido porque modifica xduas vezes entre os pontos de sequência. O padrão diz que é indefinido, portanto é indefinido.
Isso eu sei.

Mas por que?

Meu entendimento é que proibir isso permite que os compiladores otimizem melhor. Isso poderia ter feito sentido quando C foi inventado, mas agora parece um argumento fraco.
Se reinventássemos o C hoje, faríamos dessa maneira ou será melhor?
Ou talvez haja um problema mais profundo, que dificulta a definição de regras consistentes para essas expressões, por isso é melhor proibi-las.

Então, suponha que reinventássemos C hoje. Gostaria de sugerir regras simples para expressões como x=x++, que me parecem funcionar melhor do que as regras existentes.
Gostaria de obter sua opinião sobre as regras sugeridas em comparação com as existentes ou com outras sugestões.

Regras sugeridas:

  1. Entre os pontos de sequência, a ordem da avaliação não é especificada.
  2. Os efeitos colaterais ocorrem imediatamente.

Não há comportamento indefinido envolvido. As expressões são avaliadas com esse valor ou com isso, mas certamente não formatarão seu disco rígido (estranhamente, nunca vi uma implementação em que formate x=x++o disco rígido).

Expressões de exemplo

  1. x=x++- Bem definido, não muda x.
    Primeiro, xé incrementado (imediatamente quando x++é avaliado) e, em seguida, seu valor antigo é armazenado x.

  2. x++ + ++x- Incrementa xduas vezes, avalia como 2*x+2.
    Embora ambos os lados possam ser avaliados primeiro, o resultado é x + (x+2)(lado esquerdo primeiro) ou (x+1) + (x+1)(lado direito primeiro).

  3. x = x + (x=3)- Não especificado, xdefina como x+3ou 6.
    Se o lado direito é avaliado primeiro, é x+3. Também é possível que x=3seja avaliado primeiro, por isso é 3+3. Em qualquer um dos casos, a x=3atribuição acontece imediatamente quando x=3é avaliada; portanto, o valor armazenado é substituído pela outra atribuição.

  4. x+=(x=3)- Bem definido, define xcomo 6.
    Você pode argumentar que isso é apenas uma abreviação para a expressão acima.
    Mas eu diria que isso +=deve ser executado depois x=3, e não em duas partes (leia x, avalie x=3, adicione e armazene novo valor).

Qual é a vantagem?

Alguns comentários levantaram esse ponto positivo.
Eu certamente não acho que expressões como x=x++devam ser usadas em qualquer código normal.
Na verdade, eu sou muito mais rigorosa do que isso - Eu acho que o único bem de uso para x++em tão x++;sozinho.

No entanto, acho que as regras de linguagem devem ser o mais simples possível. Caso contrário, os programadores simplesmente não os entenderão. a regra que proíbe alterar uma variável duas vezes entre os pontos de sequência é certamente uma regra que a maioria dos programadores não entende.

Uma regra muito básica é a seguinte:
se A é válido e B é válido, e eles são combinados de maneira válida, o resultado é válido.
xé um valor L válido, x++é uma expressão válida e =é uma maneira válida de combinar um valor L e uma expressão; então, como x=x++é que isso não é legal?
O padrão C faz uma exceção aqui, e essa exceção complica as regras. Você pode pesquisar stackoverflow.com e ver o quanto essa exceção confunde as pessoas.
Então eu digo - livre-se dessa confusão.

=== Resumo das respostas ===

  1. Por que fazer isso?
    Tentei explicar na seção acima - quero que as regras C sejam simples.

  2. Potencial de otimização:
    isso requer alguma liberdade do compilador, mas eu não vi nada que me convenceu de que isso poderia ser significativo.
    A maioria das otimizações ainda pode ser feita. Por exemplo, a=3;b=5;pode ser reordenado, mesmo que o padrão especifique o pedido. Expressões como a=b[i++]ainda podem ser otimizadas da mesma forma.

  3. Você não pode alterar o padrão existente.
    Eu admito, não posso. Eu nunca pensei que realmente pudesse ir em frente e mudar padrões e compiladores. Eu só queria pensar se as coisas poderiam ter sido feitas de maneira diferente.

Ugoren
fonte
10
Porque é que isto é importante para ti? Deve ser definido e, em caso afirmativo, por quê? Não há muito sentido em atribuir xa si próprio e, se você quiser incrementar, xpode apenas dizer x++;- não há necessidade da atribuição. Eu diria que não deve ser definido apenas porque seria difícil lembrar o que deveria acontecer.
Caleb
4
Na minha opinião, essa é uma boa pergunta ("Alguns homens veem as coisas como são e perguntam por que, sonho com coisas que nunca foram e perguntam por que não"). É (na minha opinião) uma questão puramente sobre design de linguagem, usando a sintaxe C como exemplo, não uma pergunta sobre a sintaxe C. Pessoalmente, acho que a razão pela qual não definimos comportamento para expressões como x ++ + ++ x ou x = x ++ é simplesmente porque existe a possibilidade de elas serem mal interpretadas.
Jamie Taylor
5
@ugoren: Por que você precisa prever o resultado. Ninguém em sã consciência escreveria um código assim (como já foi mencionado várias vezes), mesmo que você escrevesse um código como esse, ele seria rejeitado na primeira revisão de código. Portanto, não há necessidade de definir o comportamento e dar ao otimizador a melhor chance de otimizá-lo. Em todos os exemplos que você propõe, eu atiraria em alguém se eles adicionassem isso à base de código.
Martin York
3
Gostaria de encontrar uma pergunta mais interessante: por que não há um erro ao escrever isso? Certamente, um compilador pode detectar seu comportamento indefinido e, portanto, não pode ser o que o usuário realmente queria; então, por que não há um erro? Eu entendo alguns casos de bahavour indefinida são difíceis de detectar, mas este isn; t
JohnB
3
" a regra que proíbe alterar uma variável duas vezes entre os pontos de sequência é certamente uma regra que a maioria dos programadores não entende. " - Você tem alguma evidência disso? A maioria das perguntas que eu vi foram feitas porque os programadores não sabiam sobre a regra. Existe alguma evidência de que a maioria deles ainda não entendeu depois que foi explicada?
Secure

Respostas:

24

Talvez você deva primeiro responder à pergunta por que ela deve ser definida? Existe alguma vantagem no estilo de programação, legibilidade, capacidade de manutenção ou desempenho ao permitir tais expressões com efeitos colaterais adicionais? É

y = x++ + ++x;

mais legível que

y = 2*x + 2;
x += 2;

Dado que essa mudança é extremamente fundamental e está quebrando a base de código existente.

Seguro
fonte
1
Eu adicionei uma seção "por que" à minha pergunta. Certamente não sugiro usar essas expressões, mas estou interessado em ter regras simples para dizer o significado de uma expressão.
Ugoren
Além disso, essa alteração não quebra o código existente, a menos que invoque um comportamento indefinido. Corrija-me se eu estiver errado.
ugoren
3
Bem, uma resposta mais filosófica: atualmente não está definida. Se nenhum programador o usar, não será necessário que você entenda essas expressões, porque não deve haver nenhum código. Se você precisar entendê-las, obviamente deve haver muito código por aí que se baseia em comportamentos indefinidos. ;)
Secure
1
É, por definição, não quebrar nenhuma base de código existente para definir os comportamentos. Se eles continham UB, eles estavam, por definição, já quebrados.
DeadMG
1
@ugoren: Sua seção "por que" ainda não responde à pergunta prática: por que você deseja essa expressão esquisita no seu código? Se você não consegue encontrar uma resposta convincente para isso, toda a discussão é discutível.
21812 Mike Baranczak
20

O argumento de que tornar esse comportamento indefinido permite uma melhor otimização não é fraco hoje. Na verdade, é muito mais forte hoje do que era quando C era novo.

Quando C era novo, as máquinas que podiam tirar proveito disso para uma melhor otimização eram principalmente modelos teóricos. As pessoas falaram sobre a possibilidade de construir CPUs onde o compilador instruiria a CPU sobre quais instruções poderiam / deveriam ser executadas em paralelo com outras instruções. Eles apontaram o fato de que permitir que esse comportamento tivesse indefinido significava que em uma CPU, se ela realmente existisse, você poderia agendar a parte "incremento" da instrução para executar em paralelo com o restante do fluxo de instruções. Enquanto eles estavam certos sobre a teoria, na época havia pouco hardware que realmente poderia tirar proveito dessa possibilidade.

Isso não é mais apenas teórico. Agora, há hardware em produção e amplo uso (por exemplo, DSPs Itanium, VLIW) que podem realmente tirar proveito disso. Eles realmente fazer permitir que o compilador para gerar um fluxo de instrução que especifica que as instruções X, Y e Z podem ser executadas em paralelo. Este não é mais um modelo teórico - é um hardware real em uso real, realizando um trabalho real.

Na IMO, tornar esse comportamento definido está próximo da pior "solução" possível para o problema. Você claramente não deve usar expressões como esta. Para a grande maioria do código, o comportamento ideal seria o compilador simplesmente rejeitar completamente essas expressões. Na época, os compiladores C não faziam a análise de fluxo necessária para detectar isso de forma confiável. Mesmo na época do padrão C original, ainda não era de todo comum.

Também não tenho certeza de que seria aceitável para a comunidade hoje - embora muitos compiladores possam fazer esse tipo de análise de fluxo, eles normalmente o fazem apenas quando você solicita otimização. Duvido que a maioria dos programadores gostaria da idéia de diminuir a compilação de "depuração" apenas para poder rejeitar o código que eles (sendo sensatos) nunca escreveriam em primeiro lugar.

O que C fez é uma segunda melhor opção semi-razoável: diga às pessoas para não fazer isso, permitindo (mas não exigindo) que o compilador rejeite o código. Isso evita (ainda mais) a compilação lenta de pessoas que nunca a usariam, mas ainda permite que alguém escreva um compilador que rejeitará esse código se quiser (e / ou tiver sinalizadores que o rejeitarão que as pessoas possam optar por usar) ou não como entenderem).

Pelo menos na IMO, tomar esse comportamento definido seria (pelo menos próximo) da pior decisão possível. No hardware do estilo VLIW, suas escolhas seriam gerar código mais lento para os usos razoáveis ​​dos operadores de incremento, apenas por causa de códigos ruins que os abusam, ou sempre exigir uma extensa análise de fluxo para provar que você não está lidando com código de baixa qualidade, para que você possa produzir o código lento (serializado) somente quando for realmente necessário.

Conclusão: se você deseja curar esse problema, deve pensar na direção oposta. Em vez de definir o que esse código faz, você deve definir a linguagem para que essas expressões simplesmente não sejam permitidas (e viva com o fato de que a maioria dos programadores provavelmente optará por uma compilação mais rápida do que a imposição desse requisito).

Jerry Coffin
fonte
Na IMO, há poucas razões para acreditar que, na maioria dos casos, as instruções mais lentas são realmente muito mais lentas que as instruções rápidas e que sempre terão impacto no desempenho do programa. Eu classificaria este em otimização prematura.
DeadMG
Talvez esteja faltando alguma coisa - se ninguém deveria escrever esse código, por que se preocupar em otimizá-lo?
ugoren
1
@ugoren: escrever código como a=b[i++];(por exemplo) é bom, e otimizá-lo é uma coisa boa. No entanto, não vejo o ponto de prejudicar um código razoável como esse, para que algo como ++i++tenha um significado definido.
perfil completo de Jerry Coffin
2
@ugoren O problema é de diagnóstico. O único objetivo de não desaprovar completamente expressões como ++i++é precisamente é geralmente difícil diferenciá-las de expressões válidas com efeitos colaterais (como a=b[i++]). Pode parecer simples o suficiente para nós, mas se me lembro do Livro dos Dragões corretamente, na verdade é um problema difícil de NP. É por isso que esse comportamento é UB, e não proibido.
Konrad Rudolph
1
Não acredito que o desempenho seja um argumento válido. Eu luto para acreditar que o caso é bastante comum, considerando a diferença muito pequena e a execução muito rápida em ambos os casos, para que uma pequena queda de desempenho seja perceptível - sem mencionar que em muitos processadores e arquiteturas, a definição é efetivamente livre.
DeadMG
9

Eric Lippert, designer principal da equipe de compiladores C #, publicou em seu blog um artigo sobre várias considerações que optam por tornar um recurso indefinido no nível de especificação de idioma. Obviamente, o C # é uma linguagem diferente, com diferentes fatores envolvidos no design da linguagem, mas os pontos que ele destaca são relevantes.

Em particular, ele aponta a questão de ter compiladores existentes para um idioma que possui implementações existentes e também ter representantes em um comitê. Não tenho certeza se esse é o caso aqui, mas tende a ser relevante para a maioria das discussões de especificações relacionadas a C e C ++.

Também é importante notar, como você disse, o potencial de desempenho para otimização do compilador. Embora seja verdade que o desempenho das CPUs hoje em dia seja muito maior do que era quando C era jovem, uma grande quantidade de programação em C atualmente é feita especificamente devido ao ganho de desempenho potencial e ao potencial (futuro hipotético). ) As otimizações de instruções da CPU e otimizações de processamento multicore seriam tolas para impedir por causa de um conjunto excessivamente restritivo de regras para lidar com efeitos colaterais e pontos de sequência.

Tanzelax
fonte
No artigo ao qual você vincula, parece que o C # não está longe do que eu sugiro. A ordem dos efeitos colaterais é definida "quando observada a partir do segmento que causa os efeitos colaterais". Eu não mencionei multi-threading, mas em geral C não garante muito para um observador em outro segmento.
ugoren
5

Primeiro, vamos dar uma olhada na definição de comportamento indefinido:

3.4.3

1 comportamento
comportamental indefinido, mediante o uso de um construto de programa não transportável ou incorreto ou de dados errôneos, para os quais esta Norma Internacional não impõe requisitos

. uma maneira documentada característica do ambiente (com ou sem a emissão de uma mensagem de diagnóstico), para encerrar uma tradução ou execução (com a emissão de uma mensagem de diagnóstico).

3 EXEMPLO Um exemplo de comportamento indefinido é o comportamento no estouro de número inteiro

Portanto, em outras palavras, "comportamento indefinido" significa simplesmente que o compilador é livre para lidar com a situação da maneira que desejar, e qualquer ação desse tipo é considerada "correta".

A raiz do problema em discussão é a seguinte cláusula:

6.5 Expressões

...
3 O agrupamento de operadores e operandos é indicado pela sintaxe. 74) excepto conforme especificada mais tarde (para a função de chamada (), &&, ||, ?:, e operadores vírgula), a ordem de avaliação de subexpress~oes e a ordem em que os efeitos colaterais ocorrem são ambos fi unspeci ed .

Enfase adicionada.

Dada uma expressão como

x = a++ * --b / (c + ++d);

os subexpress~oes a++, --b, c, e ++dpode ser avaliada por qualquer ordem . Além disso, os efeitos colaterais de a++, --be ++dpodem ser aplicados a qualquer momento antes do próximo ponto de sequência (IOW, mesmo se a++for avaliado antes --b, não há garantia de que aserá atualizado antes da --bavaliação). Como outros já disseram, a lógica desse comportamento é dar à implementação a liberdade de reordenar as operações da maneira ideal.

Por isso, no entanto, expressões como

x = x++
y = i++ * i++
a[i] = i++
*p++ = -*p    // this one bit me just yesterday

etc., produzirá resultados diferentes para diferentes implementações (ou para a mesma implementação com diferentes configurações de otimização ou com base no código circundante etc.).

O comportamento é deixado indefinido, de modo que o compilador não tem obrigação de "fazer a coisa certa", seja ela qual for. Os casos acima são fáceis de capturar, mas há um número não trivial de casos que seria difícil ou impossível de capturar em tempo de compilação.

Obviamente, é possível projetar uma linguagem de modo que a ordem da avaliação e a ordem na qual os efeitos colaterais sejam aplicados sejam estritamente definidas, e Java e C # o fazem, em grande parte para evitar os problemas que as definições de C e C ++ levam.

Então, por que essa alteração não foi feita em C após três revisões padrão? Primeiro de tudo, há 40 anos de código C legado por aí, e não é garantido que essa alteração não interrompa esse código. Isso sobrecarrega os gravadores de compiladores, pois essa alteração tornaria todos os compiladores existentes imediatamente não conformes; todo mundo teria que fazer reescritas significativas. E mesmo em CPUs modernas e rápidas, ainda é possível obter ganhos reais de desempenho, ajustando a ordem da avaliação.

John Bode
fonte
1
Muito boa explicação do problema. Discordo sobre a quebra de aplicativos herdados - a maneira como o comportamento indefinido / não especificado é implementado às vezes muda entre a versão do compilador, sem nenhuma alteração no padrão. Não sugiro alterar nenhum comportamento definido.
ugoren
4

Primeiro você precisa entender que não é apenas x = x ++ que é indefinido. Ninguém se importa com x = x ++, já que não importa o que você definiria, não há motivo para isso. O que é indefinido é mais como "a = b ++, onde aeb são os mesmos" - ie

void f(int *a, int *b) {
    *a = (*b)++;
}
int i;
f(&i, &i);

Existem várias maneiras diferentes de a função ser implementada, dependendo do que for mais eficiente para a arquitetura do processador (e para as instruções circundantes, caso essa seja uma função mais complexa que o exemplo). Por exemplo, dois óbvios:

load r1 = *b
copy r2 = r1
increment r1
store *b = r1
store *a = r2

ou

load r1 = *b
store *a = r1
increment r1
store *b = r1

Observe que o primeiro listado acima, aquele que usa mais instruções e mais registros, é o que você precisaria para ser usado em todos os casos em que não se pode provar que aeb seja diferente.

Random832
fonte
Você realmente mostra um caso em que minha sugestão resulta em mais operações da máquina, mas me parece insignificante. E o compilador ainda tem alguma liberdade - o único requisito real que eu adiciono é armazenar bantes a.
ugoren
3

Legado

A suposição de que C poderia ser reinventada hoje não pode ser mantida. Existem tantas linhas de códigos C que foram produzidas e usadas diariamente, que mudar as regras do jogo no meio da jogada é errado.

Claro que você pode inventar um novo idioma, digamos C + = , com suas regras. Mas isso não será C.

mouviciel
fonte
2
Eu realmente não acho que podemos reinventar o C hoje. Isso não significa que não possamos discutir essas questões. No entanto, o que eu sugiro não é realmente reinventar. A conversão de comportamento indefinido em definido ou não especificado pode ser feita ao atualizar um padrão, e o idioma ainda seria C.
ugoren
2

Declarar que algo está definido não mudará os compiladores existentes para respeitar sua definição. Isso é especialmente verdadeiro no caso de uma suposição que pode ter sido explicada de forma explícita ou implícita em muitos lugares.

A principal questão para a suposição não é com x = x++; (compiladores podem facilmente verificar e devem avisar), é com *p1 = (*p2)++e equivalente ( p1[i] = p2[j]++;quando p1 e p2 são parâmetros para uma função) em que o compilador não pode saber facilmente se p1 == p2(em C99 restrictfoi adicionado para espalhar a possibilidade de assumir p1! = p2 entre os pontos de sequência; portanto, considerou-se que as possibilidades de otimização eram importantes).

AProgrammer
fonte
Não vejo como minha sugestão muda nada em relação a p1[i]=p2[j]++. Se o compilador não pode assumir nenhum alias, não há problema. Se não puder, deve seguir o livro - incremente p2[j]primeiro, armazene p1[i]depois. Exceto pelas oportunidades de otimização perdidas, que não parecem significativas, não vejo problema.
ugoren
O segundo parágrafo não era independente do primeiro, mas um exemplo do tipo de lugar onde a suposição pode surgir e será difícil de rastrear.
AProgrammer
O primeiro parágrafo afirma algo bastante óbvio - os compiladores terão que ser alterados para obedecer a um novo padrão. Eu realmente não acho que tenho a chance de padronizar isso e fazer com que os escritores do compilador sigam. Só acho que vale a pena discutir.
ugoren
O problema não é que é necessário alterar os compiladores sobre qualquer mudança no idioma que seja necessário, mas as mudanças são difundidas e difíceis de encontrar. A abordagem mais prática provavelmente seria alterar o formato intermediário no qual o otimizador funciona, ou seja, fingir que x = x++;não foi escrito, mas t = x; x++; x = t;ou x=x; x++;ou o que você quiser como semântico (mas e o diagnóstico?). Para um novo idioma, apenas abandone os efeitos colaterais.
APROGRAMMETER
Eu não sei muito sobre a estrutura do compilador. Se eu realmente quisesse mudar todos os compiladores, me importaria mais. Mas talvez tratar x++como um ponto de sequência, como se fosse uma chamada de função inc_and_return_old(&x), resolvesse o problema.
ugoren
-1

Em alguns casos, esse tipo de código foi definido no novo padrão C ++ 11.

DeadMG
fonte
5
Cuidado ao elaborar?
ugoren
Eu acho que x = ++xagora está bem definido (mas não x = x++)
MM