Переполнение точности в Python
Введение
Каждый программист рано или поздно сталкивается с загадочным поведением чисел в компьютере. Почему 0.1 + 0.2 не равно 0.3? Почему финансовые расчёты иногда дают странные результаты? Ответ кроется в понимании того, как компьютеры хранят и обрабатывают числа.
Классическая проблема: числа с плавающей точкой
1.1. Неожиданный результат
# Попробуйте выполнить это в интерпретаторе Python
print(0.1 + 0.2)
# Ожидаем: 0.3
# Получаем: 0.30000000000000004
print(0.1 + 0.2 == 0.3)
# Ожидаем: True
# Получаем: False
Это не ошибка Python — это фундаментальная особенность представления чисел в компьютере.
1.2. Почему так происходит?
Компьютеры используют двоичную систему для хранения чисел. Число 0.1 в десятичной системе превращается в бесконечную периодическую дробь в двоичной:
0.1₁₀ = 0.0001100110011001100110011...₂
Так как память ограничена (64 бита для float по стандарту IEEE 754), дробь обрезается, что приводит к потере точности.
1.3. Структура числа с плавающей точкой
64-битное число float (double precision) состоит из:
┌─────────┬─────────────┬──────────────────────────────────────────────┐
│ Знак │ Экспонента │ Мантисса │
│ 1 бит │ 11 бит │ 52 бита │
└─────────┴─────────────┴──────────────────────────────────────────────┘
Это обеспечивает примерно 15-17 значащих десятичных цифр точности.
Типичные ловушки и как их избежать
2.1. Сравнение чисел с плавающей точкой
Неправильно:
result = 0.1 + 0.2
if result == 0.3:
print("Равны") # Никогда не выполнится
Правильно — используйте допуск (epsilon):
import math
result = 0.1 + 0.2
# Способ 1: math.isclose()
if math.isclose(result, 0.3):
print("Равны") # Работает!
# Способ 2: явный допуск
epsilon = 1e-9
if abs(result - 0.3) ‹ epsilon:
print("Равны") # Работает!
# Параметры math.isclose
math.isclose(a, b, rel_tol=1e-9, abs_tol=0.0)
# rel_tol — относительный допуск
# abs_tol — абсолютный допуск
2.2. Накопление ошибок
При многократных операциях ошибки накапливаются:
# Накопление ошибок в цикле
total = 0.0
for _ in range(1000):
total += 0.1
print(total) # 99.9999999999986
print(total == 100) # False
# Решение: суммирование Кэхэна или math.fsum
import math
values = [0.1] * 1000
print(math.fsum(values)) # 100.0 — точный результат!
2.3. Потеря значимости при вычитании
# Катастрофическая потеря значимости
a = 1.0000000000000001
b = 1.0000000000000000
print(a - b) # 0.0 — потеряли всю точность!
# Другой пример
x = 1e16
y = 1e16 + 1
print(y - x) # 0.0 — единица "потерялась"
Уникальность Python: целые числа произвольной точности
В отличие от большинства языков, целые числа в Python не переполняются:
# В C/C++ это вызвало бы переполнение
huge_number = 10 ** 1000
print(len(str(huge_number))) # 1001 цифра — никаких проблем!
# Факториал больших чисел
import math
factorial_1000 = math.factorial(1000)
print(len(str(factorial_1000))) # 2568 цифр
# Точная арифметика с большими числами
a = 2 ** 1000
b = 2 ** 1000 + 1
print(b - a) # 1 — точный результат
Python автоматически выделяет память для хранения сколь угодно больших целых чисел. Однако это имеет свою цену:
import sys
# Размер целых чисел в памяти
print(sys.getsizeof(0)) # 24 байта
print(sys.getsizeof(1)) # 28 байт
print(sys.getsizeof(10**100)) # 72 байта
print(sys.getsizeof(10**1000)) # 464 байта
Модуль decimal: точные десятичные вычисления
4.1. Базовое использование
from decimal import Decimal, getcontext
# Создание из строки — точное представление
a = Decimal('0.1')
b = Decimal('0.2')
c = Decimal('0.3')
print(a + b) # 0.3
print(a + b == c) # True — наконец-то!
# ВНИМАНИЕ: создание из float сохраняет ошибку!
bad = Decimal(0.1)
print(bad) # 0.1000000000000000055511151231257827...
good = Decimal('0.1')
print(good) # 0.1
4.2. Управление точностью
from decimal import Decimal, getcontext, ROUND_HALF_UP
# Установка глобальной точности
getcontext().prec = 50 # 50 значащих цифр
# Вычисление с высокой точностью
result = Decimal(1) / Decimal(7)
print(result)
# 0.14285714285714285714285714285714285714285714285714
# Округление
price = Decimal('19.995')
rounded = price.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
print(rounded) # 20.00
4.3. Финансовые расчёты
from decimal import Decimal, ROUND_HALF_UP
def calculate_total(prices: list[str], tax_rate: str) -› Decimal:
"""Расчёт суммы с налогом."""
subtotal = sum(Decimal(p) for p in prices)
tax = subtotal * Decimal(tax_rate)
tax = tax.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
return subtotal + tax
prices = ['19.99', '5.50', '3.25']
total = calculate_total(prices, '0.08') # 8% налог
print(f"Итого: ${total}") # Итого: $31.04
4.4. Контексты для изоляции настроек
from decimal import Decimal, localcontext
# Глобальная точность остаётся неизменной
with localcontext() as ctx:
ctx.prec = 5
result = Decimal('1') / Decimal('3')
print(result) # 0.33333
# За пределами контекста — стандартная точность
result = Decimal('1') / Decimal('3')
print(result) # 0.3333333333333333333333333333
Модуль fractions: рациональные числа
Для точной работы с дробями:
from fractions import Fraction
# Создание дробей
a = Fraction(1, 3)
b = Fraction(1, 6)
# Точные вычисления
print(a + b) # 1/2
print(a * 3) # 1
print(a - b) # 1/6
# Из строки
f = Fraction('3.14159')
print(f) # 314159/100000
# Из float (сохраняет ошибку представления!)
f_bad = Fraction(0.1)
print(f_bad) # 3602879701896397/36028797018963968
# Ограничение знаменателя
f = Fraction(0.1).limit_denominator(100)
print(f) # 1/10 — то, что ожидали
NumPy и научные вычисления
6.1. Типы данных NumPy
import numpy as np
# Фиксированные типы — могут переполняться!
a = np.array([127], dtype=np.int8)
print(a + 1) # [-128] — переполнение!
b = np.array([255], dtype=np.uint8)
print(b + 1) # [0] — переполнение!
# Безопасный способ
c = np.array([127], dtype=np.int64)
print(c + 1) # [128] — нормально
6.2. Специальные значения
import numpy as np
# Бесконечность
print(np.float64(1e308) * 10) # inf
# "Не число"
print(np.float64(0) / 0) # nan (с предупреждением)
# Проверка специальных значений
x = np.inf
print(np.isinf(x)) # True
print(np.isfinite(x)) # False
y = np.nan
print(np.isnan(y)) # True
print(y == y) # False — NaN не равен самому себе!
6.3. Повышение точности
import numpy as np
# float128 для повышенной точности (если поддерживается)
a = np.float128('0.1')
b = np.float128('0.2')
print(a + b) # Более точный результат
# Для целых — выбор подходящего типа
data = np.array([1, 2, 3], dtype=np.int64)
Специальные случаи переполнения
7.1. Экспонента и логарифмы
import math
# Переполнение экспоненты
try:
result = math.exp(1000)
except OverflowError as e:
print(f"Ошибка: {e}") # math range error
# Решение: логарифмическая шкала
log_result = 1000 # Работаем с логарифмом
print(f"e^1000 ≈ 10^{log_result / math.log(10):.0f}")
# Или используем специальные функции
print(math.log1p(1e-15)) # Точнее чем math.log(1 + 1e-15)
print(math.expm1(1e-15)) # Точнее чем math.exp(1e-15) - 1
7.2. Комбинаторика
import math
from functools import lru_cache
# Факториал больших чисел
n = 1000
k = 500
# Неэффективно: вычисляем огромные числа
c1 = math.factorial(n) // (math.factorial(k) * math.factorial(n - k))
# Эффективно: используем встроенную функцию
c2 = math.comb(n, k)
print(c1 == c2) # True
print(len(str(c2))) # 299 цифр
# Для очень больших значений — логарифмы
log_c = math.lgamma(n + 1) - math.lgamma(k + 1) - math.lgamma(n - k + 1)
print(f"log(C({n},{k})) ≈ {log_c:.2f}")
Практические рекомендации
8.1. Выбор типа данных
| Задача | Рекомендуемый тип |
|---|---|
| Финансы, деньги | Decimal |
| Научные расчёты | float / numpy.float64 |
| Точные дроби | Fraction |
| Подсчёт, индексы | int |
| Криптография | int (произвольной точности) |
8.2. Чек-лист для надёжных вычислений
# 1. Никогда не сравнивайте float напрямую
# if a == b:
# if math.isclose(a, b):
# 2. Для суммирования используйте math.fsum
# sum(float_list)
# math.fsum(float_list)
# 3. Создавайте Decimal из строк
# Decimal(0.1)
# Decimal('0.1')
# 4. Для денег — всегда Decimal с округлением
# price.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
# 5. Проверяйте граничные случаи
# if math.isfinite(result):
8.3. Отладка проблем с точностью
def debug_float(value: float) -› None:
"""Диагностика числа с плавающей точкой."""
from decimal import Decimal
import struct
print(f"Значение: {value}")
print(f"repr(): {repr(value)}")
print(f"Точное: {Decimal(value)}")
# Бинарное представление
packed = struct.pack('d', value)
binary = ''.join(f'{byte:08b}' for byte in packed[::-1])
print(f"Биты: {binary[0]} {binary[1:12]} {binary[12:]}")
print(f" (знак) (экспонента) (мантисса)")
debug_float(0.1)
Заключение
Ключевые выводы:
- float неточен по своей природе — это не баг, а особенность представления чисел в памяти.
- Целые числа в Python безопасны — они не переполняются (но могут замедлять программу при очень больших значениях).
- Используйте правильный инструмент:
- Decimal для финансов и точных вычислений
- Fraction для работы с дробями
- math.isclose() для сравнения float
- math.fsum() для точного суммирования
- Понимайте компромиссы: точность vs производительность vs удобство.
Полезные ресурсы:
- [Что каждый компьютерщик должен знать о представлении чисел с плавающей точкой](https://docs.oracle.com/cd/E19957-01/806-3568/ncg_goldberg.html)
- [Документация модуля decimal](https://docs.python.org/3/library/decimal.html)
- [PEP 327 — Decimal Data Type](https://peps.python.org/pep-0327/)
*Понимание особенностей представления чисел — важный навык для любого разработчика. Надеюсь, эта статья помогла вам лучше понять, когда и почему возникают проблемы с точностью, и как их избежать в ваших проектах.*