Profundidade da toca do coelho ou entrevista em C ++ no PVS-Studio

Entrevista em C ++ no PVS-Studio

Autores: Andrey Karpov, khandeliantsPhilip Handelyants.

Gostaria de compartilhar uma situação interessante quando a pergunta que usamos na entrevista se mostrou mais complicada do que o autor pretendia. Com a linguagem C ++ e os compiladores, você sempre deve estar atento. Não fique entediado.

Como em qualquer outra empresa de programação, temos conjuntos de perguntas para entrevistas para vagas de desenvolvedores em C ++, C # e Java. Temos muitas perguntas com fundo duplo ou triplo. Para perguntas sobre C # e Java, não podemos ter certeza, pois eles têm outros autores. Mas muitas das perguntas compiladas por Andrei Karpov para entrevistas em C ++ foram imediatamente concebidas para sondar a profundidade do conhecimento dos recursos da linguagem.

Essas perguntas podem receber uma resposta correta simples. Você pode ir cada vez mais fundo. Dependendo disso, durante a entrevista, determinamos o quanto uma pessoa está familiarizada com as nuances do idioma. Isso é importante para nós, pois estamos desenvolvendo um analisador de código e deve entender muito bem as sutilezas e as "piadas" do idioma.

Hoje, contaremos uma pequena história sobre como uma das primeiras perguntas feitas na entrevista foi ainda mais profunda do que planejamos. Então, mostramos este código:

void F1()
{
  int i = 1;
  printf("%d, %d\n", i++, i++);
}

e pergunte: "O que será impresso?"

Boa pergunta. Imediatamente pode dizer muito sobre o conhecimento. Casos em que uma pessoa não possa respondê-la não serão considerados. Estes são eliminados por testes preliminares no site do HeadHunter (hh.ru). Embora não, eles são besteiras. Em nossa memória, havia duas personalidades únicas que responderam algo no espírito:

Este código imprimirá no início uma porcentagem, depois d, depois outra porcentagem, d, depois uma varinha, ne duas unidades.

Claramente, nesses casos, a entrevista termina rapidamente.

Então, agora de volta à entrevista normal :). Frequentemente, eles respondem assim:

1 e 2. serão impressos.Esta

é a resposta do nível interno. Sim, é claro que esses valores podem ser impressos, mas estamos aguardando aproximadamente a seguinte resposta:

Isso não quer dizer o que exatamente esse código imprimirá. Esse é um comportamento não especificado (ou indefinido). A ordem na qual os argumentos são calculados não está definida. Todos os argumentos devem ser avaliados antes que o corpo da função chamada seja executado, mas a ordem na qual isso acontecerá é deixada a critério do compilador. Portanto, o código pode muito bem imprimir "1, 2" e vice-versa "2, 1". Em geral, escrever esse código é altamente indesejável; se for construído por pelo menos dois compiladores, é possível disparar com o pé. E muitos compiladores darão um aviso aqui.

De fato, se você usar o Clang , poderá obter "1, 2".

E se você usa o GCC , pode obter "2, 1".

Era uma vez tentamos o compilador MSVC, e ele também produziu "2, 1". Sem sinais de problemas.

Recentemente, para fins gerais de terceiros, foi novamente necessário compilar esse código usando o Visual C ++ moderno e executá-lo. Montado em Configuração da versão com a otimização / O2 ativada . E, como eles dizem, eles encontraram aventura em suas próprias cabeças :). O que você acha que aconteceu? Ha! Aqui está o que: "1, 1".

Então pense no que você quer. Acontece que a questão é ainda mais profunda e mais confusa. Nós mesmos não esperávamos que isso acontecesse.

Como o padrão C ++ não regula o cálculo dos argumentos de forma alguma, o compilador interpreta esse tipo de comportamento não especificado de uma maneira muito peculiar. Vamos dar uma olhada no código do assembler gerado pelo compilador MSVC 19.25 (Microsoft Visual Studio Community 2019, versão 16.5.1), a versão do sinalizador do padrão de idioma é '/ std: c ++ 14':


