Представление целых и вещественных чисел. Переполнение
Почему у целого числа есть «потолок», а у дробного — погрешность, заложенная в самом способе хранения.
Разрядная сетка — фиксированное число битов, отведённое под хранение числа; именно её конечность порождает и переполнение, и погрешности.
Зачем это нужно
Программисты-новички уверены, что числа в компьютере — «настоящие», как в математике. На самом деле любое число живёт в ограниченной разрядной сетке, и это объясняет целый класс багов: счётчик, который внезапно стал отрицательным; сравнение 0.1 + 0.2 == 0.3, которое возвращает False; деньги, которые «теряются» на копейках. Понимание того, как хранятся целые и вещественные числа, отличает инженера от того, кто просто пишет код наугад.
Корень всех этих явлений один: память конечна. Под каждое число отводится фиксированное число битов — байт, два, четыре, восемь. В математике числовая прямая бесконечна и непрерывна, а в компьютере она превращается в конечный набор «засечек». Между засечками значений просто нет, а за крайними засечками — обрыв. Из-за «обрыва» возникает переполнение (число выходит за границу и заворачивается), а из-за «промежутков между засечками» — погрешность вещественных чисел (нужное значение попадает между представимыми, и его округляют до ближайшего). Эти две беды — две стороны одной медали под названием «конечная разрядная сетка». Дальше в уроке мы разберём каждую, но держите в голове эту общую картину: всё, что кажется странным в компьютерной арифметике, объясняется тем, что бесконечную математику пытаются уместить в конечную память.
Целые без знака: считаем сколько влезает
Если под число отвели n бит и знак не нужен, поместятся значения от 0 до 2ⁿ − 1. В одном байте (8 бит) — от 0 до 255. Это и есть диапазон яркости цвета. Формула «2 в степени количества бит» — главная в этой теме:
for bits in (8, 16, 32):
total = 2 ** bits
print(f"{bits:>2} бит: {total} значений, без знака от 0 до {total - 1}")
Вывод:
8 бит: 256 значений, без знака от 0 до 255 16 бит: 65536 значений, без знака от 0 до 65535 32 бит: 4294967296 значений, без знака от 0 до 4294967295
Отрицательные числа: дополнительный код
Как хранить минус, если есть только нули и единицы? Можно было бы отвести один бит под знак, но тогда возникает «минус ноль» и усложняется сложение. Компьютеры используют изящный дополнительный код: старший бit означает знак, а отрицательное число −x представляется как 2ⁿ − x. Тогда сложение положительных и отрицательных идёт по одним и тем же правилам — процессору не нужна отдельная схема вычитания.
Для 8-битного числа со знаком диапазон от −128 до +127. Соберём дополнительный код вручную и посмотрим на биты:
def twos_complement(x, bits=8):
# представление x в дополнительном коде на заданном числе бит
if x < 0:
x = (1 << bits) + x # 2^bits - |x|
return format(x, "0" + str(bits) + "b")
for x in (5, -5, 127, -128, -1):
print(f"{x:>5} → {twos_complement(x)}")
Вывод:
5 → 00000101 -5 → 11111011 127 → 01111111 -128 → 10000000 -1 → 11111111
Обратите внимание: −1 — это все единицы, а старший бит у всех отрицательных равен 1. Это и есть «знаковый» бит.
Переполнение: когда число «заворачивается»
Что будет, если к максимуму 8-битного беззнакового числа (255) прибавить 1? Результат 256 не помещается в 8 бит, старший перенос «выпадает», и остаётся 0. Число переполнилось и «завернулось» по кругу. В реальных языках с фиксированными типами (C, Java, регистры процессора) это источник серьёзных багов; знаменитый пример — счётчики, которые внезапно обнуляются или становятся отрицательными.
В самом Python целые «резиновые» и растут без предела, поэтому переполнение мы смоделируем, явно ограничив разрядность маской:
MASK8 = 0xFF # 8 единиц: оставляет только младшие 8 бит
a = 255
print("255 + 1 в 8-битной сетке =", (a + 1) & MASK8) # заворот в 0
print("200 + 100 =", (200 + 100) & MASK8) # 300 не влезает
# знаковая интерпретация того же байта
def as_signed(byte):
return byte - 256 if byte > 127 else byte
print("байт 200 как знаковое:", as_signed(200))
Вывод:
255 + 1 в 8-битной сетке = 0 200 + 100 = 44 байт 200 как знаковое: -56
Вещественные числа: плавающая запятая
Дробные числа хранят в формате с плавающей запятой: число записывают как мантисса × 2^порядок, примерно как в науке пишут 6.02·10²³. Под мантиссу и порядок отведено фиксированное число бит (стандарт IEEE 754: 64 бита для типа double). Отсюда два следствия: огромный диапазон значений — и ограниченная точность. Не каждая десятичная дробь представима точно в двоичной, ровно как 1/3 не записывается конечной десятичной дробью.
Поэтому знаменитое:
a = 0.1 + 0.2
print("0.1 + 0.2 =", a)
print("равно 0.3?", a == 0.3)
print("разница =", a - 0.3)
# правильное сравнение дробных — с допуском
import math
print("почти равно?", math.isclose(a, 0.3))
Вывод:
0.1 + 0.2 = 0.30000000000000004 равно 0.3? False разница = 5.551115123125783e-17 почти равно? True
Это не ошибка Python — так устроена двоичная плавающая запятая в любом языке. Вывод: вещественные числа нельзя сравнивать через ==; для денег используют целые копейки или специальные десятичные типы.
Попробуй сам
Проверьте границы знакового байта и посмотрите, как ведёт себя переполнение прямо на грани диапазона.
MASK8 = 0xFF
def as_signed(b):
return b - 256 if b > 127 else b
print("Максимум знакового байта:", as_signed(127))
print("127 + 1 →", as_signed((127 + 1) & MASK8), "(переполнение!)")
print("Минимум знакового байта:", as_signed(128))
Вывод:
Максимум знакового байта: 127 127 + 1 → -128 (переполнение!) Минимум знакового байта: -128
Частые ошибки
- Считают диапазон знакового числа симметричным. Для 8 бит это −128…+127: отрицательных на одно больше, потому что ноль «занимает» положительную половину.
- Сравнивают дробные через ==. Используйте
math.iscloseили сравнение модуля разности с малым ε. - Думают, что переполнение — редкость. В языках с фиксированными типами это повседневный риск; в Python целые не переполняются, но при работе с байтами/масками — вполне.
- Путают точность и диапазон. Плавающая запятая даёт большой диапазон, но конечную точность мантиссы.
Итоги
- n бит без знака хранят значения 0…2ⁿ−1; со знаком (дополнительный код) — от −2^(n−1) до 2^(n−1)−1.
- Дополнительный код: −x = 2ⁿ − x, старший бит — знаковый; вычитание сводится к сложению.
- Переполнение — «заворот» числа при выходе за разрядную сетку; в Python моделируется маской.
- Вещественные хранятся как мантисса×2^порядок: большой диапазон, но конечная точность — отсюда
0.1+0.2≠0.3.