вторник, 7 августа 2012 г.

Числа в Python 3

Что приходит в голову при словах «числа в Питоне?»

int и float. Если речь идет о Python 2 — еще и упразднённый long. Наверное, вспомнится очень мало где используемый complex.

Я же хочу рассказать о Decimal и Fraction.

Decimal

Просто незаменим, если нужно считать деньги. Представим, что нам нужно работать с гривной (это такая украинская валюта, с точки зрения рассматриваемого вопроса ничем не отличающаяся от рубля, евро или доллара). Сотая часть гривны называется копейкой. Естественно думать, что гривны будут представлены целой частью числа, а копейки — дробной.

Что произойдёт, если для денег мы станем использовать float?

Как я писал в статье: 4 грн 31 коп будут на самом деле иметь внутреннюю запись 4.3099999999999996. Да, при печати всё показывается нормально если у вас Python 2.7+ — но внутри это всё же чуть-чуть иное число!

И если работать с такими числами (складывать, вычитать, делить и умножать) — ошибка будет нарастать и рано или поздно превысит копейку, а потом счет пойдет и на гривны. Чем больше операций — тем выше ошибка.

В результате дебет перестаёт сходиться с кредитом, бухгалтерия встаёт на уши и разработчик получает большую проблему на свою голову (случай из жизни моего друга).

Чтобы этого избежать, нужно использовать decimal — который никогда ничего не теряет.

Внутри decimal представлен как знак, набор цифр и положение десятичной точки — т.е. нет никакого округления.

Использование очень простое:

>>> from decimal import Decimal
>>> Decimal("4.31")
Decimal('4.31')
>>> Decimal("4.31") + Decimal("1.10")
Decimal('5.41')

Все стандартные операции над decimal работают так же хорошо, как и с просто числами.

К слову, базы данных как правило имеют встроенную поддержку этого типа, а драйвера DBAPI и ORM вроде Django и SQLAlchemy тоже умеют работать с decimal.

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

Пример:

>>> Decimal("1.10") / 3
Decimal('0.3666666666666666666666666667')

Ой! Зачем так много цифр, ведь у нас гривна с копейками?!!

Дело в том, что помимо Decimal есть еще и Context. По умолчанию у него точность в 28 чисел в дробной части, что явно многовато для валюты. Настроим на 2 знака:

>>> from decimal import getcontext
>>> getcontext().prec = 2
>>> Decimal('1.10') / 3
Decimal('0.37')

Уже лучше.

Правила округления тоже задаются контекстом. По умолчанию это ROUND_HALF_UP — округлять вверх, если цифра пять и больше. Как в школе учили. Можно настроить и другой способ — читайте документацию. Еще можно указать, чтобы при разных ситуациях (потеря точности или бесконечность в результате, например) генерировалось исключение а не происходило округление. Кому надо — пусть изучает эту самую документацию, ключевое слово trap.

Вернемся к наиболее распространенным задачам.

Что делать, если часть вычислений нужно проводить с точностью «до копеек», а некоторые (например, то же сведение баланса и подсчет налогов) — до сотых долей копеек?

Наиболее практичный способ — создание своего контекста и применение его в with statement:

>>> from decimal import Context, localcontext
>>> with localcontext(Context(4)):
...     print(repr(Decimal("1.10") / 3))
Decimal('0.3667')

Округление:

>>> Decimal('1.12').quantize(Decimal('0.1'))
Decimal('1.1')
>>> Decimal('1.16').quantize(Decimal('0.1'))
Decimal('1.2')

Внимание! Округлять можно только до той максимальной точности, которая позволена текущим контекстом. Сейчас у нас глобальный контекст имеет точность 2.

>>> getcontext().prec = 2
>>> Decimal('1.10').quantize(Decimal('0.000001'))
Traceback (most recent call last):
...
decimal.InvalidOperation: quantize result has too many digits for current context

Вот и всё.

За рамками статьи осталось многое. Моя цель — дать необходимый минимум знаний, требуемый для работы с денежными единицами. Остальное, повторюсь ещё раз, найдете в документации — она большая и исчерпывающая, содержит описание многих дюжин доступных функций.

И, тем не менее, изложенного достаточно для практически любой повседневной работы.

Важное дополнение. Изначально decimal был написан на чистом питоне. Т.е. корректно считал, но делал это довольно медленно. Не настолько плохо, чтобы отказываться от него (тем более что альтернатив нет) — но часто скорость важна и хотелось бы побыстрее.

В Python 3.3 вошёл ускоритель (подключается автоматически). Автор — Stefan Krah. Большое спасибо этому человеку. Благодаря его труду производительность decimal повысилась настолько, что скорость вычислений стала сопоставима с int и float.

