- Se o valor de
floatcom 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 estatic_cast -Walle-Wextranão emitem aviso para isso, e até-Wconversiondetecta 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,
CVTTSS2SItrata valores não representáveis comoINT_MIN, mas no AArch64,FCVTZSfaz 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-overflowdo 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)estatic_cast<int>(f)podem todos causar UB com certas entradas
- É difícil encontrar todos esses casos apenas com avisos comuns do compilador
-Walle-Wextranão avisam em nenhuma das três conversões-Wconversionsó avisa para conversões implícitas
- Mesmo que o programa continue rodando no processador e compilador atuais, o resultado pode variar entre plataformas
CVTTSS2SIno x86 mapeia entradas não representáveis paraINT_MINFCVTZSno 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::narrowda 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
- Existe uma biblioteca de prova de conceito cpp-clamp-cast, baseada no método de conversão com saturação do Rust
- 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
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
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
fctiwdo PowerPC faz conversão saturada, transforma NaN emINT_MINe também define flags no FPSCR. Ofctiddo Power ISA de 64 bits faz o mesmo para inteiros maiores, mas nenhum dos dois coincide com o comportamento de AArch64 ou x86Em 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
intO 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
Se você já usou C ou C++, a diferença entre
float32eint32, e atéint64, não deveria ser surpresa. Umfloat32com expoente grande consegue representar valores inteiros muito maiores queint64Entre 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
uint32_ttambém não consegue representar todos os valores deuint64_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 coisaEm uma linguagem de baixo nível, também seria possível mapear para instruções de assembly como
CVTTSS2SIouFCVTZS. 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 indefinidoMas é 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