8. Ошибки и исключения

До сих пор сообщения об ошибках не упоминались, но если вы пробовали работать с примерами, то наверняка их видели. Существует (по крайней мере) два вида ошибок: синтаксические ошибки и исключения.

8.1. Ошибки синтаксиса

Синтаксические ошибки, также известные как ошибки синтаксического разбора, - это, пожалуй, самый распространенный вид жалоб, которые вы получаете в процессе изучения Python:

>>> while True print('Hello world')
  File "<stdin>", line 1
    while True print('Hello world')
               ^^^^^
SyntaxError: invalid syntax

Парсер повторяет ошибочную строку и отображает маленькие «стрелочки», указывающие на лексему в строке, где была обнаружена ошибка. Ошибка может быть вызвана отсутствием лексемы перед указанной лексемой. В примере ошибка обнаружена в функции print(), так как перед ней отсутствует двоеточие (':'). Имя файла и номер строки выводятся, чтобы вы знали, где искать, если входные данные были получены из скрипта.

8.2. Исключения

Даже если оператор или выражение синтаксически корректны, при попытке их выполнения они могут привести к ошибке. Ошибки, обнаруженные во время выполнения, называются исключениями и не являются безусловно фатальными: вскоре вы узнаете, как обрабатывать их в программах на Python. Однако большинство исключений не обрабатываются программами и приводят к появлению сообщений об ошибках, как показано здесь:

>>> 10 * (1/0)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ZeroDivisionError: division by zero
>>> 4 + spam*3
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: name 'spam' is not defined
>>> '2' + 2
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: can only concatenate str (not "int") to str

В последней строке сообщения об ошибке указывается, что произошло. Исключения бывают разных типов, и тип выводится как часть сообщения: в примере это ZeroDivisionError, NameError и TypeError. Строка, выводимая в качестве типа исключения, является именем встроенного исключения, которое произошло. Это верно для всех встроенных исключений, но не обязательно должно быть верно для пользовательских исключений (хотя это полезное соглашение). Стандартные имена исключений - это встроенные идентификаторы (а не зарезервированные ключевые слова).

Остальная часть строки содержит подробную информацию о типе исключения и о том, что его вызвало.

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

В Встроенные исключения перечислены встроенные исключения и их значения.

8.3. Обработка исключений

Можно писать программы, которые обрабатывают выбранные исключения. Посмотрите на следующий пример, который запрашивает у пользователя ввод до тех пор, пока не будет введено правильное целое число, но позволяет пользователю прервать программу (используя Control-C или то, что поддерживает операционная система); обратите внимание, что прерывание, созданное пользователем, сигнализируется поднятием исключения KeyboardInterrupt.

>>> while True:
...     try:
...         x = int(input("Please enter a number: "))
...         break
...     except ValueError:
...         print("Oops!  That was no valid number.  Try again...")
...

Оператор try работает следующим образом.

  • Сначала выполняется клаузула try (оператор(ы) между ключевыми словами try и except).

  • Если исключение не возникает, то клаузула except пропускается и выполнение оператора try завершается.

  • Если во время выполнения клаузы try возникает исключение, остальная часть клаузы пропускается. Затем, если его тип соответствует исключению, названному после ключевого слова except, выполняется предложение except, а затем выполнение продолжается после блока try/except.

  • Если возникает исключение, которое не соответствует исключению, указанному в предложении except clause, оно передается во внешние операторы try; если обработчик не найден, это необработанное исключение, и выполнение останавливается с сообщением об ошибке.

В операторе try может быть более одного предложения except, чтобы указать обработчики для разных исключений. Будет выполнен только один обработчик. Обработчики обрабатывают только те исключения, которые встречаются в соответствующем предложении try clause, а не в других обработчиках того же оператора try. В предложении except можно указать несколько исключений в виде кортежа, заключенного в круглые скобки, например:

... except (RuntimeError, TypeError, NameError):
...     pass

Класс в предложении except соответствует исключениям, которые являются экземплярами самого класса или одного из его производных классов (но не наоборот - предписание except, перечисляющее производный класс, не соответствует экземплярам его базовых классов). Например, следующий код выведет B, C, D в таком порядке:

class B(Exception):
    pass

class C(B):
    pass

class D(C):
    pass

for cls in [B, C, D]:
    try:
        raise cls()
    except D:
        print("D")
    except C:
        print("C")
    except B:
        print("B")

Обратите внимание, что если бы пункты except clauses были перевернуты (с except B первым), то было бы выведено B, B, B - срабатывает первый подходящий пункт except clause.

Когда возникает исключение, оно может иметь связанные с ним значения, также известные как аргументы исключения. Наличие и типы аргументов зависят от типа исключения.

