Ordem de avaliação dos índices da matriz (versus a expressão) em C

47

Olhando para este código:

static int global_var = 0;

int update_three(int val)
{
    global_var = val;
    return 3;
}

int main()
{
    int arr[5];
    arr[global_var] = update_three(2);
}

Qual entrada de matriz é atualizada? 0 ou 2?

Existe uma parte na especificação de C que indica a precedência da operação nesse caso específico?

Jiminion
fonte
21
Isso cheira a comportamento indefinido. Certamente é algo que nunca deve ser propositalmente codificado.
Fiddling Bits
11
Concordo que é um exemplo de codificação incorreta.
Jiminion 13/01
4
Alguns resultados anedóticos: godbolt.org/z/hM2Jo2
Bob__
15
Isso não tem nada a ver com índices de matriz ou ordem de operações. Tem a ver com o que a especificação C chama de "pontos de sequência" e, em particular, com o fato de que as expressões de atribuição NÃO criam um ponto de sequência entre a expressão da esquerda e da direita, para que o compilador seja livre para fazer o que quiser. escolhe.
Lee Daniel Crocker
4
Você deve relatar uma solicitação de recurso para clangque este trecho de código ative um aviso IMHO.
malat 14/01

Respostas:

51

Ordem dos operandos esquerdo e direito

Para executar a atribuição arr[global_var] = update_three(2), a implementação C deve avaliar os operandos e, como efeito colateral, atualizar o valor armazenado do operando esquerdo. C 2018 6.5.16 (que é sobre atribuições) o parágrafo 3 nos diz que não há seqüenciamento nos operandos esquerdo e direito:

As avaliações dos operandos não são seguidas.

Isso significa que a implementação C é livre para calcular o lvalue arr[global_var] primeiro (calculando o lvalue, queremos dizer a que se refere essa expressão), depois avaliar update_three(2)e finalmente atribuir o valor desse último ao primeiro; ou avaliar update_three(2)primeiro, depois calcular o lvalue e depois atribuir o primeiro ao último; ou para avaliar o lvalue e update_three(2)de alguma maneira misturada e, em seguida, atribua o valor certo ao lvalue esquerdo.

Em todos os casos, a atribuição do valor ao lvalue deve vir por último, porque 6.5.16 3 também diz:

… O efeito colateral da atualização do valor armazenado do operando esquerdo é sequenciado após o cálculo dos valores dos operandos esquerdo e direito…

Violação de sequenciamento

Alguns podem refletir sobre o comportamento indefinido devido ao uso global_vare à atualização separadamente, em violação do 6.5 2, que diz:

Se um efeito colateral em um objeto escalar não for relacionado em relação a um efeito colateral diferente no mesmo objeto escalar ou a uma computação de valor usando o valor do mesmo objeto escalar, o comportamento é indefinido…

É bastante familiar para muitos praticantes de C que o comportamento de expressões como x + x++não é definido pelo padrão C porque ambos usam o valor xe o modificam separadamente na mesma expressão sem sequenciamento. No entanto, neste caso, temos uma chamada de função, que fornece algumas seqüências. global_varé usado arr[global_var]e atualizado na chamada de função update_three(2).

6.5.2.2 10 nos diz que há um ponto de sequência antes que a função seja chamada:

Há um ponto de sequência após as avaliações do designador de função e dos argumentos reais, mas antes da chamada real…

Dentro da função, global_var = val;é uma expressão plena , e assim é o 3de return 3;, por 6,8 4:

Uma expressão completa é uma expressão que não faz parte de outra expressão, nem faz parte de um declarador ou declarador abstrato…

Depois, há um ponto de sequência entre essas duas expressões, novamente por 6.8 4:

… Há um ponto de sequência entre a avaliação de uma expressão completa e a avaliação da próxima expressão completa a ser avaliada.

Assim, a implementação C pode avaliar arr[global_var]primeiro e depois executar a chamada de função; nesse caso, existe um ponto de sequência entre eles porque existe um antes da chamada de função ou pode avaliar global_var = val;na chamada de função e arr[global_var], em seguida , nesse caso um ponto de sequência entre eles porque existe um após a expressão completa. Portanto, o comportamento não é especificado - qualquer uma dessas duas coisas pode ser avaliada primeiro - mas não é indefinido.

