O padrão C ++ permite que um bool não inicializado trava um programa?


500

Eu sei que um "comportamento indefinido" em C ++ pode permitir que o compilador faça o que quiser. No entanto, tive um acidente que me surpreendeu, pois supus que o código era seguro o suficiente.

Nesse caso, o problema real aconteceu apenas em uma plataforma específica usando um compilador específico e somente se a otimização estivesse ativada.

Eu tentei várias coisas para reproduzir o problema e simplificá-lo ao máximo. Aqui está um extrato de uma função chamada Serialize, que pegaria um parâmetro bool e copia a string trueou falsepara um buffer de destino existente.

Essa função estaria em uma revisão de código, não haveria como dizer que, de fato, poderia travar se o parâmetro bool fosse um valor não inicializado?

// Zero-filled global buffer of 16 characters
char destBuffer[16];

void Serialize(bool boolValue) {
    // Determine which string to print based on boolValue
    const char* whichString = boolValue ? "true" : "false";

    // Compute the length of the string we selected
    const size_t len = strlen(whichString);

    // Copy string into destination buffer, which is zero-filled (thus already null-terminated)
    memcpy(destBuffer, whichString, len);
}

Se esse código for executado com otimizações clang 5.0.0 +, ele poderá / poderá falhar.

O operador ternário esperado boolValue ? "true" : "false"parecia seguro o suficiente para mim, eu estava assumindo: "Qualquer valor que o lixo boolValuecontenha não importa, pois ele será avaliado como verdadeiro ou falso de qualquer maneira".

Eu configurei um exemplo do Compiler Explorer que mostra o problema na desmontagem, aqui o exemplo completo. Nota: para reprogramar o problema, a combinação que achei que funcionou foi usar o Clang 5.0.0 com otimização -O2.

#include <iostream>
#include <cstring>

// Simple struct, with an empty constructor that doesn't initialize anything
struct FStruct {
    bool uninitializedBool;

   __attribute__ ((noinline))  // Note: the constructor must be declared noinline to trigger the problem
   FStruct() {};
};

char destBuffer[16];

// Small utility function that allocates and returns a string "true" or "false" depending on the value of the parameter
void Serialize(bool boolValue) {
    // Determine which string to print depending if 'boolValue' is evaluated as true or false
    const char* whichString = boolValue ? "true" : "false";

    // Compute the length of the string we selected
    size_t len = strlen(whichString);

    memcpy(destBuffer, whichString, len);
}

int main()
{
    // Locally construct an instance of our struct here on the stack. The bool member uninitializedBool is uninitialized.
    FStruct structInstance;

    // Output "true" or "false" to stdout
    Serialize(structInstance.uninitializedBool);
    return 0;
}

O problema surge por causa do otimizador: era inteligente o suficiente deduzir que as strings "true" e "false" diferem apenas em comprimento por 1. Portanto, em vez de realmente calcular o comprimento, ele usa o valor do próprio bool, que deve tecnicamente seja 0 ou 1 e é assim:

const size_t len = strlen(whichString); // original code
const size_t len = 5 - boolValue;       // clang clever optimization

Embora isso seja "inteligente", por assim dizer, minha pergunta é: o padrão C ++ permite que um compilador assuma que um bool pode ter apenas uma representação numérica interna de '0' ou '1' e usá-lo dessa maneira?

Ou esse é um caso de implementação definida? Nesse caso, a implementação assumiu que todos os seus bools conterão apenas 0 ou 1 e qualquer outro valor é território de comportamento indefinido?


200
É uma ótima pergunta. É uma ilustração sólida de como o comportamento indefinido não é apenas uma preocupação teórica. Quando as pessoas dizem que qualquer coisa pode acontecer como resultado do UB, essa "qualquer coisa" pode realmente ser bastante surpreendente. Pode-se supor que o comportamento indefinido ainda se manifeste de maneiras previsíveis, mas hoje em dia com otimizadores modernos isso não é de todo verdade. A OP dedicou um tempo para criar um MCVE, investigou o problema minuciosamente, inspecionou a desmontagem e fez uma pergunta clara e direta sobre ele. Não poderia pedir mais.
John Kugelman

