2 puntos por GN⁺ 2 시간 전 | 1 comentarios | Compartir por WhatsApp
  • Si el valor de un float tras 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 y static_cast
  • -Wall y -Wextra no advierten sobre esto, y -Wconversion tambié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, CVTTSS2SI trata los valores no representables como INT_MIN, pero en AArch64 FCVTZS hace 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) y static_cast<int>(f) provocan UB con algunas entradas
  • Es difícil encontrar todos los problemas solo con las advertencias habituales del compilador
    • -Wall y -Wextra no advierten sobre ninguna de las tres conversiones
    • -Wconversion solo 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, CVTTSS2SI mapea entradas no representables a INT_MIN
    • En AArch64, FCVTZS satura el resultado y mapea NaN a 0
    • Un UB ejecutado puede hacer que el código falle repentinamente cuando el compilador aplique otras conversiones

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
  • En Undefined Behavior Sanitizer de Clang y GCC, se puede usar -fsanitize=float-cast-overflow para detectar este UB
    • Se recomienda probar todo el código C++ con UBSan

1 comentarios

 
GN⁺ 2 시간 전
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.

    • Si esa fuera la razón, debería ser comportamiento definido por la implementación, no comportamiento indefinido. Tiene sentido que dividir entre cero sea comportamiento indefinido porque en algunas arquitecturas puede provocar un trap.
      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.
    • Probablemente sí. La familia fctiw de PowerPC hace conversión saturada, convierte NaN en INT_MIN y también activa flags en el FPSCR. En Power ISA de 64 bits, fctid hace 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.

    • Lo que pasó esta vez no tiene nada que ver con IEEE 754; es totalmente culpa de C++.
  • Si has usado C o C++, no debería sorprenderte la diferencia entre float32 e int32, ni siquiera int64. Un float32 con exponente grande puede representar valores enteros mucho mayores que int64.
    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.

    • Se te está escapando el punto. Que no se puedan conservar todos los valores de punto flotante y que haya comportamiento indefinido no son lo mismo.
      uint32_t tampoco puede representar todos los valores de uint64_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.
    • Que los rangos de representación sean distintos no significa que no se pueda definir una conversión segura. Rust define explícitamente la conversión de punto flotante→entero, y la especificación del lenguaje Java 26 detalla el procedimiento de conversión en la sección 5.1.3.
      En un lenguaje de bajo nivel, también sería posible mapearlo a instrucciones de ensamblador como CVTTSS2SI o FCVTZS. 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.
    • Que según el hardware una implementación produzca valores basura o que el hardware o una herramienta de comprobación hagan trap y detengan el programa no es algo tan sorprendente.
      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.
    • Esto sí era nuevo para mí. Pensaba que, al convertir un punto flotante no representable a entero, como mucho se obtenía un valor basura en el entero resultante; no sabía que todo el programa quedaba contaminado para siempre y fuera del control del estándar de C++.