Практика · Тема 18 · Помилки та винятки¶

Лекція: lecture.html · Тест: quiz.html · Домашнє завдання: homework.md

Наскрізний приклад той самий, що в лекції, — битий текстовий прайс. Кілька рядків у ньому зіпсовані, і зіпсовані по-різному. Наша мета: не впасти на першому ж із них.

Що зробимо руками:

  1. подивимось на справжній traceback, коли обробки немає взагалі;
  2. напишемо try / except на конкретний тип і переконаємось, що вузька гілка ловить те, що назвала;
  3. побачимо на власні очі, як голий except ковтає одрук програміста;
  4. перевіримо порядок гілок і знайдемо недосяжну;
  5. доведемо через assert, що finally виконався навіть тоді, коли виняток полетів далі;
  6. піднімемо виняток самі — з raise і з raise ... from;
  7. зробимо власний тип ПомилкаПрайсу за рецептом із лекції;
  8. виміряємо, скільки насправді коштує виняток, і порівняємо EAFP з LBYL.

1 · Дані, з якими працюємо¶

Прайс прийшов текстом: кожен рядок — «назва;ціна». Два рядки з пʼяти зіпсовані, і зіпсовані по-різному — це нам і потрібно.

In [1]:
рядки_прайсу = [
    "хліб;28.5",
    "молоко;32.0",
    "яблука;девʼятнадцять",   # ціна словами — float() на цьому спіткнеться
    "мед",                    # крапки з комою немає — другого елемента не буде
    "сіль;12.0",
]

print("рядків у прайсі:", len(рядки_прайсу))
for номер, рядок in enumerate(рядки_прайсу, start=1):
    print(f"{номер}. {рядок}")
рядків у прайсі: 5
1. хліб;28.5
2. молоко;32.0
3. яблука;девʼятнадцять
4. мед
5. сіль;12.0

2 · Спершу — без жодної обробки¶

Напишемо найпростішу функцію розбору й запустимо її на всьому прайсі. Вона впаде. Це нормально: traceback тут — навчальний матеріал, а не невдача.

Наступна клітинка навмисно падає. Прочитай її вивід з кінця: останній рядок — діагноз, рядки вище — маршрут, яким виняток летів по стеку (про це був інтерактив 5 у лекції).

In [2]:
def розібрати(рядок):
    """Розкладає "назва;ціна" на пару (назва, ціна). Поки що без жодного захисту."""
    частини = рядок.split(";")
    return частини[0], float(частини[1])


# перший рядок розбирається без пригод
print("перший рядок:", розібрати(рядки_прайсу[0]))
перший рядок: ('хліб', 28.5)
In [3]:
# а на третьому все зупиниться — навмисна помилка
підсумок = []
for рядок in рядки_прайсу:
    підсумок.append(розібрати(рядок))
print("сюди виконання не дійде:", підсумок)
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[3], line 4
      2 підсумок = []
      3 for рядок in рядки_прайсу:
----> 4     підсумок.append(розібрати(рядок))
      5 print("сюди виконання не дійде:", підсумок)

Cell In[2], line 4, in розібрати(рядок)
      2 """Розкладає "назва;ціна" на пару (назва, ціна). Поки що без жодного захисту."""
      3 частини = рядок.split(";")
----> 4 return частини[0], float(частини[1])

ValueError: could not convert string to float: 'девʼятнадцять'

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

In [4]:
print("у підсумку опинилось:", підсумок)
print("а рядків у прайсі було:", len(рядки_прайсу))
print("втрачено рядків:", len(рядки_прайсу) - len(підсумок))
у підсумку опинилось: [('хліб', 28.5), ('молоко', 32.0)]
а рядків у прайсі було: 5
втрачено рядків: 3

3 · try / except на конкретний тип¶

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

Дивись, де стоїть try: навколо виклику всередині циклу, а не навколо всього циклу. Саме тому цикл переживе поганий рядок і піде далі.