В предложении except после имени исключения может быть указана переменная. Переменная привязывается к экземпляру исключения, который обычно имеет атрибут args, хранящий аргументы. Для удобства встроенные типы исключений определяют __str__(), чтобы вывести все аргументы без явного обращения к .args.

>>> try:
...     raise Exception('spam', 'eggs')
... except Exception as inst:
...     print(type(inst))    # the exception type
...     print(inst.args)     # arguments stored in .args
...     print(inst)          # __str__ allows args to be printed directly,
...                          # but may be overridden in exception subclasses
...     x, y = inst.args     # unpack args
...     print('x =', x)
...     print('y =', y)
...
<class 'Exception'>
('spam', 'eggs')
('spam', 'eggs')
x = spam
y = eggs

Вывод __str__() исключения выводится как последняя часть («detail») сообщения для необработанных исключений.

BaseException - общий базовый класс всех исключений. Один из его подклассов, Exception, является базовым классом всех нефатальных исключений. Исключения, не являющиеся подклассами Exception, обычно не обрабатываются, поскольку они используются для указания на то, что программа должна завершиться. К ним относятся SystemExit, которое вызывается sys.exit(), и KeyboardInterrupt, которое вызывается, когда пользователь хочет прервать работу программы.

Exception можно использовать как подстановочный знак, который улавливает (почти) все. Однако хорошей практикой является максимально конкретизировать типы исключений, которые мы собираемся обрабатывать, и позволять любым неожиданным исключениям распространяться дальше.

Наиболее распространенная схема обработки Exception заключается в том, чтобы вывести или записать исключение в журнал, а затем повторно поднять его (позволяя вызывающей стороне также обработать исключение):

import sys

try:
    f = open('myfile.txt')
    s = f.readline()
    i = int(s.strip())
except OSError as err:
    print("OS error:", err)
except ValueError:
    print("Could not convert data to an integer.")
except Exception as err:
    print(f"Unexpected {err=}, {type(err)=}")
    raise

Оператор tryexcept имеет необязательное предложение else, которое, если оно присутствует, должно следовать за всеми предложениями except. Оно полезно для кода, который должен быть выполнен, если предложение try не вызывает исключения. Например:

for arg in sys.argv[1:]:
    try:
        f = open(arg, 'r')
    except OSError:
        print('cannot open', arg)
    else:
        print(arg, 'has', len(f.readlines()), 'lines')
        f.close()

Использование условия else лучше, чем добавление дополнительного кода в условие try, поскольку позволяет избежать случайной поимки исключения, которое не было вызвано кодом, защищаемым условием tryexcept.

Обработчики исключений обрабатывают не только исключения, возникающие непосредственно в предложении try clause, но и те, которые возникают внутри функций, вызываемых (даже косвенно) в предложении try clause. Например:

>>> def this_fails():
...     x = 1/0
...
>>> try:
...     this_fails()
... except ZeroDivisionError as err:
...     print('Handling run-time error:', err)
...
Handling run-time error: division by zero

8.4. Возбуждение исключений

Оператор raise позволяет программисту заставить произойти указанное исключение. Например:

>>> raise NameError('HiThere')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: HiThere

Единственный аргумент raise указывает на исключение, которое должно быть поднято. Это должен быть либо экземпляр исключения, либо класс исключения (класс, производный от BaseException, например Exception или один из его подклассов). Если передан класс исключения, он будет неявно инстанцирован вызовом его конструктора без аргументов:

raise ValueError  # shorthand for 'raise ValueError()'

Если вам нужно определить, было ли поднято исключение, но вы не собираетесь его обрабатывать, более простая форма оператора raise позволяет повторно поднять исключение:

>>> try:
...     raise NameError('HiThere')
... except NameError:
...     print('An exception flew by!')
...     raise
...
An exception flew by!
Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
NameError: HiThere

8.5. Цепочка исключений

Если необработанное исключение возникает внутри секции except, к ней будет прикреплено обрабатываемое исключение, которое будет включено в сообщение об ошибке:

>>> try:
...     open("database.sqlite")
... except OSError:
...     raise RuntimeError("unable to handle error")
...
Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
FileNotFoundError: [Errno 2] No such file or directory: 'database.sqlite'

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "<stdin>", line 4, in <module>
RuntimeError: unable to handle error

Чтобы указать, что одно исключение является прямым следствием другого, в выражении raise допускается необязательная формулировка from:

# exc must be exception instance or None.
raise RuntimeError from exc

Это может быть полезно при преобразовании исключений. Например:

>>> def func():
...     raise ConnectionError
...
>>> try:
...     func()
... except ConnectionError as exc:
...     raise RuntimeError('Failed to open database') from exc
...
Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
  File "<stdin>", line 2, in func
