Python з нуля · Блок 4 · Тема 18

Помилки та винятки

Програма падає не тому, що ти поганий програміст, а тому, що світ складніший за твої припущення. Файлу немає, користувач увів «дванадцять» замість 12, мережа зникла. Ця тема — про те, як зустріти таку мить гідно.

У темі 02 ми домовились, що traceback — не привід засмучуватись, а звіт, який треба прочитати з кінця. Відтоді ти бачив десятки таких звітів: NameError, коли одрукувався в імені, TypeError, коли спробував помножити None, KeyError, коли поліз у словник по ключ, якого там немає. Щоразу програма зупинялась, і це було правильно.

Але не завжди зупинка — правильна поведінка. Уяви програму, яка читає прайс із тисячі рядків, і в 348-му рядку хтось написав ціну словами. Чи справді варто викинути всю роботу через один зіпсований рядок? Часто ні: розумніше пропустити його, записати в журнал і рахувати далі. Щоб так вміти, потрібен інструмент — і він у Python є. Ця тема про нього.

Наскрізний приклад — розбір текстового прайсу. Це той самий кошик, з яким ми працювали в темі 06, темі 12 і темі 14, тільки тепер він прийшов до нас у вигляді рядків тексту — а текст, на відміну від наших власних літералів, буває битим:

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

01 / РозрізненняДва роди помилок

Перше, що треба чітко розділити, — це дві принципово різні речі, які новачок часто звалює в купу під словом «помилка».

Синтаксична помилка (SyntaxError) — програма не запустилась. Python читає файл цілком, перевіряє граматику й, якщо граматика зламана, відмовляється виконувати навіть перший рядок. Не має значення, що помилка в кінці файлу: перший print теж не спрацює.