7
Observe que o requisito para o qual “diferente de zero é avaliado true” é uma regra sobre operações booleanas, incluindo “atribuição a um bool” (que pode implicitamente chamar um static_cast<bool>()dependendo de detalhes). No entanto, não é um requisito sobre a representação interna de uma boolescolhida pelo compilador.
Euro Micelli

2
Comentários não são para discussão prolongada; esta conversa foi movida para o bate-papo .
Samuel Liew

3
Em uma nota muito relacionada, essa é uma fonte "divertida" de incompatibilidade binária. Se você possui um ABI A que zero pads valor antes de chamar uma função, mas compila funções de forma que assume que os parâmetros são zero padded, e um ABI B é o oposto (não pad zero, mas não assume zero -padded padded), funcionará principalmente , mas uma função usando a ABI B causará problemas se chamar uma função usando a ABI A que usa um parâmetro 'pequeno'. IIRC você tem isso em x86 com clang e ICC.
TLW

1
@TLW: Embora a Norma não exija que as implementações forneçam meios de chamar ou serem chamadas por código externo, teria sido útil ter um meio de especificar essas coisas para implementações onde elas são relevantes (implementações onde esses detalhes não são relevantes podem ignorar esses atributos).
supercat

Respostas:


285

Sim, o ISO C ++ permite (mas não requer) implementações para fazer essa escolha.

Mas observe também que o ISO C ++ permite que um compilador emita código que trava de propósito (por exemplo, com uma instrução ilegal) se o programa encontrar UB, por exemplo, como uma maneira de ajudá-lo a encontrar erros. (Ou porque é um DeathStation 9000. Estar em conformidade estrita não é suficiente para uma implementação de C ++ ser útil para qualquer finalidade real). Portanto, o ISO C ++ permitiria que um compilador fizesse o asm travar (por razões totalmente diferentes), mesmo em código semelhante que lesse um não inicializado uint32_t. Mesmo que seja necessário ser um tipo de layout fixo sem representações de interceptação.

É uma pergunta interessante sobre como as implementações reais funcionam, mas lembre-se de que, mesmo que a resposta fosse diferente, seu código ainda não seria seguro porque o C ++ moderno não é uma versão portátil da linguagem assembly.


Você está compilando para a ABI do x86-64 System V , que especifica que a boolcomo uma função arg em um registro é representado pelos padrões de bits false=0etrue=1 nos baixos 8 bits do registro 1 . Na memória, boolé um tipo de 1 byte que novamente deve ter um valor inteiro de 0 ou 1.

(Uma ABI é um conjunto de opções de implementação que os compiladores da mesma plataforma concordam para que possam criar código que chame as funções uns dos outros, incluindo tamanhos de tipo, regras de layout de estrutura e convenções de chamada.)

O ISO C ++ não o especifica, mas essa decisão ABI é generalizada porque torna barata a conversão bool-> int (apenas extensão zero) . Não conheço nenhuma ABIs que não permita que o compilador assuma 0 ou 1 para bool, para qualquer arquitetura (não apenas x86). Ele permite otimizações como !myboolcom xor eax,1para inverter o bit baixo: qualquer código possível que possa inverter um bit / número inteiro / bool entre 0 e 1 na instrução de CPU única . Ou compilando a&&bpara um E bit a bit para booltipos. Alguns compiladores realmente aproveitam os valores booleanos como 8 bits nos compiladores. As operações neles são ineficientes? .

Em geral, a regra como se permite permite que o compilador aproveite as coisas verdadeiras na plataforma de destino que está sendo compilada , porque o resultado final será um código executável que implementa o mesmo comportamento visível externamente que a fonte C ++. (Com todas as restrições que o comportamento indefinido impõe sobre o que é realmente "visível externamente": não com um depurador, mas com outro encadeamento em um programa C ++ legal / bem formado.)