In [5]:
розібрані = []
пропущені = []

for рядок in рядки_прайсу:
    try:
        розібрані.append(розібрати(рядок))
    except ValueError:
        # тільки «ціна не число» — усі інші біди летять далі, як і має бути
        пропущені.append(рядок)

print("розібрано:", розібрані)
print("пропущено:", пропущені)
---------------------------------------------------------------------------
IndexError                                Traceback (most recent call last)
Cell In[5], line 6
      4 for рядок in рядки_прайсу:
      5     try:
----> 6         розібрані.append(розібрати(рядок))
      7     except ValueError:
      8         # тільки «ціна не число» — усі інші біди летять далі, як і має бути
      9         пропущені.append(рядок)

Cell In[2], line 4, in розібрати(рядок)
      2 """Розкладає "назва;ціна" на пару (назва, ціна). Поки що без жодного захисту."""
      3 частини = рядок.split(";")
----> 4 return частини[0], float(частини[1])

IndexError: list index out of range

Знову падіння — і це найкорисніший момент усієї практики. Ми впіймали ValueError, але рядок "мед" кидає IndexError: після split(";") у списку один елемент, а ми просимо другий. Вузька гілка чесно пропустила чужий тип далі.

Перевіримо це прямо, без циклу:

In [6]:
# два різні рядки — два різні типи винятку
for поганий in ["яблука;девʼятнадцять", "мед"]:
    try:
        розібрати(поганий)
    except BaseException as e:      # тут ловимо широко НАВМИСНО — щоб показати тип
        print(f"{поганий!r:<28} → {type(e).__name__}: {e}")
'яблука;девʼятнадцять'       → ValueError: could not convert string to float: 'девʼятнадцять'
'мед'                        → IndexError: list index out of range

4 · Дві гілки на дві біди¶

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

In [7]:
розібрані = []
журнал = []

for рядок in рядки_прайсу:
    try:
        розібрані.append(розібрати(рядок))
    except ValueError:
        журнал.append(f"{рядок!r}: ціна не число")
    except IndexError:
        журнал.append(f"{рядок!r}: немає крапки з комою")

print("розібрано позицій:", len(розібрані))
for пара in розібрані:
    print("   ", пара)
print("\nжурнал проблем:")
for запис in журнал:
    print("   ", запис)

# перевіряємо, що жоден рядок не загубився: кожен або розібрано, або записано в журнал
assert len(розібрані) + len(журнал) == len(рядки_прайсу), "рядки десь загубились!"
print("\n✅ усі 5 рядків обліковано: 3 розібрано + 2 у журналі")
розібрано позицій: 3
    ('хліб', 28.5)
    ('молоко', 32.0)
    ('сіль', 12.0)

журнал проблем:
    'яблука;девʼятнадцять': ціна не число
    'мед': немає крапки з комою

✅ усі 5 рядків обліковано: 3 розібрано + 2 у журналі

Якщо реакція на обидві біди однакова, гілки зливають в одну через кортеж типів. Дужки обовʼязкові. Результат має бути точнісінько той самий — це ми зараз і доведемо.

In [8]:
розібрані_2 = []
пропущені_2 = []

for рядок in рядки_прайсу:
    try:
        розібрані_2.append(розібрати(рядок))
    except (ValueError, IndexError):
        # одна реакція на дві різні біди — кортеж типів у дужках
        пропущені_2.append(рядок)

print("розібрано:", розібрані_2)
print("пропущено:", пропущені_2)

# та сама відповідь, що й у версії з двома гілками
assert розібрані_2 == розібрані, "кортеж типів дав інший результат!"
print("✅ кортеж типів дає рівно те саме, що дві окремі гілки")
розібрано: [('хліб', 28.5), ('молоко', 32.0), ('сіль', 12.0)]
пропущено: ['яблука;девʼятнадцять', 'мед']
✅ кортеж типів дає рівно те саме, що дві окремі гілки