ConnectionError

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "<stdin>", line 4, in <module>
RuntimeError: Failed to open database

Он также позволяет отключить автоматическую цепочку исключений с помощью идиомы from None:

>>> try:
...     open('database.sqlite')
... except OSError:
...     raise RuntimeError from None
...
Traceback (most recent call last):
  File "<stdin>", line 4, in <module>
RuntimeError

Более подробную информацию о механике цепочек вы найдете в разделе Встроенные исключения.

8.6. Определяемые пользователем исключения

Программы могут называть свои собственные исключения, создавая новый класс исключений (подробнее о классах Python см. в разделе Занятия). Как правило, исключения должны быть производными от класса Exception, прямо или косвенно.

Можно определить классы исключений, которые делают все, что может сделать любой другой класс, но обычно они остаются простыми, предлагая только ряд атрибутов, которые позволяют извлекать информацию об ошибке обработчиками исключений.

Большинство исключений имеют имена, заканчивающиеся на «Error», аналогично именованию стандартных исключений.

Многие стандартные модули определяют свои собственные исключения для сообщения об ошибках, которые могут возникнуть в определенных ими функциях.

8.7. Определение действий по очистке

Оператор try имеет еще один необязательный пункт, который предназначен для определения действий по очистке, которые должны быть выполнены при любых обстоятельствах. Например:

>>> try:
...     raise KeyboardInterrupt
... finally:
...     print('Goodbye, world!')
...
Goodbye, world!
Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
KeyboardInterrupt

Если присутствует предложение finally, то предложение finally будет выполнено как последняя задача перед завершением оператора try. Предложение finally выполняется независимо от того, выдает ли оператор try исключение или нет. В следующих пунктах рассматриваются более сложные случаи, когда возникает исключение:

  • Если во время выполнения пункта try возникает исключение, оно может быть обработано пунктом except. Если исключение не обработано предложением except, оно будет повторно поднято после выполнения предложения finally.

  • Исключение может возникнуть во время выполнения предложения except или else. И снова исключение поднимается после выполнения предложения finally.

  • Если предложение finally выполняет оператор break, continue или return, исключения повторно не поднимаются.

  • Если оператор try достигает оператора break, continue или return, то предложение finally будет выполнено непосредственно перед выполнением оператора break, continue или return.

  • Если предложение finally включает оператор return, то возвращаемым значением будет значение из оператора finally предложения return, а не значение из оператора try предложения return.

Например:

>>> def bool_return():
...     try:
...         return True
...     finally:
...         return False
...
>>> bool_return()
False

Более сложный пример:

>>> def divide(x, y):
...     try:
...         result = x / y
...     except ZeroDivisionError:
...         print("division by zero!")
...     else:
...         print("result is", result)
...     finally:
...         print("executing finally clause")
...
>>> divide(2, 1)
result is 2.0
executing finally clause
>>> divide(2, 0)
division by zero!
executing finally clause
>>> divide("2", "1")
executing finally clause
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 3, in divide
TypeError: unsupported operand type(s) for /: 'str' and 'str'

Как видите, предложение finally выполняется в любом случае. Возникшая при делении двух строк строка TypeError не обрабатывается предложением except и поэтому поднимается повторно после выполнения предложения finally.

В реальных приложениях предложение finally полезно для освобождения внешних ресурсов (например, файлов или сетевых соединений), независимо от того, было ли использование ресурса успешным.

8.8. Предопределенные действия по очистке

Некоторые объекты определяют стандартные действия по очистке, которые должны выполняться, когда объект больше не нужен, независимо от того, удалась или не удалась операция с его использованием. Посмотрите на следующий пример, который пытается открыть файл и распечатать его содержимое на экране.

for line in open("myfile.txt"):
    print(line, end="")

Проблема с этим кодом заключается в том, что он оставляет файл открытым на неопределенное время после завершения выполнения этой части кода. Это не является проблемой для простых скриптов, но может стать проблемой для больших приложений. Оператор with позволяет использовать такие объекты, как файлы, таким образом, что они всегда своевременно и правильно очищаются.

with open("myfile.txt") as f:
    for line in f:
        print(line, end="")

После выполнения оператора файл f всегда закрывается, даже если при обработке строк возникла проблема. Объекты, которые, как и файлы, обеспечивают предопределенные действия по очистке, указывают на это в своей документации.

8.9. Возбуждение и обработка нескольких несвязанных исключений

Бывают ситуации, когда необходимо сообщить о нескольких возникших исключениях. Это часто случается в параллельных системах, когда несколько задач могут не справиться параллельно, но есть и другие случаи использования, когда желательно продолжить выполнение и собрать несколько ошибок, а не поднимать первое исключение.

