¿Comparar la igualdad de números flotantes engaña a los desarrolladores junior incluso si no se produce un error de redondeo en mi caso?

31

Por ejemplo, quiero mostrar una lista de botones de 0,0.5, ... 5, que salta por cada 0.5. Utilizo un bucle for para hacer eso, y tengo un color diferente en el botón STANDARD_LINE:

var MAX=5.0;
var DIFF=0.5
var STANDARD_LINE=1.5;

for(var i=0;i<=MAX;i=i+DIFF){
    button.text=i+'';
    if(i==STANDARD_LINE){
      button.color='red';
    }
}

En este caso, no debería haber errores de redondeo ya que cada valor es exacto en IEEE 754. Pero estoy luchando si debería cambiarlo para evitar la comparación de igualdad de punto flotante:

var MAX=10;
var STANDARD_LINE=3;

for(var i=0;i<=MAX;i++){
    button.text=i/2.0+'';
    if(i==STANDARD_LINE/2.0){
      button.color='red';
    }
}

Por un lado, el código original es más simple y reenviado a mí. Pero hay una cosa que estoy considerando: ¿i == STANDARD_LINE engaña a los compañeros de equipo junior? ¿Oculta el hecho de que los números de coma flotante pueden tener errores de redondeo? Después de leer los comentarios de esta publicación:

https://stackoverflow.com/questions/33646148/is-hardcode-float-precise-if-it-can-be-represented-by-binary-format-in-ieee-754

Parece que hay muchos desarrolladores que no saben que algunos números flotantes son exactos. ¿Debo evitar las comparaciones de igualdad de números flotantes incluso si es válido en mi caso? ¿O estoy pensando demasiado en esto?

ocomfd
fuente
23
El comportamiento de estos dos listados de códigos no es equivalente. 3 / 2.0 es 1.5 pero isolo serán números enteros en la segunda lista. Intenta eliminar el segundo /2.0.
candied_orange
27
Si realmente necesita comparar dos FP para la igualdad (lo cual no es obligatorio como otros señalaron en sus excelentes respuestas, ya que puede usar una comparación de contador de bucle con enteros), pero si lo hizo, entonces un comentario debería ser suficiente. Personalmente, he estado trabajando con IEEE FP durante mucho tiempo y todavía estaría confundido si viera, por ejemplo, la comparación directa de SPFP sin ningún tipo de comentario ni nada. Es un código muy delicado, vale la pena comentar al menos cada vez que en mi humilde opinión.
14
Independientemente de cuál elija, este es uno de esos casos en los que un comentario que explica cómo y por qué es absolutamente esencial. Es posible que un desarrollador posterior ni siquiera considere las sutilezas sin un comentario para llamar su atención. Además, estoy muy distraído por el hecho de que buttonno cambia en ninguna parte de tu circuito. ¿Cómo se accede a la lista de botones? ¿Por índice en matriz o algún otro mecanismo? Si es por acceso de índice a una matriz, este es otro argumento a favor de cambiar a enteros.
jpmc26
99
Escribe ese código. Hasta que alguien piense que 0.6 sería un mejor tamaño de paso y simplemente cambia esa constante.
tofro
11
"... engañar a los desarrolladores junior" También engañará a los desarrolladores senior. A pesar de la cantidad de pensamiento que ha puesto en esto, asumirán que no sabía lo que estaba haciendo y, de todos modos, es probable que lo cambie a la versión entera.
GrandOpener

Respuestas:

116

Siempre evitaría las sucesivas operaciones de punto flotante a menos que el modelo que estoy computando los requiera. La aritmética de punto flotante no es intuitiva para la mayoría y es una fuente importante de errores. ¡Y contar los casos en los que causa errores de aquellos en los que no es una distinción aún más sutil!

Por lo tanto, el uso de flotantes como contadores de bucle es un defecto que espera suceder y requeriría al menos un comentario de fondo grueso que explique por qué está bien usar 0.5 aquí, y que esto depende del valor numérico específico. En ese punto, reescribir el código para evitar contadores flotantes probablemente sea la opción más legible. Y la legibilidad está al lado de la corrección en la jerarquía de los requisitos profesionales.