Eric Postpischil
fonte
24

O resultado aqui não é especificado .

Enquanto a ordem das operações em uma expressão, que determina como as subexpressões são agrupadas, está bem definida, a ordem da avaliação não é especificada. Nesse caso, significa que global_varpode ser lido primeiro ou a chamada update_threepode acontecer primeiro, mas não há como saber qual.

não um comportamento indefinido aqui porque uma chamada de função apresenta um ponto de ordem, como faz cada declaração na função incluindo a que modifica global_var.

Para esclarecer, o padrão C define comportamento indefinido na seção 3.4.3 como:

comportamento indefinido

comportamento, mediante o uso de uma construção de programa não transportável ou incorreta ou de dados errados, para os quais esta Norma Internacional não impõe requisitos

e define o comportamento não especificado na seção 3.4.4 como:

comportamento não especificado

uso de um valor não especificado ou outro comportamento em que esta Norma Internacional ofereça duas ou mais possibilidades e não imponha requisitos adicionais sobre os quais é escolhido em qualquer instância

O padrão declara que a ordem de avaliação dos argumentos da função não é especificada, o que neste caso significa que arr[0]é definido como 3 ou arr[2]definido como 3.

dbush
fonte
“Uma chamada de função introduz um ponto de sequência” é insuficiente. Se o operando esquerdo for avaliado primeiro, basta, pois o ponto de sequência separa o operando esquerdo das avaliações na função. Porém, se o operando esquerdo for avaliado após a chamada da função, o ponto de sequência devido à chamada da função não estará entre as avaliações na função e a avaliação do operando esquerdo. Você também precisa do ponto de sequência que separa expressões completas.
Eric Postpischil 13/01
2
@EricPostpischil Na terminologia pré-C11, há um ponto de sequência na entrada e saída de uma função. Na terminologia C11, todo o corpo da função é sequenciado indeterminadamente em relação ao contexto de chamada. Ambos estão especificando a mesma coisa, apenas usando termos diferentes
MM
Isto está absolutamente errado. A ordem de avaliação dos argumentos da tarefa não é especificada. Quanto ao resultado dessa atribuição específica, é a criação de uma matriz com um conteúdo não confiável, não portável e intrinsecamente errado (inconsistente com a semântica ou com os resultados pretendidos). Um caso perfeito de comportamento indefinido.
kuroi neko 15/01
11
@kuroineko O simples fato de a saída poder variar não torna automaticamente um comportamento indefinido. O padrão possui definições diferentes para comportamento indefinido vs. não especificado e, nessa situação, é o último.
dbush 15/01
@EricPostpischil Você tem pontos de sequência aqui (no Anexo C11 informativo C11): "Entre as avaliações do designador de função e os argumentos reais em uma chamada de função e a chamada real. (6.5.2.2)", "Entre a avaliação de uma expressão completa e a próxima expressão completa a ser avaliada ... / - / ... a expressão (opcional) em uma declaração de retorno (6.8.6.4) ". E bem, em cada ponto e vírgula também, já que é uma expressão completa.
Lundin
1

Eu tentei e consegui a entrada 0 atualizada.

No entanto, de acordo com esta pergunta: o lado direito de uma expressão sempre será avaliado primeiro

A ordem da avaliação não é especificada e não tem seqüência. Então, acho que um código como esse deve ser evitado.

Mickael B.
fonte
Também recebi a atualização na entrada 0.
Jiminion
11
O comportamento não é indefinido, mas não especificado. Naturalmente, dependendo de qualquer um deve ser evitado.
Antti Haapala 13/01
@AnttiHaapala que eu editei
Mickael B.
11
Hmm ah, e não é sequencial, mas sequenciada indeterminadamente ... 2 pessoas em pé aleatoriamente em uma fila são sequenciadas indeterminadamente. Neo dentro do Agente Smith são imprevisíveis e um comportamento indefinido acontecerá.
Antti Haapala 13/01
0

Como não faz sentido emitir código para uma atribuição antes que você tenha um valor para atribuir, a maioria dos compiladores C emitirá primeiro o código que chama a função e salva o resultado em algum lugar (registrador, pilha etc.), depois emitirá o código que grava esse valor em seu destino final e, portanto, eles lerão a variável global depois que ela for alterada. Vamos chamar isso de "ordem natural", não definida por nenhum padrão, mas pela pura lógica.