5 · Чим небезпечний голий except¶

А тепер найважливіша демонстрація теми. Зробимо в коді одрук: напишемо flot замість float. Це NameError — помилка програміста, а не погані дані.

Спершу подивимось, як поводиться голий except.

In [9]:
def розібрати_голий(рядок):
    """Та сама функція, але з одруком у коді й з голим except."""
    try:
        частини = рядок.split(";")
        return частини[0], flot(частини[1])     # ← одрук: flot замість float
    except:                                     # ← ловить геть усе
        return рядок, 0.0


результат_голий = [розібрати_голий(р) for р in рядки_прайсу]
for пара in результат_голий:
    print(пара)
('хліб;28.5', 0.0)
('молоко;32.0', 0.0)
('яблука;девʼятнадцять', 0.0)
('мед', 0.0)
('сіль;12.0', 0.0)

Подивись уважно на вивід: жодної помилки, жодного попередження. Програма відпрацювала, повернула пʼять пар, усі ціни дорівнюють нулю. Звіт зійдеться, сума порахується, і ніхто ніколи не дізнається, що в коді одрук.

Доведімо це числом: правильна сума прайсу — 72.5 (28.5 + 32.0 + 12.0), а голий except дав нуль.

In [10]:
сума_голого = sum(ціна for _, ціна in результат_голий)
правильна_сума = 28.5 + 32.0 + 12.0

print("сума за версією з голим except:", сума_голого)
print("правильна сума трьох цілих рядків:", правильна_сума)

# бага не видно ніде, крім самого числа — саме це й робить його найдорожчим
assert сума_голого == 0.0, "очікували, що голий except підставить нулі"
assert сума_голого != правильна_сума, "числа мали розійтись"
print("\n❌ програма не впала, але порахувала неправильно — і мовчить про це")
сума за версією з голим except: 0.0
правильна сума трьох цілих рядків: 72.5

❌ програма не впала, але порахувала неправильно — і мовчить про це

Тепер той самий одрук, але з конкретним except ValueError. Наступна клітинка навмисно падає — і це саме те, чого ми хотіли: помилку в коді видно одразу, з іменем, номером рядка й типом.

In [11]:
def розібрати_конкретний(рядок):
    """Той самий одрук, але ловимо лише ValueError."""
    try:
        частини = рядок.split(";")
        return частини[0], flot(частини[1])     # ← той самий одрук
    except ValueError:
        return рядок, 0.0


розібрати_конкретний("хліб;28.5")
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Cell In[11], line 10
      6     except ValueError:
      7         return рядок, 0.0
---> 10 розібрати_конкретний("хліб;28.5")

Cell In[11], line 5, in розібрати_конкретний(рядок)
      3 try:
      4     частини = рядок.split(";")
----> 5     return частини[0], flot(частини[1])     # ← той самий одрук
      6 except ValueError:
      7     return рядок, 0.0

NameError: name 'flot' is not defined

Той самий одрук — два зовсім різні наслідки. Різниця в одному слові після except.

Ще одна річ, яку ковтає голий except, — це KeyboardInterrupt (Ctrl+C). У зошиті натиснути Ctrl+C складно, тому змоделюємо: кинемо KeyboardInterrupt самі й подивимось, чи проходить він крізь обидві версії.

In [12]:
def ковтає_все():
    try:
        raise KeyboardInterrupt          # так виглядає натиснутий Ctrl+C зсередини
    except:
        return "голий except: Ctrl+C проковтнуто, програма працює далі"


def пропускає_ctrl_c():
    try:
        raise KeyboardInterrupt
    except Exception:                    # KeyboardInterrupt НЕ є підтипом Exception
        return "сюди ми не потрапимо"


print(ковтає_все())

# перевіряємо, що except Exception справді пропускає KeyboardInterrupt повз себе
try:
    пропускає_ctrl_c()
    зупинилось = False
