Потеря точности из Double во Float или «Куда пропадали копейки?»

    Преобразование чисел из одного типа в другой обычно ведется таким образом, чтобы не потерять лишних чисел, т.е. из меньшего типа к более вместительному. Но что, если предыдущий разрабочик использовал конвертацию из Double во Float и стали пропадать копейки в отчетах?
    В статье приводится изучение конвертации плавающих чисел в Java:
        99999999.33333333 -> 100000000.0000000
        98888888.33333333 ->  98888888.0000000
         2974815.78000000 ->   2974815.7500000
    

    Давайте разберемся, к чему приводит такое преобразование и почему все происходит именно так. Ведь казалось бы, раз используемые в проекте числа далеки от максимальных значений типов float и double, то конвертация его из первого во второй не должна повлечь за собой отрицательных последствий в большинстве случаев.

    Любые мысли лучше подкреплять конкретными примерами, поэтому сразу же код, который родился сначала на дрожжах реальных цифр, но потом, под влиянием дискуссии на stackoverflow о подобной конвертации, он превратился в нечто более интригующее.
    public class Main {
    
        static void testDoubleToFloat(double d) {
            float f = (float) d;
    
            System.out.println();
            System.out.println(String.format("double %.10f\t%s", d, Long.toBinaryString(Double.doubleToRawLongBits(d))));
            System.out.println(String.format("float  %.10f\t   %s", f, Integer.toBinaryString(Float.floatToRawIntBits(f))));
        }
    
        public static void main(String[] args) {
            System.out.println(String.format("double: %.10f / %.10f", Double.MIN_VALUE, Double.MAX_VALUE));
            System.out.println(String.format("float: %.10f / %.10f", Float.MIN_VALUE, Float.MAX_VALUE));
    
            /*
                По умолчанию, вычисления с плавающей точкой ведутся с помощью double.
            */
            testDoubleToFloat(99999999.0 + 1.0 / 3.0); // Добавим периодичности
            testDoubleToFloat(98888888.0 + 1.0 / 3.0); // Вариант без округления девяток
            testDoubleToFloat(2974815.78);
            testDoubleToFloat(-2974815.78);
        }
    }
    

    Результат выполнения
    double: 0.0000000000 / 179769313486231570000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000.0000000000
    float: 0.0000000000 / 340282346638528860000000000000000000000.0000000000
    
    double 99999999.3333333300  100000110010111110101111000001111111101010101010101010101010101
    float  100000000.0000000000    1001100101111101011110000100000
    
    double 98888888.3333333300  100000110010111100100111011001011100001010101010101010101010101
    float  98888888.0000000000     1001100101111001001110110010111
    
    double 2974815.7800000000   100000101000110101100100010111111100011110101110000101000111101
    float  2974815.7500000000      1001010001101011001000101111111
    
    double -2974815.7800000000  1100000101000110101100100010111111100011110101110000101000111101
    float  -2974815.7500000000    11001010001101011001000101111111
    

    Одинаково для
    /opt/jdk1.7/bin/java -version
    java version "1.7.0_25"
    Java(TM) SE Runtime Environment (build 1.7.0_25-b15)
    Java HotSpot(TM) 64-Bit Server VM (build 23.25-b01, mixed mode)
    

    И для
    java -version
    java version "1.7.0_25"
    OpenJDK Runtime Environment (IcedTea 2.3.12) (7u25-2.3.12-4ubuntu3)
    OpenJDK 64-Bit Server VM (build 23.7-b01, mixed mode)
    


    Способ конвертации


    Для демонстрации того, что разнообразные конструкции одинаково конвертируют double во float, добавим разнообразные способы и сравним результаты:
    public class Main {
    
        static void testDoubleToFloat(double d) {
            float f = (float) d;
            Float f2 = new Float(d);
            float f3 = Float.parseFloat(new Double(d).toString());
            float f4 = Float.parseFloat(String.format("%.10f", d));
    
            System.out.println();
            System.out.println(String.format("double %.10f\t%s", d, Long.toBinaryString(Double.doubleToRawLongBits(d))));
            System.out.println(String.format("float  %.10f\t   %s", f, Integer.toBinaryString(Float.floatToRawIntBits(f))));
            System.out.println(String.format("Float  %.10f\t   %s", f2, Integer.toBinaryString(Float.floatToRawIntBits(f2))));
            System.out.println(String.format("float  %.10f\t   %s", f3, Integer.toBinaryString(Float.floatToRawIntBits(f3))));
    
            System.out.println(String.format("float  %.10f\t   %s", f4, Integer.toBinaryString(Float.floatToRawIntBits(f4))));
        }
    
        public static void main(String[] args) {
            System.out.println(String.format("double: %.10f / %.10f", Double.MIN_VALUE, Double.MAX_VALUE));
            System.out.println(String.format("float: %.10f / %.10f", Float.MIN_VALUE, Float.MAX_VALUE));
    
            testDoubleToFloat(99999999.0 + 1.0 / 3.0);
            testDoubleToFloat(98888888.0 + 1.0 / 3.0);
            testDoubleToFloat(2974815.78);
            testDoubleToFloat(-2974815.78);
        }
    }
    

    Результат выполнения
        double: 0.0000000000 / 179769313486231570000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000.0000000000
        float: 0.0000000000 / 340282346638528860000000000000000000000.0000000000
    
        double 99999999.3333333300  100000110010111110101111000001111111101010101010101010101010101
        float  100000000.0000000000    1001100101111101011110000100000
        Float  100000000.0000000000    1001100101111101011110000100000
        float  100000000.0000000000    1001100101111101011110000100000
        float  100000000.0000000000    1001100101111101011110000100000
    
        double 98888888.3333333300  100000110010111100100111011001011100001010101010101010101010101
        float  98888888.0000000000     1001100101111001001110110010111
        Float  98888888.0000000000     1001100101111001001110110010111
        float  98888888.0000000000     1001100101111001001110110010111
        float  98888888.0000000000     1001100101111001001110110010111
    
        double 2974815.7800000000   100000101000110101100100010111111100011110101110000101000111101
        float  2974815.7500000000      1001010001101011001000101111111
        Float  2974815.7500000000      1001010001101011001000101111111
        float  2974815.7500000000      1001010001101011001000101111111
        float  2974815.7500000000      1001010001101011001000101111111
    
        double -2974815.7800000000  1100000101000110101100100010111111100011110101110000101000111101
        float  -2974815.7500000000    11001010001101011001000101111111
        Float  -2974815.7500000000    11001010001101011001000101111111
        float  -2974815.7500000000    11001010001101011001000101111111
        float  -2974815.7500000000    11001010001101011001000101111111
    


    Как можно убедиться, выражения «new Float(d)» и "(float) d" дают одинаковый результат, т.к. первое использует второе:
    /*
     * Copyright (c) 1994, 2010, Oracle and/or its affiliates. All rights reserved.
     * ORACLE PROPRIETARY/CONFIDENTIAL. Use is subject to license terms.
     */
    ...
    public final class Float extends Number implements Comparable<Float> {
    ...
        public Float(double value) {
            this.value = (float)value;
        }
    ...
    }
    

    Если разбираться с функцией Float.parseFloat, то она отсылает нас через несколько других функций до следующей строки:
        return (float)Double.longBitsToDouble( lbits );
    

    Которая точно таким же образом конвертирует double-переменную во float.
    Таким образом, мы убедились, что, по крайней мере, в openjdk наиболее очевидные пути преобразования double во float сводятся к одной конструкции:
    float f = (float) d;
    

    Форматы хранения float и double


    В каждом примере мы вызывали функции Long.toBinaryString для Double и Integer.toBinaryString для Float, чтобы продемонстрировать форматы низкоуровневого хранения созданных переменных. Прекрасная статья об этом уже была написана (Что нужно знать про арифметику с плавающей запятой, которая стала отличным переводом английской вики, где хорошо рассказано и про двойную точность), поэтому здесь мы рассмотрим только то, касается округления.
    Представленные выше программы вернули следующие результаты:
        double 2974815.7800000000   100000101000110101100100010111111100011110101110000101000111101
        float  2974815.7500000000      1001010001101011001000101111111
    

    Тип double занимает 64 бита, а тип float 32, но мы видим 63 и 31 знак — это издержка реализации вывода, который заканчивается, когда остаются только нули. Следовательно, эти числа должны выглядеть так:
        double 2974815.7800000000   0 10000010100 0110101100100010111111100011110101110000101000111101
        float  2974815.7500000000      0 10010100 01101011001000101111111
    

    Первый бит — это знак числа. Далее 11 (для double) или 8 бит (для float) — экспонента. После — мантисса, которая и играет самую интересную роль. Первое впечатление: просто теряются все числа после 23-его бита при конвертации из double во float. Но давайте сначала попробуем восстановить эти числа, чтобы разобраться во всем по порядку:
    • 1-ые биты 0 => знаки положительные
    • Далее экспоненты: 0100000101002-102310=21

      и 100101002-12710=21
    • Мантиссы: 1.01101011001000101111111000111101011100001010001111012*221 — 52=2974815.779999749

      и 1.011010110010001011111112*221 — 23=2974815.75

    Таким образом, отсекая от двоичного 0110 1011 0010 0010 1111 1110 0011 1101 0111 0000 1010 0011 1101 из double последние 29 цифр, мы получаем 011 0101 1001 0001 0111 1111 во float, которое дает нам несколько другое число.

    Вывод


    Таким образом, конвертация из формата большей точности может привести к нетривиальным потерям достоверности используемых чисел. Что же касается непосредственно Java, то в ней лучше использовать тип Double, т.к. работа с Float чревата конвертациями из Double с потерями. А для хранения денег использовать BigDecimal, чтобы не терять копейки.

    P.S.


    В системе отслеживания ошибок проекта OpenJDK нашлись плотно связанные с данной темой:

    В качетве обхода для этих задач написано:
    CUSTOMER SUBMITTED WORKAROUND:
    Avoiding direct String-to-float conversion by using intermediate doubles.


    Предложенные решения


    1. Хранить деньги в банкахBigDecimal:
              BigDecimal bg = new BigDecimal("2974815.78");
              System.out.println(bg);
      

    2. Класс Currency по типу:
      class Currency {
          long value;
          ...
          public double asDouble() {
             return value / 100.0;
          }
          ...
          <всяческие удобства для работы, в т.ч. свой парсинг строк, например, на StringTokenizer>
          ...
      }
      

      Думаю, решение повлечет за собой другие ошибки/особенности, хотя имеет право на жизнь.
    Поделиться публикацией

    Похожие публикации

    Комментарии 18
      +22
      Вы храните деньги в double? Зачем?
        –5
        У нас система, взаимодействующая с другими системами, некоторые из которых даже передают числа в строках, а отдельные экземпляры и хранят их там же (что ж поделаешь с чужими системами?). И когда тестировщик видит в одной системе 2974815.78, а в другой 2974815.75, то начинаются вопросы о том, какую же сумму будет корректно указывать в договоре.
          +7
          Вот именно. Fixed-point 56.7 использовать надо. Ну а если денег до 16,777,215.99 рублей — то 24.7.
            –3
            Думаете, лучше хранить в Integer (а кому-то и в Long) в формате <сумма>*100? — Т.е. при выводе и пр. делить на 100, чтобы получить копейки.
              +11
              В BigDecimal же.
                +4
                Просто удивительно, сколько раз люди попадают на эту проблему.
                Хотя мне кажется уже на каждом углу говорят об этом.
                  +1
                  На самом деле BigDecimal крайне неудобен в использовании, поэтому если нет явных требований в точности double самое оно.
                    +4
                    «Маловато будет». Пара умножений/делений — и вы вылетите за пределы точности типа даже не заметив.
                    0
                    Простите, а можете скинуть ссылки на эти «углы», чтобы ищущие по теме смогли сразу раскрутить клубок, особенно учитывая ограниченность данного материала Явой?
                      +1
                      Это не ограниченность явой, это ограниченность модели представления данных с плавающей точкой, описанной в IEEE 754, которая применяется сейчас практически в любом ЯП.

                      float money problem: About 30,600,000 results (0.38 seconds)
                        –2
                        Я про ограниченность не Явы, а примеров в данном материале, изложенных исключительно на Яве.
                  +7
                  Это общепринятый подход. В MS Office, например, даже специальный целочисленный тип данных есть — Currency
                    +5
                    В точных операциях (в том числе и фанансовых) нельзя использовать типы с плавающими точками из-за их ограниченной точности. Даже строка лучше в этой ситуации. В идеале — длинная арифметика. BigDecimal, как я понимаю (java я не знаю) — это оно и есть.
                      0
                      В том языке, который Вы предпочитаете, какой тип лучше подходит для хранения денег?
                        0
                        Не сталкивался я на практике с расчётными финансовыми задачами.
                        В Objective-C для отображения цен использую NSDecimalNumber потому как его для этих целей использует iOS SDK.
                        Но это таки тип с плавающей точкой, пусть и способен хранить до 38 значимых цифр, и ничего большего чем просто хранение цены с ним делать всё равно не стоит.

                        Если бы мне пришлось делать какие-то расчётные операции — я бы поискал реализацию длинной арифметики на C и использовал бы её. Из минусов — большое потребление памяти и низкое быстродействие, в сравнении со стандартными типами. Из плюсов — максимальное хранимое значение упирается только в объём доступной оперативной памяти.
                  0
                  BigDecimal bg = new BigDecimal(«2974815.78»);
                  bg.setScale(2);
                  System.out.println(bg);

                  Пара вопросов:
                  — зачем вам bg.setScale (особой нужды быть не должно...)?
                  — вы в курсе, что BigDecimal immutable-объект и ваш код — источник проблем?
                    0
                    Верно, исправил.
                    +5
                    Если у нас Java, то никто не мешает сделать класс с полями хранения рублей и копеек отдельными полями long и int, тот же Currency назвать.
                    А про игры с плавающей точкой уже действительно много написано, на том же хабре куча статей почему это происходит и т.п.

                    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                    Самое читаемое