2 pontos por GN⁺ 2 시간 전 | 1 comentários | Compartilhar no WhatsApp
  • Se o valor de float com a parte fracionária descartada ficar fora do intervalo do tipo inteiro de destino, ocorre comportamento indefinido (UB), afetando conversão implícita, cast funcional e static_cast
  • -Wall e -Wextra não emitem aviso para isso, e até -Wconversion detecta apenas conversões implícitas, então é fácil deixar passar
  • A função segura de conversão com redução do Microsoft GSL, gsl::narrow, também pode causar UB em algumas entradas de ponto flutuante → inteiro e, assim, não cumpre o comportamento documentado de lançar exceção para valores não representáveis
  • No x86, CVTTSS2SI trata valores não representáveis como INT_MIN, mas no AArch64, FCVTZS faz conversão com saturação e transforma NaN em 0, então o resultado pode variar conforme o hardware
  • Para converter com segurança, é preciso verificar o intervalo antes do cast, e o problema pode ser detectado com a opção UBSan -fsanitize=float-cast-overflow do Clang e GCC

Regras de conversão e limites da detecção

  • Segundo as regras de conversão entre ponto flutuante e inteiro em C++, se após descartar a parte fracionária o valor não couber no tipo inteiro de destino, isso se torna comportamento indefinido
    • Mesmo se o destino for unsigned, aritmética modular não se aplica
    • int i0 = f, int(f) e static_cast<int>(f) podem todos causar UB com certas entradas
  • É difícil encontrar todos esses casos apenas com avisos comuns do compilador
    • -Wall e -Wextra não avisam em nenhuma das três conversões
    • -Wconversion só avisa para conversões implícitas
  • Mesmo que o programa continue rodando no processador e compilador atuais, o resultado pode variar entre plataformas
    • CVTTSS2SI no x86 mapeia entradas não representáveis para INT_MIN
    • FCVTZS no AArch64 faz saturação e mapeia NaN para 0
    • UB executado pode fazer o código falhar subitamente quando o compilador aplicar outras transformações

O caso do GSL e a resposta segura

  • O gsl::narrow da Guidelines Support Library da Microsoft se apresenta como uma conversão segura com redução que lança exceção para valores que não podem ser representados no tipo de destino
    • Na prática, a conversão de ponto flutuante → inteiro primeiro executa UB em algumas entradas, então o comportamento não corresponde ao da documentação
    • O lado do GSL considerou que, na plataforma-alvo, esse UB interno é inofensivo por não tocar representações de trap de hardware, e essa lógica permaneceu refletida no código, sem correção do problema
  • A solução correta é verificar o intervalo antes do cast
  • No Undefined Behavior Sanitizer do Clang e GCC, é possível detectar esse UB com -fsanitize=float-cast-overflow
    • Recomenda-se testar todo o código C++ com UBSan

