Неточности и погрешности при работе с Number. Чем это грозит и как решить?

Данная публикация является адаптированным переводом статьи о десятичных числах с моего блога.
Наверное, большинство камрадов сталкивались с непонятными «артефактами» и неточностями при работе с переменными типа Number. Попытайтесь умножить 0.1 на 3 и вы поймете, о чем идет речь. Хотя здравый смысл подсказывает, что результат должен быть 0.3, все же, как оказывается, алгоритмы AS3 не всегда поддаются логике и выдают что-то наподобие 0.30000000000000004.

round decimal numbers
Оказывается, такая погрешность возникает вследствие ограничения размера памяти, которую AS3 выделяет для хранения переменной Number. Если бы такого ограничения не существовало, банальная попытка сохранить значение числа «Пи» привела бы к полному истощению ресурсов.
К сожалению результатом этого ограничения становятся неприятные погрешности в тех местах, где мы их совсем не ожидали увидеть.
Рассмотрим следующий код:

var i:int;
var divisor:Number = 10;
var multiplier:Number = 0.1;

for (i = 0; i<10; i++) {
        trace (i/divisor);
}

trace("");

for (i = 0; i<10; i++) {
        trace (i*multiplier);
}


Результат выполнения:

0
0.1
0.2
0.3
0.4
0.5
0.6
0.7
0.8
0.9

0
0.1
0.2
0.30000000000000004
0.4
0.5
0.6000000000000001
0.7000000000000001
0.8
0.9


В первом цикле мы делили числа от 0 до 9 на 10, во втором – умножали на 0.1. Поскольку с точки зрения математики эти действия являются идентичными, результат должен был быть одинаковым. Но, как мы видим, при умножении целого числа на дробное, появились погрешности. Коих нет при использовании целых чисел (даже если результат, как в первом цикле – число с плавающей точкой).

Зачем это нужно

В большинстве случаев точности алгоритмов AS3 вполне хватит для наших потребностей. Погрешность в 0.0000000000000001 вряд ли окажется критичной.
Но бывают случаи, при которых возникновение подобных неточностей очень нежелательно. Именно с этим я столкнулся, занимаясь разработкой своей новой игры. Мне нужно было встроить в пользовательский интерфейс характеристики юнитов, таких как атака, защита и т. п. При этом предполагалось, что значение показателей может быть дробным (одно число после запятой). Каково же было мое удивление, когда вместо аккуратного интерфейса я увидел показатели типа 3.5000000000000001 атаки и 6.00000000000000004 защиты.

Как исправить

Зная, что деления целого числа на кратное десяти никогда не дает погрешностей, мы можем создать метод, который всегда будет возвращать значение с определенным количеством знаков после запятой.
Более того, такой метод можно использовать и для округления длинных чисел, если нам надо получить число с ограниченным количеством знаков после запятой. Например, такой метод позволить округлить число «Пи» как до 3.14159 так и до 3.14, оставаясь при этом уверенными, что мы не получим 3.1400000000000001.
Вот как выглядит сам метод (в моем случае он статический, чтобы было удобнее использовать в любом нужном месте):

public static function roundToDecimal (base:Number, decimalPlace:int):Number
{
        return Math.round(base * decimalPlace)/decimalPlace;
}

В двух словах о том, что происходит в методе.
Функция принимает два параметра:
base – это число, которое мы будем округлять;
decimalPlace – это число, кратное 10, которое определяет, сколько знаков после запятой мы желаем получить (10 – 1 знак после запятой; 100 – 2 знака после запятой; 1000 – 3 знака после запятой и так далее).
Допустим, что мы пытаемся округлить число 5.324569 до двух знаков после запятой.
1. roundToDecimal (5.324569, 100);
2. 5.324569 * 100 = 532.4569
3. Math.round(532.4569) = 532
4. 532/100 = 5.32

Как видите, такие нехитрые действия позволяют получить нужный результат при этом гарантируя, что мы получим именно два, а не пятнадцать знаков после запятой.

Буду благодарен, если вы поделитесь в комментариях о том, как решаете проблему погрешностей при работе с Number и в каких случаях это критично для вас (если критично, конечно).
  • +11

Комментарии (15)

+2
Пост хороший, плюс.

Проблема решается использованием целых чисел.
В подавляющем большинстве случаев нам необходима только определенная точность (в среднем 2-3 знака после запятой), поэтому достаточно хранить x*100 и все.
Это из личного опыта, как у других — не знаю.
0
Погрешность возникает из-за того что число хранится в представлении «с плавающей точкой», т.е. не десятичной дробью, а мантисса и степень. У Number двойная точность и погрешность в несколько стаквадриллионных, это вполне нормально, в других языках такая погрешность тоже есть. Для сравнения двух Number, вместо a == b, нужно использовать abs(a-b)> 0.00000001 число справа подбирается в зависимости от требуемой точности.
+5
Упустил немного суть проблемы. Для нормального вывода Number, есть форматирование в число с фиксированной точкой number.toFixed(2), аргумент указывает сколько знаков после запятой оставляется.
0
toFixed не панацея, для показа в интерфейсе мне, например, 6.00 не нравится, хочется «6 секунд» а не «6.00 секунд» если это возможно. Поэтому у меня метод типа как в посте.
0
Спасибо. Не обратил внимание на этот метод. Может быть полезно, хотя в случаих, о которых упомянул scmorr не подходит.
0
Кстати, еще одна полезная особенность метода, описаного в статье — что он именно округляет, тогда как toFixed просто отсекает остаток. К примеру, если нам нужно округлить 1.19 до одного знака после запятой, то метод roundToDecimal вернет 1.2, а toFixed(1) — 1.1
0
Откуда такие наблюдения? toFixed в твоем примере тоже будет 1.2
0
Действительно, Вы правы. Был уверен, что toFixed просто отбрасывает остаток.
0
Запарился с утреца, конечно:
abs(a-b) < 0.00000001
0
На форуме как-то обсуждали flashgamedev.ru/viewtopic.php?f=6&t=1263&start=0
правда там погрешность вызывалась уже при сложении, если оно производилось в цикле
0
Спасибо за ссылку, пропустил эту тему. Из нового узнал для себя, что на разных VM trace может работать по разному, иногда автоматически округляя остаток.
0
Конкретно твоя проблема с характеристиками юнитов решается так:
var n:Number = 0.30000000000000004;
n.toFixed(1);
0
Насколько я понимаю, в таком случае остаток будет показываться всегда, даже если он равняется 0. При атаке 3 будет отображаться 3.0, что конечно же лучше, чем вообще без отсекания лишней части, но не очень удобно, если хочется, чтобы при круглых числах остаток не отображался. В любом случае спасибо за еще одно решение.
0
Согласен, умное округление интереснее.
0
Есть книга хорошая — ActionScript 3.0 Сборник рецептов www.books.ru/books/actionscript-30-sbornik-retseptov-538001/ там много разных приемов и хитростей проиллюстрировано, представленный метод там тоже есть. К ней поставляется библиотека ASCBLibrary со всеми методами и примерами описанными в книге. Скачать библиотеку можно тут wayback.archive.org/web/jsp/Interstitial.jsp?seconds=5&date=1171453542000&url=http%3A%2F%2Frightactionscript.com%2Fascb%2FAS3CBLibrary.zip&target=http%3A%2F%2Fweb.archive.org%2Fweb%2F20070214114542%2Fhttp%3A%2F%2Frightactionscript.com%2Fascb%2FAS3CBLibrary.zip
Только зарегистрированные и авторизованные пользователи могут оставлять комментарии.