No entanto, no processo de otimização, os compiladores tentarão eliminar a etapa intermediária de armazenar temporariamente o valor em algum lugar e tentar gravar o resultado da função o mais diretamente possível ao destino final e, nesse caso, eles geralmente terão que ler o índice primeiro , por exemplo, para um registrador, para poder mover diretamente o resultado da função para o array. Isso pode fazer com que a variável global seja lida antes de ser alterada.

Portanto, esse é basicamente um comportamento indefinido com a propriedade muito ruim que é bem provável que o resultado seja diferente, dependendo se a otimização é executada e quão agressiva é essa otimização. É sua tarefa como desenvolvedor resolver esse problema através da codificação:

int idx = global_var;
arr[idx] = update_three(2);

ou codificação:

int temp = update_three(2);
arr[global_var] = temp;

Como uma boa regra geral: a menos que as variáveis ​​globais sejam const(ou não sejam, mas você saiba que nenhum código as alterará como efeito colateral), você nunca deve usá-las diretamente no código, como em um ambiente com vários threads, mesmo isso pode ser indefinido:

int result = global_var + (2 * global_var);
// Is not guaranteed to be equal to `3 * global_var`!

Como o compilador pode lê-lo duas vezes e outro encadeamento pode alterar o valor entre as duas leituras. No entanto, novamente, a otimização definitivamente faria com que o código o lesse apenas uma vez; portanto, você pode ter resultados diferentes que agora também dependem do tempo de outro encadeamento. Portanto, você terá muito menos dor de cabeça se armazenar variáveis ​​globais em uma variável de pilha temporária antes do uso. Lembre-se de que, se o compilador achar que isso é seguro, provavelmente otimizará isso e usará a variável global diretamente. Portanto, no final, pode não fazer diferença no desempenho ou no uso da memória.

(Apenas no caso de alguém perguntar por que alguém faria isso em x + 2 * xvez de 3 * x- em algumas CPUs, a adição é ultra-rápida e a multiplicação é por uma potência dois, pois o compilador as transforma em turnos de bits ( 2 * x == x << 1), mas a multiplicação com números arbitrários pode ser muito lenta , portanto, em vez de multiplicar por 3, você obtém um código muito mais rápido deslocando x por 1 e adicionando x ao resultado - e até esse truque é executado pelos compiladores modernos se você multiplicar por 3 e ativar a otimização agressiva, a menos que seja um destino moderno CPU em que a multiplicação é igualmente rápida como adição desde então, o truque atrasaria o cálculo.)

Mecki
fonte
2
Não é um comportamento indefinido - o padrão lista as possibilidades e uma delas é escolhida em qualquer instância
Antti Haapala
O compilador não se transformará 3 * xem duas leituras de x. Ele pode ler x uma vez e, em seguida, executar o método x + 2 * x no registro em que lê x
MM
6
@Mecki "Se você não pode dizer qual é o resultado, apenas olhando o código, o resultado é indefinido" - o comportamento indefinido tem um significado muito específico em C / C ++, e não é isso. Outros respondentes explicaram por que essa instância específica não é especificada , mas não é indefinida .
marcelm 14/01
3
Aprecio a intenção de lançar alguma luz nas partes internas de um computador, mesmo que isso vá além do escopo da pergunta original. No entanto, o UB é um jargão C / C ++ muito preciso e deve ser usado com cuidado, especialmente quando se trata de um detalhe técnico da linguagem. Você pode considerar usar o termo "comportamento não especificado" adequado, o que melhoraria significativamente a resposta.
kuroi neko 14/01
2
@Mecki " Indefinido tem um significado muito especial no idioma inglês " ... mas em uma pergunta rotulada language-lawyer, onde o idioma em questão tem seu próprio "significado muito especial" para indefinido , você só causará confusão se não usar a definição da linguagem.
TripeHound 15/01
-1

Edição global: desculpe pessoal, eu fiquei demitida e escrevi muitas bobagens. Apenas um velhote velho reclamando.

Eu queria acreditar que o C havia sido poupado, mas, desde o C11, ele foi comparado com o C ++. Aparentemente, saber o que o compilador fará com efeitos colaterais em expressões requer agora resolver um pequeno enigma matemático envolvendo uma ordenação parcial de seqüências de código com base em um "está localizado antes do ponto de sincronização de".