Встроенный ExceptionGroup оборачивает список экземпляров исключений, чтобы их можно было поднять вместе. Он сам является исключением, поэтому его можно перехватить, как любое другое исключение.

>>> def f():
...     excs = [OSError('error 1'), SystemError('error 2')]
...     raise ExceptionGroup('there were problems', excs)
...
>>> f()
  + Exception Group Traceback (most recent call last):
  |   File "<stdin>", line 1, in <module>
  |   File "<stdin>", line 3, in f
  | ExceptionGroup: there were problems
  +-+---------------- 1 ----------------
    | OSError: error 1
    +---------------- 2 ----------------
    | SystemError: error 2
    +------------------------------------
>>> try:
...     f()
... except Exception as e:
...     print(f'caught {type(e)}: e')
...
caught <class 'ExceptionGroup'>: e
>>>

Используя except* вместо except, мы можем выборочно обрабатывать только те исключения в группе, которые соответствуют определенному типу. В следующем примере, демонстрирующем вложенную группу исключений, каждый пункт except* извлекает из группы исключения определенного типа, позволяя всем остальным исключениям распространяться по другим пунктам и в конечном итоге быть поднятыми.

>>> def f():
...     raise ExceptionGroup(
...         "group1",
...         [
...             OSError(1),
...             SystemError(2),
...             ExceptionGroup(
...                 "group2",
...                 [
...                     OSError(3),
...                     RecursionError(4)
...                 ]
...             )
...         ]
...     )
...
>>> try:
...     f()
... except* OSError as e:
...     print("There were OSErrors")
... except* SystemError as e:
...     print("There were SystemErrors")
...
There were OSErrors
There were SystemErrors
  + Exception Group Traceback (most recent call last):
  |   File "<stdin>", line 2, in <module>
  |   File "<stdin>", line 2, in f
  | ExceptionGroup: group1
  +-+---------------- 1 ----------------
    | ExceptionGroup: group2
    +-+---------------- 1 ----------------
      | RecursionError: 4
      +------------------------------------
>>>

Обратите внимание, что исключения, вложенные в группу исключений, должны быть экземплярами, а не типами. Это связано с тем, что на практике исключения обычно являются теми, которые уже были вызваны и пойманы программой, по следующей схеме:

>>> excs = []
... for test in tests:
...     try:
...         test.run()
...     except Exception as e:
...         excs.append(e)
...
>>> if excs:
...    raise ExceptionGroup("Test Failures", excs)
...

8.10. Обогащение исключений с помощью заметок

Когда исключение создается для того, чтобы быть поднятым, оно обычно инициализируется информацией, описывающей произошедшую ошибку. Бывают случаи, когда полезно добавить информацию после того, как исключение было поймано. Для этого в исключениях есть метод add_note(note), который принимает строку и добавляет ее в список примечаний исключения. Стандартный рендеринг трассировки включает все заметки, в порядке их добавления, после исключения.

>>> try:
...     raise TypeError('bad type')
... except Exception as e:
...     e.add_note('Add some information')
...     e.add_note('Add some more information')
...     raise
...
Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
TypeError: bad type
Add some information
Add some more information
>>>

Например, собирая исключения в группу исключений, мы можем захотеть добавить контекстную информацию для отдельных ошибок. В следующем примере каждое исключение в группе имеет примечание, указывающее, когда произошла эта ошибка.

>>> def f():
...     raise OSError('operation failed')
...
>>> excs = []
>>> for i in range(3):
...     try:
...         f()
...     except Exception as e:
...         e.add_note(f'Happened in Iteration {i+1}')
...         excs.append(e)
...
>>> raise ExceptionGroup('We have some problems', excs)
  + Exception Group Traceback (most recent call last):
  |   File "<stdin>", line 1, in <module>
  | ExceptionGroup: We have some problems (3 sub-exceptions)
  +-+---------------- 1 ----------------
    | Traceback (most recent call last):
    |   File "<stdin>", line 3, in <module>
    |   File "<stdin>", line 2, in f
    | OSError: operation failed
    | Happened in Iteration 1
    +---------------- 2 ----------------
    | Traceback (most recent call last):
    |   File "<stdin>", line 3, in <module>
    |   File "<stdin>", line 2, in f
    | OSError: operation failed
    | Happened in Iteration 2
    +---------------- 3 ----------------
    | Traceback (most recent call last):
    |   File "<stdin>", line 3, in <module>
    |   File "<stdin>", line 2, in f
    | OSError: operation failed
    | Happened in Iteration 3
    +------------------------------------
>>>