except KeyboardInterrupt:
    зупинилось = True

assert зупинилось, "except Exception не мав ловити KeyboardInterrupt!"
print("except Exception: Ctrl+C пролетів далі — програму можна зупинити ✅")
голий except: Ctrl+C проковтнуто, програма працює далі
except Exception: Ctrl+C пролетів далі — програму можна зупинити ✅

6 · Порядок гілок вирішує все¶

Гілки перебираються згори вниз. Якщо широкий тип поставити раніше за вузький, вузький не спрацює ніколи — і Python про це не скаржиться.

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

In [13]:
def версія_A(що_кинути):
    """Широкий тип першим — класична пастка."""
    try:
        raise що_кинути
    except Exception:
        return "загальне"
    except ValueError:
        return "точне"       # цей рядок недосяжний


def версія_B(що_кинути):
    """Вузький тип першим — так і треба."""
    try:
        raise що_кинути
    except ValueError:
        return "точне"
    except Exception:
        return "загальне"


винятки = [ValueError("не число"), KeyError("мед"), ZeroDivisionError("ділення на нуль")]

for виняток in винятки:
    імʼя = type(виняток).__name__
    print(f"{імʼя:<20} A → {версія_A(виняток):<9} B → {версія_B(виняток)}")

точних_у_A = sum(1 for в in винятки if версія_A(в) == "точне")
точних_у_B = sum(1 for в in винятки if версія_B(в) == "точне")

print(f"\nточна гілка спрацювала: у версії A — {точних_у_A} раз(и), у версії B — {точних_у_B}")
assert точних_у_A == 0, "у версії A точна гілка мала бути недосяжною"
assert точних_у_B == 1, "у версії B точна гілка мала спрацювати рівно раз"
print("✅ підтверджено: широкий except вище робить вузький мертвим кодом")
ValueError           A → загальне  B → точне
KeyError             A → загальне  B → загальне
ZeroDivisionError    A → загальне  B → загальне

точна гілка спрацювала: у версії A — 0 раз(и), у версії B — 1
✅ підтверджено: широкий except вище робить вузький мертвим кодом

7 · else і finally: доводимо, що finally виконується завжди¶

Найважливіша обіцянка finally — «виконаюсь у будь-якому разі». Перевіримо її не на слово, а assert-ом. Заведемо список-слід: кожен блок дописуватиме туди своє імʼя, і ми потім подивимось, що там опинилось.

In [14]:
слід = []


def прочитати_ціну(текст):
    """Повертає ціну; лишає в списку `слід` запис про кожен блок, який відпрацював."""
    try:
        ціна = float(текст)
    except ValueError:
        слід.append("except")
        ціна = 0.0
    else:
        # виконується ЛИШЕ якщо в try не було винятку
        слід.append("else")
    finally:
        # виконується завжди — і після успіху, і після впійманого винятку
        слід.append("finally")
    return ціна


слід.clear()
print("нормальний рядок  →", прочитати_ціну("28.5"), " слід:", слід)

слід.clear()
print("битий рядок       →", прочитати_ціну("девʼятнадцять"), " слід:", слід)
нормальний рядок  → 28.5  слід: ['else', 'finally']
битий рядок       → 0.0  слід: ['except', 'finally']

Тепер найцікавіший випадок: виняток, якого except не ловить. Він полетить угору по стеку — а finally усе одно має відпрацювати перед його відльотом.

float(None) кидає TypeError, а не ValueError — саме те, що нам потрібно.

In [15]:
слід.clear()
полетів_далі = None

try:
    прочитати_ціну(None)          # float(None) → TypeError, наш except його не ловить
except TypeError as e:
    полетів_далі = str(e)

print("слід після неспійманого винятку:", слід)
print("виняток таки долетів до нас:", полетів_далі)

