23. (Л) Обробка винятків (Exceptions)¶
Зміст лекції¶
- Коли програма ламається
- Синтаксична помилка і виняток
- Як читати traceback
- Найчастіші вбудовані винятки
- Конструкція
try/except - Кілька гілок
except - Обʼєкт винятку:
as e - Ієрархія винятків
- Гілка
else - Гілка
finally - Як виняток «проходить» крізь функції
- Два стилі: LBYL та EAFP
- Піднімаємо виняток самі:
raise - Повторне піднімання і ланцюжки:
raise ... from - Власні винятки
assert— перевірка припущень- Винятки та файли
- Код завершення програми і
sys.exit() - Повторне введення до коректного значення
- Приклад: стійке завантаження журналу оцінок
- Типові помилки
Коли програма ламається¶
Досі ми писали програми так, ніби все завжди йде за планом: користувач вводить число, файл існує, у словнику є потрібний ключ. Реальність інша:
- користувач замість
18вводитьвісімнадцятьабо просто натискає Enter; - файл, який «точно був», видалили або перейменували;
- у рядку
Kovalenko: 73,,81зайва кома, іint("")не може перетворити порожній рядок на число; - мережа зникла посеред завантаження.
У минулій лекції від таких ситуацій ми захищалися перевірками if: path.exists(), len(sys.argv) > 1, if not line: continue. Цей підхід працює, але не завжди: усі випадки наперед не передбачиш, а деякі перевірки взагалі неможливо зробити заздалегідь. Python пропонує інший механізм — винятки (exceptions).
Виняток — це обʼєкт, який Python створює в момент, коли операцію неможливо виконати. Виняток перериває звичайне виконання програми і «летить» вгору, доки його хтось не перехопить. Якщо ніхто не перехопив — програма завершується і друкує повідомлення про помилку.
Синтаксична помилка і виняток¶
Помилки в Python бувають двох принципово різних видів.
Синтаксична помилка (SyntaxError) — код написано не за правилами мови. Python знаходить її ще до запуску, під час читання файлу, тому не виконується жоден рядок — навіть ті, що стоять вище за помилку.
Зверніть увагу: start не надруковано. Програма не почала працювати.
Виняток (помилка часу виконання, runtime error) — код написано правильно, але конкретна операція з конкретними даними неможлива. Виконання доходить до проблемного рядка і лише тоді зупиняється.
# Program: an exception happens while the program is running
print("start")
total = 100
count = 0
print("average:", total / count)
print("finish")
start
Traceback (most recent call last):
File "main.py", line 5, in <module>
print("average:", total / count)
~~~~~~^~~~~~~
ZeroDivisionError: division by zero
start надруковано, finish — ні. Синтаксичну помилку ми виправляємо в коді, і обробляти її не можна. Винятки ж можна і потрібно обробляти, і саме про це лекція.
Як читати traceback¶
Повідомлення про неперехоплений виняток називається traceback (зворотне трасування). Початківці часто лякаються його розміру, хоча все необхідне в ньому зазвичай видно з двох-трьох рядків.
# Program: a traceback through several functions
def parse_grade(text):
return int(text)
def average(grades_text):
grades = [parse_grade(part) for part in grades_text.split(",")]
return sum(grades) / len(grades)
print(average("90,85,77"))
print(average("90,eighty,77"))
84.0
Traceback (most recent call last):
File "main.py", line 12, in <module>
print(average("90,eighty,77"))
~~~~~~~^^^^^^^^^^^^^^^^
File "main.py", line 7, in average
grades = [parse_grade(part) for part in grades_text.split(",")]
~~~~~~~~~~~^^^^^^
File "main.py", line 3, in parse_grade
return int(text)
ValueError: invalid literal for int() with base 10: 'eighty'
Як це читати:
- Починайте з останнього рядка.
ValueError— тип винятку, після двокрапки — пояснення:int()не зміг перетворити рядок'eighty'на число. - Рядком вище — місце, де виняток виник: файл
main.py, рядок 3, функціяparse_grade, і сам рядок коду. - Ще вище — хто цю функцію викликав. Traceback — це ланцюжок викликів від зовнішнього (
<module>— код верхнього рівня файлу) до внутрішнього, де все зламалося. Звідси заголовокmost recent call last: найсвіжіший виклик — останній. - Символи
~~~^^^підкреслюють конкретний вираз у рядку, який спричинив проблему.
graph LR
A["<module><br/>line 12"] -->|"calls"| B["average<br/>line 7"]
B -->|"calls"| C["parse_grade<br/>line 3"]
C -->|"ValueError"| X["💥"]
Помилка не завжди там, де виняток
Traceback показує, де виняток виник, але не завжди, чому. Тут int() поводиться абсолютно правильно — проблема в даних, які передали на рівень вище. Пройдіться ланцюжком викликів угору, щоб знайти, звідки прийшло погане значення.
Найчастіші вбудовані винятки¶
Кожен виняток має тип — його назва закінчується на Error і одразу підказує, що сталося.
| Виняток | Коли виникає | Приклад |
|---|---|---|
ValueError |
тип правильний, а значення — ні | int("abc") |
TypeError |
операція не підтримує такий тип | "5" + 5, len(42) |
ZeroDivisionError |
ділення на нуль | 10 / 0, 10 % 0 |
IndexError |
індекс поза межами послідовності | [1, 2][5] |
KeyError |
ключа немає у словнику | {"a": 1}["b"] |
NameError |
імʼя (змінна, функція) не визначене | print(totl) |
AttributeError |
в обʼєкта немає такого атрибута чи методу | [1, 2].push(3) |
FileNotFoundError |
файлу за шляхом немає | open("nothing.txt") |
FileExistsError |
файл уже існує | open("a.txt", "x") для наявного файлу |
PermissionError |
немає прав на операцію | запис у /etc/passwd |
IsADirectoryError |
очікували файл, а це каталог | open(".") |
UnicodeDecodeError |
байти не декодуються в обраному кодуванні | читання не-UTF-8 файлу як UTF-8 |
RecursionError |
занадто глибока рекурсія | функція без базового випадку |
KeyboardInterrupt |
користувач натиснув Ctrl+C | під час input() або довгого циклу |
Подивимося на кілька з них у дії. Щоб програма не зупинилася на першому ж винятку, скористаємося конструкцією try / except, яку детально розберемо в наступному розділі.
# Program: typical built-in exceptions
grades = [90, 85, 77]
student = {"name": "Ivan", "group": "PZ-11"}
try:
int("ninety")
except ValueError as e:
print("ValueError:", e)
try:
"grade: " + 90
except TypeError as e:
print("TypeError:", e)
try:
grades[3]
except IndexError as e:
print("IndexError:", e)
try:
student["age"]
except KeyError as e:
print("KeyError:", e)
try:
grades.push(100)
except AttributeError as e:
print("AttributeError:", e)
try:
print(totl)
except NameError as e:
print("NameError:", e)
ValueError: invalid literal for int() with base 10: 'ninety'
TypeError: can only concatenate str (not "int") to str
IndexError: list index out of range
KeyError: 'age'
AttributeError: 'list' object has no attribute 'push'
NameError: name 'totl' is not defined
NameError і AttributeError — це помилки в коді
ValueError, FileNotFoundError, KeyError зазвичай спричинені даними: користувач ввів не те, файл зник, у словнику немає ключа. Їх має сенс обробляти. А NameError чи AttributeError найчастіше означають опечатку в коді. Їх не перехоплюють — їх виправляють.
Конструкція try / except¶
Щоб перехопити виняток, код, який може зламатися, поміщають у блок try, а реакцію на помилку — у блок except:
try:
<risky code> # код, який може підняти виняток
except ExceptionType:
<handler> # що робити, якщо виняток стався
# Program: catching a ValueError
text = "eighteen"
try:
age = int(text)
print("parsed:", age)
except ValueError:
print("not a number:", text)
print("program continues")
Як це виконується:
- Python виконує блок
tryрядок за рядком. - Якщо жодного винятку не сталося — блок
exceptпропускається повністю. - Якщо в якомусь рядку виник виняток — решта блоку
tryне виконується (томуparsed:не надруковано). Python шукає гілкуexceptз відповідним типом. - Якщо тип збігся — виконується тіло
except, і програма продовжує роботу після всієї конструкції. - Якщо тип не збігся — виняток летить далі, так ніби
tryне було.
graph TD
S["try: block"] --> Q{"exception?"}
Q -->|"no"| N["skip except"]
Q -->|"yes"| M{"type matches<br/>except?"}
M -->|"yes"| H["run except block"]
M -->|"no"| U["exception propagates up"]
N --> C["code after try"]
H --> C
Пункт 5 важливий: except ValueError ловить тільки ValueError. Інші винятки проходять крізь нього.
# Program: except catches only the listed type
values = ["42", "7"]
try:
number = int(values[5])
except ValueError:
print("not a number")
Traceback (most recent call last):
File "main.py", line 5, in <module>
number = int(values[5])
~~~~~~^^^
IndexError: list index out of range
Тепер перехоплення у циклі — типовий випадок, коли одне погане значення не повинно зупиняти обробку всіх інших:
# Program: skip bad values, keep processing the rest
raw = ["95", "88", "absent", "100", "", "73"]
grades = []
for item in raw:
try:
grades.append(int(item))
except ValueError:
print(f"skipped: {item!r}")
print("grades:", grades)
print("average:", round(sum(grades) / len(grades), 2))
Кілька гілок except¶
Один блок try може підняти винятки різних типів, і на кожен можна реагувати по-своєму. Гілки except перевіряються згори донизу, виконується перша відповідна.
# Program: different reactions to different errors
def safe_divide(a, b):
try:
result = float(a) / float(b)
except ValueError:
return "error: not a number"
except ZeroDivisionError:
return "error: division by zero"
return f"{result:.2f}"
print(safe_divide("10", "4"))
print(safe_divide("10", "zero"))
print(safe_divide("10", "0"))
Якщо реакція на кілька типів однакова, їх перелічують кортежем в одній гілці:
# Program: one handler for several exception types
data = {"grades": [90, 85]}
def get_grade(key, index):
try:
return data[key][index]
except (KeyError, IndexError):
return None
print(get_grade("grades", 1))
print(get_grade("grades", 10))
print(get_grade("scores", 0))
Обʼєкт винятку: as e¶
Виняток — це звичайний обʼєкт Python. Конструкція except ТипВинятку as e привʼязує його до змінної e, і з нього можна дістати корисну інформацію:
str(e)(або простоprint(e)) — текст повідомлення;type(e).__name__— назва типу винятку;e.args— кортеж аргументів, з якими виняток створили.
# Program: inspecting the exception object
try:
int("12abc")
except ValueError as e:
print("message:", e)
print("type: ", type(e).__name__)
print("args: ", e.args)
message: invalid literal for int() with base 10: '12abc'
type: ValueError
args: ("invalid literal for int() with base 10: '12abc'",)
Деякі винятки мають додаткові атрибути. Наприклад, у винятків файлової системи є filename і strerror:
# Program: extra attributes of OSError
try:
open("missing_file.txt", encoding="utf-8")
except FileNotFoundError as e:
print("file: ", e.filename)
print("reason:", e.strerror)
print("errno: ", e.errno)
e існує лише всередині except
Після виходу з блоку except Python видаляє змінну e, щоб не тримати в памʼяті обʼєкт винятку разом з усім його traceback. Якщо повідомлення знадобиться пізніше, збережіть його в іншу змінну: error_text = str(e).
Ієрархія винятків¶
Типи винятків утворюють дерево: загальніші винятки стоять угорі, конкретніші — нижче. Ось фрагмент цього дерева:
graph TD
BE["BaseException"] --> KI["KeyboardInterrupt"]
BE --> SE["SystemExit"]
BE --> EX["Exception"]
EX --> AE["ArithmeticError"]
AE --> ZD["ZeroDivisionError"]
EX --> LE["LookupError"]
LE --> IE["IndexError"]
LE --> KE["KeyError"]
EX --> OE["OSError"]
OE --> FNF["FileNotFoundError"]
OE --> FEE["FileExistsError"]
OE --> PE["PermissionError"]
EX --> VE["ValueError"]
VE --> UE["UnicodeDecodeError"]
EX --> TE["TypeError"]
Правило: except X перехоплює виняток типу X і всіх його нащадків. Тож except LookupError спіймає і IndexError, і KeyError, а except OSError — будь-яку проблему з файлами.
# Program: a parent type catches its children
def lookup(container, key):
try:
return container[key]
except LookupError as e:
return f"{type(e).__name__} caught as LookupError"
print(lookup([10, 20], 5))
print(lookup({"a": 1}, "b"))
print(lookup({"a": 1}, "a"))
Перевірити спорідненість типів можна функцією issubclass():
# Program: checking the exception hierarchy
print(issubclass(FileNotFoundError, OSError))
print(issubclass(KeyError, LookupError))
print(issubclass(ValueError, LookupError))
print(issubclass(ZeroDivisionError, Exception))
З ієрархії випливає важливе правило про порядок гілок: спочатку конкретні винятки, потім загальні. Інакше загальна гілка перехопить усе, і конкретна ніколи не виконається.
# Program: wrong and right order of except branches
def read_config_wrong(path):
try:
with open(path, encoding="utf-8") as f:
return f.read()
except OSError:
return "some OS problem"
except FileNotFoundError: # ніколи не виконається
return "file not found"
def read_config_right(path):
try:
with open(path, encoding="utf-8") as f:
return f.read()
except FileNotFoundError:
return "file not found"
except OSError:
return "some OS problem"
print(read_config_wrong("no_such_config.txt"))
print(read_config_right("no_such_config.txt"))
Exception і BaseException¶
На вершині дерева — BaseException. Від нього походять Exception (усі «звичайні» помилки програми) і кілька спеціальних винятків, які помилками не є:
KeyboardInterrupt— користувач натиснув Ctrl+C, щоб зупинити програму;SystemExit— програма викликалаsys.exit().
Саме тому except Exception не заважає зупинити програму через Ctrl+C, а «голий» except: без типу — заважає: він ловить узагалі все, зокрема й KeyboardInterrupt та SystemExit.
Подивимося, чим це загрожує:
# Program: a bare except hides a typo
def parse_grades(texts):
grades = []
for text in texts:
try:
grades.append(int(txt)) # опечатка: txt замість text
except: # голий except ловить і NameError
pass
return grades
print(parse_grades(["90", "85", "abc"]))
Програма не впала, але повернула порожній список: NameError через опечатку «проковтнуто» так само, як очікуваний ValueError. З except ValueError: ми одразу побачили б traceback з NameError: name 'txt' is not defined.
Не пишіть голий except:
Голий except: ховає будь-яку помилку, навіть опечатку, і ще й не дає зупинити програму через Ctrl+C. Завжди вказуйте конкретний тип винятку. Якщо справді треба перехопити будь-яку помилку програми (наприклад, щоб записати її в журнал) — пишіть except Exception as e і обовʼязково виводьте або записуйте e.
Гілка else¶
Після всіх except можна додати гілку else. Вона виконується, лише якщо в try не виникло жодного винятку.
# Program: else runs only when there was no exception
def parse_age(text):
try:
age = int(text)
except ValueError:
print(f"{text!r}: not a number")
else:
print(f"{text!r}: ok, next year you will be {age + 1}")
parse_age("19")
parse_age("nineteen")
Навіщо else, якщо той самий print можна поставити в кінець try? Щоб не перехоплювати зайвого. Блок try має містити лише ті рядки, від яких ми очікуємо виняток. Якщо в «успішному» коді теж станеться ValueError, але з іншої причини, — в else він не буде помилково прийнятий за «не число», а покаже справжню проблему.
# Program: a too wide try hides a different bug
def report(text, divider):
try:
value = int(text)
print("share:", value / int(divider))
except ValueError:
print("input is not a number")
report("100", "4")
report("100", "four") # проблема в divider, а повідомлення про text
Правильно — тримати в try мінімум коду й обробляти кожне джерело помилки окремо.
Гілка finally¶
Гілка finally виконується завжди: після успішного try, після обробленого винятку, після необробленого винятку і навіть після return усередині try. Її використовують для «прибирання»: закрити зʼєднання, видалити тимчасовий файл, вивести підсумкове повідомлення.
# Program: finally runs in every case
def divide(a, b):
try:
print(f" dividing {a} by {b}")
return a / b
except ZeroDivisionError:
print(" cannot divide by zero")
return None
finally:
print(" finally: done")
print("result:", divide(10, 4))
print("result:", divide(10, 0))
dividing 10 by 4
finally: done
result: 2.5
dividing 10 by 0
cannot divide by zero
finally: done
result: None
Зверніть увагу: return a / b уже «вирішив» повернути 2.5, але спочатку виконався finally, і лише потім функція повернула значення.
Якщо виняток не перехоплено, finally однаково виконується — а потім виняток летить далі:
# Program: finally runs even when the exception is not handled
try:
print("opening connection")
int("oops")
finally:
print("closing connection")
print("never printed")
opening connection
closing connection
Traceback (most recent call last):
File "main.py", line 4, in <module>
int("oops")
~~~^^^^^^^^
ValueError: invalid literal for int() with base 10: 'oops'
with — це вбудований finally
Згадайте, як у минулій лекції with open(...) «гарантовано закривав файл навіть після помилки». Всередині with працює саме такий механізм: при виході з блоку — хоч нормальному, хоч через виняток — викликається код закриття. Для файлів використовуйте with, а finally — для решти випадків, де потрібне прибирання.
Повна форма¶
Усі чотири гілки разом, у суворо такому порядку:
try:
<risky code> # код, який може зламатися
except TypeA:
<handler A> # реакція на TypeA
except (TypeB, TypeC) as e:
<handler B or C> # реакція на TypeB або TypeC
else:
<on success> # виконується, якщо винятку не було
finally:
<cleanup> # виконується завжди
Обовʼязкові лише try і хоча б одна з гілок except або finally. else без except писати не можна.
# Program: all four branches together
def load_number(text):
print(f"--- {text!r}")
try:
number = int(text)
except ValueError as e:
print("except:", e)
else:
print("else: parsed", number)
finally:
print("finally")
load_number("42")
load_number("forty two")
--- '42'
else: parsed 42
finally
--- 'forty two'
except: invalid literal for int() with base 10: 'forty two'
finally
Як виняток «проходить» крізь функції¶
Виняток не обовʼязково обробляти там, де він виник. Якщо у функції немає відповідного except, виняток виходить з неї і потрапляє у місце виклику, звідти — у наступне, і так далі аж до верхнього рівня програми. Цей процес називають розкручуванням стеку (stack unwinding).
# Program: an exception travels up through the call chain
def level3():
print(" level3: start")
int("x")
print(" level3: end")
def level2():
print(" level2: start")
level3()
print(" level2: end")
def level1():
print("level1: start")
try:
level2()
except ValueError as e:
print("level1: caught ->", e)
print("level1: end")
level1()
level1: start
level2: start
level3: start
level1: caught -> invalid literal for int() with base 10: 'x'
level1: end
Рядки level3: end і level2: end не надруковано: виняток перервав обидві функції, і виконання продовжилося лише там, де його перехопили.
sequenceDiagram
participant L1 as level1
participant L2 as level2
participant L3 as level3
L1->>L2: call
L2->>L3: call
L3--xL2: ValueError
L2--xL1: ValueError (not handled in level2)
Note over L1: except ValueError
Це дає змогу розділити обовʼязки:
- низькорівнева функція (розбір рядка, читання файлу) лише повідомляє про проблему — вона не знає, що з нею робити;
- високорівнева функція (головне меню,
main) вирішує, як реагувати: показати повідомлення, пропустити запис, спробувати ще раз.
Тому не варто в кожній функції загортати все в try. Обробляйте виняток там, де є достатньо контексту, щоб прийняти рішення.
Два стилі: LBYL та EAFP¶
Є два підходи до потенційно небезпечних операцій.
LBYL — Look Before You Leap, «подивись, перш ніж стрибати». Спочатку перевіряємо всі умови, потім виконуємо дію.
EAFP — Easier to Ask for Forgiveness than Permission, «легше попросити вибачення, ніж дозволу». Одразу виконуємо дію, а якщо не вийшло — обробляємо виняток.
# Program: LBYL versus EAFP
from pathlib import Path
prices = {"apple": 25, "bread": 32}
# LBYL: перевіряємо наперед
def price_lbyl(item):
if item in prices:
return prices[item]
return 0
# EAFP: пробуємо, а потім обробляємо невдачу
def price_eafp(item):
try:
return prices[item]
except KeyError:
return 0
print(price_lbyl("bread"), price_lbyl("milk"))
print(price_eafp("bread"), price_eafp("milk"))
# Для файлів EAFP надійніший
def read_lbyl(path):
if path.is_file():
return path.read_text(encoding="utf-8") # файл міг зникнути між рядками
return ""
def read_eafp(path):
try:
return path.read_text(encoding="utf-8")
except FileNotFoundError:
return ""
print(repr(read_lbyl(Path("absent.txt"))), repr(read_eafp(Path("absent.txt"))))
Коли що обирати:
| Ситуація | Кращий стиль | Чому |
|---|---|---|
Проста перевірка, яка повністю описує умову (x != 0, key in d, len(items) > 0) |
LBYL | читається природно, виняток не потрібен |
| Перевірити наперед складно (чи є рядок коректним числом, датою, JSON) | EAFP | функція розбору сама знає правила — простіше спробувати |
| Стан може змінитися між перевіркою і дією (файли, мережа) | EAFP | перевірка exists() не гарантує, що файл буде на місці через мить |
| Помилка — звичайна частина потоку (у половини рядків немає ключа) | LBYL або dict.get() |
винятки для рідкісних подій, а не для звичайної логіки |
Перевірка «чи рядок — ціле число» — класичний приклад на користь EAFP. Метод isdigit() здається підходящим, але помиляється в обидва боки:
# Program: isdigit() is not a reliable number check
for text in ["42", "-5", " 7 ", "²"]:
try:
parsed = int(text)
except ValueError:
parsed = "ValueError"
print(f"{text!r:6} isdigit={text.isdigit()!s:6} int() -> {parsed}")
'42' isdigit=True int() -> 42
'-5' isdigit=False int() -> -5
' 7 ' isdigit=False int() -> 7
'²' isdigit=True int() -> ValueError
isdigit() відкидає коректні -5 і " 7 " і водночас пропускає символ ², на якому int() падає. Тож найнадійніший спосіб дізнатися, чи рядок перетворюється на число, — спробувати перетворити.
Піднімаємо виняток самі: raise¶
Досі винятки піднімав Python. Але і наша функція може повідомити про проблему за допомогою інструкції raise:
Навіщо це потрібно? Уявіть функцію, яка встановлює оцінку. Значення 150 — коректне int, Python тут нічого не помітить. Але для нашої задачі воно неприпустиме. Можна повернути None чи -1, але тоді кожен виклик мусить про це памʼятати й перевіряти результат. Виняток ігнорувати не вийде: або його обробили, або програма зупинилася з чітким повідомленням.
# Program: raising an exception for invalid input
def validate_grade(grade: int) -> int:
if not isinstance(grade, int):
raise TypeError(f"grade must be int, got {type(grade).__name__}")
if not 0 <= grade <= 100:
raise ValueError(f"grade must be in 0..100, got {grade}")
return grade
for value in [95, 150, -3, "90"]:
try:
print("accepted:", validate_grade(value))
except (TypeError, ValueError) as e:
print(f"rejected: {type(e).__name__}: {e}")
accepted: 95
rejected: ValueError: grade must be in 0..100, got 150
rejected: ValueError: grade must be in 0..100, got -3
rejected: TypeError: grade must be int, got str
Як обрати тип винятку для raise:
ValueError— значення правильного типу, але неприйнятне (оцінка 150, порожнє імʼя, дата в майбутньому);TypeError— переданий обʼєкт узагалі не того типу;KeyError,IndexError— не знайдено елемент за ключем чи індексом;FileNotFoundError— потрібного файлу немає;- власний тип — коли жоден стандартний не описує проблему точно (див. нижче).
Добре повідомлення про помилку
Повідомлення у винятку читатиме людина, яка шукає причину збою. Воно має відповідати на питання «що саме не так і з яким значенням»: grade must be in 0..100, got 150 набагато корисніше, ніж bad grade чи error.
Повторне піднімання і ланцюжки: raise ... from¶
Іноді виняток треба перехопити, щось зробити (наприклад, записати в журнал) і пропустити далі, бо на цьому рівні вирішити проблему неможливо. Для цього в блоці except пишуть raise без аргументів — він повторно піднімає той самий виняток:
# Program: log the problem and re-raise the same exception
def to_int(text):
try:
return int(text)
except ValueError:
print(f"log: failed to convert {text!r}")
raise
try:
to_int("3.5")
except ValueError as e:
print("main:", e)
Інший випадок — перетворити низькорівневий виняток на зрозуміліший. Наприклад, функція завантаження налаштувань отримує KeyError: 'port', але користувачу корисніше побачити, що саме не так із конфігурацією. Для цього використовують raise НовийВиняток(...) from e: новий виняток зберігає посилання на початковий, і traceback покаже обидва.
# Program: exception chaining with raise ... from
def read_port(settings):
try:
return int(settings["port"])
except KeyError as e:
raise ValueError("config: required field 'port' is missing") from e
read_port({"host": "localhost"})
Traceback (most recent call last):
File "main.py", line 4, in read_port
return int(settings["port"])
~~~~~~~~^^^^^^^^
KeyError: 'port'
The above exception was the direct cause of the following exception:
Traceback (most recent call last):
File "main.py", line 9, in <module>
read_port({"host": "localhost"})
~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^
File "main.py", line 6, in read_port
raise ValueError("config: required field 'port' is missing") from e
ValueError: config: required field 'port' is missing
Traceback складається з двох частин: спершу початкова причина (KeyError), потім фраза The above exception was the direct cause of the following exception і новий виняток. Так не втрачається жодна інформація.
Виняток усередині except без from
Якщо в блоці except виникне інший виняток (наприклад, через помилку в коді обробника), Python теж покаже обидва, але з фразою During handling of the above exception, another exception occurred. Це підказка: зламався сам обробник, а не лише основний код.
Власні винятки¶
Стандартних типів вистачає не завжди. Коли програма має власні «правила гри» — формат файлу журналу, бізнес-обмеження, — зручно завести власний тип винятку. Тоді його можна перехоплювати окремо від усіх інших і не сплутати з випадковим ValueError з глибини бібліотеки.
Власний виняток створюється одним рядком — оголошенням класу, що походить від Exception:
# Program: a custom exception type
class GradeFormatError(Exception):
"""Raised when a line of the grades file is malformed."""
def parse_line(line: str) -> tuple[str, int]:
name, sep, grade_text = line.partition(":")
if not sep:
raise GradeFormatError(f"missing ':' in {line!r}")
try:
grade = int(grade_text)
except ValueError as e:
raise GradeFormatError(f"bad grade in {line!r}") from e
return name.strip(), grade
for line in ["Bondar: 90", "Kovalenko 85", "Marchenko: ninety"]:
try:
print(parse_line(line))
except GradeFormatError as e:
print("format error:", e)
('Bondar', 90)
format error: missing ':' in 'Kovalenko 85'
format error: bad grade in 'Marchenko: ninety'
Класи детально розглядатимемо в наступних семестрах. Поки що достатньо шаблону:
- Назву закінчуйте на
Error— так роблять усі вбудовані винятки. - У дужках — батьківський тип.
Exceptionпідходить майже завжди; якщо помилка за змістом — різновид неправильного значення, можна успадкувати відValueError, і тоді її спіймає іexcept ValueError. - Рядок документації замість
pass— тіло класу, яке пояснює, коли виняток виникає.
assert — перевірка припущень¶
Інструкція assert перевіряє умову і, якщо вона хибна, піднімає AssertionError:
# Program: assert checks the programmer's assumptions
def average(grades: list[int]) -> float:
assert len(grades) > 0, "average() of an empty list"
return sum(grades) / len(grades)
print(average([90, 80]))
print(average([]))
85.0
Traceback (most recent call last):
File "main.py", line 8, in <module>
print(average([]))
~~~~~~~^^^^
File "main.py", line 3, in average
assert len(grades) > 0, "average() of an empty list"
^^^^^^^^^^^^^^^
AssertionError: average() of an empty list
assert схожий на if ...: raise, але має інше призначення. Він перевіряє припущення програміста — умови, які при правильному коді не можуть порушитися ніколи. Якщо assert спрацював — у програмі баг.
assert — не для перевірки вхідних даних
Python, запущений з ключем -O (python3 -O main.py), повністю пропускає всі assert. Тому перевірку того, що ввів користувач або що прочитано з файлу, завжди робіть через if і raise ValueError(...). assert лишайте для внутрішніх перевірок і тестів.
Винятки та файли¶
Робота з файлами — головне джерело винятків у реальних програмах: файл може бути відсутній, недоступний, бути каталогом або мати не те кодування. Всі ці винятки — нащадки OSError (крім UnicodeDecodeError, який походить від ValueError).
# Program: handling file errors
from pathlib import Path
Path("latin1_demo.txt").write_bytes("Café".encode("latin-1"))
Path("demo_dir").mkdir(exist_ok=True)
def read_file(path: str) -> str:
try:
with open(path, encoding="utf-8") as f:
return f.read()
except FileNotFoundError:
return f"error: {path} not found"
except IsADirectoryError:
return f"error: {path} is a directory"
except PermissionError:
return f"error: no permission to read {path}"
except UnicodeDecodeError:
return f"error: {path} is not a UTF-8 text file"
for name in ["nothing.txt", "demo_dir", "latin1_demo.txt"]:
print(read_file(name))
error: nothing.txt not found
error: demo_dir is a directory
error: latin1_demo.txt is not a UTF-8 text file
У минулій лекції ми уникали перезапису файлу через перевірку exists(). Тепер можна зробити це надійніше — режимом "x" і перехопленням FileExistsError. Між перевіркою та відкриттям файл не «зʼявиться» непомітно: операційна система перевіряє і створює файл однією дією.
# Program: create a file only once, the EAFP way
from pathlib import Path
path = Path("created_once.txt")
path.unlink(missing_ok=True)
for attempt in [1, 2]:
try:
with open(path, "x", encoding="utf-8") as f:
f.write("created on the first attempt\n")
print(f"attempt {attempt}: file created")
except FileExistsError:
print(f"attempt {attempt}: file already exists, not overwritten")
Код завершення програми і sys.exit()¶
Кожна програма, завершуючись, повідомляє операційній системі код завершення (exit code): 0 — успіх, будь-яке інше число — збій. Код останньої команди в терміналі показує змінна $?.
Коли виняток не перехоплено, Python друкує traceback і завершується з кодом 1. Для кінцевого користувача traceback — це «страшний» текст, який нічого йому не каже. Тому в утилітах командного рядка прийнято перехоплювати очікувані помилки на верхньому рівні, друкувати коротке повідомлення в потік помилок sys.stderr і завершуватися через sys.exit(1).
# Program: word counter with friendly error messages (word_count.py)
import sys
from pathlib import Path
def count_words(path: Path) -> int:
with open(path, encoding="utf-8") as f:
return sum(len(line.split()) for line in f)
def main() -> int:
if len(sys.argv) != 2:
print(f"usage: python3 {Path(sys.argv[0]).name} <file>", file=sys.stderr)
return 2
path = Path(sys.argv[1])
try:
words = count_words(path)
except FileNotFoundError:
print(f"error: file not found: {path}", file=sys.stderr)
return 1
except IsADirectoryError:
print(f"error: {path} is a directory", file=sys.stderr)
return 1
except UnicodeDecodeError:
print(f"error: {path} is not a UTF-8 text file", file=sys.stderr)
return 1
print(f"{path.name}: {words} words")
return 0
sys.exit(main())
$ python3 word_count.py word_count.py
word_count.py: 92 words
$ echo $?
0
$ python3 word_count.py nothing.txt
error: file not found: nothing.txt
$ echo $?
1
$ python3 word_count.py
usage: python3 word_count.py <file>
$ echo $?
2
Що тут варто помітити:
main()повертає код завершення, аsys.exit(main())передає його системі. Так логіка програми не розкидана по викликахsys.exit()у різних місцях.- Повідомлення про помилки йдуть у
sys.stderr, а корисний результат — уsys.stdout. Якщо перенаправити вивід у файл (> result.txt), помилки все одно зʼявляться на екрані. - Код
2за традицією означає неправильне використання команди (не ті аргументи). sys.exit()сам по собі піднімає винятокSystemExit. Саме тому він не походить відException:except Exceptionйого не перехопить і не завадить програмі завершитися.
Повторне введення до коректного значення¶
Класичний приклад з input(): питати користувача, доки він не введе коректне значення. Логіку перевірки варто винести в окрему функцію — тоді її легко протестувати без клавіатури.
# Program: ask again until the value is valid
def parse_grade(text: str) -> int:
"""Convert text to a grade 0..100 or raise ValueError."""
grade = int(text)
if not 0 <= grade <= 100:
raise ValueError(f"grade must be in 0..100, got {grade}")
return grade
def ask_grade(prompt: str) -> int:
while True:
text = input(prompt)
try:
return parse_grade(text)
except ValueError as e:
print(" invalid:", e)
grade = ask_grade("Enter grade: ")
print("saved:", grade)
Enter grade: ninety
invalid: invalid literal for int() with base 10: 'ninety'
Enter grade: 150
invalid: grade must be in 0..100, got 150
Enter grade: 90
saved: 90
Зверніть увагу: parse_grade піднімає один і той самий ValueError і коли текст не число (це робить int()), і коли число поза межами (це робимо ми через raise). Тому ask_grade потрібна лише одна гілка except.
Якщо під час input() натиснути Ctrl+C, виникне KeyboardInterrupt; якщо Ctrl+D (кінець вводу) — EOFError. Щоб програма завершувалася акуратно, без traceback, їх перехоплюють на верхньому рівні:
# Program: leave quietly on Ctrl+C or Ctrl+D
import sys
def ask_grade(prompt: str) -> int:
while True:
text = input(prompt)
try:
grade = int(text)
except ValueError:
print(" invalid: not a number")
continue
if 0 <= grade <= 100:
return grade
print(" invalid: grade must be in 0..100")
try:
grade = ask_grade("Enter grade: ")
except (KeyboardInterrupt, EOFError):
print("\ncancelled")
sys.exit(1)
print("saved:", grade)
Приклад: стійке завантаження журналу оцінок¶
Повернімося до журналу оцінок з минулої лекції. Реальний файл рідко буває ідеальним: в ньому бувають рядки без двокрапки, текст замість оцінок, оцінки поза межами. Програма не повинна падати на першому ж поганому рядку — вона має завантажити все, що можна, і докладно повідомити про решту.
# Program: tolerant loader for a grades journal
from pathlib import Path
class JournalFormatError(Exception):
"""Raised when a line of the journal cannot be parsed."""
def parse_grade(text: str) -> int:
try:
grade = int(text)
except ValueError:
raise JournalFormatError(f"not a number: {text!r}") from None
if not 0 <= grade <= 100:
raise JournalFormatError(f"out of range: {grade}")
return grade
def parse_line(line: str) -> tuple[str, list[int]]:
name, sep, grades_text = line.partition(":")
if not sep:
raise JournalFormatError("missing ':'")
name = name.strip()
if not name:
raise JournalFormatError("empty student name")
grades = [parse_grade(part.strip()) for part in grades_text.split(",")]
return name, grades
def load_journal(path: Path) -> tuple[dict[str, list[int]], list[str]]:
"""Return (journal, errors). Bad lines are reported, not fatal."""
journal: dict[str, list[int]] = {}
errors: list[str] = []
with open(path, encoding="utf-8") as f:
for number, raw in enumerate(f, start=1):
line = raw.strip()
if not line or line.startswith("#"):
continue
try:
name, grades = parse_line(line)
except JournalFormatError as e:
errors.append(f"line {number}: {e}")
continue
journal.setdefault(name, []).extend(grades)
return journal, errors
def main() -> None:
path = Path(__file__).resolve().parent / "journal_demo.txt"
path.write_text(
"# student: grades\n"
"Shevchenko: 95, 88, 100\n"
"Kovalenko 73, 81\n"
"Bondar: 60, 67, seventy\n"
"\n"
"Marchenko: 85, 120\n"
": 90\n"
"Kovalenko: 94\n",
encoding="utf-8",
)
try:
journal, errors = load_journal(path)
except FileNotFoundError:
print(f"journal not found: {path}")
return
for name, grades in journal.items():
print(f"{name:<12} {grades} avg={sum(grades) / len(grades):.2f}")
if errors:
print(f"\nskipped {len(errors)} bad line(s):")
for error in errors:
print(" " + error)
main()
Shevchenko [95, 88, 100] avg=94.33
Kovalenko [94] avg=94.00
skipped 4 bad line(s):
line 3: missing ':'
line 4: not a number: 'seventy'
line 6: out of range: 120
line 7: empty student name
Що варто помітити:
- Низькорівневі функції
parse_gradeіparse_lineнічого не друкують і не вирішують — вони лише піднімаютьJournalFormatErrorз чітким поясненням. - Рішення приймає
load_journal: поганий рядок записується у список помилок, обробка продовжується з наступного. Номер рядка додається саме тут, бо лише тут він відомий. raise ... from Noneприховує початковийValueErrorз traceback: наше повідомленняnot a number: 'seventy'уже містить усю потрібну інформацію.- Власний тип
JournalFormatErrorвідокремлює «очікувані» помилки формату від справжніх багів: якби в коді була опечатка і виникNameError, вона не потрапила б у список «поганих рядків», а зупинила б програму з traceback — як і має бути. FileNotFoundErrorобробляється на найвищому рівні, уmain, бо лише там відомо, що робити, якщо файлу немає.
Типові помилки¶
| Помилка | Чому погано | Як правильно |
|---|---|---|
except: без типу |
ловить усе, включно з Ctrl+C, SystemExit і опечатками в коді |
вказувати конкретний тип: except ValueError: |
except Exception: pass |
помилка зникає безслідно, програма мовчки робить не те | обробити або хоча б вивести e; якщо не знаєте, що робити, — не перехоплюйте |
Увесь код функції в одному try |
перехоплюються помилки, яких не очікували, і отримують не те пояснення | у try — лише рядки, які можуть підняти очікуваний виняток; решта — в else або після |
Загальний виняток перед конкретним (except OSError перед except FileNotFoundError) |
конкретна гілка ніколи не виконається | від вузького до широкого |
except ValueError, TypeError: |
синтаксична помилка в Python 3 | кортеж: except (ValueError, TypeError): |
raise ValueError без повідомлення |
незрозуміло, що саме не так | raise ValueError(f"grade must be in 0..100, got {grade}") |
raise "error" |
піднімати можна лише винятки, а не рядки (TypeError) |
raise ValueError("error") |
Повернення «спецзначень» (-1, None) замість винятку |
виклик може забути перевірку, і -1 піде в обчислення |
raise, а обробку залишити тому, хто знає, що робити |
assert для перевірки введення |
з ключем -O перевірка зникає |
if ...: raise ValueError(...) |
Використання e після блоку except |
NameError: змінна видаляється при виході |
зберегти message = str(e) всередині except |
return усередині finally |
«проковтує» будь-який виняток і перекриває return з try |
у finally — лише прибирання, без return |
Перехоплення NameError, AttributeError, TypeError від власних опечаток |
маскується баг у коді | такі винятки виправляють, а не обробляють |
Останній пункт про finally варто побачити, бо він неочевидний:
# Program: return in finally swallows the exception
def broken():
try:
return int("oops")
finally:
return -1 # погано: ValueError зникає безслідно
print(broken())
Функція мала впасти з ValueError, але return у finally мовчки замінив виняток на -1. Починаючи з Python 3.14, інтерпретатор виводить про таку конструкцію попередження SyntaxWarning, але код однаково виконується.
Підсумок¶
- Синтаксична помилка виявляється до запуску і виправляється в коді. Виняток виникає під час виконання, коли операцію з конкретними даними неможливо виконати.
- Traceback читають знизу вгору: тип і повідомлення винятку, місце, де він виник, і ланцюжок викликів, що до нього привів.
try/exceptперехоплює виняток: рештаtryпропускається, виконується перша відповідна гілкаexcept, програма продовжує роботу після конструкції.- Кілька типів — кілька гілок або кортеж:
except (KeyError, IndexError):. Обʼєкт винятку — черезas e. - Винятки утворюють ієрархію;
except XловитьXі всіх нащадків. Гілки пишуть від конкретних до загальних.except Exception— найширше допустиме перехоплення, голийexcept:— ні. elseвиконується, якщо винятку не було;finally— завжди. Уtryтримають мінімум коду.- Неперехоплений виняток піднімається крізь виклики функцій; обробляти його варто там, де достатньо контексту для рішення.
- EAFP («спробуй і оброби») надійніший за LBYL («перевір спочатку»), коли умову важко перевірити або стан може змінитися (файли, розбір чисел).
raise ValueError("...")— повідомити про неприйнятне значення;raiseбез аргументів — пропустити виняток далі;raise New(...) from e— перетворити виняток, зберігши причину.- Власний виняток:
class MyError(Exception):з рядком документації. assert— лише для перевірки припущень програміста, не вхідних даних.- В утилітах командного рядка очікувані помилки перехоплюють на верхньому рівні, пишуть коротке повідомлення в
sys.stderrі завершуються ненульовим кодом черезsys.exit().
Корисні посилання¶
- Помилки та винятки — підручник Python
- Вбудовані винятки та їх ієрархія
- Інструкція
try— довідник мови - Інструкція
raise— довідник мови - Інструкція
assert— довідник мови - EAFP і LBYL — глосарій Python
Домашнє завдання¶
Мета — навчитися читати traceback, обирати правильний тип винятку, піднімати власні винятки і будувати програми, які не падають на поганих даних, але й не ховають справжніх помилок. Усі дані — ваші власні, латиницею. Кожне завдання — окрема програма.
-
Напишіть пʼять коротких фрагментів коду, кожен з яких піднімає інший виняток:
ValueError,TypeError,KeyError,IndexError,ZeroDivisionError— з вашими даними (імʼя, група, оцінки). Запустіть кожен без обробки, збережіть traceback і для кожного одним реченням поясніть, що означає останній рядок і на якому рядку вашого коду виникла проблема. -
Напишіть програму з трьох функцій, які викликають одна одну (
main→load→parse), причомуparseпіднімаєValueError. Запустіть її без обробки й поясніть кожен блок traceback. Потім додайте перехоплення спочатку вload, потім замість цього вmain— і покажіть, які рядкиprintвиконуються в кожному варіанті. -
Напишіть функцію
safe_average(values: list[str]) -> float | None, яка перетворює рядки на числа, пропускає некоректні (з повідомленням, яке саме значення пропущено) і повертає середнє. Якщо коректних значень немає — повертаєNone. Перевірте її на списку з ваших оцінок, де є щонайменше три «погані» значення різного вигляду ("", текст,"8.5.1"). -
Напишіть функцію
validate_student(name: str, age: int, group: str) -> None, яка піднімаєTypeError, якщо аргумент не того типу, іValueErrorз інформативним повідомленням, якщо: імʼя порожнє або містить цифри; вік поза межами 15..100; група не відповідає шаблонуXX-NN(дві великі латинські літери, дефіс, дві цифри). Перевірте її на своїх даних і щонайменше на шести некоректних наборах, виводячи тип і текст кожного винятку. -
Напишіть програму, яка питає в користувача через
input()два числа і операцію (+,-,*,/) і виводить результат. Некоректне число, невідома операція і ділення на нуль мають давати зрозуміле повідомлення і повторне запитування саме того значення, яке введено неправильно. Ctrl+C має завершувати програму повідомленнямcancelledбез traceback. -
Напишіть функцію
read_numbers(path: Path) -> list[float], яка читає числа з файлу (по одному в рядку). Обробіть окремо: відсутній файл, шлях до каталогу, файл не в UTF-8, некоректні рядки (їх треба пропустити, запамʼятавши номер рядка). Підготуйте чотири тестові випадки й покажіть вивід для кожного. -
Створіть власний виняток
ExpenseFormatErrorі програму, яка читає файлexpenses.txtз вашими реальними витратами у форматіdate;category;amount;description(щонайменше 12 рядків, з них 3–4 навмисно зіпсовані різними способами). Програма має завантажити всі коректні записи, вивести суми по категоріях і окремим списком — номер кожного поганого рядка з причиною. -
Візьміть програму з будь-якого домашнього завдання минулої лекції, яка працює з файлом, і перепишіть її в стилі утиліти командного рядка: шлях до файлу — з
sys.argv, функціяmain()повертає код завершення, всі очікувані помилки дають коротке повідомлення вsys.stderrбез traceback. Покажіть запуски з кодами0,1і2та вивідecho $?після кожного. -
Напишіть функцію
divide_all(numbers: list[float], divider: float) -> list[float]у двох варіантах — LBYL і EAFP. Потім напишіть функціюget_nested(data: dict, path: list[str]), яка дістає значення з вкладених словників (наприклад,["student", "address", "city"]) і піднімаєKeyErrorз повним шляхом у повідомленні, якщо якогось ключа немає (використайтеraise ... from). Покажіть traceback з ланцюжком винятків і поясніть обидві його частини. -
Знайдіть у своєму коді з попередніх лабораторних щонайменше три місця, де програма може впасти на некоректних даних. Для кожного опишіть: який виняток виникне і за яких даних, чи варто його обробляти в цьому місці чи вище (і чому), та як виглядає виправлений код.
Знайшли помилку чи бажаєте додати інформацію, щоб покращити курс? Створіть issue на GitHub