Практика · Числа й арифметика¶

Лекція: lecture.html · Тест: quiz.html · ДЗ: homework.md

Тут ми руками помацаємо все, про що йшлося в лекції:

  1. побудуємо простий калькулятор і подивимось на порядок дій;
  2. перевіримо, що цілі числа в Python справді не мають стелі;
  3. подивимось, що насправді лежить у пам'яті замість 0.1;
  4. зламаємо порівняння float через == — і полагодимо його через math.isclose;
  5. побачимо, як похибка накопичується в циклі, і як її прибирає math.fsum;
  6. розберемо банківське округлення;
  7. порахуємо гроші так, щоб жодна копійка не загубилась.

Зошит виконується згори вниз без жодних правок. Запускай клітинки по черзі.

1 · Калькулятор: сім операторів¶

Почнемо з найпростішого — просто подивимось, що дає кожен оператор на тих самих числах. Зверни увагу на четвертий рядок: / повертає float навіть тоді, коли ділиться націло.

In [1]:
ділене = 17
дільник = 5

print("ділене =", ділене, " дільник =", дільник)
print()
print("додавання      17 + 5  =", ділене + дільник)
print("віднімання     17 - 5  =", ділене - дільник)
print("множення       17 * 5  =", ділене * дільник)
print("ділення        17 / 5  =", ділене / дільник)      # завжди float
print("цілочисельне   17 // 5 =", ділене // дільник)
print("остача         17 % 5  =", ділене % дільник)
print("степінь        17 ** 5 =", ділене ** дільник)
ділене = 17  дільник = 5

додавання      17 + 5  = 22
віднімання     17 - 5  = 12
множення       17 * 5  = 85
ділення        17 / 5  = 3.4
цілочисельне   17 // 5 = 3
остача         17 % 5  = 2
степінь        17 ** 5 = 1419857

Тип результату теж змінюється¶

type(x) показує, до якого типу належить об'єкт. Порівняй 8 / 2 і 8 // 2: однакове число на екрані — різні типи всередині.

In [2]:
print("8 / 2  ->", 8 / 2,  type(8 / 2))
print("8 // 2 ->", 8 // 2, type(8 // 2))
print("2 ** 10 ->", 2 ** 10, type(2 ** 10))
print("2 ** -1 ->", 2 ** -1, type(2 ** -1))   # відʼємний степінь дає float
8 / 2  -> 4.0 <class 'float'>
8 // 2 -> 4 <class 'int'>
2 ** 10 -> 1024 <class 'int'>
2 ** -1 -> 0.5 <class 'float'>

2 · Порядок дій і дужки¶

Три вирази з однаковими числами, але різними дужками. Якщо якийсь результат здивував — відкрий у лекції інтерактив «Дерево обчислення виразу» й набери його там.

In [3]:
print("2 + 3 * 4      =", 2 + 3 * 4)        # множення сильніше за додавання
print("(2 + 3) * 4    =", (2 + 3) * 4)      # дужки міняють форму дерева
print("2 ** 3 ** 2    =", 2 ** 3 ** 2)      # степінь правоасоціативний: 2 ** (3 ** 2)
print("(2 ** 3) ** 2  =", (2 ** 3) ** 2)
print("-2 ** 2        =", -2 ** 2)          # унарний мінус слабший за степінь
print("(-2) ** 2      =", (-2) ** 2)
2 + 3 * 4      = 14
(2 + 3) * 4    = 20
2 ** 3 ** 2    = 512
(2 ** 3) ** 2  = 64
-2 ** 2        = -4
(-2) ** 2      = 4

3 · Три ділення й відʼємні числа¶

Ось те, на чому спотикаються навіть досвідчені: // округлює вниз, а не «до нуля». Перевіримо, що залізна рівність b * (a // b) + (a % b) == a тримається для всіх комбінацій знаків.

In [4]:
пари = [(7, 2), (-7, 2), (7, -2), (-7, -2)]

print(f"{'a':>4} {'b':>4} | {'a // b':>7} {'a % b':>6} | перевірка b*(a//b) + a%b")
print("-" * 58)
for a, b in пари:                 # цикл ще попереду за програмою — тут він лише для таблиці
    частка = a // b
    остача = a % b
    print(f"{a:>4} {b:>4} | {частка:>7} {остача:>6} | {b * частка + остача}")

# рівність має виконуватись завжди — це і є визначення // та % у Python
for a, b in пари:
    assert b * (a // b) + (a % b) == a, f"рівність зламалась на {a}, {b}"
print()
print("✅ рівність b * (a // b) + (a % b) == a тримається для всіх знаків")
   a    b |  a // b  a % b | перевірка b*(a//b) + a%b
----------------------------------------------------------
   7    2 |       3      1 | 7
  -7    2 |      -4      1 | -7
   7   -2 |      -4     -1 | 7
  -7   -2 |       3     -1 | -7

✅ рівність b * (a // b) + (a % b) == a тримається для всіх знаків

Остача завжди має знак дільника¶

Саме тому % зручний для циклічних величин: година % 12 завжди дасть 0…11, навіть якщо година відʼємна.

In [5]:
години = [-25, -13, -1, 0, 11, 25]
for година in години:
    print(f"{година:>4} % 12 = {година % 12:>3}    divmod: {divmod(година, 12)}")

# int() відрізає до нуля, а // округлює вниз — на відʼємних це різні відповіді
print()
print("int(-3.9)   =", int(-3.9), "   (відрізає до нуля)")
print("-3.9 // 1   =", -3.9 // 1, " (округлює вниз)")
 -25 % 12 =  11    divmod: (-3, 11)
 -13 % 12 =  11    divmod: (-2, 11)
  -1 % 12 =  11    divmod: (-1, 11)
   0 % 12 =   0    divmod: (0, 0)
  11 % 12 =  11    divmod: (0, 11)
  25 % 12 =   1    divmod: (2, 1)

int(-3.9)   = -3    (відрізає до нуля)
-3.9 // 1   = -4.0  (округлює вниз)

4 · Цілі числа без стелі¶

Перевіримо на практиці, що int росте скільки треба, а float — ні. Формула розміру з лекції: 24 + 4 * ceil(бітів / 30) байтів.

In [6]:
import sys
import math

велике = 2 ** 1000

print("2 ** 1000 має", len(str(велике)), "десяткових цифр")
print("двійкових розрядів:", велике.bit_length())

тридцятибітних_цифр = math.ceil(велике.bit_length() / 30)
розмір_за_формулою = 24 + 4 * тридцятибітних_цифр

print("30-бітних цифр:", тридцятибітних_цифр)
print("розмір за формулою:", розмір_за_формулою, "байтів")
print("що каже sys.getsizeof:", sys.getsizeof(велике), "байтів")

assert розмір_за_формулою == sys.getsizeof(велике), "формула розміру не зійшлася"
print()
print("✅ наша формула збіглася з реальним розміром обʼєкта")
2 ** 1000 має 302 десяткових цифр
двійкових розрядів: 1001
30-бітних цифр: 34
розмір за формулою: 160 байтів
що каже sys.getsizeof: 160 байтів

✅ наша формула збіглася з реальним розміром обʼєкта

А тепер те саме через float¶

2 ** 1000 як ціле — звичайне число. 2.0 ** 1000 як float — на межі можливого, а 2.0 ** 1024 уже не існує: поле порядку в 11 бітів закінчилось.

In [7]:
print("2.0 ** 1000 =", 2.0 ** 1000)

# показуємо помилку навмисне — traceback теж навчальний матеріал
try:
    print(2.0 ** 1024)
except OverflowError as помилка:
    print("2.0 ** 1024 -> OverflowError:", помилка)

# а ціле такого розміру Python рахує без жодних скарг
print("2 ** 1024 має", len(str(2 ** 1024)), "цифр і жодних помилок")
2.0 ** 1000 = 1.0715086071862673e+301
2.0 ** 1024 -> OverflowError: (34, 'Numerical result out of range')
2 ** 1024 має 309 цифр і жодних помилок

5 · Що насправді лежить у пам'яті замість 0.1¶

Decimal уміє показати точне значення збереженого числа з рухомою крапкою. Не округлене, не скорочене — усі цифри до останньої.

In [8]:
from decimal import Decimal

for літерал in ["0.1", "0.2", "0.3", "0.5", "0.75"]:
    число = float(літерал)
    print(f"{літерал:>5} зберігається як {Decimal(число)}")
  0.1 зберігається як 0.1000000000000000055511151231257827021181583404541015625
  0.2 зберігається як 0.200000000000000011102230246251565404236316680908203125
  0.3 зберігається як 0.299999999999999988897769753748434595763683319091796875
  0.5 зберігається як 0.5
 0.75 зберігається як 0.75

Бачиш різницю? 0.5 і 0.75 записались точно й коротко — у них знаменник є степенем двійки. А 0.1, 0.2, 0.3 розтягнулись на 55 цифр: точно їх записати неможливо.

Метод .hex() показує те саме компактніше — мантису й степінь двійки:

In [9]:
print("(0.1).hex()  =", (0.1).hex())    # 1.999...a × 2^-4
print("(0.5).hex()  =", (0.5).hex())    # 1.0 × 2^-1 — рівно, без хвоста
print()
print("машинний епсилон:", sys.float_info.epsilon)
print("він же 2 ** -52 :", 2.0 ** -52)
(0.1).hex()  = 0x1.999999999999ap-4
(0.5).hex()  = 0x1.0000000000000p-1

машинний епсилон: 2.220446049250313e-16
він же 2 ** -52 : 2.220446049250313e-16

6 · Порівняння float: як не треба і як треба¶

Спершу зламаємо. Потім полагодимо.

In [10]:
print("0.1 + 0.2       =", 0.1 + 0.2)
print("0.1 + 0.2 == 0.3 ->", 0.1 + 0.2 == 0.3)      # False!
print()
print("точна сума ліворуч:", Decimal(0.1 + 0.2))
print("точне значення 0.3:", Decimal(0.3))
print()
print("а от тут пощастило:")
print("0.2 + 0.3 == 0.5 ->", 0.2 + 0.3 == 0.5)      # True — похибки погасились
print("0.5 + 0.25 == 0.75 ->", 0.5 + 0.25 == 0.75)  # True — обидва числа точні
0.1 + 0.2       = 0.30000000000000004
0.1 + 0.2 == 0.3 -> False

точна сума ліворуч: 0.3000000000000000444089209850062616169452667236328125
точне значення 0.3: 0.299999999999999988897769753748434595763683319091796875

а от тут пощастило:
0.2 + 0.3 == 0.5 -> True
0.5 + 0.25 == 0.75 -> True

Правильний інструмент — math.isclose¶

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

In [11]:
def майже_рівні(a, b, відносний_допуск=1e-09):
    """Наша власна версія math.isclose із допуском, пропорційним самим числам."""
    # беремо більше з двох за модулем — саме воно задає масштаб допуску
    масштаб = max(abs(a), abs(b))
    return abs(a - b) <= відносний_допуск * масштаб


перевірки = [
    (0.1 + 0.2, 0.3),
    (1.0, 1.0000000001),
    (1.0, 1.1),
    (1e16 + 1, 1e16),
    (1000000.0, 1000000.0001),
]

print(f"{'a':>22} {'b':>22} | наша | math.isclose")
print("-" * 70)
for a, b in перевірки:
    наш = майже_рівні(a, b)
    бібліотечний = math.isclose(a, b)
    print(f"{a!r:>22} {b!r:>22} | {str(наш):>5} | {бібліотечний}")
    assert наш == бібліотечний, f"розійшлися на {a}, {b}"

print()
print("✅ наша реалізація збіглася з math.isclose на всіх прикладах")
                     a                      b | наша | math.isclose
----------------------------------------------------------------------
   0.30000000000000004                    0.3 |  True | True
                   1.0           1.0000000001 |  True | True
                   1.0                    1.1 | False | False
                 1e+16                  1e+16 |  True | True
             1000000.0           1000000.0001 |  True | True

✅ наша реалізація збіглася з math.isclose на всіх прикладах

Пастка з нулем¶

Відносний допуск не працює для порівняння з нулем: у нуля немає масштабу. Для цього випадку допуск задають абсолютний.

In [12]:
крихітне = 0.0000001

print("math.isclose(0.0000001, 0.0)                =", math.isclose(крихітне, 0.0))
print("math.isclose(0.0000001, 0.0, abs_tol=1e-06) =", math.isclose(крихітне, 0.0, abs_tol=1e-06))
print()
print("Висновок: порівнюєш із нулем — обовʼязково задавай abs_tol.")
math.isclose(0.0000001, 0.0)                = False
math.isclose(0.0000001, 0.0, abs_tol=1e-06) = True

Висновок: порівнюєш із нулем — обовʼязково задавай abs_tol.

7 · Накопичення похибки¶

Тут нам знадобиться цикл for. Це забігання наперед — циклі докладно розберемо в темі 12. Поки що читай його так: «повтори те, що всередині, n разів».

In [13]:
def наївна_сума(скільки_разів):
    """Додає 0.1 у циклі — так, як це зробив би будь-хто вручну."""
    підсумок = 0.0
    for _ in range(скільки_разів):
        підсумок = підсумок + 0.1
    return підсумок


print(f"{'n':>6} | {'наївний цикл':>22} | {'math.fsum':>22} | збігаються?")
print("-" * 76)
for n in [3, 10, 100, 1000, 10000]:
    цикл = наївна_сума(n)
    чесна = math.fsum([0.1] * n)
    print(f"{n:>6} | {цикл!r:>22} | {чесна!r:>22} | {цикл == чесна}")
     n |           наївний цикл |              math.fsum | збігаються?
----------------------------------------------------------------------------
     3 |    0.30000000000000004 |    0.30000000000000004 | True
    10 |     0.9999999999999999 |                    1.0 | False
   100 |       9.99999999999998 |                   10.0 | False
  1000 |       99.9999999999986 |                  100.0 | False
 10000 |     1000.0000000001588 |                 1000.0 | False

Похибка не випадкова — вона систематично росте. Причина в тому, що кожне збережене 0.1 трохи більше за справжню десятину, тому промахи складаються, а не гасять одне одного.

Полагодимо це власними руками: реалізуємо компенсоване підсумовування (алгоритм Ноймаєра). Ідея — на кожному кроці запам'ятати «хвостик», який загубився при округленні, і в кінці повернути його назад.

In [14]:
def компенсована_сума(числа):
    """Підсумовує, не втрачаючи молодші розряди: те саме, що робить math.fsum."""
    сума = 0.0
    компенсація = 0.0          # тут накопичується все, що загубилось при округленні
    for число in числа:
        нова_сума = сума + число
        # який доданок «більший», такий і втрачає молодші біти — звідси дві гілки
        if abs(сума) >= abs(число):
            компенсація += (сума - нова_сума) + число
        else:
            компенсація += (число - нова_сума) + сума
        сума = нова_сума
    return сума + компенсація


набори = [
    [0.1] * 10,
    [0.1] * 1000,
    [1.0, 1e100, 1.0, -1e100],      # класичний приклад катастрофічного скорочення
    [1e16, 1.0, 1.0, 1.0],
]

for набір in набори:
    наша = компенсована_сума(набір)
    бібліотечна = math.fsum(набір)
    print(f"наша = {наша!r:>22}   math.fsum = {бібліотечна!r:>22}")
    assert наша == бібліотечна, "компенсована сума розійшлася з math.fsum"

print()
print("✅ наша компенсована сума збіглася з math.fsum на всіх наборах")
наша =                    1.0   math.fsum =                    1.0
наша =                  100.0   math.fsum =                  100.0
наша =                    2.0   math.fsum =                    2.0
наша = 1.0000000000000004e+16   math.fsum = 1.0000000000000004e+16

✅ наша компенсована сума збіглася з math.fsum на всіх наборах

Важлива чесна дрібниця¶

math.fsum прибирає похибку підсумовування, але не може прибрати похибку, яка вже сидить у самих числах. Три збережені «десятини» в сумі дають не 0.3 — і жоден алгоритм тут не допоможе.

І окремо: з Python 3.12 вбудований sum() для float теж компенсує похибку. Ручний цикл total += 0.1 — ні.

In [15]:
print("math.fsum([0.1] * 3) =", repr(math.fsum([0.1] * 3)))
print("це дорівнює 0.3?     ", math.fsum([0.1] * 3) == 0.3)
print()
print("sum([0.1] * 10)      =", repr(sum([0.1] * 10)))       # з 3.12 компенсоване
print("наївна_сума(10)      =", repr(наївна_сума(10)))       # звичайний цикл — ні
print()
print("версія Python:", sys.version.split()[0])
math.fsum([0.1] * 3) = 0.30000000000000004
це дорівнює 0.3?      False

sum([0.1] * 10)      = 1.0
наївна_сума(10)      = 0.9999999999999999

версія Python: 3.12.2

8 · round() і банківське округлення¶

Реалізуємо правило «половина йде до парного» самі — і перевіримо, що воно збігається з вбудованим round().

In [16]:
def округлити_до_парного(x):
    """Округлення half-to-even: половинки йдуть до найближчого парного цілого."""
    низ = math.floor(x)
    дробова = x - низ
    if дробова > 0.5:
        return низ + 1
    if дробова < 0.5:
        return низ
    # рівно половина: обираємо той із двох сусідів, який парний
    return низ if низ % 2 == 0 else низ + 1


print(f"{'x':>6} | {'round(x)':>9} | {'наша':>5} | «шкільне» floor(x+0.5)")
print("-" * 52)
значення = [-2.5, -1.5, -0.5, 0.5, 1.5, 2.5, 3.5, 4.5, 2.4, 2.6]
for x in значення:
    наше = округлити_до_парного(x)
    print(f"{x:>6} | {round(x):>9} | {наше:>5} | {math.floor(x + 0.5)}")
    assert наше == round(x), f"наше округлення розійшлося з round() на {x}"

print()
print("✅ наша реалізація банківського округлення збігається з round()")
     x |  round(x) |  наша | «шкільне» floor(x+0.5)
----------------------------------------------------
  -2.5 |        -2 |    -2 | -2
  -1.5 |        -2 |    -2 | -1
  -0.5 |         0 |     0 | 0
   0.5 |         0 |     0 | 1
   1.5 |         2 |     2 | 2
   2.5 |         2 |     2 | 3
   3.5 |         4 |     4 | 4
   4.5 |         4 |     4 | 5
   2.4 |         2 |     2 | 2
   2.6 |         3 |     3 | 3

✅ наша реалізація банківського округлення збігається з round()

Друга пастка round()¶

round(2.675, 2) дає 2.67, а не 2.68. Це не помилка округлення — це наслідок того, що збережене 2.675 насправді трохи менше за 2.675.

In [17]:
print("round(2.675, 2) =", round(2.675, 2))
print("а насправді 2.675 зберігається як:")
print(" ", Decimal(2.675))
print()
print("тобто число справді ближче до 2.67 — round() спрацював правильно")
round(2.675, 2) = 2.67
а насправді 2.675 зберігається як:
  2.67499999999999982236431605997495353221893310546875

тобто число справді ближче до 2.67 — round() спрацював правильно

9 · Гроші¶

Класична помилка: рахувати ціни через float. Порахуємо чек із трьох позицій двома способами й подивимось на різницю.

In [18]:
ціни_рядками = ["19.99", "4.35", "0.99"]

# спосіб 1 — через float
сума_float = 0.0
for ціна in ціни_рядками:
    сума_float += float(ціна)

# спосіб 2 — через Decimal, створений З РЯДКА (це принципово)
сума_decimal = Decimal("0")
for ціна in ціни_рядками:
    сума_decimal += Decimal(ціна)

print("через float  :", repr(сума_float))
print("через Decimal:", сума_decimal)
print()
print("точне значення float-суми:", Decimal(сума_float))
print("вони рівні?", Decimal(сума_float) == сума_decimal)

assert сума_decimal == Decimal("25.33"), "Decimal мав дати рівно 25.33"
print()
print("✅ Decimal дав рівно 25.33 — жодного хвоста")
через float  : 25.329999999999995
через Decimal: 25.33

точне значення float-суми: 25.329999999999994741983755375258624553680419921875
вони рівні? False

✅ Decimal дав рівно 25.33 — жодного хвоста

Головна помилка з Decimal¶

Decimal(0.1) і Decimal('0.1') — це різні числа. У першому випадку ти передаєш уже зіпсований float, і Decimal чесно копіює всі його 55 цифр.

In [19]:
з_float = Decimal(0.1)
з_рядка = Decimal("0.1")

print("Decimal(0.1)   =", з_float)
print("Decimal('0.1') =", з_рядка)
print("рівні?", з_float == з_рядка)
print()
print("Decimal('0.1') + Decimal('0.2') =", Decimal("0.1") + Decimal("0.2"))
print("це дорівнює Decimal('0.3')?", Decimal("0.1") + Decimal("0.2") == Decimal("0.3"))
Decimal(0.1)   = 0.1000000000000000055511151231257827021181583404541015625
Decimal('0.1') = 0.1
рівні? False

Decimal('0.1') + Decimal('0.2') = 0.3
це дорівнює Decimal('0.3')? True

Найпростіший спосіб: цілі копійки¶

Якщо Decimal здається важким — зберігай гроші цілим числом копійок. Тип int точний за визначенням, і жодних питань про округлення не виникає.

In [20]:
# ті самі три ціни, але в копійках
копійки = [1999, 435, 99]
разом_копійок = sum(копійки)

гривні = разом_копійок // 100      # цілочисельне ділення дає гривні
залишок = разом_копійок % 100      # остача — копійки

print("разом копійок:", разом_копійок)
print(f"це {гривні} грн {залишок} коп")

assert разом_копійок == 2533, "сума в копійках порахована неправильно"
print()
print("✅ цілі копійки збіглися з Decimal-версією (25.33 грн)")
разом копійок:
 2533
це 25 грн 33 коп

✅ цілі копійки збіглися з Decimal-версією (25.33 грн)

10 · Fraction: точні дроби¶

Fraction зберігає чисельник і знаменник цілими числами — а вони необмежені. Тому дріб тут справді дріб, а не наближення до нього.

In [21]:
from fractions import Fraction

# 49 узято не випадково: саме на ньому float спотикається, а на 3 чи 7 — ні
print("Fraction(1, 49)        =", Fraction(1, 49))
print("Fraction(1, 49) * 49   =", Fraction(1, 49) * 49)
print("а через float: 1/49*49 =", repr(1 / 49 * 49))
print()
print("Fraction(1, 49) * 49 == 1 ->", Fraction(1, 49) * 49 == 1)
print("1 / 49 * 49 == 1.0        ->", 1 / 49 * 49 == 1.0)
print()
print("Fraction(1, 3) * 3 == 1   ->", Fraction(1, 3) * 3 == 1, " (третина теж точна)")

# Fraction уміє показати, яким дробом насправді є збережений float
print()
print("яким дробом насправді є 0.1:")
print(" ", Fraction(0.1))
print("  (це і є 3602879701896397 / 2**55)")

assert Fraction(0.1) == Fraction(3602879701896397, 2 ** 55), "дріб не збігся"
print()
print("✅ збережене 0.1 — це рівно 3602879701896397 / 2**55")
Fraction(1, 49)        = 1/49
Fraction(1, 49) * 49   = 1
а через float: 1/49*49 = 0.9999999999999999

Fraction(1, 49) * 49 == 1 -> True
1 / 49 * 49 == 1.0        -> False

Fraction(1, 3) * 3 == 1   -> True  (третина теж точна)

яким дробом насправді є 0.1:
  3602879701896397/36028797018963968
  (це і є 3602879701896397 / 2**55)

✅ збережене 0.1 — це рівно 3602879701896397 / 2**55

11 · Модуль math і перетворення типів¶

Останній блок — інструменти, які знадобляться далі щодня.

In [22]:
print("math.pi        =", math.pi)
print("math.sqrt(2)   =", math.sqrt(2))
print("math.floor(3.7)=", math.floor(3.7), "  math.ceil(3.2) =", math.ceil(3.2))
print("math.floor(-3.7)=", math.floor(-3.7), " math.ceil(-3.2) =", math.ceil(-3.2))
print()
print("int('42') + 8  =", int("42") + 8)
print("float('3.5')   =", float("3.5"))
print("int(3.9)       =", int(3.9), "   (відрізає, а не округлює)")
print("round(3.9)     =", round(3.9))

# int() від рядка з дробом не працює — це типова помилка новачка
try:
    int("3.9")
except ValueError as помилка:
    print()
    print("int('3.9') -> ValueError:", помилка)
    print("правильно так: int(float('3.9')) =", int(float("3.9")))
math.pi        =
 3.141592653589793
math.sqrt(2)   = 1.4142135623730951
math.floor(3.7)= 3   math.ceil(3.2) = 4
math.floor(-3.7)= -4  math.ceil(-3.2) = -3

int('42') + 8  = 50
float('3.5')   = 3.5
int(3.9)       = 3    (відрізає, а не округлює)
round(3.9)     = 4

int('3.9') -> ValueError: invalid literal for int() with base 10: '3.9'
правильно так: int(float('3.9')) = 3

Підсумок¶

Що ми перевірили власними руками:

  • рівність b * (a // b) + (a % b) == a тримається для всіх знаків;
  • формула розміру цілого 24 + 4 * ceil(бітів / 30) збігається з sys.getsizeof;
  • 2.0 ** 1024 переповнюється, а 2 ** 1024 — ні;
  • наша майже_рівні дає те саме, що math.isclose;
  • наша компенсована сума дає те саме, що math.fsum;
  • наше округлення до парного дає те саме, що round;
  • Decimal із рядка й цілі копійки дають однакову точну суму;
  • збережене 0.1 — це рівно 3602879701896397 / 2**55.

Головне, що варто винести: всередині бібліотек немає магії. Там ті самі кілька рядків, які ти щойно написав сам.


Завдання¶

🟢 Рівень 1 — База¶

Порахуй, скільки часу минуло, якщо відомо загальну кількість секунд. Візьми всього_секунд = 100_000 і виведи результат у форматі X год Y хв Z с, використавши лише // і %.

Зроблено, якщо: сума години * 3600 + хвилини * 60 + секунди дорівнює вихідному числу (перевір це через assert).

🟡 Рівень 2 — Плюс¶

Напиши функцію порівняти(a, b), яка приймає два числа й повертає рядок: "рівні", "майже рівні" або "різні". «Майже рівні» — це коли == каже False, але math.isclose каже True.

Перевір її щонайменше на п'яти парах, серед яких обовʼязково мають бути (0.1 + 0.2, 0.3), (0.5 + 0.25, 0.75) і (1.0, 1.1).

Зроблено, якщо: функція повертає "майже рівні" для першої пари, "рівні" для другої і "різні" для третьої.

🔴 Рівень 3 — Виклик¶

Напиши функцію розкласти_float(x), яка без struct і без Decimal повертає кортеж (знак, поле_порядку, мантиса) — три цілі числа з внутрішнього представлення float.

Підказка: math.frexp(x) розкладає число на мантису в діапазоні [0.5, 1) і показник степеня двійки. Далі мантису треба домножити на 2 ** 53 і врахувати зсув порядку на 1023.

Зроблено, якщо: для 0.1 функція повертає (0, 1019, 2702159776422298), а зібране назад значення (-1)**знак * 2**(поле_порядку - 1023) * (1 + мантиса / 2**52) точно дорівнює 0.1 (перевір через ==).