Теория и практика Java: Где ваша точка?

Уловки и ловушки, связанные с плавающей точкой и десятичными числами

Многие программисты на протяжении всей своей карьеры спокойно обходятся без использования чисел с фиксированной или плавающей точкой, вероятно за исключением нечетно-временного тестирования или программ оценки производительности. Библиотеки классов и язык Java поддерживают два вида дробных числовых типов - IEEE 754 с плавающей точкой (float и double, и классы-оболочки (Float и Double), а также десятичные числа с произвольной разрядностью (java.math.BigDecimal). В этом месяце в статье Теории и практики Java, Брайан Гетц приводит описание некоторых ловушек и "сбоев", зачастую встречающихся при использовании дробных числовых типов в программах Java.

Брайан Гетц, главный консультант, Quiotix

Брайан Гетц (Brian Goetz) - консультант по ПО и последние 15 лет работал профессиональными разработчиком ПО. Сейчас он является главным консультантом в фирме Quiotix, занимающейся разработкой ПО и консалтингом и находящейся в Лос-Альтос, Калифорния. Следите за публикациями Брайана в популярных промышленных изданиях. Вы можете связаться с Брайаном по адресу brian@quiotix.com



27.02.2007

Хотя практически каждый процессор и язык программирования и поддерживают арифметику с плавающей точкой, но большинство программистов почти не обращают на это внимания. И это понятно - мало кому из нас часто приходиться использовать дробные числовые типы. За исключением научных вычислений и редких случаев тестирования времени или оценки производительности, до этого просто не доходит дело. Десятичные числа с произвольной разрядностью, предоставляемые java.math.BigDecimal, также не используются большинством разработчиков, так как основная масса приложений не нуждается в их применении. Тем не менее, превратности представления дробных чисел все-таки иногда проникают и в программы, базирующиеся на целых числах. Например, JDBC использует BigDecimal как предпочтительный формат обмена для колонок DECIMAL SQL.

Плавающая точка IEEE

Язык Java поддерживает два простых типа с плавающей точкой: float и double, и их прототип - класс-оболочка Float и Double. Они основаны на стандарте IEEE 754, определяющем двоичный стандарт для двоично-десятичных чисел с 32-битовой плавающей точкой и 64-битовой плавающей точкой удвоенной точности.

IEEE 754 представляет числа с плавающей точкой как десятичные числа с основанием 2 в экспоненциальном формате. В IEEE числе с плавающей точкой выделяется 1 бит на знак, 8 бит на порядок и 23 бита на мантиссу, или дробную часть числа. Порядок расшифровывается как целое число со знаком, допускающее как положительный, так и отрицательный экспоненты. Дробь представляется как двоично-десятичное (основание 2) число, где самый старший бит соответствует значению ½ (2-1), следующий бит ¼ (2-2), и так далее. Для плавающей точки с удвоенной точностью на порядок выделяется 11 бит, а на мантиссу - 52 бит. Формат значений плавающей точки IEEE показан на рисунке 1.

Рисунок 1. Формат плавающей точки IEEE 754
Рисунок 1. Формат плавающей точки IEEE 754

Так как любое заданное число может быть представлено в экспоненциальном формате различными способами, то числа с плавающей точкой нормализуются таким образом, чтобы они могли быть представлены как десятичные числа с основанием 2, с 1 слева от десятичной точки, подбирая порядок таким образом, чтобы соблюсти данное требование. Следовательно, например, число 1.25 будет представлено с мантиссой 1.01 и порядком 0:
(-1)

Число 10.0 будет представлено с мантиссой 1.01 и порядком 3:
(-1)

Специальные числа

Помимо стандартного множества значений, разрешенного кодированием (от 1.4e-45 до 3.4028235e+38 для float), существуют и специальные значения, представляющие бесконечность, минус бесконечность, -0, и NaN (обозначает "не число"). Эти значения существуют для того, чтобы в случае возникновения ошибок, например, арифметического переполнения, извлечения квадратного корня из отрицательного числа и деления на 0, можно было получить результат, представленный в диапазоне значений с плавающей точкой.

У таких специальных чисел есть несколько необычных характеристик. Например, 0 и -0 являются четко различимыми значениями, но при сравнении на тождественность они считаются равными. Деление ненулевого числа на бесконечность дает 0. Специальное число NaN является неупорядоченным, а любое сравнение между NaN и другими значениями с плавающей точкой, с использованием операций ==, < и > выдаст неверно. Даже (f == f) выдаст ложь, если f является NaN. Если Вы хотите сравнить значение с плавающей точкой с NaN, то используйте метод Float.isNaN(). В таблице 1 показаны некоторые особенности бесконечности и NaN.

Таблица 1. Особенности специальных значений с плавающей точкой

ВыражениеРезультат
Math.sqrt(-1.0)-> NaN
0.0 / 0.0-> NaN
1.0 / 0.0-> бесконечность
-1.0 / 0.0-> -бесконечность
NaN + 1.0-> NaN
бесконечность + 1.0-> бесконечность
бесконечность + бесконечность-> бесконечность
NaN > 1.0-> ложь
NaN == 1.0-> ложь
NaN < 1.0-> ложь
NaN == NaN-> ложь
0.0 == -0.01-> истина

Простой тип с плавающей точкой и класс-оболочка с плавающей точкой по-разному выполняют сравнение

Еще более усугубляет положение еще и то, что правила сравнения NaN и -0 у простого типа с плавающей точкой и класса-оболочки Float различаются. Для значений float, сравнение на тождественность двух значений NaN выдаст ложь, но при сравнении двух NaN объектов Float при использовании Float.equals(), будет выдано истина. Обоснованием этому может служить то, что иначе будет невозможно использовать NaN объект Float в качестве ключа в HashMap. Аналогично, хотя 0 и -0 и считаются равными, но в случае представления их в качестве значений с плавающей точкой, сравнение 0 и -0 как объектов Float при помощи Float.compareTo() указывает на то, что -0 является меньшей величиной, чем 0.


Непредвиденные обстоятельства использования плавающей точки

В связи с особым поведением бесконечности, NaN и 0 определенные трансформации и оптимизации могут показаться безвредными, но при применении к числам с плавающей точкой будут приводить к ошибкам. Например, несмотря на то, что равенство 0.0-f и -f кажется очевидным, но оно ложно, если f равно 0. Существуют и другие подобные проблемы, некоторые из которых показаны в таблице 2.

Таблица 2. Ошибочные представления о числах с плавающей точкой

Данное выражение...не обязательно тождественно...при условии, что...
0.0 - f-ff равно 0
f < g! (f >= g)f или g являются NaN
f == fверноf является NaN
f + g - gfg является бесконечностью или NaN

Ошибки округления

Арифметика чисел с плавающей точкой не отличается особой точностью. Тогда как некоторые числа, например, 0.5, можно точно представить как двоично-десятичные (основание 2) (поскольку 0.5 равно 2-1), но другие числа, например, 0.1 - невозможно. В итоге операции над числами с плавающей точкой могут привести к ошибкам округления, выдавая результат, близкий - но не равный - тому результату, который можно было ожидать. Например, простое вычисление, приведенное ниже, равняется 2.600000000000001, а не 2.6:

  double s=0;

  for (int i=0; i<26; i++)
    s += 0.1;
  System.out.println(s);

Аналогично, умножение .1*26 выдает результат, отличный от прибавления .1 к самому себе 26 раз. Ошибки округления могут оказаться даже более серьезными при преобразовании типа от вещественного к целому, поскольку при преобразовании к целому типу нецелая часть отбрасывается, даже для вычислений, которые "выглядят похожими", они должны иметь целые значения. Например, следующие выражения:

  double d = 29.0 * 0.01;
  System.out.println(d);
  System.out.println((int) (d * 100));

на выходе дадут:

  0.29
  28

что не совсем соответствует первоначально ожидаемому результату.


Рекомендации по сравнению чисел с плавающей точкой

В связи с необычным поведением сравнения NaN и ошибок округления, которые фактически гарантированы практически при всех вычислениях с плавающей точкой, расшифровывание результатов, выдаваемых операторами сравнения значений с плавающей точкой, является весьма запутанным.

Лучше всего вообще стараться избегать сравнений чисел с плавающей точкой. Конечно же, это не всегда возможно, но Вы должны понимать ограничения сравнения чисел с плавающей точкой. Если Вам нужно сравнить числа с плавающей точкой для того, чтобы узнать, тождественны ли они, то вместо этого следует сравнивать абсолютное значение их разности с каким-либо предварительно выбранным значением эпсилон. То есть, таким образом, Вы проверяете насколько они "близки". (Если Вам не известен коэффициент масштабирования основных измерений, то использование проверки "abs(a/b - 1) < эпсилон", вероятно, будет более надежным, чем простое сравнение разности.) Даже проверка значения для того, чтобы узнать, является ли оно большим или меньшим, чем ноль, является рискованной - вычисления, в результате которых "предполагается" получить значение чуть больше нуля, на самом деле могут привести к числам чуть меньше нуля вследствие суммарных ошибок округления.

Неупорядоченность NaN увеличивает вероятность появления ошибок при сравнении чисел с плавающей точкой. Хорошим практическим приемом для предотвращения многих сбоев, связанных с бесконечностью и NaN при сравнении чисел с плавающей точкой, является явная проверка валидности значения, вместо попыток исключения невалидных значений. В листинге 1 присутствуют две возможных реализации схемы настройки для свойства, которое может принимать только неотрицательные значения. В первом случае будет допустим NaN, во втором - нет. Вторая форма является более предпочтительной, так как она явно проверяет ту область значений, которую Вы считаете валидной.

Листинг 1. Лучшие и худшие способы для обеспечения неотрицательного плавающего значения
    // Trying to test by exclusion - this doesn't catch NaN or infinity
    public void setFoo(float foo) { 
      if (foo < 0) 
          throw new IllegalArgumentException(Float.toString(f));
        this.foo = foo;
    }

    // Testing by inclusion - this does catch NaN 
    public void setFoo(float foo) { 
      if (foo >= 0 && foo < Float.INFINITY) 
        this.foo = foo;
      else 
        throw new IllegalArgumentException(Float.toString(f));
    }

Не используйте числа с плавающей точкой для точных значений

Некоторые нецелые значения, например десятичные дроби в стоимостном выражении (долларо-центы), требуют точности. Числа с плавающей точкой не являются точными, и манипулирование ими приведет к ошибкам округления. В общем, не очень хорошо использовать плавающую точку для представления таких точных величин, как денежные суммы. Использование плавающей точки для вычислений стоимости является решением данной проблемы. Числа с плавающей точкой лучше всего приберечь для таких значений, например измерения, где величины являются изначально неточными.


Большие десятичные дроби для маленьких чисел

С появлением JDK 1.3 разработчики Java получили альтернативу для нецелых чисел: BigDecimal. BigDecimal является стандартным классом, без специальной поддержки в компиляторе, для представления двоичных чисел произвольной разрядности и выполнения арифметических действий над ними. По сути дела BigDecimal представлен как недифференцированное значение произвольной разрядности и коэффициент масштабирования, который указывает насколько сдвинуть влево десятичную точку для получения значения, разбитого на дробную и целую части. Итак, число, представленное BigDecimal является unscaledValue*10-scale.

Арифметические действия над значениями BigDecimal реализуются с помощью методов для сложения, вычитания, умножения и деления. Поскольку объекты BigDecimal являются неизменяемыми, то каждый из этих методов производит новый объект BigDecimal. В итоге BigDecimal не слишком хорошо подходит для интенсивных вычислений из-за затрат на создание объекта, но он спроектирован для представления точных десятичных чисел. Если Вам необходимо представить такие точные величины как денежные суммы, то BigDecimal прекрасно подойдет для этой задачи.

Все методы сравнения не созданы равными

Как и у всех типов с плавающей точкой, у BigDecimal тоже есть несколько особенностей. А именно, будьте осторожны с использованием метода equals() для проверки тождественности чисел. Два значения BigDecimal, представляющих одно и то же число, но имеющих различные коэффициенты масштабирования (например, 100.00 и 100.000) не будут равными при использовании метода equals(). Тем не менее, метод compareTo() будет считать их равными, а, следовательно, Вы должны использовать compareTo() вместо equals() при количественном сравнении двух значений BigDecimal.

Существуют некоторые случаи, когда для получения точных результатов недостаточно двоичной арифметики с произвольной разрядностью. Например, деление 1 на 9 выдает бесконечную периодическую двоичную дробь .111111... По этой причине BigDecimal позволяет Вам явно контролировать округление при выполнении операций деления. Точное деление на показатель степени 10 поддерживается методом movePointLeft().

Используйте BigDecimal в качестве типа обмена

В SQL-92 есть тип данных DECIMAL, который является точным числовым типом для представления десятичных чисел с фиксированной точкой и выполняет основную арифметическую операцию над двоичными числами. В некоторых диалектах SQL предпочитают называть этот тип NUMERIC, а другие также содержат тип данных MONEY, который определяется как десятичное число с двумя знаками справа от десятичной дроби.

Если Вы хотите сохранить число в поле DECIMAL в базе данных или извлечь значение из поля DECIMAL, то как можно убедиться в том, что число будет передано точно? Вы не хотите использовать методы setFloat() и getFloat(), обеспечиваемые JDBC классами PreparedStatement и ResultSet, поскольку конвертация из числа с плавающей точкой в десятичное число может привести к потере точности. Вместо этого, используйте setBigDecimal() и getBigDecimal() методы PreparedStatement и ResultSet.

Аналогично, XML-инструменты привязки данных, например, Castor, сгенерируют механизмы получения и установки для десятичных атрибутов и элементов (которые поддерживаются в качестве основного типа данных в XSD-схеме), используя BigDecimal.

Построение чисел BigDecimal

Существует несколько конструкторов для BigDecimal. Один в качестве входной информации использует плавающую точку удвоенной точности, второй - целое число и коэффициент масштабирования, а третий - String - представление десятичного числа. Вам нужно быть осторожными с конструктором BigDecimal(double), поскольку он может позволить ошибкам округления появиться в Ваших вычислениях раньше, чем вы об этом узнаете. Вместо этого используйте целое число или конструкторы, основанные на String.

Ненадлежащее использование конструктора BigDecimal(double) может проявляться в кажущихся странными ошибках в JDBC-драйверах при передаче в JDBC setBigDecimal() метод. Например, рассмотрим следующий JDBC-код, который хочет сохранить число 0.01 в десятичном поле:

  PreparedStatement ps = 
    connection.prepareStatement("INSERT INTO Foo SET name=?, value=?");
  ps.setString(1, "penny");
  ps.setBigDecimal(2, new BigDecimal(0.01));
  ps.executeUpdate();

В зависимости от Вашего драйвера JDBC, этот кажущийся безобидным код может при выполнении выдать некоторые запутанные ошибки, потому что приближенная величина с удвоенной точностью 0.01 приведет к большому дифференцированному значению, которое может запутать JDBC-драйвер и базу данных. Ошибка появится в JDBC драйвере, но весьма маловероятно, что она проявит себя, и Вы не будете знать, что не так с Вашим кодом, до тех пор, пока Вы не поймете ограничения двоичных чисел с плавающей точкой. Чтобы избежать возникновения таких проблем, сконструируйте BigDecimal, используя BigDecimal("0.01") или BigDecimal(1, 2) , так как каждый из них, в конечном счете, приведет к точному десятичному представлению.


Заключение

Использование чисел с плавающей точкой и десятичных чисел в программах Java чревато трудностями. Числа с плавающей точкой и десятичные числа ведут себя совсем не так хорошо как целые, и Вы не можете предположить, что в действительности делают вычисления с плавающей точкой, у которых "должны" быть целочисленные или точные результаты. Лучше всего приберечь использование арифметики с плавающей точкой для вычислений, которые требуют принципиально неточных значений, например измерений. Если Вам необходимо представить величины с фиксированной точкой, например, доллары и центы, то используйте BigDecimal.

Ресурсы

Комментарии

developerWorks: Войти

Обязательные поля отмечены звездочкой (*).


Нужен IBM ID?
Забыли Ваш IBM ID?


Забыли Ваш пароль?
Изменить пароль

Нажимая Отправить, Вы принимаете Условия использования developerWorks.

 


Профиль создается, когда вы первый раз заходите в developerWorks. Информация в вашем профиле (имя, страна / регион, название компании) отображается для всех пользователей и будет сопровождать любой опубликованный вами контент пока вы специально не укажите скрыть название вашей компании. Вы можете обновить ваш IBM аккаунт в любое время.

Вся введенная информация защищена.

Выберите имя, которое будет отображаться на экране



При первом входе в developerWorks для Вас будет создан профиль и Вам нужно будет выбрать Отображаемое имя. Оно будет выводиться рядом с контентом, опубликованным Вами в developerWorks.

Отображаемое имя должно иметь длину от 3 символов до 31 символа. Ваше Имя в системе должно быть уникальным. В качестве имени по соображениям приватности нельзя использовать контактный e-mail.

Обязательные поля отмечены звездочкой (*).

(Отображаемое имя должно иметь длину от 3 символов до 31 символа.)

Нажимая Отправить, Вы принимаете Условия использования developerWorks.

 


Вся введенная информация защищена.


static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=40
Zone=Технология Java
ArticleID=198215
ArticleTitle=Теория и практика Java: Где ваша точка?
publish-date=02272007