# головна перевірка теми: finally відпрацював, хоча функція завершилась винятком
assert "finally" in слід, "finally НЕ виконався — а мусив!"
assert "except" not in слід, "except не мав спрацювати на TypeError"
assert "else" not in слід, "else не виконується, коли був виняток"
assert полетів_далі is not None, "TypeError мав долетіти до зовнішнього try"
print("\n✅ finally виконався навіть тоді, коли виняток полетів далі по стеку")
слід після неспійманого винятку: ['finally']
виняток таки долетів до нас: float() argument must be a string or a real number, not 'NoneType'

✅ finally виконався навіть тоді, коли виняток полетів далі по стеку

І окремо — пастка, про яку попереджала лекція: return усередині finally перебиває виняток, який летів угору. Виняток просто зникає. Дивись, як тихо це відбувається.

In [16]:
def підступна():
    try:
        raise ValueError("я мав долетіти нагору")
    finally:
        return "значення з finally"      # ← виняток мовчки зникає тут


результат = підступна()
print("функція повернула:", результат)
print("а виняток? його немає — його проковтнув return у finally")

assert результат == "значення з finally"
print("\n⚠️ саме тому у finally прибирають, але не повертають")
функція повернула: значення з finally
а виняток? його немає — його проковтнув return у finally

⚠️ саме тому у finally прибирають, але не повертають

8 · raise: піднімаємо виняток самі¶

Замість того щоб чекати на незрозумілий IndexError десь усередині, чесніше перевірити вхід самому й кинути виняток зі змістовним текстом. Це та сама «рання відмова» з теми 14.

In [17]:
def розібрати_суворо(рядок):
    """Розкладає "назва;ціна". Кидає ValueError зі зрозумілим поясненням."""
    частини = рядок.split(";")
    if len(частини) != 2:
        raise ValueError(f"очікував дві частини через ';', а маю {len(частини)}: {рядок!r}")
    назва, текст_ціни = частини
    if not назва.strip():
        raise ValueError(f"порожня назва в рядку {рядок!r}")
    return назва, float(текст_ціни)


print("нормальний рядок:", розібрати_суворо("хліб;28.5"))

for поганий in ["мед", ";50.0"]:
    try:
        розібрати_суворо(поганий)
    except ValueError as e:
        print(f"{поганий!r:<10} → ValueError: {e}")
нормальний рядок: ('хліб', 28.5)
'мед'      → ValueError: очікував дві частини через ';', а маю 1: 'мед'
';50.0'    → ValueError: порожня назва в рядку ';50.0'

Обʼєкт винятку зблизька¶

Виняток — звичайний обʼєкт. Через as e до нього можна дотягнутись і подивитись, що всередині: тип, текст повідомлення, кортеж аргументів.

In [18]:
try:
    float("девʼятнадцять")
except ValueError as e:
    print("тип         :", type(e).__name__)
    print("str(e)      :", str(e))
    print("e.args      :", e.args)
    print("аргументів  :", len(e.args))
    текст_помилки = str(e)      # переписуємо в іншу змінну: імʼя e живе лише в блоці

print("\nпісля блоку except імʼя 'e' уже не існує, а копія тексту лишилась:")
print("   ", текст_помилки)

assert "девʼятнадцять" in текст_помилки, "у повідомленні мав бути сам зіпсований рядок"
print("\n✅ повідомлення винятку містить те саме значення, яке зламало float()")
тип         : ValueError
str(e)      : could not convert string to float: 'девʼятнадцять'
e.args      : ("could not convert string to float: 'девʼятнадцять'",)
аргументів  : 1

після блоку except імʼя 'e' уже не існує, а копія тексту лишилась:
    could not convert string to float: 'девʼятнадцять'

✅ повідомлення винятку містить те саме значення, яке зламало float()

Ланцюжок: raise ... from e¶

Часто виняток піднімають усередині except, щоб перевести технічну біду в поняття своєї задачі. Слово from каже: «ось первинна причина». Python зберігає її в атрибуті __cause__.