Kilian Foth
fuente
48
Me gusta "un defecto esperando a suceder". Claro, podría funcionar ahora , pero una ligera brisa de alguien que pasa lo romperá.
AakashM
10
Por ejemplo, suponga que los requisitos cambian de modo que en lugar de 11 botones igualmente espaciados de 0 a 5 con la "línea estándar" en el cuarto botón, tenga 16 botones igualmente espaciados de 0 a 5 con la "línea estándar" en el sexto botón. Entonces, quienquiera que haya heredado este código de usted cambia 0.5 a 1.0 / 3.0 y cambia 1.5 a 5.0 / 3.0. ¿Qué pasa entonces?
David K
8
Sí, me incomoda la idea de que cambiar lo que parece ser un número arbitrario (tan "normal" como podría ser un número) por otro número arbitrario (que parece igualmente "normal") en realidad introduce un defecto.
Alexander - Restablece a Mónica el
77
@Alexander: correcto, necesitarías un comentario que diga DIFF must be an exactly-representable double that evenly divides STANDARD_LINE. Si no desea escribir ese comentario (y confiar en que todos los futuros desarrolladores sepan lo suficiente sobre el punto flotante binario IEEE754 para comprenderlo), no escriba el código de esta manera. es decir, no escriba el código de esta manera. Especialmente porque probablemente no sea aún más eficiente: la adición de FP tiene una latencia más alta que la suma de enteros, y es una dependencia transportada en bucle. Además, los compiladores (¿incluso los compiladores JIT?) Probablemente funcionen mejor para hacer bucles con contadores enteros.
Peter Cordes
39

Como regla general, los bucles deben escribirse de tal manera que se piense hacer algo n veces. Si está utilizando índices de coma flotante, ya no se trata de hacer algo n veces sino de ejecutarlo hasta que se cumpla una condición. Si esta condición es muy similar a la i<nque esperan muchos programadores, entonces el código parece estar haciendo una cosa cuando en realidad está haciendo otra que los programadores pueden malinterpretar fácilmente.

Es algo subjetivo, pero en mi humilde opinión, si puede reescribir un bucle para usar un índice entero para recorrer un número fijo de veces, debe hacerlo. Considere la siguiente alternativa:

var DIFF=0.5;                           // pixel increment
var MAX=Math.floor(5.0/DIFF);           // 5.0 is max pixel width
var STANDARD_LINE=Math.floor(1.5/DIFF); // 1.5 is pixel width

for(var i=0;i<=MAX;i++){
    button.text=(i*DIFF)+'';
    if(i==STANDARD_LINE){
      button.color='red';
    }
}

El ciclo funciona en términos de números enteros. En este caso, ies un número entero y STANDARD_LINEse coacciona a un número entero también. Esto, por supuesto, cambiaría la posición de su línea estándar si ocurriera un redondeo y lo mismo MAX, por lo que aún debe esforzarse por evitar el redondeo para una representación precisa. Sin embargo, aún tiene la ventaja de cambiar los parámetros en términos de píxeles y no de números enteros sin tener que preocuparse por la comparación de los puntos flotantes.

Neil
fuente
3
También puede considerar redondear en lugar de pisos en las tareas, dependiendo de lo que desee. Si se supone que la división da un resultado entero, el piso puede dar sorpresas si te topas con números en los que la división está ligeramente desviada.
ilkkachu
1
@ilkkachu Cierto. Pensé que si está configurando 5.0 como la cantidad máxima de píxeles, entonces a través del redondeo, preferiría estar en el lado inferior de ese 5.0 en lugar de un poco más. 5.0 sería efectivamente un máximo. Aunque el redondeo puede ser preferible según lo que necesite hacer. En cualquier caso, hace poca diferencia si la división crea un número entero de todos modos.
Neil
44
Estoy totalmente en desacuerdo. La mejor manera de detener un ciclo es por la condición que más naturalmente expresa la lógica de negocios. Si la lógica de negocios es que necesita 11 botones, el ciclo debe detenerse en la iteración 11. Si la lógica de negocios es que los botones están separados por 0.5 hasta que la línea esté llena, el ciclo debe detenerse cuando la línea esté llena. Hay otras consideraciones que pueden impulsar la elección hacia un mecanismo u otro, pero en ausencia de esas consideraciones, elija el mecanismo que más se ajuste al requisito comercial.
Vuelva a instalar a Mónica el
Su explicación sería completamente correcto para Java / C ++ / Ruby / Python / ... Pero Javascript no tiene números enteros, por lo que i, y STANDARD_LINEsólo se verá como números enteros. No hay coerción en absoluto, y DIFF, MAXy STANDARD_LINEtodos son solo Numbers. NumberLos s usados ​​como enteros deberían estar seguros debajo 2**53, sin embargo, todavía son números de coma flotante.
Eric Duminil
@EricDuminil Sí, pero esta es la mitad. La otra mitad es legibilidad. Lo menciono como la razón principal para hacerlo de esta manera, no para la optimización.
Neil
20

Estoy de acuerdo con todas las otras respuestas de que usar una variable de bucle no entero es generalmente un mal estilo, incluso en casos como este, donde funcionará correctamente. Pero me parece que hay otra razón por la cual es un mal estilo aquí.