O compilador é definitivamente autorizados a tirar o máximo proveito de uma garantia ABI em seu código-gen, e fazer um código como você encontrou o que otimiza strlen(whichString)a
5U - boolValue.
(BTW, essa otimização é meio inteligente, mas talvez míope vs. ramificada e embutida memcpycomo armazenamento de dados imediatos 2 ).

Ou o compilador poderia ter criado uma tabela de ponteiros e a indexado com o valor inteiro de bool, novamente assumindo que fosse 0 ou 1. ( Essa possibilidade é o que a resposta de @ Barmar sugeriu .)


Seu __attribute((noinline))construtor com otimização ativada levou ao clang apenas carregar um byte da pilha para usar como uninitializedBool. Abriu espaço para o objeto maincom push rax(que é menor e, por várias razões, tão eficiente quanto sub rsp, 8), portanto, qualquer lixo que estava no AL na entrada mainé o valor usado para ele uninitializedBool. É por isso que você realmente obteve valores que não eram justos 0.

5U - random garbagepode agrupar facilmente em um grande valor não assinado, levando o memcpy a entrar na memória não mapeada. O destino está no armazenamento estático, não na pilha, portanto você não está substituindo um endereço de retorno ou algo assim.


Outras implementações podem fazer escolhas diferentes, por exemplo, false=0e true=any non-zero value. Então o clang provavelmente não criaria código que trava para esta instância específica do UB. (Mas ainda assim seria permitido, se quisesse.) Não conheço nenhuma implementação que escolha outra coisa que o x86-64 faça bool, mas o padrão C ++ permite muitas coisas que ninguém faz ou até gostaria de fazer em hardware semelhante às CPUs atuais.

O ISO C ++ não especifica o que você encontrará quando examinar ou modificar a representação do objeto de abool . (por exemplo, memcpyinserindo o boolinto unsigned char, o que você pode fazer porque char*pode ter qualquer apelido. E unsigned charé garantido que não há bits de preenchimento, portanto o padrão C ++ permite formalmente fazer o hexdump de representações de objetos sem qualquer UB. representação é diferente de atribuir char foo = my_bool, é claro, portanto a booleanização como 0 ou 1 não aconteceria e você obteria a representação do objeto bruto.)

Você parcialmente "ocultou" o UB neste caminho de execução do compilador comnoinline . Mesmo que não esteja alinhado, as otimizações interprocedurais ainda podem fazer uma versão da função que depende da definição de outra função. (Primeiro, o clang está tornando uma biblioteca executável, não uma biblioteca compartilhada Unix, onde a interposição de símbolos pode acontecer. Segundo, a definição dentro da class{}definição, para que todas as unidades de tradução tenham a mesma definição. Como na inlinepalavra - chave.)

Portanto, um compilador pode emitir apenas uma retou ud2(instrução ilegal) como a definição para main, porque o caminho da execução que começa no topo do maininevitável encontra o comportamento indefinido. (Que o compilador pode ver em tempo de compilação se decidir seguir o caminho através do construtor não-inline.)

Qualquer programa que encontre o UB é totalmente indefinido por toda a sua existência. Mas o UB dentro de uma função ou if()ramo que nunca é executado realmente não corrompe o restante do programa. Na prática, isso significa que os compiladores podem decidir emitir uma instrução ilegal, ou a ret, ou não, emitir algo e cair no próximo bloco / função, para todo o bloco básico que pode ser comprovado em tempo de compilação como contendo ou levar ao UB.

O GCC e o Clang, na prática , às vezes emitem ud2no UB, em vez de tentarem gerar código para caminhos de execução que não fazem sentido. Ou para casos como cair no final de uma não voidfunção, o gcc às vezes omite uma retinstrução. Se você estava pensando que "minha função retornará apenas com o que houver no RAX", você está muito enganado. Os compiladores C ++ modernos não tratam mais a linguagem como uma linguagem assembly portátil. Seu programa realmente precisa ser C ++ válido, sem fazer suposições sobre como uma versão independente e não embutida de sua função pode parecer em asm.