In [19]:
def ціна_або_помилка(рядок):
    """Переводить будь-яку біду розбору в один зрозумілий ValueError із причиною."""
    try:
        return розібрати_суворо(рядок)[1]
    except ValueError as e:
        raise ValueError(f"рядок прайсу непридатний: {рядок!r}") from e


try:
    ціна_або_помилка("яблука;девʼятнадцять")
except ValueError as e:
    print("наш виняток :", e)
    print("__cause__   :", repr(e.__cause__))
    причина = e.__cause__

# from e не втрачає первинної інформації — вона лежить поруч, у __cause__
assert причина is not None, "причина мала зберегтись у __cause__"
assert isinstance(причина, ValueError)
print("\n✅ первинна причина збережена й доступна програмно, не лише очима в traceback")
наш виняток : рядок прайсу непридатний: 'яблука;девʼятнадцять'
__cause__   : ValueError("could not convert string to float: 'девʼятнадцять'")

✅ первинна причина збережена й доступна програмно, не лише очима в traceback

9 · Власний виняток¶

Рецепт із лекції: один рядок class, успадкування від Exception, у тілі — докстрінг. Детально про класи буде в темі 25; тут це просто спосіб дати своїй біді власне імʼя.

In [20]:
class ПомилкаПрайсу(Exception):
    """Рядок прайсу не вдалося розібрати."""


def розібрати_з_власним(рядок):
    """Будь-яку біду розбору перетворює на ПомилкаПрайсу — з причиною всередині."""
    try:
        return розібрати_суворо(рядок)
    except ValueError as e:
        raise ПомилкаПрайсу(f"не можу розібрати {рядок!r}") from e


try:
    розібрати_з_власним("мед")
except ПомилкаПрайсу as e:
    print("зловили власний тип:", type(e).__name__)
    print("текст              :", e)

# власний тип — усе ще підтип Exception, тому загальні обробники його теж бачать
assert issubclass(ПомилкаПрайсу, Exception)
assert not issubclass(ПомилкаПрайсу, KeyboardInterrupt)
print("\nПомилкаПрайсу є підтипом Exception:", issubclass(ПомилкаПрайсу, Exception))
print("✅ значить, чужий except Exception її зловить, а Ctrl+C вона не зачепить")
зловили власний тип: ПомилкаПрайсу
текст              : не можу розібрати 'мед'

ПомилкаПрайсу є підтипом Exception: True
✅ значить, чужий except Exception її зловить, а Ctrl+C вона не зачепить

Перевага власного типу — можливість відрізнити «мої дані погані» від «усередині щось зламалось». Порівняй дві гілки:

In [21]:
def обробити_прайс(рядки):
    """Повертає (список цін, список скарг). Свою біду відрізняє від чужої."""
    ціни, скарги = [], []
    for рядок in рядки:
        try:
            назва, ціна = розібрати_з_власним(рядок)
        except ПомилкаПрайсу as e:
            скарги.append(str(e))          # очікувана біда: погані дані
        else:
            ціни.append((назва, ціна))
    return ціни, скарги


ціни, скарги = обробити_прайс(рядки_прайсу)
print("розібрано:")
for пара in ціни:
    print("   ", пара)
print("скарги:")
for скарга in скарги:
    print("   ", скарга)

сума = sum(ціна for _, ціна in ціни)
print("\nсума придатних позицій:", round(сума, 2))
assert round(сума, 2) == 72.5, "сума трьох цілих рядків має бути 72.5"
assert len(скарги) == 2, "зіпсованих рядків було рівно два"
print("✅ прайс оброблено повністю: 3 позиції на 72.5, 2 скарги")
розібрано:
    ('хліб', 28.5)
    ('молоко', 32.0)
    ('сіль', 12.0)
скарги:
    не можу розібрати 'яблука;девʼятнадцять'
    не можу розібрати 'мед'

сума придатних позицій: 72.5
✅ прайс оброблено повністю: 3 позиції на 72.5, 2 скарги

