У попередній темі ми з'ясували, що ім'я в 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 байти службової шапки об'єкта:
Перевіримо руками. У числа 2 ** 1000 рівно 1001 двійковий розряд.
Ділимо: 1001 / 30 = 33.4, округлюємо вгору — 34 цифри. Отже 24 + 4 × 34 = 160 байтів.
Саме це й покаже sys.getsizeof(2 ** 1000). Для порівняння, найменше можливе
ціле — 0 — займає 28 байтів: та сама шапка плюс одна цифра.
А тепер порухай повзунок і подивись, як зростає число — і де ламається його побратим з рухомою крапкою.
Інтерактив 1 · Довге ціле: де межа
Повзунок задає степінь n. Дивись на кількість цифр, розмір у пам'яті — і на колонку float.
float: далі там лише inf. Крива цілого числа не має
вертикальної лінії взагалі.numpy бере фіксовані 64-бітні цілі, свідомо повертаючи собі переповнення
в обмін на швидкість.02 / ОпераціїОперації та порядок дій
Арифметичних операторів у Python сім. Шість із них знайомі зі школи, сьомий —
// — школа не показувала:
| оператор | що робить | приклад | результат |
|---|---|---|---|
| + | додавання | 7 + 2 | 9 |
| − | віднімання | 7 - 2 | 5 |
| * | множення | 7 * 2 | 14 |
| / | ділення — завжди дає float | 8 / 2 | 4.0 |
| // | цілочисельне ділення (вниз) | 7 // 2 | 3 |
| % | остача від ділення | 7 % 2 | 1 |
| ** | піднесення до степеня | 2 ** 10 | 1024 |
Найпоширеніша пастка тут — четвертий рядок. 8 / 2 дає не 4,
а 4.0: оператор / завжди повертає число з рухомою крапкою, навіть
коли ділиться націло. Це свідоме рішення Python 3 — щоб 1 / 3 не перетворювалось
мовчки на нуль, як було в Python 2 і як досі буває в C.
Порядок дій шкільний, але з доповненнями. Від найсильнішого до найслабшого:
**— степінь, і він правоасоціативний:2 ** 3 ** 2— це2 ** (3 ** 2)= 512, а не 64;- унарний мінус:
-x; *,/,//,%— зліва направо;+,−— зліва направо.
І окремо — витончена дрібниця: унарний мінус слабший за степінь. Тому
-2 ** 2 дорівнює -4, а не 4: спершу підноситься
двійка, потім результат отримує мінус. Набери цей вираз у інтерактиві нижче й подивись,
у якому порядку дерево його обчислює.
Інтерактив 2 · Дерево обчислення виразу
Введи вираз — і крок за кроком побач, у якому порядку Python його рахує.
числа, дужки, + − * / // % **
(2 + 3) * 4 і 2 + 3 * 4: дерева різні, тому й відповіді різні.03 / ДіленняТри різні ділення
Ділення в Python — це три різні операції, і плутати їх дорого:
/— звичайне ділення, результат завждиfloat;//— цілочисельне: скільки цілих разів дільник уміщається в діленому;%— остача: що лишилось після того, як умістилось.
Дві останні пов'язані залізним правилом, яке виконується завжди, для будь-яких знаків:
Словами: якщо взяти дільник, помножити на цілу частку і додати остачу, отримаєш назад ділене. Python гарантує цю рівність — і саме заради неї приймає рішення, яке багатьох дивує.
Чому −7 // 2 дорівнює −4
Розберімо покроково. Нам треба поділити −7 на 2.
Точна відповідь — −3.5. Ціле число поруч є два: −3 (округлення до нуля) і −4 (округлення вниз, до мінус нескінченності). Обидва варіанти зустрічаються в мовах програмування.
Остача мусить бути такою, щоб b × (a // b) + r = a зійшлося:
2 × (−4) + r = −7 ⟹ r = +1 ← так робить Python
У першому випадку остача від'ємна, у другому — лежить у діапазоні від 0 до дільника.
Бо тоді % завжди має знак дільника, а a % 12
завжди лежить у 0…11 — хоч би яким було a. Це рівно те, що потрібно для
циклічних величин: годин на циферблаті, днів тижня, індексів у кільцевому буфері.
У мовах із «мінусовою» остачею для цього доводиться писати
((a % 12) + 12) % 12.
Покрути обидва повзунки — особливо заведи в мінус дільник:
Інтерактив 3 · Цілочисельне ділення й остача на числовій прямій
Бірюзові поділки — кратні дільника. Бурштинова стрілка — остача.
// округлює вниз
(до мінус нескінченності), а не «до нуля». Тому остача % завжди має знак
дільника. Функція divmod(a, b) повертає обидва результати однією парою —
і рахує їх за одне ділення.04 / ДробиЧому 0.1 не існує
Тепер найголовніше. Візьми аркуш і спробуй записати одну третину десятковим дробом. Вийде 0.3333…, і крапки ніколи не закінчаться — не тому, що ти поганий математик, а тому, що трійка не є дільником десятки. У десятковій системі точно записуються лише ті дроби, у знаменнику яких стоять двійки й п'ятірки.
Комп'ютер рахує у двійковій системі, де є тільки двійки. Отже там точно записуються лише
дроби зі знаменником — степенем двійки: 1/2, 1/4, 1/8, 3/16. А десятина — 1/10 —
має в знаменнику п'ятірку. Тому у двійковій системі 0.1 — нескінченний періодичний
дріб, рівно як 1/3 у десятковій.
Переконаємось руками. Щоб перевести дріб у двійкову систему, його множать на 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
Лишок 0.2 у нас уже був — на другому рядку. Отже далі все повториться дослівно: 0011, 0011, 0011… Період знайдено, і кінця не буде:
Комп'ютер має скінченну пам'ять — 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 біт. Нуль означає плюс, одиниця — мінус;
- поле порядку
E— 11 бітів, ціле число від 0 до 2047. Реальний показник степеня — цеE − 1023: зсув на 1023 потрібен, щоб зберігати від'ємні степені без окремого знака; - мантиса — 52 біти. Це дробова частина числа виду
1.xxxxxу двійковій системі. Одиниця перед крапкою не зберігається взагалі — вона там завжди, тому її просто мають на увазі. Так безкоштовно отримують 53-й біт точності.
Ось головний інструмент цієї лекції. Введи будь-яке число — і побачиш його справжній вміст: усі 64 біти й точне десяткове значення, яке насправді лежить у пам'яті.
Інтерактив 4 · Число з рухомою крапкою по бітах
Знак, порядок, мантиса — і точне десяткове значення, до останньої цифри.
напр. 0.1 · 0.5 · 2.675 · 1e16
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
Тепер у нас є все, щоб розібрати загадку до кінця. Помилка тут не одна, а три послідовні округлення.
- Літерал
0.1округлюється до найближчого двійкового дробу — виходить трохи більше за одну десяту. - Літерал
0.2округлюється так само — теж трохи більше. - Процесор додає ці два збережені числа точно, а потім округлює результат до найближчого представного. Обидві похибки були в один бік, тому вони не погасились, а склались.
А літерал 0.3 округлився в інший бік — до числа трохи меншого
за три десятих. Тож ліворуч і праворуч від знака рівності опиняються два різні представні
числа — сусіди, між якими немає нічого. Подивись на це буквально:
Інтерактив 5 · Три округлення, одна нерівність
Поділки — сусідні представні числа. Відстань між ними тут — 5.55e-17.
0.2+0.3) найближча поділка виявляється саме
тією, якої ми чекали, — і рівність несподівано спрацьовує.0.30000000000000004 видадуть JavaScript, C, Java, Go, Rust, Excel і калькулятор
твого телефона, якщо змусити його показати 17 значущих цифр. Це властивість формату
IEEE 754, а не мови. Python лише чесно друкує те, що є.07 / ПорівнянняЯк порівнювати правильно
Головний практичний висновок: ніколи не порівнюй числа з рухомою крапкою через
==. Не тому, що це «погана практика», а тому, що ти питаєш не те,
що думаєш. a == b питає «чи це біт у біт той самий набір з 64 нулів та
одиниць». А спитати ти хотів «чи це практично те саме число».
Наскільки «практично те саме»? Мірою слугує машинний епсилон — найменше число, яке ще змінює одиницю при додаванні. Для 64-бітного float це 2−52:
Це не абсолютна похибка, а відносна. Біля одиниці сусідні представні
числа стоять на відстані 2.2e−16. Біля мільйона — приблизно на 1.2e−10. Біля 1016
відстань уже перевищує одиницю: саме тому 10.0**16 + 1 == 10.0**16 дає
True. Сітка представних чисел не рівномірна — вона згущується біля нуля
й розріджується на великих значеннях.
Правильний інструмент є в стандартній бібліотеці:
Він перевіряє, чи різниця менша за 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.
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(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(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 на початку файлу.
Три функції звідти варто знати одразу:
math.floor(x)іmath.ceil(x)— округлення строго вниз і строго вгору, без банківських тонкощів;math.isclose(a, b)— порівняння, про яке ми говорили;math.fsum(послідовність)— чесна сума.
Перетворення між типами роблять функції-конструктори: float(7) дає
7.0, int('42') перетворює рядок у число. Пастка тут одна, зате
болюча: int() від дробу не округлює, а відрізає дробову частину,
причому відкидання йде до нуля. Тому int(-3.9) = −3, а
-3.9 // 1 = −4.0 — те саме число, дві різні відповіді. Якщо потрібне саме
округлення, бери round().
12 / ПідсумокЩо з цього забрати
Якщо в пам'яті залишиться лише п'ять пунктів, нехай це будуть ці:
- int точний і безмежний. Якщо задача про підрахунок, індекси або копійки — тримайся цілих чисел, і похибки не буде взагалі;
- float — це наближення. Не «число 0.1», а «найближче до 0.1 число, яке взагалі можна записати 64 бітами»;
==для float — майже завжди помилка. Бериmath.isclose, а для порівняння з нулем задавайabs_tol;//округлює вниз,int()— до нуля. На від'ємних числах це різні відповіді;- Гроші — не для float. Або
Decimalз рядка, або цілі копійки.
І найголовніше в дусі цього курсу: коли комп'ютер видає дивне число, він майже ніколи
не помиляється. Він робить рівно те, що написано в стандарті, — просто стандарт складніший,
ніж здається на шкільному рівні. Уміння побачити за 0.30000000000000004 три
округлення замість містики — це і є різниця між тим, хто пише код, і тим, хто його розуміє.
practice.ipynb ти зараз побудуєш
калькулятор, зламаєш порівняння float, полагодиш його через math.isclose
й порахуєш гроші як дорослий.Далі в темі
Теорію прочитано. Тепер закріпи її на практиці.