Python з нуля · Блок 1 · Тема 04

Числа, яких немає

Цілі числа в Python не мають стелі. А ось десяткові дроби здебільшого не існують — комп'ютер зберігає замість них щось дуже схоже. Розберімо, наскільки схоже.

У попередній темі ми з'ясували, що ім'я в Python — це просто ярлик, наліплений на об'єкт у пам'яті. Тепер подивимось, що це за об'єкти, коли всередині них числа. Здавалося б, тут нема про що говорити: два плюс два. Але саме з чисел починається найвідоміша загадка для новачка й найдорожча помилка для професіонала.

Відкрий інтерпретатор і набери 0.1 + 0.2. Python відповість 0.30000000000000004. Це не баг і не «майже правильна відповідь». Це чесна арифметика над числами, які насправді лежать у пам'яті — а лежать там зовсім не 0.1 і 0.2. За кілька розділів ти побачиш їх до останньої цифри. Але почнемо з приємного: з місця, де Python поводиться краще за майже всі інші мови.

01 / ЦіліЦілі числа без стелі

У більшості мов ціле число — це фіксована коробка. У C або Java int займає 32 біти, отже вміщає значення від −2 147 483 648 до 2 147 483 647. Додай одиницю до максимуму — і число мовчки перескочить у мінус. Це переповнення (overflow), і на ньому збудовано цілі класи вразливостей.

У Python такої коробки немає. Тип intдовільної точності (arbitrary precision): число росте рівно настільки, наскільки треба, поки в комп'ютера є вільна пам'ять. Тому 2 ** 1000 обчислюється миттєво й дає всі 302 цифри.

Як це влаштовано всередині

CPython зберігає ціле не як одне машинне слово, а як масив «цифр». Тільки цифри там не десяткові, а по 30 біт кожна — тобто кожна вміщає число від 0 до 1 073 741 823. Окремо зберігається знак і кількість цифр. Звідси формула розміру: число з k двійкових розрядів потребує ceil(k / 30) цифр по 4 байти, плюс 24 байти службової шапки об'єкта:

розмір у байтах = 24 + 4 × ceil( бітів / 30 )

Перевіримо руками. У числа 2 ** 1000 рівно 1001 двійковий розряд. Ділимо: 1001 / 30 = 33.4, округлюємо вгору — 34 цифри. Отже 24 + 4 × 34 = 160 байтів. Саме це й покаже sys.getsizeof(2 ** 1000). Для порівняння, найменше можливе ціле — 0 — займає 28 байтів: та сама шапка плюс одна цифра.

А тепер порухай повзунок і подивись, як зростає число — і де ламається його побратим з рухомою крапкою.

Інтерактив 1 · Довге ціле: де межа

Повзунок задає степінь n. Дивись на кількість цифр, розмір у пам'яті — і на колонку float.

десяткових цифр
30-бітних цифр
байтів у пам'яті
2.0 ** n
Що читати: сіра крива — скільки десяткових цифр має 2n, вона росте рівно лінійно (кожні ~3.32 степені додають одну цифру). Вертикальна лінія на n = 1024 — межа типу float: далі там лише inf. Крива цілого числа не має вертикальної лінії взагалі.
Ціна безмежності. Довге ціле не безкоштовне: арифметика над масивом цифр у рази повільніша за одну машинну інструкцію, а множення довгих чисел коштує ще дорожче. Для типових задач це непомітно. Але якщо ти пишеш числовий код на мільйони операцій — саме тому numpy бере фіксовані 64-бітні цілі, свідомо повертаючи собі переповнення в обмін на швидкість.

02 / ОпераціїОперації та порядок дій

Арифметичних операторів у Python сім. Шість із них знайомі зі школи, сьомий — // — школа не показувала:

операторщо робитьприкладрезультат
+додавання7 + 29
віднімання7 - 25
*множення7 * 214
/ділення — завжди дає float8 / 24.0
//цілочисельне ділення (вниз)7 // 23
%остача від ділення7 % 21
**піднесення до степеня2 ** 101024