Outro exemplo divertido é: por que o acesso desalinhado à memória mmap'ed às vezes falha na AMD64? . x86 não falha em números inteiros não alinhados, certo? Então, por que um desalinhado uint16_t*seria um problema? Porque alignof(uint16_t) == 2, e violar essa suposição levou a um segfault ao vetorizar automaticamente com o SSE2.

Consulte também O que todo programador C deve saber sobre o comportamento indefinido nº 1/3 , um artigo de um desenvolvedor de clang.

Ponto-chave: se o compilador notasse o UB em tempo de compilação, ele poderia "interromper" (emitir um asm surpreendente) o caminho através do código que causa o UB, mesmo se visar uma ABI em que qualquer padrão de bits é uma representação de objeto válida bool.

Espere hostilidade total em relação a muitos erros do programador, especialmente as coisas que os compiladores modernos alertam. É por isso que você deve usar -Walle corrigir avisos. O C ++ não é uma linguagem amigável e algo em C ++ pode não ser seguro, mesmo que seja seguro no destino que você está compilando. (por exemplo, o overflow assinado é UB em C ++ e os compiladores assumirão que isso não acontece, mesmo ao compilar o complemento x86 de 2, a menos que você use clang/gcc -fwrapv.)

O UB visível em tempo de compilação é sempre perigoso, e é realmente difícil ter certeza (com a otimização do tempo do link) que você realmente ocultou o UB do compilador e, portanto, pode raciocinar sobre que tipo de asm ele irá gerar.

Para não ser excessivamente dramático; frequentemente os compiladores permitem que você se dê bem com algumas coisas e emita códigos como você espera, mesmo quando algo é UB. Mas talvez seja um problema no futuro se os desenvolvedores do compilador implementarem alguma otimização que obtenha mais informações sobre intervalos de valores (por exemplo, que uma variável não é negativa, talvez seja possível otimizar a extensão de sinal para liberar a extensão zero em x86- 64) Por exemplo, no atual gcc e clang, fazer tmp = a+INT_MINnão é otimizado a<0como sempre falso, apenas isso tmpé sempre negativo. (Como INT_MIN+ a=INT_MAXé negativo na meta de complemento deste 2 e anão pode ser maior que isso.)

Portanto, o gcc / clang atualmente não recua para obter informações de intervalo para as entradas de um cálculo, apenas nos resultados com base na suposição de que não haja excesso de sinal : exemplo no Godbolt . Não sei se essa otimização é intencionalmente "perdida" em nome da facilidade de uso ou o quê.

Observe também que as implementações (também conhecidas como compiladores) têm permissão para definir o comportamento que o ISO C ++ deixa indefinido . Por exemplo, todos os compiladores que suportam as intrínsecas da Intel (como _mm_add_ps(__m128, __m128)na vetorização SIMD manual) devem permitir a formação de ponteiros desalinhados, que são UB em C ++, mesmo que você não os desreferencie. __m128i _mm_loadu_si128(const __m128i *)carrega desalinhados usando um __m128i*argumento desalinhado , não um void*ou char*. O `reinterpret_cast`ing entre o ponteiro de vetor de hardware e o tipo correspondente é um comportamento indefinido?