Por acaso, eu projetei e implementei alguns sistemas embarcados críticos em tempo real nos dias de K&R (incluindo o controlador de um carro elétrico que poderia fazer com que as pessoas colidissem com a parede mais próxima se o motor não fosse controlado, um industrial de 10 toneladas um robô que poderia esmagar as pessoas em uma pasta, se não fosse comandado adequadamente, e uma camada de sistema que, apesar de inofensiva, teria algumas dezenas de processadores sugando seu barramento de dados com menos de 1% de sobrecarga do sistema).

Talvez eu seja muito senil ou estúpido para obter a diferença entre indefinido e não especificado, mas acho que ainda tenho uma boa idéia do que significa execução simultânea e acesso a dados. Na minha opinião indiscutivelmente informada, essa obsessão do C ++ e agora do C com seus idiomas de estimação assumindo problemas de sincronização é um sonho caro. Você sabe o que é execução simultânea e não precisa de nenhum desses dispositivos, ou não, e faria um favor ao mundo em geral, sem tentar mexer com ele.

Todo esse caminhão de abstrações de barreira de memória de dar água nos olhos é simplesmente devido a um conjunto temporário de limitações dos sistemas de cache com várias CPUs, que podem ser encapsulados com segurança em objetos comuns de sincronização do SO, como, por exemplo, os mutexes e variáveis ​​de condição C ++ ofertas.
O custo desse encapsulamento é apenas uma queda minúscula no desempenho, em comparação com o que um uso de instruções específicas da CPU de baixa granularidade poderia alcançar em alguns casos.
A volatilepalavra-chave (ou um#pragma dont-mess-with-that-variablepor tudo que eu, como programador de sistemas, cuidado) teria sido suficiente para dizer ao compilador para parar de reordenar os acessos à memória. O código ideal pode ser facilmente produzido com diretivas diretas asm para espalhar códigos de driver e SO de baixo nível com instruções específicas da CPU ad hoc. Sem um conhecimento profundo de como o hardware subjacente (sistema de cache ou interface de barramento) funciona, você provavelmente escreverá um código inútil, ineficiente ou com defeito.

Um ajuste minucioso da volatilepalavra - chave e de Bob teria sido todo mundo, menos o tio dos programadores de nível mais baixo. Em vez disso, a turma habitual de loucos por matemática em C ++ teve um dia de campo projetando mais uma abstração incompreensível, cedendo à tendência típica de projetar soluções procurando problemas inexistentes e confundindo a definição de uma linguagem de programação com as especificações de um compilador.

Somente desta vez a mudança necessária para desfigurar um aspecto fundamental de C também, uma vez que essas "barreiras" precisavam ser geradas mesmo em código C de baixo nível para funcionar corretamente. Que, entre outras coisas, causou estragos na definição de expressões, sem nenhuma explicação ou justificativa.

Como conclusão, o fato de um compilador produzir um código de máquina consistente a partir dessa parte absurda de C é apenas uma conseqüência distante da maneira como os funcionários do C ++ lidaram com possíveis inconsistências nos sistemas de cache do final dos anos 2000.
Isso causou uma confusão terrível em um aspecto fundamental de C (definição de expressão), de modo que a grande maioria dos programadores de C - que não se importam com os sistemas de cache, e com razão - agora são obrigados a confiar nos gurus para explicar a diferença entre a = b() + c()e a = b + c.

Tentar adivinhar o que será dessa matriz infeliz é uma perda líquida de tempo e esforços de qualquer maneira. Independentemente do que o compilador faça disso, esse código está patologicamente errado. A única coisa responsável a fazer com isso é enviá-lo para a lixeira.
Conceitualmente, os efeitos colaterais sempre podem ser removidos das expressões, com o esforço trivial de permitir explicitamente que a modificação ocorra antes ou depois da avaliação, em uma declaração separada.
Esse tipo de código de merda pode ter sido justificado nos anos 80, quando você não esperava que um compilador otimizasse nada. Mas agora que os compiladores há muito se tornaram mais inteligentes do que a maioria dos programadores, tudo o que resta é um pedaço de código de merda.

Também não entendo a importância desse debate indefinido / não especificado. Você pode confiar no compilador para gerar código com um comportamento consistente ou não pode. Se você chama isso de indefinido ou não especificado, parece um ponto discutível.

Na minha opinião indiscutivelmente informada, C já é perigoso o suficiente em seu estado de K&R. Uma evolução útil seria adicionar medidas de segurança de senso comum. Por exemplo, ao usar essa ferramenta avançada de análise de código, as especificações forçam o compilador a implementar para, pelo menos, gerar avisos sobre o código bonkers, em vez de gerar silenciosamente um código potencialmente não confiável ao extremo.
Mas, em vez disso, os caras decidiram, por exemplo, definir uma ordem de avaliação fixa no C ++ 17. Agora, todo imbecil de software é incitado ativamente a colocar efeitos colaterais em seu código de propósito, aproveitando a certeza de que os novos compiladores lidarão ansiosamente com a ofuscação de uma maneira determinística.

K&R foi uma das verdadeiras maravilhas do mundo da computação. Por vinte dólares, você obtém uma especificação abrangente da linguagem (vi indivíduos solteiros escreverem compiladores completos usando apenas este livro), um excelente manual de referência (o índice geralmente indica algumas páginas da resposta à sua pergunta). questão) e um livro didático que ensina a usar o idioma de maneira sensata. Complete com argumentos, exemplos e sábias palavras de aviso sobre as inúmeras maneiras pelas quais você pode abusar do idioma para fazer coisas muito, muito estúpidas.