Найпоширеніша пастка тут — четвертий рядок. 8 / 2 дає не 4, а 4.0: оператор / завжди повертає число з рухомою крапкою, навіть коли ділиться націло. Це свідоме рішення Python 3 — щоб 1 / 3 не перетворювалось мовчки на нуль, як було в Python 2 і як досі буває в C.

Порядок дій шкільний, але з доповненнями. Від найсильнішого до найслабшого:

  1. ** — степінь, і він правоасоціативний: 2 ** 3 ** 2 — це 2 ** (3 ** 2) = 512, а не 64;
  2. унарний мінус: -x;
  3. *, /, //, % — зліва направо;
  4. +, — зліва направо.

І окремо — витончена дрібниця: унарний мінус слабший за степінь. Тому -2 ** 2 дорівнює -4, а не 4: спершу підноситься двійка, потім результат отримує мінус. Набери цей вираз у інтерактиві нижче й подивись, у якому порядку дерево його обчислює.

Інтерактив 2 · Дерево обчислення виразу

Введи вираз — і крок за кроком побач, у якому порядку Python його рахує.

числа, дужки, + − * / // % **

результат
Як читати дерево: обчислення йде знизу вгору — спершу найглибші вузли. Дужки не «виконуються», вони просто змінюють форму дерева. Порівняй (2 + 3) * 4 і 2 + 3 * 4: дерева різні, тому й відповіді різні.

03 / ДіленняТри різні ділення

Ділення в Python — це три різні операції, і плутати їх дорого:

Дві останні пов'язані залізним правилом, яке виконується завжди, для будь-яких знаків:

b × (a // b) + (a % b) = a

Словами: якщо взяти дільник, помножити на цілу частку і додати остачу, отримаєш назад ділене. Python гарантує цю рівність — і саме заради неї приймає рішення, яке багатьох дивує.

Чому −7 // 2 дорівнює −4

Розберімо покроково. Нам треба поділити −7 на 2.

крок 1 · два кандидати

Точна відповідь — −3.5. Ціле число поруч є два: −3 (округлення до нуля) і −4 (округлення вниз, до мінус нескінченності). Обидва варіанти зустрічаються в мовах програмування.

крок 2 · підставляємо обидва в рівність

Остача мусить бути такою, щоб b × (a // b) + r = a зійшлося:

2 × (−3) + r = −7  ⟹  r = −1   ← так робить C і Java
2 × (−4) + r = −7  ⟹  r = +1   ← так робить Python

У першому випадку остача від'ємна, у другому — лежить у діапазоні від 0 до дільника.

крок 3 · навіщо Python обрав −4

Бо тоді % завжди має знак дільника, а a % 12 завжди лежить у 0…11 — хоч би яким було a. Це рівно те, що потрібно для циклічних величин: годин на циферблаті, днів тижня, індексів у кільцевому буфері. У мовах із «мінусовою» остачею для цього доводиться писати ((a % 12) + 12) % 12.

Покрути обидва повзунки — особливо заведи в мінус дільник:

Інтерактив 3 · Цілочисельне ділення й остача на числовій прямій

Бірюзові поділки — кратні дільника. Бурштинова стрілка — остача.

a // b
a % b
як у C: a / b
як у C: a % b
Правило, яке варто запам'ятати: // округлює вниз (до мінус нескінченності), а не «до нуля». Тому остача % завжди має знак дільника. Функція divmod(a, b) повертає обидва результати однією парою — і рахує їх за одне ділення.

04 / ДробиЧому 0.1 не існує

Тепер найголовніше. Візьми аркуш і спробуй записати одну третину десятковим дробом. Вийде 0.3333…, і крапки ніколи не закінчаться — не тому, що ти поганий математик, а тому, що трійка не є дільником десятки. У десятковій системі точно записуються лише ті дроби, у знаменнику яких стоять двійки й п'ятірки.

Комп'ютер рахує у двійковій системі, де є тільки двійки. Отже там точно записуються лише дроби зі знаменником — степенем двійки: 1/2, 1/4, 1/8, 3/16. А десятина — 1/10 — має в знаменнику п'ятірку. Тому у двійковій системі 0.1 — нескінченний періодичний дріб, рівно як 1/3 у десятковій.

Переконаємось руками. Щоб перевести дріб у двійкову систему, його множать на 2 і щоразу записують цілу частину як черговий біт:

кроки 1–5 · множимо на 2 і знімаємо цілу частину
0.1 × 2 = 0.2  →  біт 0, лишається 0.2
0.2 × 2 = 0.4  →  біт 0, лишається 0.4
0.4 × 2 = 0.8  →  біт 0, лишається 0.8
0.8 × 2 = 1.6  →  біт 1, лишається 0.6
0.6 × 2 = 1.2  →  біт 1, лишається 0.2
крок 6 · пастка захлопнулась

Лишок 0.2 у нас уже був — на другому рядку. Отже далі все повториться дослівно: 0011, 0011, 0011… Період знайдено, і кінця не буде:

0.110 = 0.0001100110011001100110011…2

Комп'ютер має скінченну пам'ять — 52 біти під дробову частину. Тому він обриває цей нескінченний хвіст і округлює до найближчого представного числа. Це округлення й породжує всі дивацтва, які ми зараз побачимо.

Точно зберігаються ці. 0.5, 0.25, 0.125, 2.5, 3.75 — усе, що є сумою степенів двійки. Тому 0.5 + 0.25 == 0.75 дає True без жодних сюрпризів. Не зберігаються точно: 0.1, 0.2, 0.3 і взагалі майже будь-яка «побутова» ціна з копійками.

05 / РозтинЧисло по бітах

Тип float у Python — це подвійна точність за стандартом IEEE 754: рівно 64 біти, які діляться на три поля.

значення = (−1)знак × 2E − 1023 × 1.мантиса

Ось головний інструмент цієї лекції. Введи будь-яке число — і побачиш його справжній вміст: усі 64 біти й точне десяткове значення, яке насправді лежить у пам'яті.

Інтерактив 4 · Число з рухомою крапкою по бітах

Знак, порядок, мантиса — і точне десяткове значення, до останньої цифри.

напр. 0.1 · 0.5 · 2.675 · 1e16

знак
поле E
степінь 2^(E−1023)
крок до сусіда
Що показує нижній рядок: точне десяткове значення збереженого числа. Для 0.5 воно коротке й закінчується — число представлене точно. Для 0.1 воно тягнеться на 55 цифр і закінчується на …5625: це найближчий до однієї десятої двійковий дріб, і він трохи більший за 0.1.
Для тих, хто хоче перевірити руками. Метод float.hex() показує те саме компактно: (0.1).hex() дає '0x1.999999999999ap-4'. Читається так: мантиса 1.999…a у шістнадцятковій, помножена на 2 у степені −4. А Decimal(0.1) друкує повне точне десяткове значення — ті самі 55 цифр з інтерактиву.

06 / СумаЧому 0.1 + 0.2 ≠ 0.3

Тепер у нас є все, щоб розібрати загадку до кінця. Помилка тут не одна, а три послідовні округлення.

  1. Літерал 0.1 округлюється до найближчого двійкового дробу — виходить трохи більше за одну десяту.
  2. Літерал 0.2 округлюється так само — теж трохи більше.
  3. Процесор додає ці два збережені числа точно, а потім округлює результат до найближчого представного. Обидві похибки були в один бік, тому вони не погасились, а склались.

А літерал 0.3 округлився в інший бік — до числа трохи меншого за три десятих. Тож ліворуч і праворуч від знака рівності опиняються два різні представні числа — сусіди, між якими немає нічого. Подивись на це буквально:

Інтерактив 5 · Три округлення, одна нерівність

Поділки — сусідні представні числа. Відстань між ними тут — 5.55e-17.

a + b дає
очікували
різниця, кроків сітки
Головне спостереження: бурштинова мітка — точна сума двох збережених чисел. Вона майже ніколи не влучає в поділку, тому процесор змушений посунути її до найближчої. Іноді (як у 0.2+0.3) найближча поділка виявляється саме тією, якої ми чекали, — і рівність несподівано спрацьовує.
Не «Python погано рахує». Точно ті самі 0.30000000000000004 видадуть JavaScript, C, Java, Go, Rust, Excel і калькулятор твого телефона, якщо змусити його показати 17 значущих цифр. Це властивість формату IEEE 754, а не мови. Python лише чесно друкує те, що є.

07 / ПорівнянняЯк порівнювати правильно

Головний практичний висновок: ніколи не порівнюй числа з рухомою крапкою через ==. Не тому, що це «погана практика», а тому, що ти питаєш не те, що думаєш. a == b питає «чи це біт у біт той самий набір з 64 нулів та одиниць». А спитати ти хотів «чи це практично те саме число».

Наскільки «практично те саме»? Мірою слугує машинний епсилон — найменше число, яке ще змінює одиницю при додаванні. Для 64-бітного float це 2−52:

ε = 2−52 ≈ 2.220446049250313 × 10−16

Це не абсолютна похибка, а відносна. Біля одиниці сусідні представні числа стоять на відстані 2.2e−16. Біля мільйона — приблизно на 1.2e−10. Біля 1016 відстань уже перевищує одиницю: саме тому 10.0**16 + 1 == 10.0**16 дає True. Сітка представних чисел не рівномірна — вона згущується біля нуля й розріджується на великих значеннях.

Правильний інструмент є в стандартній бібліотеці:

math.isclose(a, b, rel_tol=1e-09, abs_tol=0.0)

Він перевіряє, чи різниця менша за rel_tol від більшого з чисел — тобто автоматично масштабує допуск. math.isclose(0.1 + 0.2, 0.3) дає True. Але є один випадок, коли відносний допуск не працює: порівняння з нулем. Нуль не має масштабу, тому math.isclose(0.0000001, 0.0) дасть False. Для порівняння з нулем треба задавати абсолютний допуск явно: math.isclose(x, 0.0, abs_tol=1e-9).

08 / НакопиченняКоли похибка росте

Одне округлення — це 10−16, дрібниця. Проблема в тому, що округлення накопичуються. Класичний приклад: додати 0.1 десять разів у циклі. Вийде 0.9999999999999999, а не 1.0. Механіка проста: кожне додавання дає число, яке не влучає в сітку, і його знову округлюють. Якщо промахи випадкові, похибка росте як корінь із кількості кроків; якщо систематичні — лінійно.

Рятує компенсоване підсумовування: алгоритм, що на кожному кроці запам'ятовує втрачений «хвостик» і повертає його назад у суму. У Python це math.fsum — результат, округлений рівно один раз, ніби вся сума рахувалась з нескінченною точністю.

Інтерактив 6 · Накопичення похибки

Скільки разів додати 0.1 у циклі. Нульова лінія — те, що дає math.fsum.

цикл total += 0.1
math.fsum
різниця
Що на графіку: рожева крива — наскільки наївний цикл відхилився від чесної суми на кожному кроці. Вона не шумить, а систематично повзе в один бік: усі збережені 0.1 однаково більші за справжню десятину, тому й похибки складаються, а не гасять одна одну.
Тонкість, про яку майже не пишуть. Навіть math.fsum не поверне тобі рівно 0.3 для трьох десятих: він чесно підсумовує збережені числа, а вони вже не є десятинами. І другий сюрприз: з Python 3.12 вбудований sum() для float теж використовує компенсоване підсумовування (алгоритм Ноймаєра). Тому sum([0.1] * 10) сьогодні дає рівно 1.0, а на Python 3.11 давало 0.9999999999999999. Ручний цикл total += 0.1 компенсації не має — і помиляється досі.

09 / Округленняround() і банківське округлення

Спитай будь-кого, скільки буде round(2.5), і почуєш «три». Python відповідає 2 — і це не помилка. Він використовує округлення до парного (round half to even), яке історично звуть банківським: якщо число рівно посередині між двома цілими, воно йде до парного. Тому round(0.5) = 0, round(1.5) = 2, round(2.5) = 2, round(3.5) = 4.

Навіщо так? Бо звичне «половину завжди вгору» має системний перекіс: сума округлених чисел виходить стабільно більшою за суму оригіналів, і на мільйоні транзакцій це реальні гроші. Округлення до парного розкидає половинні випадки порівну вгору й вниз, тому в середньому суму не зміщує.

Інтерактив 7 · Куди дивиться round()

Стрілки показують, куди йде кожна половинка. Порівняй зі «шкільним» правилом.

round(x)
«шкільне»
Що видно: напрямки стрілок чергуються. Половинки біля парних цілих притягуються до них, половинки біля непарних — теж до сусіднього парного. У сумі підйомів і спусків порівну — саме цього й домагалися.
І одразу друга пастка. round(2.675, 2) дає 2.67, хоча за правилом мало би бути 2.68. Причина не в округленні, а в тому, що ми вже знаємо: збережене 2.675 насправді дорівнює 2.67499999999999982… — трохи менше за 2.675. Тобто це навіть не половинний випадок: число справді ближче до 2.67. round() спрацював ідеально правильно над тим числом, яке йому дали.

10 / ГрошіDecimal і Fraction

Якщо десяткові дроби не представляються точно — як же рахувати гроші? Не через float. У стандартній бібліотеці є два типи, кожен зі своєю нішею.

Decimal — десяткова арифметика

decimal.Decimal зберігає число десятковими цифрами, а не двійковими. Тому 0.1 у ньому — це рівно одна десята, і Decimal('0.1') + Decimal('0.2') дає рівно Decimal('0.3'). Ще важливіше: Decimal зберігає кількість знаків після коми як частину значення, тому 2.50 грн лишається двома знаками й у звіті.

Найчастіша помилка з Decimal. Створювати його з float: Decimal(0.1) дасть ті самі 55 цифр, бо ти передав уже зіпсоване число. Створюй з рядка: Decimal('0.1'). Це та сама різниця, що між «перепиши цифру з чека» і «перепиши те, що встиг запам'ятати».

Fraction — точні дроби

fractions.Fraction зберігає чисельник і знаменник як цілі числа — а вони, як ми пам'ятаємо, необмежені. Тому Fraction(1, 3) — це рівно одна третя, без жодного округлення, і Fraction(1, 3) * 3 дає рівно 1. Ціна — швидкість і зростання знаменників при довгих обчисленнях.

типсильна сторонаде брати
intточний, необмеженийлічильники, індекси, гроші в копійках
floatшвидкий, апаратнийвимірювання, наука, машинне навчання
Decimalточна десяткова арифметикагроші, бухгалтерія, податки
Fractionточні раціональні дробиматематика, символьні викладки
Найпростіше рішення для грошей. Часто взагалі не треба ані Decimal, ані float: зберігай суму цілим числом копійок. Тоді 19.99 грн — це 1999, тип int, точність абсолютна, арифметика миттєва. Так роблять платіжні системи. Ділити доведеться акуратно, але це чесна складність замість прихованої.

11 / ІнструментиМодуль math і перетворення типів

Усе, чого немає серед операторів, живе в модулі math: корінь math.sqrt, логарифми, тригонометрія, константи math.pi і math.e. Підключається рядком import math на початку файлу. Три функції звідти варто знати одразу:

Перетворення між типами роблять функції-конструктори: float(7) дає 7.0, int('42') перетворює рядок у число. Пастка тут одна, зате болюча: int() від дробу не округлює, а відрізає дробову частину, причому відкидання йде до нуля. Тому int(-3.9) = −3, а -3.9 // 1 = −4.0 — те саме число, дві різні відповіді. Якщо потрібне саме округлення, бери round().

12 / ПідсумокЩо з цього забрати

Якщо в пам'яті залишиться лише п'ять пунктів, нехай це будуть ці:

І найголовніше в дусі цього курсу: коли комп'ютер видає дивне число, він майже ніколи не помиляється. Він робить рівно те, що написано в стандарті, — просто стандарт складніший, ніж здається на шкільному рівні. Уміння побачити за 0.30000000000000004 три округлення замість містики — це і є різниця між тим, хто пише код, і тим, хто його розуміє.

Що далі. У наступній темі — рядки, і там на нас чекає схожа історія: те, що виглядає як «просто текст», усередині виявляється послідовністю кодів символів зі своїми пастками. А в practice.ipynb ти зараз побудуєш калькулятор, зламаєш порівняння float, полагодиш його через math.isclose й порахуєш гроші як дорослий.

Далі в темі

Теорію прочитано. Тепер закріпи її на практиці.