O GNU C / C ++ também define o comportamento de alternar para a esquerda um número assinado negativo (mesmo sem -fwrapv), separadamente das regras normais do UB com excesso de sinal. ( Isso é UB no ISO C ++ , enquanto os turnos corretos dos números assinados são definidos pela implementação (lógico vs. aritmético); as implementações de boa qualidade escolhem a aritmética no HW que possui turnos aritméticos à direita, mas o ISO C ++ não especifica. Isso está documentado na seção Inteiro do manual do GCC , juntamente com a definição do comportamento definido pela implementação, de que os padrões C exigem que as implementações definam uma maneira ou de outra.

Definitivamente, existem problemas de qualidade de implementação com os quais os desenvolvedores de compiladores se preocupam; eles geralmente não estão tentando criar compiladores que são intencionalmente hostis, mas tirar proveito de todos os buracos de UB em C ++ (exceto aqueles que eles escolhem definir) para otimizar melhor pode ser quase indistinguível às vezes.


Nota de rodapé 1 : Os 56 bits superiores podem ser lixo que o receptor deve ignorar, como de costume para tipos mais estreitos que um registro.

( Outros ABIs fazer fazer escolhas diferentes aqui . Alguns exigem tipos inteiros estreitas para ser zero ou para preencher um cadastro estendeu-sinal quando passados para ou retornados de funções, como MIPS64 e PowerPC64. Veja a última seção de esta resposta x86-64 que compara com os ISAs anteriores .)

Por exemplo, um chamador pode ter calculado a & 0x01010101no RDI e usado para outra coisa antes de chamar bool_func(a&1). O responsável pela chamada pode otimizar o processo &1porque já fez isso com o byte baixo como parte de and edi, 0x01010101, e sabe que o chamado é necessário para ignorar os bytes altos.

Ou, se um bool é passado como terceiro argumento, talvez um chamador que otimize o tamanho do código o carregue em mov dl, [mem]vez de movzx edx, [mem], economizando 1 byte ao custo de uma falsa dependência do valor antigo do RDX (ou outro efeito de registro parcial, dependendo no modelo de CPU). Ou para o primeiro argumento, em mov dil, byte [r10]vez de movzx edi, byte [r10], porque ambos exigem um prefixo REX de qualquer maneira.

É por isso que o clang emite , movzx eax, dilem Serializevez de sub eax, edi. (Para args inteiros, clang viola essa regra ABI, dependendo do comportamento não documentado de gcc e clang para números inteiros estreitos com extensão de zero ou sinal para 32 bits. É uma extensão de sinal ou zero necessária ao adicionar um deslocamento de 32 bits a um ponteiro para ABI x86-64? Então, eu estava interessado em ver que ele não faz a mesma coisa bool.)


Nota de rodapé 2: após a ramificação, você teria um movintermediário de 4 bytes ou um armazenamento de 4 bytes + 1 byte. O comprimento está implícito nas larguras da loja + compensações.

OTOH, o glibc memcpy fará dois carregamentos / armazenamentos de 4 bytes com uma sobreposição que depende do comprimento, então isso realmente acaba deixando a coisa toda livre de ramificações condicionais no booleano. Veja o L(between_4_7):bloco em memcpy / memmove da glibc. Ou, pelo menos, siga o mesmo caminho para booleano na ramificação do memcpy para selecionar um tamanho de bloco.

Se for embutido, você pode usar o 2x mov-imediato + cmove um deslocamento condicional, ou pode deixar os dados da string na memória.

Ou, se estiver ajustando para o Intel Ice Lake ( com o recurso Fast Short REP MOV ), um real rep movsbpode ser o ideal. O glibc memcpypode começar a usar rep movsb em tamanhos pequenos em CPUs com esse recurso, economizando muitas ramificações.


Ferramentas para detectar UB e uso de valores não inicializados

No gcc e no clang, você pode compilar com -fsanitize=undefinedpara adicionar instrumentação em tempo de execução que avisará ou ocorrerá um erro no UB que acontece no tempo de execução. Isso não captura variáveis ​​unitializadas, no entanto. (Como não aumenta o tamanho dos tipos para dar espaço a um bit "não inicializado").

Consulte https://developers.redhat.com/blog/2014/10/16/gcc-undefined-behavior-sanitizer-ubsan/

Para encontrar o uso de dados não inicializados, há o Sanitizer de Endereço e o Sanitizer de Memória no clang / LLVM. https://github.com/google/sanitizers/wiki/MemorySanitizer mostra exemplos de clang -fsanitize=memory -fPIE -piedetecção de leituras de memória não inicializadas. Pode funcionar melhor se você compilar sem otimização, para que todas as leituras de variáveis ​​acabem sendo carregadas da memória no asm. Eles mostram que está sendo usado -O2em um caso em que a carga não seria otimizada. Eu não tentei eu mesmo. (Em alguns casos, por exemplo, não inicializando um acumulador antes de somar uma matriz, o clang -O3 emitirá código que soma um registro vetorial que nunca foi inicializado. Portanto, com a otimização, você pode ter um caso em que não há memória lida associada ao UB . Mas-fsanitize=memory altera o asm gerado e pode resultar em uma verificação para isso.)

Ele tolerará a cópia de memória não inicializada e também operações lógicas e aritméticas simples. Em geral, o MemorySanitizer rastreia silenciosamente a propagação de dados não inicializados na memória e relata um aviso quando uma ramificação de código é obtida (ou não), dependendo de um valor não inicializado.

O MemorySanitizer implementa um subconjunto de funcionalidades encontradas no Valgrind (ferramenta Memcheck).

Deve funcionar nesse caso, porque a chamada para glibc memcpycom uma lengthmemória calculada a partir de não inicializada (dentro da biblioteca) resultará em uma ramificação baseada em length. Se tivesse descrito uma versão totalmente sem ramificação que apenas usava cmov, indexava e duas lojas, talvez não funcionasse.

O Valgrindmemcheck também procurará esse tipo de problema, novamente sem reclamar se o programa simplesmente copiar dados não inicializados. Mas ele diz que detectará quando um "salto ou movimento condicional depende de valores não inicializados", para tentar capturar qualquer comportamento visível externamente que dependa de dados não inicializados.

Talvez a idéia por trás de não sinalizar apenas uma carga seja que as estruturas possam ter preenchimento, e copiar toda a estrutura (incluindo preenchimento) com uma ampla carga / armazenamento de vetores não é um erro, mesmo que os membros individuais tenham sido escritos apenas um de cada vez. No nível ASM, as informações sobre o que estava preenchendo e o que realmente faz parte do valor foram perdidas.


2
Eu vi um caso pior em que a variável assumiu um valor não no intervalo de um número inteiro de 8 bits, mas apenas de todo o registro da CPU. E Itanium tem uma pior ainda, o uso de uma variável não inicializada pode falhar completamente.
Joshua Joshua

2
@ Josué: oh, bom ponto, a especulação explícita do Itanium marcará os valores do registro com o equivalente a "não um número", de modo que o uso do valor falhe.
Peter Cordes

11
Além disso, isso também ilustra por que o UB featurebug foi introduzido no design das linguagens C e C ++ em primeiro lugar: porque fornece ao compilador exatamente esse tipo de liberdade, que agora permite que os compiladores mais modernos executem esses recursos de alta qualidade. otimizações que tornam o C / C ++ em linguagens de nível médio de alto desempenho.
The_Sympathizer

2
E assim a guerra entre escritores do compilador C ++ e programadores C ++ tentando escrever programas úteis continua. Essa resposta, totalmente abrangente para responder a esta pergunta, também poderia ser usado como é tão convincente cópia do anúncio para fornecedores de ferramentas de análise estática ...
davidbak

4
@The_Sympathizer: o UB foi incluído para permitir que as implementações se comportassem da maneira que seria mais útil para seus clientes . Não se pretendia sugerir que todos os comportamentos deveriam ser considerados igualmente úteis.
Supercat

56

O compilador pode assumir que um valor booleano passado como argumento é um valor booleano válido (ou seja, um que foi inicializado ou convertido em trueou false). O truevalor não precisa ser o mesmo que o número inteiro 1 - de fato, pode haver várias representações de truee false- mas o parâmetro deve ser uma representação válida de um desses dois valores, em que "representação válida" é implementada - definiram.

Portanto, se você não inicializar um bool, ou se conseguir substituí-lo por algum ponteiro de um tipo diferente, as suposições do compilador estarão erradas e o Comportamento Indefinido será seguido. Você foi avisado:

50) Usar um valor bool nos modos descritos por esta Norma como "indefinidos", como examinar o valor de um objeto automático não inicializado, pode fazer com que ele se comporte como se não fosse verdadeiro nem falso. (Nota de rodapé no parágrafo 6 do §6.9.1, Tipos fundamentais)