Su código "sabe" que los anchos de línea disponibles son precisamente los múltiplos de 0.5 desde 0 hasta 5.0. ¿Deberia? Parece que es una decisión de la interfaz de usuario que podría cambiar fácilmente (por ejemplo, tal vez desee que los espacios entre los anchos disponibles se hagan más grandes a medida que los anchos lo hacen. 0.25, 0.5, 0.75, 1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0 o algo así).

Su código "sabe" que los anchos de línea disponibles tienen representaciones "agradables" tanto como números de coma flotante como decimales. Eso también parece algo que podría cambiar. (Es posible que desee 0.1, 0.2, 0.3, ... en algún momento).

Su código "sabe" que el texto para poner en los botones es simplemente en lo que Javascript convierte esos valores de punto flotante. Eso también parece algo que podría cambiar. (Por ejemplo, quizás algún día desee anchos como 1/3, que probablemente no quiera mostrar como 0.33333333333333 o lo que sea. O tal vez quiera ver "1.0" en lugar de "1" para mantener la coherencia con "1.5" .)

Todos estos me parecen manifestaciones de una sola debilidad, que es una especie de mezcla de capas. Esos números de punto flotante son parte de la lógica interna del software. El texto que se muestra en los botones es parte de la interfaz de usuario. Deberían estar más separados de lo que están en el código aquí. Nociones como "¿cuál de estos es el valor predeterminado que debe resaltarse?" son asuntos relacionados con la interfaz de usuario, y probablemente no deberían estar vinculados a esos valores de punto flotante. Y su bucle aquí es realmente (o al menos debería ser) un bucle sobre botones , no sobre anchos de línea . Escrito de esa manera, la tentación de usar una variable de bucle que toma valores no enteros desaparece: solo estarías usando enteros sucesivos o un for ... in / for ... of loop.

Mi opinión es que la mayoría de los casos en los que uno podría verse tentado a recorrer números no enteros son así: hay otras razones, totalmente ajenas a los problemas numéricos, por las cuales el código debe estar organizado de manera diferente. (No todos los casos; me imagino que algunos algoritmos matemáticos podrían expresarse de manera más clara en términos de un bucle sobre valores no enteros).

Gareth McCaughan
fuente
8

Un código de olor está usando flotadores en bucle como ese.

El bucle se puede hacer de muchas maneras, pero en el 99.9% de los casos, debe apegarse a un incremento de 1 o definitivamente habrá confusión, no solo por los desarrolladores junior.

Pieter B
fuente
No estoy de acuerdo, creo que los múltiplos enteros de 1 no son confusos en un ciclo for. No lo consideraría un código de olor. Solo fracciones.
CodeMonkey
3

Sí, quieres evitar esto.

Los números de coma flotante son una de las mayores trampas para el programador desprevenido (lo que significa, en mi experiencia, casi todos). Desde depender de las pruebas de igualdad de punto flotante, hasta representar el dinero como punto flotante, todo es un gran atolladero. Agregar una carroza sobre la otra es uno de los mayores delincuentes. Hay volúmenes enteros de literatura científica sobre cosas como esta.

Use números de coma flotante exactamente en los lugares donde sean apropiados, por ejemplo, al hacer cálculos matemáticos reales donde los necesita (como trigonometría, gráficos de funciones de trazado, etc.) y tenga mucho cuidado al realizar operaciones en serie. La igualdad está justo afuera. El conocimiento sobre qué conjunto particular de números es exacto según los estándares IEEE es muy arcano y nunca dependería de él.

En su caso, no habrá , por la Ley Murphys, llegado el punto en el que la gestión quiere que usted no tiene 0.0, 0.5, 1.0 ... pero 0.0, 0.4, 0.8 ... o lo que sea; usted será inmediatamente molestado, y su programador junior (o usted mismo) lo depurará durante mucho tiempo hasta que encuentre el problema.

En su código particular, de hecho tendría una variable de bucle entero. Representa el ibotón th, no el número de ejecución.

Y que probablemente, en aras de la claridad adicional, no escribir i/2, pero i*0.5lo que lo hace muy claro lo que está pasando.

var BUTTONS=11;
var STANDARD_LINE=3;

for(var i=0; i<BUTTONS; i++) {
    button.text = (i*0.5)+'';
    if (i==STANDARD_LINE) {
      button.color='red';
    }
}