10 · EAFP проти LBYL¶

Одне завдання — взяти ціну зі словника, з нулем як запасним варіантом — двома способами. Спершу переконаємось, що відповідь однакова, потім поміряємо, скільки це коштує.

In [22]:
ціни_словник = {"хліб": 28.5, "молоко": 32.0, "сіль": 12.0}


def через_lbyl(словник, ключ):
    """Look Before You Leap: спершу перевір, потім бери."""
    if ключ in словник:
        return словник[ключ]
    return 0.0


def через_eafp(словник, ключ):
    """Easier to Ask Forgiveness than Permission: бери, а як не вийде — обробляй."""
    try:
        return словник[ключ]
    except KeyError:
        return 0.0


for ключ in ["хліб", "мед"]:
    a, b = через_lbyl(ціни_словник, ключ), через_eafp(ціни_словник, ключ)
    print(f"{ключ:<8} LBYL → {a:<6} EAFP → {b}")
    assert a == b, "два підходи мали дати однакову відповідь!"

print("\n✅ на однакових даних обидва підходи дають однаковий результат")
хліб     LBYL → 28.5   EAFP → 28.5
мед      LBYL → 0.0    EAFP → 0.0

✅ на однакових даних обидва підходи дають однаковий результат

Тепер про ціну. Виміряємо два випадки окремо: коли ключ є (винятку немає) і коли ключа немає (виняток кидається й ловиться на кожній ітерації).

Числа на різних машинах будуть різні — важливе співвідношення, а не абсолют.

In [23]:
import time

ПОВТОРІВ = 100_000


def поміряти(функція, ключ):
    """Скільки мікросекунд у середньому займає один виклик."""
    початок = time.perf_counter()
    for _ in range(ПОВТОРІВ):
        функція(ціни_словник, ключ)
    минуло = time.perf_counter() - початок
    return минуло / ПОВТОРІВ * 1_000_000       # мікросекунди на один виклик


є_lbyl = поміряти(через_lbyl, "хліб")
є_eafp = поміряти(через_eafp, "хліб")
нема_lbyl = поміряти(через_lbyl, "мед")
нема_eafp = поміряти(через_eafp, "мед")

print(f"{'':<22}{'LBYL':>10}{'EAFP':>10}   (мкс на виклик)")
print(f"{'ключ Є':<22}{є_lbyl:>10.3f}{є_eafp:>10.3f}")
print(f"{'ключа НЕМАЄ':<22}{нема_lbyl:>10.3f}{нема_eafp:>10.3f}")
# у кожне число входить іще й вартість самого виклику функції — щоб її прибрати,
# беремо РІЗНИЦЮ між двома вимірами на однакових промахах
надлишок = нема_eafp - нема_lbyl
print(f"\nна промахах EAFP повільніший у {нема_eafp / нема_lbyl:.1f} раза")
print(f"чистий надлишок одного кинутого й спійманого винятку: {надлишок:.2f} мкс")
print("а коли ключ є, EAFP швидший: він робить одне звернення замість двох")
                            LBYL      EAFP   (мкс на виклик)
ключ Є                     0.137     0.107
ключа НЕМАЄ                0.089     0.526

на промахах EAFP повільніший у 5.9 раза
чистий надлишок одного кинутого й спійманого винятку: 0.44 мкс
а коли ключ є, EAFP швидший: він робить одне звернення замість двох

Висновок з чисел вище: EAFP програє лише тоді, коли промахи часті. Якщо ключ знаходиться майже завжди, зайва перевірка коштує дорожче за рідкісний виняток.

Зверни увагу на «чистий надлишок»: у ньому вже немає вартості виклику функції, і саме він порівнюється зі звичайним зверненням до словника (десятки наносекунд). Звідси й орієнтир із лекції — виняток дорожчий за перевірку приблизно в 15-30 разів.

І окремо — інструмент, який знімає питання зовсім: метод get зі словника (тема 08). Одне звернення, жодного винятку.