11
O " truevalor não precisa ser o mesmo que o número inteiro 1" é enganoso. Claro, o padrão de bits real pode ser outra coisa, mas quando convertido / promovido implicitamente (a única maneira de ver um valor diferente de true/ false), trueé sempre 1e falsesempre0 . Obviamente, esse compilador também não seria capaz de usar o truque que esse compilador estava tentando usar (usando o fato de que boolo padrão de bits real só poderia ser 0ou 1), por isso é meio irrelevante para o problema do OP.
ShadowRanger

4
@ShadowRanger Você sempre pode inspecionar a representação do objeto diretamente.
TC

7
@ shadowranger: o que quero dizer é que a implementação está no comando. Se limitar representações válidas trueao padrão de bits 1, é uma prerrogativa. Se escolher algum outro conjunto de representações, ele realmente não poderá usar a otimização observada aqui. Se ele escolher essa representação específica, poderá. Ele só precisa ser consistente internamente. Você pode examinar a representação de a boolcopiando-a em uma matriz de bytes; que não é UB (mas é definido pela implementação)
rici

3
Sim, otimizar compiladores (ou seja, implementação C ++ no mundo real) geralmente emitirá código que depende de um boolpadrão de bits 0ou 1. Eles não re-booleanizam booltoda vez que o lêem da memória (ou um registro contendo uma função arg). É isso que esta resposta está dizendo. exemplos : o gcc4.7 + pode otimizar return a||bpara or eax, ediuma função retornada boolou o MSVC pode otimizar a&bpara test cl, dl. x86 testé um bit a bitand , portanto, se cl=1e dl=2teste definir sinalizadores de acordo com cl&dl = 0.
Peter Cordes