Всем читающим — намёк: переходите на Python 3.3

Fraction

Предназначен для работы с обыкновенными дробями

В школе все учили дроби: «одна треть плюс одна треть равно две трети».

Не все обыкновенные дроби имеют точное конечное представление, укладывающееся в границы float.

Пример:

>>> 7/71*71 == 7
False

А теперь с fraction:

>>> from fractions import Fraction
>>> Fraction(7, 71) * 71 == 7
True

Практическое применение:

С конца мая 2011 я работаю в проекте Апокалипсис. Это онлайновая игра с пошаговой боёвкой. Каждый игрок имеет определённое количество очков на ход. Передвижение на одну клетку — одно очко. Выстрел — скажем, три очка. Ходят все одновременно.

У меня 7 очков движения, у противника 8. Значит, я совершаю свои действия со скоростью 1/7, а противник — 1/8.

Я сдвинулся на одну клетку и выстрелил.

И тут очень важно правильно посчитать, когда произошел выстрел: чуть раньше супостата, чуть позже или точно одновременно. Буквально каждое мгновение — вопрос жизни или смерти в игровом бою.

float верить нельзя: если событие должно наступить одновременно — ошибка округления наверняка соврёт в ту или другую сторону. А пользователи игры забросают нас, разработчиков, виртуальными гнилыми помидорами.

Fraction считает честно, что к тому же значительно облегчает игровую математику: 1/3 всегда равна «одной трети».

Заключение

Если читатель знал о существовании Fraction и Decimal, использовал их на полную катушку — честь и хвала, я ничего нового не сказал.

Если же, столкнувшись с областью применения этих числовых типов, читатель вспомнит мои рецепты правильного их приготовления — статья свою работу выполнила.