ціна = float("28.5" ← дужку не закрито SyntaxError: '(' was never closed

Виняток під час виконання (exception) — програма запустилась і працювала, дійшла до якогось рядка й на ньому спіткнулась. Граматика бездоганна, але дія в цю конкретну мить неможлива.

>>> float("девʼятнадцять") Traceback (most recent call last): File "<stdin>", line 1, in <module> ValueError: could not convert string to float: 'девʼятнадцять'

Різницю видно навіть на око — саме про це була врізка в темі 02. У синтаксичної помилки немає шапки Traceback (most recent call last), бо шапка описує маршрут виконання, а виконання не починалось. Побачив traceback із шапкою — знай: програма таки бігла.

Практичний наслідок величезний. Синтаксичну помилку неможливо «обробити»: не існує способу написати код, який упорається з тим, що файл узагалі не прочитався. Її можна тільки виправити. А виняток під час виконання — можна перехопити, бо на момент його появи програма жива й здатна щось зробити.

Чому «виняток», а не «помилка». Англійське exception означає «виняток із правила», і це точна назва. Функція float має правило: «перетворюю рядок на число». Рядок "девʼятнадцять" — виняток із цього правила, і функція повідомляє про нього єдиним способом, який має: припиняє нормальний хід і кидає нагору спеціальний обʼєкт. Слово «помилка» вводить в оману, бо виняток — не завжди чиясь провина. Кінець файлу, обірваний звʼязок, натиснутий Ctrl+C — це теж винятки.

02 / Основаtry і except

Конструкція, заради якої написана вся тема, складається з двох частин:

try: ціна = float(текст) # ризикована дія except ValueError: ціна = 0.0 # план Б, якщо ризикована дія не вдалась

Читається дослівно: «спробуй зробити те, що в першому блоці; якщо станеться ValueError — виконай другий блок замість падіння». Механіка така:

  1. Python виконує тіло try рядок за рядком, як звичайний код.
  2. Якщо все пройшло без винятків — блок except просто пропускається. Він не виконується ніколи, і жодного сліду від нього не лишається.
  3. Якщо на якомусь рядку стався виняток — решта тіла try не виконується, а Python дивиться, чи підходить тип винятку під назву в except. Підходить — керування стрибає в блок except, і після нього програма спокійно йде далі.
  4. Не підходить — виняток летить далі вгору так, наче try тут і не було.

Пункт 3 варто перечитати: «решта тіла try не виконується». Якщо в try три рядки й упав другий, третій не спрацює. Тому в try не пхають півпрограми — там має лежати рівно та дія, яка може впасти, і бажано одна.

03 / КаркасІєрархія винятків

У except ValueError стоїть не чарівне слово, а імʼя типу. Усі винятки в Python — обʼєкти, а всі їхні типи вишикувані в одне дерево спадкування. Це і є ключ до всієї теми: except ловить не лише названий тип, а весь піддерево під ним.

Корінь дерева — BaseException. Одразу під ним три особливі гілки (SystemExit, KeyboardInterrupt, GeneratorExit) і четверта, у якій живе все звичайне, — Exception. Ці три винесені з-під Exception навмисно, і зараз ти побачиш навіщо.

Інтерактив 1 · Що саме зловить твій except

Обери тип у except — підсвітиться все, що він перехопить. Дивись на дві нижні гілки зліва вгорі.

ловить типів
з показаних15
KeyboardInterrupt
Ctrl+C зупинить?
Головне — різниця між двома останніми кнопками. except Exception ловить 11 типів із 15 і свідомо не чіпає KeyboardInterrupt та SystemExit — тому Ctrl+C зупиняє програму навіть усередині обробки. except BaseException ловить усі 15, і Ctrl+C перестає працювати. Саме тому перше пишуть часто, а друге — майже ніколи.

Дерево пояснює речі, які інакше виглядають довільними:

Перевірка на дереві — це звичайний isinstance. Коли Python вирішує, чи підходить except X до винятку, він питає рівно одне: «чи є цей обʼєкт екземпляром X або будь-якого його підкласу». Тому ZeroDivisionError ловиться і як ZeroDivisionError, і як ArithmeticError, і як Exception, і як BaseException — чотирма різними рядками, і всі чотири правильні. Питання лише в тому, наскільки широко ти хочеш ловити.

04 / АнтипатернЧому голий except — біда

Python дозволяє написати except: без жодного типу. Це синтаксично законно й означає «злови взагалі все» — тобто те саме, що except BaseException:. Виглядає зручно: одна конструкція на всі випадки життя, ніколи нічого не впаде. Саме тому цей рядок і трапляється в чужому коді регулярно.

Проблема в тому, що «ніколи нічого не впаде» — не мета. Мета — щоб програма робила правильні речі. А голий except ковтає три категорії подій, які ковтати не можна ніколи.

Інтерактив 2 · Той самий код із except: і з except ValueError:

Чотири ситуації. Перші дві — однакові. На третій і четвертій версії розходяться.

голий except
except ValueError
помилку помітно?
Ctrl+C працює?
Третя ситуація — найдорожча. Одрук flot замість float — це NameError, тобто помилка програміста. Голий except перетворює її на охайне повідомлення «не вдалося розібрати» й підставляє нуль. Програма працює далі, звіти сходяться, а числа неправильні. Це найдорожчий сорт багу: без падіння й без сліду.

Отже, три категорії, які голий except ковтає разом із корисним:

Найгірший рядок у кодовій базі. Це поєднання голого перехоплення з порожнім тілом:
try: щось_важливе() except: pass
Він означає буквально: «якщо тут будь-коли щось піде не так — мовчки забудь про це». Ані повідомлення, ані запису в журнал, ані сліду в traceback. Помилка не зникає — вона просто стає невидимою, і хтось витрачатиме дні, шукаючи, чому в звіті нулі. Якщо ти справді хочеш проігнорувати конкретний виняток — про це є окремий чесний інструмент, contextlib.suppress, і ми до нього ще дійдемо.

05 / ПорядокКілька except і чому порядок важить

Одна дія може впасти по-різному, і на кожен випадок можна написати свою реакцію. Наш розбір рядка прайсу — саме такий:

def розібрати(рядок): частини = рядок.split(";") назва = частини[0] ціна = float(частини[1]) return назва, ціна

Рядок "яблука;девʼятнадцять" дасть ValueError на float. Рядок "мед" дасть IndexError ще раніше, на частини[1]: після split у списку один елемент, а ми просимо другий. Дві біди — дві гілки:

try: назва, ціна = розібрати(рядок) except ValueError: print("ціна не число:", рядок) except IndexError: print("немає крапки з комою:", рядок)

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

Інтерактив 3 · Порядок гілок вирішує все

Ті самі дві гілки, переставлені місцями. Перемикай тип винятку й дивись, яка спрацювала.

версія A каже
версія B каже
за всі 3 типи гілка «це не число» спрацювала
Прогони всі три кнопки й порівняй нижній лічильник. У версії A гілка except ValueError не спрацьовує жодного разу з трьох: широкий except Exception стоїть вище й забирає все. Python на це не скаржиться — недосяжна гілка не є синтаксичною помилкою, тож знайти її можна тільки очима.

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

except (ValueError, IndexError): print("рядок зіпсовано:", рядок)

Дужки тут обовʼязкові: без них Python зрозуміє запис інакше. Це звичайний кортеж типів, і перевірка проходить, якщо виняток підходить хоч під один із них.

06 / Повна формаelse і finally

У конструкції є ще дві частини, і обидві мають чітко визначену мить.

Інтерактив 4 · Хто коли виконується

Чотири сценарії, крок за кроком. Стеж за рядком finally: він у виводі всюди.

ціна
повернуто
else спрацював
finally спрацював
Порівняй сценарії 2 і 3. В обох стався виняток, але в другому else пропущено, а в третьому пропущено ще й except — і все одно finally надрукувався перед тим, як виняток полетів угору. Сценарій 4 — найтонший: return уже обчислив значення, але функція ще не вийшла, доки finally не відпрацював.
Пастка з return у finally. Якщо всередині finally написати return, він переб'є все: і значення, яке готувався віддати try, і виняток, який летів угору. Виняток мовчки зникне — програма отримає значення з finally і навіть не дізнається, що щось падало. Це рідкісний випадок, коли інструмент прибирання тихо ковтає аварію. Правило: у finally прибирай, але не повертай.

07 / ПолітКуди летить виняток

Тепер найважливіша механіка теми, і вона спирається просто на стек викликів із теми 14. Нагадаю: кожен виклик функції кладе на стопку свій кадр із параметрами й локальними іменами; return знімає кадр і віддає значення туди, звідки викликали.

Виняток користується тим самим стеком, тільки в інший спосіб. Коли він виникає, Python питає поточний кадр: «чи є тут try з відповідним except?». Немає — кадр знищується, і те саме питання дістається кадру нижче. І так далі, вниз по стопці, доки хтось не відповість «є».

Якщо не відповів ніхто, включно з найнижчим кадром <module>, Python друкує traceback і завершує програму. І тепер зрозуміло, чому в traceback кілька рядків «File …, line …, in …»: це список кадрів, які виняток пролетів наскрізь, від найнижчого до того, у якому все почалось.

Інтерактив 5 · Виняток піднімається по стеку

Той самий стек кадрів, що в темі 14. Обери, де стоїть try, і пройди кроки.

глибина стеку
летить
кадр під питанням
зловлено?
Дивись, скільки кадрів гине. Коли try стоїть у сума(), помирає один кадр і цикл продовжується — зіпсований рядок пропущено, решта прайсу порахована. Коли try аж у <module>, гинуть два кадри разом із недорахованою сумою. Коли try немає ніде — гине вся програма. Один і той самий виняток, три різні наслідки.

Звідси випливає найважливіше практичне правило теми: лови виняток там, де знаєш, що з ним робити. Не там, де він стався, і не там, де зручно, — а там, де є достатньо контексту для рішення. Функція розібрати не знає, чи можна пропустити рядок: може, це критично важливий прайс і краще впасти. А цикл, який читає файл, знає — і саме йому вирішувати.

08 / Ініціативаraise: підняти виняток самому

Досі винятки кидав хтось інший — float, список, словник. Але кидати може й твій власний код, і робити це варто частіше, ніж здається. Слово — raise:

def розібрати(рядок): частини = рядок.split(";") if len(частини) != 2: raise ValueError(f"очікував дві частини, а маю {len(частини)}: {рядок!r}") return частини[0], float(частини[1])

Замість IndexError із незрозумілим текстом ми тепер кидаємо ValueError зі змістовним поясненням. Це та сама «рання відмова», що була в темі 14, тільки замість повернення спеціального значення функція чесно повідомляє: «так не можна».

Чому raise кращий за return None у ролі «щось не так»: None легко проігнорувати — він мовчки поїде далі й вибухне рядків через двадцять у зовсім іншому місці. Виняток проігнорувати неможливо: або ти його обробляєш, або програма зупиняється тут і зараз, з точною адресою.

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

Часто виняток піднімають усередині except — щоб перевести низькорівневу технічну біду в поняття своєї задачі. І тут постає питання: що робити з первинним винятком, який ми щойно спіймали?

Інтерактив 6 · Три способи підняти виняток із except

Один і той самий код, три варіанти рядка raise — три різні traceback.

рядків у traceback
первинну причину видно
як названо звʼязок
Різниця між першим і другим — одне слово в звіті, але слово важливе: «під час обробки» означає «ми впали, поки розгрібали інше падіння», а «прямою причиною» означає «ми свідомо переклали одну біду в іншу». Третій варіант обриває ланцюжок: 6 рядків замість 13, зате первинної ValueError у звіті немає взагалі.

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

Ще одна форма — raise без нічого всередині except. Вона піднімає той самий виняток далі, зберігаючи його traceback недоторканим. Класичне застосування — «записати в журнал і не приховувати»:

except ValueError: print("рядок зіпсовано, передаю далі:", рядок) raise

09 / ОбʼєктЩо всередині винятку

Виняток — це обʼєкт, і до нього можна дотягнутись через as:

try: ціна = float("девʼятнадцять") except ValueError as e: print(type(e)) # <class 'ValueError'> print(str(e)) # could not convert string to float: 'девʼятнадцять' print(e.args) # ("could not convert string to float: 'девʼятнадцять'",)

Три речі, які варто знати:

Для тих, хто вже пише код. У деяких винятків є власні корисні поля: у KeyError в args[0] лежить сам відсутній ключ, у FileNotFoundError є .filename і .errno, у UnicodeDecodeError — точна позиція байта, на якому все зламалось. Це рятує від розбору тексту повідомлення регулярними виразами: потрібне вже лежить полем.

10 / ПідхідEAFP проти LBYL

Дві абревіатури, за якими стоять два способи писати той самий код.

LBYLLook Before You Leap, «подивись, перш ніж стрибати». Спершу перевір умову, потім дій. Так пишуть у більшості мов, і так природно думає новачок.

EAFPEasier to Ask Forgiveness than Permission, «легше попросити пробачення, ніж дозволу». Дій одразу, а якщо не вийшло — обробляй виняток. Це пітонівський стиль за замовчуванням, і в стандартній бібліотеці він скрізь.

Інтерактив 7 · Перевірити наперед чи спробувати

Одне завдання — взяти ціну зі словника, з нулем як запасним варіантом. Три ситуації.

LBYL дав
EAFP дав
звернень: LBYL
звернень: EAFP
Третя ситуація — суть суперечки. Між перевіркою й дією є щілина, і якщо в неї встиг втрутитись хтось інший, перевірка вже не гарантує нічого. Для звичайного словника в однопотоковій програмі така щілина безпечна — але той самий код із файлом («перевірив, що існує» → «відкрив») ламається в реальному житті регулярно.

Чесно про те, коли який доречний:

І окремо: для словника є третій шлях, який знімає питання зовсім — метод get зі теми 08: ціни.get("мед", 0.0). Одне звернення, жодного винятку, жодної щілини. Коли інструмент такий є, сперечатися про підхід не треба.

11 / Прибиранняwith у двох словах

Ти напевно бачив запис with open(...) as f:. Тепер зрозуміло, звідки він узявся: це try/finally, спакований у два слова.

f = open("prays.txt") try: вміст = f.read() finally: f.close() # закриваємо навіть якщо read() упав # те саме, коротше: with open("prays.txt") as f: вміст = f.read() # close() гарантовано, хай там що

Обʼєкт, який уміє стояти після with, називають менеджером контексту (context manager). Він знає дві речі: що зробити на вході в блок і що зробити на виході — причому вихід відбувається завжди, як у finally. Детально про файли й про те, як писати власні менеджери контексту, буде в темі 20. Тут достатньо запамʼятати звʼязок: with — це не магія, це finally з людським обличчям.

12 / СвоїВласний виняток

Коли задача має власні поняття, зручно мати й власні типи винятків. Рецепт складається з одного рядка:

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

Далі він поводиться як будь-який інший тип: raise ПомилкаПрайсу("рядок 348") кидає, except ПомилкаПрайсу: ловить, а except Exception: теж ловить — бо ми успадкували саме від нього.

Що тут за class. Це синтаксис створення власного типу, і повністю ми розберемо його в темі 25. Поки що прийми рядок як рецепт: слово class, імʼя нового винятку, у дужках — від кого успадковуємо, а в тілі досить докстрінга або pass. Успадковувати треба від Exception, а не від BaseException — інакше твій виняток проскочить повз усі чужі except Exception і поведеться як Ctrl+C.

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

13 / ГлибшеДля тих, хто вже пише код

Кілька речей, які не потрібні новачкові сьогодні, але потрібні на робочому проєкті.

Скільки коштує виняток

Питання не пусте, бо на ньому будується вибір між EAFP і LBYL у гарячому циклі. Порядки величин на CPython 3.12 такі: звернення до словника — десятки наносекунд; кинутий і спійманий виняток — приблизно мікросекунда, тобто в 15-30 разів дорожче. Звучить страшно, але дивись на добуток: якщо винятки трапляються в одному випадку зі ста, EAFP усе одно виграє, бо в решті девʼяноста девʼяти випадках він робить менше роботи.

І головне уточнення, яке багато хто пропустив: починаючи з Python 3.11 сам вхід у блок try коштує нуль. Раніше інтерпретатор на кожному вході клав службовий запис на стек; тепер інформація про обробники лежить у таблиці поруч із байткодом і потрібна лише в момент падіння. Тобто try, у якому нічого не сталось, більше не сповільнює код взагалі — аргумент «не обгортай цикл у try, це дорого» застарів.

contextlib.suppress

Якщо виняток справді треба проігнорувати — є чесний спосіб сказати це вголос:

from contextlib import suppress with suppress(FileNotFoundError): os.remove("тимчасовий.txt") # немає файлу — і добре

Відмінність від except: pass не косметична. Тут названо конкретний тип, тому будь-яка інша біда (немає прав, шлях — це тека) полетить далі як належить. І намір видно з першого погляду: «цей виняток тут очікуваний і нецікавий», а не «я не знаю, що тут може статись».

Логувати, а не друкувати

У бойовому коді замість print у блоці except пишуть logging.exception("не вдалося розібрати рядок %s", рядок). Ця функція сама додає до повідомлення повний traceback спійманого винятку — тобто ти отримуєш і своє пояснення, і машинні подробиці. Половина розслідувань зводиться саме до пошуку рядка, який хтось написав через print(e), загубивши стек.

Групи винятків

З Python 3.11 існує ExceptionGroup — обʼєкт, який несе кілька винятків одночасно, і синтаксис except* для їх розбору. Потрібно це там, де паралельно виконується багато задач і впасти може кілька відразу: одна гілка except у старому сенсі тут просто не описує ситуацію. Для послідовного коду це не знадобиться, але знати про існування варто — інакше зірочка в чужому except* виглядає як одрук.

14 / ПідсумокЩо забрати з теми

Тепер у тебе є все, щоб писати код, який не розсипається від першого поганого рядка. Але лишилось питання, яке напрошується саме тут: а звідки ти знаєш, що твоя обробка помилок узагалі працює? Що except IndexError справді спрацьовує на рядку без крапки з комою, а finally справді закриває файл? Перевіряти це руками щоразу — марна праця. Її автоматизують, і саме про це тема 19: тестування з pytest, де рядок assert, який ти вже бачив у практиці, виростає в повноцінний захист від власних майбутніх помилок.

Далі в практиці. У practice.ipynb ти розбереш той самий битий прайс: спершу без обробки й побачиш справжній traceback, потім з конкретними гілками, потім із голим except — і власними очима переконаєшся, як він приховує одрук у коді. Далі напишеш власний ПомилкаПрайсу, порівняєш EAFP і LBYL на однакових даних і доведеш через assert, що finally виконався навіть тоді, коли виняток полетів далі.

Далі в темі

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