5
O ponto sobre o comportamento indefinido é que o compilador pode tirar muito mais conclusões sobre ele, por exemplo, supor que um caminho de código que levaria ao acesso a um valor não inicializado nunca seja usado, garantindo que essa seja exatamente a responsabilidade do programador . Portanto, não se trata apenas da possibilidade de que os valores de baixo nível possam ser diferentes de zero ou um.
Holger

52

A função em si está correta, mas em seu programa de teste, a instrução que chama a função causa um comportamento indefinido usando o valor de uma variável não inicializada.

O bug está na função de chamada e pode ser detectado por revisão de código ou análise estática da função de chamada. Usando o link do explorador do compilador, o compilador gcc 8.2 detecta o erro. (Talvez você possa enviar um relatório de erro contra o clang de que ele não encontra o problema).

Comportamento indefinido significa que tudo pode acontecer, o que inclui o programa travando algumas linhas após o evento que acionou o comportamento indefinido.

NB. A resposta para "Um comportamento indefinido pode causar _____?" é sempre "sim". Essa é literalmente a definição de comportamento indefinido.


2
A primeira cláusula é verdadeira? Simplesmente copiar um boolUB de gatilho não inicializado ?
Joshua Green

10
@JoshuaGreen consulte [dcl.init] / 12 "Se um valor indeterminado for produzido por uma avaliação, o comportamento será indefinido, exceto nos seguintes casos:" (e nenhum desses casos tem uma exceção bool). A cópia requer a avaliação da fonte
MM

8
@ JoshuaGreen E a razão para isso é que você pode ter uma plataforma que aciona uma falha de hardware se acessar alguns valores inválidos para alguns tipos. Isso às vezes é chamado de "representação de armadilha".
David Schwartz

7
O Itanium, embora obscuro, é uma CPU que ainda está em produção, possui valores de interceptação e possui pelo menos dois compiladores C ++ semi-modernos (Intel / HP). Ele tem literalmente true, falsee not-a-thingvalores para booleans.
MSalters