In [24]:
print("через get:", ціни_словник.get("мед", 0.0))

# усі три способи дають те саме — просто get найкоротший і найшвидший
assert ціни_словник.get("мед", 0.0) == через_lbyl(ціни_словник, "мед") == через_eafp(ціни_словник, "мед")
print("✅ get, LBYL і EAFP дали однакову відповідь — коли є готовий інструмент, бери його")
через get: 0.0
✅ get, LBYL і EAFP дали однакову відповідь — коли є готовий інструмент, бери його

11 · contextlib.suppress — чесний спосіб проігнорувати¶

Якщо виняток справді очікуваний і нецікавий, є конструкція, яка каже це вголос — на відміну від except: pass, який мовчить про все на світі.

In [25]:
from contextlib import suppress

значення = {"а": 1, "б": 2}

# ігноруємо ТІЛЬКИ відсутність ключа; будь-яка інша біда полетить далі
with suppress(KeyError):
    del значення["в"]        # такого ключа немає — і це нормально

print("словник після suppress:", значення)

# доводимо, що suppress не ковтає чужі типи
спіймали_чуже = False
try:
    with suppress(KeyError):
        значення["а"] + "текст"   # TypeError — suppress його не стосується
except TypeError:
    спіймали_чуже = True

assert спіймали_чуже, "suppress(KeyError) не мав ковтати TypeError"
print("✅ suppress пропустив TypeError далі — на відміну від голого except")
словник після suppress: {'а': 1, 'б': 2}
✅ suppress пропустив TypeError далі — на відміну від голого except

Завдання¶

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

Напиши функцію безпечне_ділення(a, b), яка повертає a / b, а при діленні на нуль повертає рядок "на нуль ділити не можна". Ловити треба конкретний тип.

Перевір себе:

assert безпечне_ділення(10, 2) == 5.0
assert безпечне_ділення(10, 0) == "на нуль ділити не можна"
assert безпечне_ділення(0, 5) == 0.0
print("✅ рівень 1")

Зроблено, якщо: усі три assert мовчать, а у функції написано except ZeroDivisionError, а не голий except і не except Exception.

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

Візьми функцію обробити_прайс із розділу 9 і додай до неї третій список — аварії, куди потрапляють несподівані винятки (усе, що не ПомилкаПрайсу). Потім:

  • передай їй список, у якому один елемент — не рядок, а число 42 (на ньому .split кине AttributeError);
  • переконайся assert-ом, що число опинилось саме в аварії, а зіпсовані рядки — у скарги;
  • додай finally, який дописує в лічильник кількість оброблених рядків, і доведи assert-ом, що лічильник дорівнює довжині вхідного списку.

Зроблено, якщо: у зошиті є три assert — на скарги, на аварії й на лічильник, — і всі три мовчать.

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

Побудуй маленьку ієрархію власних винятків і доведи, що вона працює як дерево:

ПомилкаПрайсу(Exception)
├── ПомилкаЦіни        — ціна не число або відʼємна
└── ПомилкаФормату     — рядок не розкладається на дві частини

Далі:

  1. Перепиши розібрати_суворо так, щоб вона кидала точний підтип у кожному випадку, зберігаючи первинну причину через from e.
  2. Напиши три обробники й доведи assert-ами, що:
    • except ПомилкаЦіни ловить помилку ціни й не ловить помилку формату;
    • except ПомилкаПрайсу ловить обидві;
    • except Exception теж ловить обидві, а except KeyboardInterrupt — жодної.
  3. Виміряй, скільки рядків traceback дає варіант from e і варіант from None на одному й тому самому зіпсованому рядку. Підказка: traceback.format_exc() повертає traceback рядком, а len(...splitlines()) рахує рядки.

Зроблено, якщо: усі assert мовчать, а в зошиті надруковані два числа — довжина traceback із from e і без нього — і вони різні.