Nota: como se señaló en los comentarios, JavaScript en realidad no tiene un tipo separado para enteros. Pero se garantiza que los números enteros de hasta 15 dígitos sean precisos / seguros (consulte https://www.ecma-international.org/ecma-262/6.0/#sec-number.max_safe_integer ), por lo tanto, para argumentos como este ("es más confuso / propenso a errores para trabajar con enteros o no enteros ") esto está cerca de tener un tipo separado" en espíritu "; en el uso diario (bucles, coordenadas de pantalla, índices de matriz, etc.) no habrá sorpresas con números enteros representados Numbercomo JavaScript.

AnoE
fuente
Cambiaría el nombre BUTTONS a otra cosa: hay 11 botones después de todo y no 10. Quizás FIRST_BUTTON = 0, LAST_BUTTON = 10, STANDARD_LINE_BUTTON = 3. Aparte de eso, sí, así es como debes hacerlo.
gnasher729
Es cierto, @EricDuminil, y he agregado un poco sobre esto en la respuesta. ¡Gracias!
AnoE
1

No creo que ninguna de sus sugerencias sea buena. En cambio, introduciría una variable para la cantidad de botones en función del valor máximo y el espaciado. Luego, es lo suficientemente simple como para recorrer los índices del botón.

function precisionRound(number, precision) {
  let factor = Math.pow(10, precision);
  return Math.round(number * factor) / factor;
}

var maxButtonValue = 5.0;
var buttonSpacing = 0.5;

let countEstimate = precisionRound(maxButtonValue / buttonSpacing, 5);
var buttonCount = Math.floor(countEstimate) + 1;

var highlightPosition = 3;
var highlightColor = 'red';

for (let i=0; i < buttonCount; i++) {
    let buttonValue = i / buttonSpacing;
    button.text = buttonValue.toString();
    if (i == highlightPosition) {
        button.color = highlightColor;
    }
}

Puede ser más código, pero también es más legible y más robusto.

Jared Goguen
fuente
0

Puede evitar todo calculando el valor que está mostrando en lugar de usar el contador de bucle como valor:

var MAX=5.0;
var DIFF=0.5
var STANDARD_LINE=1.5;

for(var i=0; (i*DIFF) < MAX ; i=i+1){
    var val = i * DIFF

    button.text=val+'';

    if(val==STANDARD_LINE){
      button.color='red';
    }
}
Arnab Datta
fuente
-1

La aritmética de coma flotante es lenta y la aritmética de enteros es rápida, por lo que cuando uso coma flotante, no la usaría innecesariamente cuando se pueden usar enteros. Es útil pensar siempre en números de coma flotante, incluso constantes, como aproximados, con algún pequeño error. Es muy útil durante la depuración reemplazar los números de coma flotante nativos con objetos de coma flotante más / menos donde trata cada número como un rango en lugar de un punto. De esa manera, descubrirá imprecisiones de crecimiento progresivo después de cada operación aritmética. Entonces, "1.5" debe considerarse como "algún número entre 1.45 y 1.55", y "1.50" debe considerarse como "algún número entre 1.495 y 1.505".

Jacquez
fuente
55
La diferencia de rendimiento entre enteros y flotantes es importante al escribir código C para un microprocesador pequeño, pero las CPU modernas derivadas de x86 hacen un punto flotante tan rápido que cualquier penalización se eclipsa fácilmente por la sobrecarga de usar un lenguaje dinámico. En particular, ¿Javascript no representa cada número como punto flotante de todos modos, utilizando la carga útil de NaN cuando es necesario?
Leftaroundabout
1
"La aritmética de coma flotante es lenta y la aritmética de enteros es rápida" es una verdad histórica que no debes conservar a medida que avanza el evangelio. Para agregar a lo que dijo @leftaroundabout, no solo es cierto que la penalización sería casi irrelevante, es muy posible que las operaciones de punto flotante sean más rápidas que sus operaciones enteras equivalentes, gracias a la magia de autovectorizar compiladores y conjuntos de instrucciones que pueden aplastar Grandes cantidades de flotadores en un ciclo. Para esta pregunta no es relevante, pero la suposición básica de "entero es más rápido que flotante" no ha sido cierta durante bastante tiempo.
Jeroen Mostert
1
@JeroenMostert SSE / AVX tiene operaciones vectorizadas para números enteros y flotantes, y es posible que pueda usar números enteros más pequeños (porque no se desperdician bits en el exponente), por lo que, en principio , a menudo se puede obtener más rendimiento del código entero altamente optimizado que con carrozas Pero nuevamente, esto no es relevante para la mayoría de las aplicaciones y definitivamente no para esta pregunta.
Leftaroundabout 01 de
1
@leftaroundabout: Claro. Mi punto no era sobre cuál es definitivamente más rápido en una situación dada, solo que "Sé que FP es lento y entero es rápido, así que usaré enteros si es posible" no es una buena motivación incluso antes de abordar el pregunta si lo que estás haciendo necesita optimización.
Jeroen Mostert