¿Por qué C y Java flotan de forma diferente?




floating-point printf (2)

Considere el número de coma flotante 0.644696875. Convirtámoslo a una cadena con ocho decimales usando Java y C:

Java

import java.lang.Math;
public class RoundExample{
     public static void main(String[] args){
        System.out.println(String.format("%10.8f",0.644696875));
     }
}

resultado: 0.6446968 8

Pruébelo usted mismo: http://tpcg.io/oszC0w

C

#include <stdio.h>

int main()
{
    printf("%10.8f", 0.644696875); //double to string
    return 0;
}

resultado: 0.6446968 7

Pruébelo usted mismo: http://tpcg.io/fQqSRF

La pregunta

¿Por qué es diferente el último dígito?

Antecedentes

El número 0.644696875 no se puede representar exactamente como un número de máquina. Se representa como la fracción 2903456606016923/4503599627370496 que tiene un valor de 0.6446968749999999

Este es ciertamente un caso extremo. Pero tengo mucha curiosidad sobre la fuente de la diferencia.

Relacionado: https://mathematica.stackexchange.com/questions/204359/is-numberform-double-rounding-numbers


Conclusión

La especificación de Java requiere un doble redondeo problemático en esta situación. El número 0.6446968749999999470645661858725361526012420654296875 se convierte primero a 0.644696875 y luego se redondea a 0.64469688.

En contraste, la implementación en C simplemente redondea 0.6446968749999999470645661858725361526012420654296875 directamente a ocho dígitos, produciendo 0.64469687.

Preliminares

Para Double , Java utiliza IEEE-754 de punto flotante binario básico de 64 bits. En este formato, el valor más cercano al número en el texto fuente, 0.644696875, es 0.6446968749999999470645661858725361526012420654296875, y creo que este es el valor real que se formateará con String.format("%10.8f",0.644696875) . 1

Lo que dice la especificación Java

La documentación para formatear con el tipo Double y el formato f dice:

… Si la precisión es menor que la cantidad de dígitos que aparecerían después del punto decimal en la cadena devuelta por Float.toString(float) o Double.toString(double) respectivamente, entonces el valor se redondeará utilizando el algoritmo de redondear hacia arriba . De lo contrario, se pueden agregar ceros para alcanzar la precisión ...

Consideremos "la cadena devuelta por ... Double.toString(double) ". Para el número 0.6446968749999999470645661858725361526012420654296875, esta cadena es “0.644696875”. Esto se debe a que la especificación Java dice que toString produce los dígitos decimales suficientes para distinguir de forma exclusiva el número dentro del conjunto de valores Double , y "0.644696875" tiene los dígitos suficientes en este caso. 2

Ese número tiene nueve dígitos después del punto decimal, y "%10.8f" solicita ocho, por lo que el pasaje citado arriba dice que "el valor" se redondea. ¿Qué valor significa: el operando real del format , que es 0.6446968749999999470645661858725361526012420654296875, o esa cadena que menciona, "0.644696875"? Como este último no es un valor numérico, habría esperado que "el valor" significara el primero. Sin embargo, la segunda oración dice "De lo contrario [es decir, si se solicitan más dígitos], se pueden agregar ceros ..." Si estuviéramos usando el operando real del format , mostraríamos sus dígitos, no usaríamos ceros. Pero, si tomamos la cadena como un valor numérico, su representación decimal solo tendría ceros después de los dígitos que se muestran en ella. Entonces parece que esta es la interpretación que se pretende, y las implementaciones de Java parecen ajustarse a eso.

Entonces, para formatear este número con "%10.8f" , primero lo convertimos a 0.644696875 y luego lo redondeamos usando la regla de redondear hacia arriba, que produce 0.64469688.

Esta es una mala especificación porque:

  • Requiere dos redondeos, lo que puede aumentar el error.
  • Los redondeos ocurren en lugares difíciles de predecir y difíciles de controlar. Algunos valores se redondearán después de dos decimales. Algunos se redondearán después de 13. Un programa no puede predecir esto fácilmente o ajustarlo.

(Además, es una lástima que escribieran ceros "pueden estar" anexados. ¿Por qué no "de lo contrario, se añaden ceros para alcanzar la precisión"? Con "may", parece que están dando una opción a la implementación, aunque sospecho que significa que el "mayo" se basa en si se necesitan ceros para alcanzar la precisión, no en si el implementador elige agregarlos).

Nota

1 Cuando 0.644696875 en el texto fuente se convierte en Double , creo que el resultado debería ser el valor más cercano representable en el formato Double . (No lo he encontrado en la documentación de Java, pero se ajusta a la filosofía de Java de exigir que las implementaciones se comporten de manera idéntica, y sospecho que la conversión se realiza de acuerdo con Double.valueOf(String s) , que sí lo requiere ). Double a 0.644696875 es 0.6446968749999999470645661858725361526012420654296875.

2 Con menos dígitos, el 0.64469687 de siete dígitos es insuficiente porque el valor Double más cercano es 0.6446968 699999999774519210404832847416400909423828125 . Por lo tanto, se necesitan ocho dígitos para distinguir de manera única 0.6446968 749999999470645661858725361526012420654296875 .


Probablemente lo que está sucediendo aquí es que están utilizando métodos ligeramente diferentes para convertir el número en una cadena, lo que introduce un error de redondeo. También es posible que el método por el cual la cadena se convierte en un flotante durante la compilación sea diferente entre ellos, lo que nuevamente puede dar valores ligeramente diferentes debido al redondeo.

Sin embargo, recuerde que float tiene 24 bits de precisión para su fracción, que sale a ~ 7.22 dígitos decimales [log10 (2) * 24], y los primeros 7 dígitos coinciden entre ellos, por lo que son solo los últimos bits menos significativos los que son diferente.

Bienvenido al divertido mundo de Floating Point Math, donde 2 + 2 no siempre es igual a 4.







printf