- Si el valor de un
floattras descartar la parte fraccionaria queda fuera del rango del tipo entero de destino, se produce comportamiento indefinido (UB); esto afecta por igual a conversiones implícitas, casts de estilo funcional ystatic_cast -Wally-Wextrano advierten sobre esto, y-Wconversiontambién es fácil de pasar por alto porque solo detecta conversiones implícitas- La función de conversión segura con reducción de Microsoft GSL,
gsl::narrow, también provoca UB en algunas entradas de punto flotante→entero, por lo que no cumple el comportamiento documentado de lanzar una excepción ante valores no representables - En x86,
CVTTSS2SItrata los valores no representables comoINT_MIN, pero en AArch64FCVTZShace una conversión saturada y convierte NaN en 0, por lo que los resultados pueden variar según el hardware - Para convertir de forma segura, hay que verificar el rango antes del cast; el problema puede detectarse con la opción de UBSan de Clang y GCC
-fsanitize=float-cast-overflow
Reglas de conversión y límites de detección
- Según las reglas de conversión entre punto flotante y entero en C++, si después de descartar la parte fraccionaria el valor no cabe en el tipo entero de destino, se convierte en comportamiento indefinido
- Aunque el destino sea unsigned, no se aplica aritmética modular
int i0 = f,int(f)ystatic_cast<int>(f)provocan UB con algunas entradas
- Es difícil encontrar todos los problemas solo con las advertencias habituales del compilador
-Wally-Wextrano advierten sobre ninguna de las tres conversiones-Wconversionsolo advierte sobre conversiones implícitas
- Aunque el programa siga ejecutándose en el procesador y compilador actuales, los resultados pueden variar entre plataformas
- En x86,
CVTTSS2SImapea entradas no representables aINT_MIN - En AArch64,
FCVTZSsatura el resultado y mapea NaN a 0 - Un UB ejecutado puede hacer que el código falle repentinamente cuando el compilador aplique otras conversiones
- En x86,
Caso de GSL y respuesta segura
gsl::narrow, de la Guidelines Support Library de Microsoft, se presenta como una conversión segura con reducción que lanza una excepción ante valores que no se pueden representar en el tipo de destino- En la conversión real de punto flotante→entero, para algunas entradas primero se ejecuta UB, por lo que no coincide con la documentación
- El equipo de GSL consideró que, en las plataformas de destino, el UB interno era inocuo porque no tocaba representaciones de trampa de hardware; ese razonamiento quedó reflejado en el código, y el problema no se corrigió
- La solución correcta es verificar el rango antes del cast
- Existe una biblioteca de prueba de concepto cpp-clamp-cast basada en el enfoque de conversiones saturadas de Rust
- En Undefined Behavior Sanitizer de Clang y GCC, se puede usar
-fsanitize=float-cast-overflowpara detectar este UB- Se recomienda probar todo el código C++ con UBSan
1 comentarios
Comentarios en Lobste.rs
Conocía muchos casos sutiles de comportamiento indefinido en C y C++, pero este me sorprendió.
Rust también heredó durante un tiempo el mismo comportamiento indefinido por las reglas de conversión de punto flotante→entero de LLVM IR, y recién en 2020 se corrigió para generar un IR más complejo. Como la diferencia de rendimiento es grande, también ofrece una conversión de punto flotante→entero sin comprobaciones para bucles de alto rendimiento donde se sabe que el valor es finito y está dentro del rango del tipo destino.
Es absurdo que la C++ Core Guidelines Library trate esto como si no fuera gran cosa. LLVM usó la información obtenida de la conversión para eliminar comprobaciones de límites, y como resultado hubo casos de acceso fuera de rango en arreglos aun con comprobación de límites. Si se toma en serio la seguridad de memoria y evitar comportamiento indefinido, esto no se puede ignorar, y también decepciona que Herb Sutter haya hablado de “comportamiento indefinido benigno”
Sabía que C++ tiene mucho comportamiento indefinido, pero esto sorprende especialmente. Me pregunto si esto se dejó como comportamiento indefinido porque se intentó hacer la conversión lo más rápida posible y hay instrucciones que manejan estos valores extremos de forma distinta según la arquitectura. Parece una razón principal para que exista esta regla, similar al desbordamiento de enteros con signo.
El comportamiento indefinido debería limitarse a casos donde ni siquiera en la misma plataforma se puede garantizar un resultado consistente por efectos fuera de la máquina abstracta de C, como usar memoria después de liberarla, o cuando en algunos destinos puede haber un trap.
Las implementaciones conformes al estándar pueden definir por su cuenta el comportamiento indefinido, y GCC lo hace en algunos casos. Si se pudiera garantizar una semántica estable sin costo de rendimiento, también se podría definir tal cual el comportamiento de las instrucciones de punto flotante→entero según cada destino, aunque en otras implementaciones seguiría siendo comportamiento indefinido.
fctiwde PowerPC hace conversión saturada, convierte NaN enINT_MINy también activa flags en el FPSCR. En Power ISA de 64 bits,fctidhace lo mismo con enteros más grandes, pero en ninguno de los dos casos coincide con el comportamiento de AArch64 o x86.En casos así sería mejor tratarlo como comportamiento definido por la implementación o como un valor no especificado. No tiene sentido que, solo por convertir infinito a
int, el programa completo quede fuera del control del estándar de C++.C++26 eliminó algunos comportamientos indefinidos absurdos, y este es claramente otro candidato a ser eliminado. Según pruebas, GCC y Clang parecen no usar este comportamiento indefinido para optimización, así que el impacto real parece limitado.
Este es otro caso desagradable en el que no hay razón para que la especificación defina esta operación como comportamiento indefinido.
Una razón más para odiar IEEE 754. A menos que realmente lo necesite por otras librerías o por rendimiento, trato de usar lo más posible enteros puros, racionales con numerador y denominador enteros grandes, o decimales de punto fijo en vez de punto flotante.
Si has usado C o C++, no debería sorprenderte la diferencia entre
float32eint32, ni siquieraint64. Unfloat32con exponente grande puede representar valores enteros mucho mayores queint64.Entre tipos que no son superconjuntos uno del otro y que tienen capacidades de representación distintas, no hay razón para asumir que una conversión de punto flotante→entero sea segura, independientemente de la sintaxis del lenguaje.
uint32_ttampoco puede representar todos los valores deuint64_t, pero la semántica de la conversión está bien definida como truncamiento. Aquí el problema es esencialmente distinto: para ciertas entradas, el compilador puede hacer cualquier cosa.En un lenguaje de bajo nivel, también sería posible mapearlo a instrucciones de ensamblador como
CVTTSS2SIoFCVTZS. Como los lenguajes de bajo nivel están muy ligados al ensamblador y otros comportamientos indefinidos “benignos” también son muy discutidos, resulta todavía más sorprendente que esta conversión permita comportamiento indefinido.Pero que además se permita miscompilar código no relacionado, formatear el disco duro o “hacer que salgan demonios de la nariz” sí sorprende.
Tiene sentido el concepto de comportamiento indefinido en casos como doble liberación o escritura fuera de los límites de un arreglo, donde realmente puede pasar cualquier cosa, pero C y C++ abusan de él incluso donde podrían imponer reglas más estrictas, como valores basura definidos por la implementación o la terminación del programa. Rust hace trap en algunos desbordamientos enteros en modo depuración y devuelve un valor no especificado en modo release, pero no permite que eso rompa código que no tiene relación.
C++ no necesita volverse Java o Rust, pero claramente sería un mejor lenguaje si redujera el comportamiento indefinido innecesario.