Destruir essa herança por tão pouco ganho parece um desperdício cruel para mim. Mas, novamente, eu poderia muito bem deixar de entender completamente o ponto. Talvez uma alma amável possa me apontar na direção de um exemplo de novo código C que aproveite significativamente esses efeitos colaterais.

kuroi neko
fonte
É um comportamento indefinido se houver efeitos colaterais no mesmo objeto na mesma expressão, C17 6.5 / 2. Estes não são sequenciais conforme C17 6.5.18 / 3. Mas o texto de 6.5 / 2 "Se um efeito colateral em um objeto escalar for não relacionado em relação a um efeito colateral diferente no mesmo objeto escalar ou a uma computação de valor usando o valor do mesmo objeto escalar, o comportamento será indefinido." não se aplica, pois o cálculo do valor dentro da função é sequenciado antes ou depois do acesso ao índice da matriz, independentemente do operador de atribuição ter operandos não sequenciais em si.
Lundin
A chamada de função funciona como "um mutex contra acesso não sequencial", se você desejar. Semelhante ao operador de vírgula obscuro, porcaria 0,expr,0.
Lundin
Acho que você acreditou nos autores do Padrão quando disseram "O comportamento indefinido concede ao implementador licença para não detectar certos erros de programa difíceis de diagnosticar. Também identifica áreas de possível extensão de linguagem em conformidade: o implementador pode aumentar o idioma fornecendo uma definição do comportamento oficialmente indefinido ". e disse que o padrão não deveria depreciar programas úteis que não estavam em conformidade estrita. Eu acho que a maioria dos autores da Norma teria pensado óbvio que as pessoas que procuram escrever compiladores de qualidade ...
supercat
... devem procurar usar o UB como uma oportunidade para tornar seus compiladores o mais úteis possível para seus clientes. Duvido que alguém possa ter imaginado que os escritores do compilador o usariam como uma desculpa para responder a reclamações de "Seu compilador processa esse código com menos utilidade do que todo mundo" com "Isso é porque o Padrão não exige que o processemos de maneira útil e implementações" que utilmente processe programas cujo comportamento não é determinado pela Norma apenas promove a redação de programas quebrados ".
supercat 6/02
Não vejo o ponto em sua observação. Confiar no comportamento específico do compilador é uma garantia de não portabilidade. Também requer muita fé no fabricante do compilador, que pode interromper qualquer uma dessas "definições extras" a qualquer momento. A única coisa que um compilador pode fazer é gerar avisos, que um programador sábio e experiente pode decidir manipular como erros. O problema que vejo com esse monstro ISO é que ele torna legítimos códigos atrozes como o exemplo do OP (por razões extremamente incertas, em comparação com a definição K&R de uma expressão).
kuroi neko