3
Por outro lado, a resposta para "O padrão exige que todos os compiladores processem algo de uma certa maneira" geralmente é "não", mesmo / especialmente nos casos em que é óbvio que qualquer compilador de qualidade deve fazê-lo; quanto mais óbvio, menor é a necessidade de os autores da Norma realmente dizerem isso.
Supercat

23

Um bool é permitido apenas para manter os valores dependentes de implementação usados ​​internamente para truee false, e o código gerado pode assumir que ele conterá apenas um desses dois valores.

Normalmente, a implementação usará o número inteiro 0para falsee 1para true, para simplificar as conversões entre boole inte if (boolvar)gerar o mesmo código que if (intvar). Nesse caso, pode-se imaginar que o código gerado para o ternário na atribuição usaria o valor como índice em uma matriz de ponteiros para as duas strings, ou seja, poderia ser convertido para algo como:

// the compile could make asm that "looks" like this, from your source
const static char *strings[] = {"false", "true"};
const char *whichString = strings[boolValue];

Se boolValuenão for inicializado, ele poderá conter qualquer valor inteiro, o que causaria o acesso fora dos limites da stringsmatriz.


1
@SidS Obrigado. Teoricamente, as representações internas podem ser o oposto de como elas são convertidas para / de números inteiros, mas isso seria perverso.
Barmar

1
Você está certo e seu exemplo também falhará. No entanto, é "visível" para uma revisão de código que você esteja usando uma variável não inicializada como um índice para uma matriz. Além disso, ele travaria mesmo na depuração (por exemplo, algum depurador / compilador será inicializado com padrões específicos para facilitar a visualização de falhas). No meu exemplo, a parte surpreendente é que o uso do bool é invisível: o otimizador decidiu usá-lo em um cálculo não presente no código-fonte.
Remz

3
@ Remem Estou apenas usando a matriz para mostrar o que o código gerado poderia ser equivalente, não sugerindo que alguém realmente escrevesse isso.
Barmar

1
@Remz Atualize o boolpara intcom *(int *)&boolValuee imprima-o para fins de depuração, veja se é algo diferente de 0ou 1quando falha. Se for esse o caso, confirma praticamente a teoria de que o compilador está otimizando o inline-if como uma matriz que explica por que está travando.
Havenard 10/01/19

2
@ MSalters: std::bitset<8>não me dá nomes agradáveis ​​para todas as minhas bandeiras diferentes. Dependendo do que são, isso pode ser importante.
Martin Bonner apoia Monica

15

Resumindo muito sua pergunta, você está perguntando. O padrão C ++ permite que um compilador assuma boolque só pode ter uma representação numérica interna de '0' ou '1' e usá-la dessa maneira?

O padrão não diz nada sobre a representação interna de a bool. Ele define apenas o que acontece ao converter um boolpara um int(ou vice-versa). Principalmente, por causa dessas conversões integrais (e pelo fato de as pessoas confiarem bastante nelas), o compilador usará 0 e 1, mas não precisará (embora deva respeitar as restrições de qualquer ABI de nível inferior que use )

Portanto, o compilador, quando vê a, booltem o direito de considerar que o dito boolcontém um dos padrões de bits ' true' ou ' false' e fazer o que quiser. Portanto, se os valores para truee falsesão 1 e 0, respectivamente, o compilador pode realmente otimizar strlenpara 5 - <boolean value>. Outros comportamentos divertidos são possíveis!

Como afirmado repetidamente aqui, o comportamento indefinido tem resultados indefinidos. Incluindo mas não limitado a

  • Seu código está funcionando conforme o esperado
  • Seu código falhou em momentos aleatórios
  • Seu código não está sendo executado.

Consulte O que todo programador deve saber sobre comportamento indefinido

Ao utilizar nosso site, você reconhece que leu e compreendeu nossa Política de Cookies e nossa Política de Privacidade.
Licensed under cc by-sa 3.0 with attribution required.