20 комментариев:

  1. Python 2.7.1 (r271:86832, Nov 27 2010, 18:30:46) [MSC v.1500 32 bit (Intel)] on win32
    Type "help", "copyright", "credits" or "license" for more information.
    >>> 25/8+25/8 == 50/8
    True

    А у вас опечатка: «50/80» вместо «50/8».

    ОтветитьУдалить
  2. Да, 50/80 и ДокумеТНация.
    Статья как всегда хороша

    ОтветитьУдалить
  3. В ПХП, где нет decimal и аналогов мы много лет назад хранили деньги просто в копейках.

    ОтветитьУдалить
    Ответы
    1. Так тоже можно — но с decimal как-то удобней и естественней

      Удалить
    2. Жаль только, что нет отдельного формата записи, а только класс Decimal. Например 0.37D.
      А то, как-то очень неопрятно и некрасиво записывать число в виде строки — Decimal('0.37').
      Да и арифметика выглядит хуже
      Decimal('0.37') + Decimal('0.92') / Decimal('0.15'),
      вместо 0.37D + 0.92D/0.15D

      Но это я конечно придираюсь, тип полезный.

      Удалить
  4. От Decimal мало пользы, если тебе приходит коряво распознанный Numeric*. Тогда вместо 12,32 ты можешь получить 12,31999 или 12,31998. Причем, хоть в float, хоть в Decimal. Я выкрутился такой функцией:
    def FloatRound(val, presc=3):
    return int((val * 10**presc) + 0.5) / float(10**presc)
    К тому же Decimal бесполезен**, когда надо нестандартное округление

    -------------
    * - в Java и Jython ситуация с этим не в пример лучше.
    ** - по крайней мере был

    ОтветитьУдалить
    Ответы
    1. garbage in garbage out, не вижу проблем. Вернее, они не в Decimal как таковом.

      Про нестандартное округление, честно сказать, совсем ничего не понял.
      Не хватило стандартных???

      ROUND_CEILING (towards Infinity),
      ROUND_DOWN (towards zero),
      ROUND_FLOOR (towards -Infinity),
      ROUND_HALF_DOWN (to nearest with ties going towards zero),
      ROUND_HALF_EVEN (to nearest with ties going to nearest even integer),
      ROUND_HALF_UP (to nearest with ties going away from zero), or
      ROUND_UP (away from zero).
      ROUND_05UP (away from zero if last digit after rounding towards zero would have been 0 or 5; otherwise towards zero)

      Удалить
  5. Мне довелось столкнуться с неуместным использованием Decimal. Искал библиотеку для анализа gpx-файлов. Нашел https://github.com/fxdgear/pygpx Там тотоварищ форкнул старую библиотеку и внес улучшения. Одним таким "улучшением" стало использование Decimal для географических координат. Пришлось форкать и убирать это, потому что ряд других улучшений было в тему.

    ОтветитьУдалить
    Ответы
    1. Такое бывает. Кстати, а что использовалось до «улучшения»?
      Как я понимаю, географическиие координаты имеют конечную разрядность и float — это не совсем то что нужно? Впрочем, могу ошибаться.
      Другое дело, что Decimal до 3.3 изрядно тормозной.

      Удалить
  6. С Fraction нужно быть осторожнее, чтобы не попасть в ловушку. Гвидо ван Россум рассказал интересную историю об этом (http://python-history.blogspot.com/2009/03/problem-with-integer-division.html). Проблема в том, что сложность операций с Fraction зависит от данных. Обычно всё работает предсказуемо, но если встретятся несколько десятков игроков с взаимно простым количеством очков движения (или ещё при каких редких случайных совпадениях), и знаменатели с числителями резко вырастают, потребление памяти увеличивается, просчёт тормозится. Причём в коде это не видно, операции с Fraction записываются как с обычными int или float, ничто не предупреждает о возможной сложности (в отличие от того, если бы операции с дробями реализовали вручную). Если такая ситуация невозможна сейчас, может возникнуть в будущем, после каких-то изменений. Поэтому если используете Fraction, огородите вокруг красными флажками-предупреждениями.

    Избежать проблемы одновременности можно, вводя дополнительную случайность. Например, каждый игрок начинает ход со случайной задержкой. Или пуля вылетает со случайной задержкой. Или скорость полёта пули конечна, так что возможна двойная смерть даже если успел нажать курок чуть раньше.

    ОтветитьУдалить
  7. Ну, у нас всё довольно просто. Количество знаменателей конечно и не слишком большое.
    Не превышает 30 (а в новой версии будет раза в два меньше).
    При этом главные операции — складывание дробей с одинаковым знаменателем и сравнение разных дробей.
    Тут проблем быть не должно, правильно?
    Спасибо за ссылку, я эту статью как-то пропустил.

    ОтветитьУдалить
  8. Кстати, Fraction замечательно подходит для демонстрации особенностей плавучки.

    >>> Fraction(4.31)
    Fraction(4852628598491709, 1125899906842624)
    >>> Fraction(4.31) - 4
    Fraction(349028971121213, 1125899906842624)

    Т.е. компьютер не может записать десятичную дробь 4.31 в виде двоичной дроби точно, он работает с ближайшим приближением — 4 целых и 349028971121213/1125899906842624. Дарю идею для лекции. Домашнее задание — разделить вручную 349028971121213 на 1125899906842624 до 16 знаков.

    ОтветитьУдалить
    Ответы
    1. >>> from fractions import Fraction
      >>> from decimal import Decimal
      >>> Fraction(Decimal('4.31'))-4
      Fraction(31, 100)

      Удалить
  9. Я вообще-то хотел узнать о разрядности типа float Сколько байт занимает, сколько знаков можно задать? Можно ли задать тип как real 16 как в fortran 16-ти байтовый с точностью 36 знаков, но с плавающей точкой? А вы про numeric(decimal) рассказали... Это неинтересно, особенно для Python, где реализована целая арифметика с произвольной разрядностью... А как насчёт арифметики с плавающей точкой?

    ОтветитьУдалить
    Ответы
    1. Уважаемый аноним. После прочтения вашего комментария у меня сложилось впечатление, что вы просили меня написать как обстоят дела с 16-байтовыми числами с плавающей запятой, а я вам понес пургу про фиксированную точку любой разрядности. Это, мягко говоря, действительности не соответствует.

      Теперь по существу.
      1. Целочисленная арифметика произвольной разрядности имеет мало общего с практической задачей подсчета денег, для последней нужна быстрая арифметика с фиксированной точкой. Т.е. Decimal.
      2. 16-байтовый тип с плавающей точкой есть в numpy собранном с поддержкой этого типа. В numpy он зовётся float128. В Питоне такого типа нет и не будет.

      Удалить
  10. Замечательная статья!

    Однако есть одна неточность.
    > По умолчанию у него точность в 28 чисел в дробной части, что явно многовато для валюты.
    Насколько я понял из документации и практики — точность (precision) задает общее количество знаков, а не только количество знаков в дробной части.

    При prec = 2 эта операция выведет 2 знака:
    >>> Decimal('1.10') / 3
    Decimal('0.37')

    Эта 2:
    >>> Decimal('11.10') / 3
    Decimal('3.7')

    И эта тоже 2:
    >>> Decimal('111.10') / 3
    Decimal('37')

    ОтветитьУдалить