1 comentários

 
GN⁺ 2 시간 전
Opiniões no Lobste.rs
  • Eu já conhecia muitos comportamentos indefinidos sutis em C e C++, mas esse caso me surpreendeu
    Rust também herdou do LLVM IR as mesmas regras de conversão de ponto flutuante→inteiro e, por um tempo, teve comportamento indefinido; só em 2020 isso foi corrigido para gerar um IR mais complexo. Como a diferença de desempenho é grande, a linguagem também oferece uma conversão de ponto flutuante→inteiro sem verificação para laços de alto desempenho em que se sabe que o valor é finito e está dentro do intervalo do tipo de destino
    É absurdo que a C++ Core Guidelines Library trate isso como algo sem importância. O LLVM usou as informações obtidas pela conversão para eliminar verificações de limites, e há um caso em que ocorreu acesso fora dos limites do array mesmo com verificação de limites. Se levamos segurança de memória e evitar comportamento indefinido a sério, não dá para ignorar isso; também é decepcionante que Herb Sutter tenha falado em “comportamento indefinido benigno”

  • Eu sabia que C++ tinha muito comportamento indefinido, mas este é especialmente surpreendente. Fico me perguntando se tentaram tornar a conversão o mais rápida possível e a deixaram como comportamento indefinido porque arquiteturas diferentes têm instruções que tratam esses valores extremos de maneiras diferentes. Parece ser um motivo parecido com o overflow de inteiros com sinal

    • Se esse for o motivo, deveria ser comportamento definido pela implementação, não comportamento indefinido. Divisão por zero pode causar trap em algumas arquiteturas, então faz sentido que seja comportamento indefinido
      Comportamento indefinido deveria se limitar a casos em que não é possível garantir um resultado consistente nem na mesma plataforma por causa de efeitos fora da máquina abstrata de C, como uso após liberação, ou a casos que podem causar trap em alguns destinos
      Uma implementação em conformidade com o padrão pode definir por conta própria um comportamento indefinido, e o GCC faz isso em alguns itens. Se for possível garantir sem custo de desempenho uma semântica estável, poderia simplesmente definir o comportamento das instruções de ponto flutuante→inteiro de cada alvo como ele é; mas, em outras implementações, ainda seria comportamento indefinido
    • Provavelmente é isso. A família fctiw do PowerPC faz conversão saturada, transforma NaN em INT_MIN e também define flags no FPSCR. O fctid do Power ISA de 64 bits faz o mesmo para inteiros maiores, mas nenhum dos dois coincide com o comportamento de AArch64 ou x86
  • Em casos assim, o correto seria tratar como comportamento definido pela implementação ou como um valor não especificado. Não faz sentido o programa inteiro sair do controle do padrão C++ só porque alguém converteu infinito para int
    O C++26 removeu alguns comportamentos indefinidos absurdos, e este é claramente um candidato à remoção. Em testes, GCC e Clang não parecem usar esse comportamento indefinido para otimização, então o impacto prático parece limitado

  • É mais um caso desagradável em que não há motivo para a especificação definir essa operação como comportamento indefinido

  • Mais um motivo para não gostar do IEEE 754. Se não for estritamente necessário por causa de alguma outra biblioteca ou por desempenho, tento usar ao máximo inteiros puros, racionais com numerador e denominador em inteiros grandes, ou decimais de ponto fixo em vez de ponto flutuante

    • Este caso não tem relação com o IEEE 754; a culpa é inteiramente do C++
  • Se você já usou C ou C++, a diferença entre float32 e int32, e até int64, não deveria ser surpresa. Um float32 com expoente grande consegue representar valores inteiros muito maiores que int64
    Entre formatos que não são superconjuntos um do outro e têm capacidades de representação diferentes, não há motivo para presumir que uma conversão de ponto flutuante→inteiro seja segura, independentemente da sintaxe da linguagem

    • Você está perdendo o ponto principal. Não conseguir preservar todos os valores de ponto flutuante é diferente de comportamento indefinido
      uint32_t também não consegue representar todos os valores de uint64_t, mas a semântica da conversão é definida como truncamento. Aqui, o problema essencialmente diferente é que, para certas entradas, o compilador pode fazer qualquer coisa
    • Ter intervalos de representação diferentes não significa que não seja possível definir uma conversão segura. Rust define explicitamente a conversão de ponto flutuante→inteiro, e a especificação da linguagem Java 26 detalha o procedimento de conversão na seção 5.1.3
      Em uma linguagem de baixo nível, também seria possível mapear para instruções de assembly como CVTTSS2SI ou FCVTZS. Justamente porque uma linguagem de baixo nível é próxima do assembly e outros comportamentos indefinidos “benignos” já são bastante controversos, é ainda mais surpreendente que essa conversão permita comportamento indefinido
    • Não é tão surpreendente que, dependendo do hardware, ela produza algum valor lixo definido pela implementação ou que o hardware/ferramentas de verificação gerem um trap e interrompam o programa
      Mas é surpreendente que isso também permita compilar incorretamente código não relacionado, formatar o disco rígido ou fazer “demônios voarem para fora do nariz”
      O conceito de comportamento indefinido, em que qualquer coisa pode acontecer, faz sentido para double free ou escrita fora dos limites de um array; mas C e C++ o usam em excesso até em lugares onde poderiam impor regras mais estritas, como valor lixo definido pela implementação ou encerramento do programa. Rust faz alguns overflows de inteiros causarem trap no modo debug e retornarem valor não especificado no modo release, mas não permite que isso destrua código sem relação
      C++ não precisa virar Java ou Rust, mas certamente se tornaria uma linguagem melhor se reduzisse comportamentos indefinidos desnecessários
    • Para mim, isso era novidade. Eu achava que, ao converter para inteiro um ponto flutuante que não pode ser representado, o máximo que aconteceria seria o inteiro resultante receber algum valor errado; não sabia que o programa inteiro ficaria contaminado para sempre e sairia do controle do padrão C++