Formalmente, o otimizador transformou o código acima no seguinte:

void F1()
{
  int i = 1;
  int tmp = i;
  i += 2;
  printf("%d, %d\n", tmp, tmp);
}

Do ponto de vista do compilador, essa otimização não altera o comportamento observado do programa. Olhando para isso, você começa a entender que, por uma boa razão, o padrão C ++ 11, além de ponteiros inteligentes, também adicionou a função "mágica" make_shared (e o C ++ 14 também adicionou make_unique ). Um exemplo tão inofensivo, e também pode "quebrar lenha":

void foo(std::unique_ptr<int>, std::unique_ptr<double>);

int main()
{
  foo(std::unique_ptr<int> { new int { 0 } },
      std::unique_ptr<double> { new double { 0.0 } });
}

Um compilador astuto pode transformar isso na seguinte ordem de cálculos (o mesmo MSVC , por exemplo):

new int { .... };
new double { .... };
std::unique_ptr<int>::unique_ptr
std::unique_ptr<double>::unique_ptr

Se a segunda chamada para o novo operador gerar uma exceção, ocorreremos um vazamento de memória.

Mas voltando ao tópico original. Apesar do fato de que tudo estava bem do ponto de vista do compilador, ainda tínhamos certeza de que a saída "1, 1" foi incorretamente considerada o comportamento esperado pelo desenvolvedor. E então tentamos compilar o código-fonte com o compilador MSVC com o sinalizador de versão padrão '/ std: c ++ 17'. E tudo começa a funcionar como esperado, e "2, 1" é impresso. Dê uma olhada no código do assembler:


Tudo é justo, o compilador passou os valores 2 e 1. Mas por que tudo mudou tão dramaticamente? Acontece que o seguinte foi adicionado ao padrão C ++ 17:

A expressão postfix é sequenciada antes de cada expressão na lista de expressões e em qualquer argumento padrão. A inicialização de um parâmetro, incluindo todo cálculo de valor associado e efeito colateral, é sequenciada indeterminadamente em relação a qualquer outro parâmetro.

O compilador ainda tem o direito de calcular argumentos em uma ordem arbitrária, mas agora, a partir do padrão C ++ 17, tem o direito de começar a calcular o próximo argumento e seus efeitos colaterais apenas a partir do momento em que todos os cálculos e efeitos colaterais do argumento anterior forem concluídos.

A propósito, se você compilar o mesmo exemplo com ponteiros inteligentes com o sinalizador '/ std: c ++ 17', tudo ficará bomtambém - usar std :: make_unique agora é opcional.

Aqui está outra medida de profundidade na questão que acabou. Existe uma teoria, mas existe prática na forma de um compilador específico ou uma interpretação diferente do padrão :). O mundo do C ++ é sempre mais complexo e inesperado do que parece.

Se alguém puder explicar com mais precisão o que está acontecendo, informe-nos nos comentários. Finalmente, precisamos entender a pergunta para, pelo menos, nós mesmos sabermos a resposta na entrevista! :)

Aqui está uma história tão informativa. Esperamos que tenha sido interessante e você compartilhe sua opinião sobre esse tópico. E recomendamos usar o mais moderno padrão de linguagem possível, para ficar menos surpreso com o que os compiladores de otimização atuais podem. Melhor ainda, não escreva esse código :).

PS Podemos dizer que "iluminamos a pergunta" e agora ela terá que ser removida do questionário. Não vemos o ponto nisso. Se uma pessoa não está com preguiça de estudar nossas publicações antes da entrevista, lê esse material e depois o usa, então ele está bem-feito e, merecidamente, receberá uma vantagem :).


Se você deseja compartilhar este artigo com um público que fala inglês, use o link para a tradução: Andrey Karpov, Phillip Khandeliants. Qual é a profundidade da toca do coelho ou entrevistas de emprego em C ++ no PVS-Studio .

All Articles