Як мати справу з виключеннями в Python

17 хв. читання

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

Try ... Except

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

f = open('my_file.dat')
data = f.readfile()
print('Data loaded')

При запуску коду виведеться наступне повідомлення:

FileNotFoundError: [Errno 2] No such file or directory: 'my_file.dat'

При появі помилки, решта коду не буде виконуватися. Ось чому ви не бачите у консолі Data loaded. Це досить незначна проблема. Проте, уявіть, що ви спілкуєтесь з пристроєм. Якщо у вашій програмі є помилка, тоді у вас не буде можливості завершити зв'язок із пристроєм, щоб запобігти пошкодженню детекторів тощо.

Робота з таким типом помилок зазвичай виконується у блоці try/except. Це означає, що якщо всередині try буде помилка, тоді виконається блок except. У вищевказаному прикладі можна зробити наступне:

try:
    f = open('my_file.dat')
    f.readfile()
    print('Loaded data')
except:
    print('Data not loaded')

Якщо ви запустите код, то у виводі побачите повідомлення Data not loaded. Це чудово! Тепер наша програма не крашиться й можна припинити зв'язок з нашим пристроєм. Проте, ми все ще не знаємо причини, чому дані не були завантажені.

Перед тим як продовжити, створіть пустий файл з назвою my_file.dat й запустіть скрипт знову. Ви побачите, що незалежно від того, існує файл чи ні, дані не завантажуються. За допомогою цього тривіального прикладу ви можете бачити ризики, пов'язані з блоками except. Якщо зробити простіший скрипт:

f = open('my_file.dat')
f.readfile()
print('Loaded data')

Вивід буде наступним:

AttributeError: '_io.TextIOWrapper' object has no attribute 'readfile'

Що говорить нам про те, що проблема в методі, який ми використовуємо, — readfile не існує. Коли ви використовуєте простий блок try/except, ви обробляєте всі можливі винятки, але у вас немає способу дізнатися, що пішло не так. У такому простому випадку як вище, у вас є тільки два рядки коду для виявлення помилки, однак, якщо ви створюєте пакет або функцію, деякі помилки будуть розповсюджуватись, і ви не знатимите, як вони вплинуть на решту програми.

Щоб ви мали уявлення про важливість правильної обробки помилок, я розкажу вам, свідком чого я став, коли використовував програму, яка поставлялася з дуже складним лабораторним інструментом. Програма, що керує Nano Sight, має дуже приємний користувацький інтерфейс. Однак, при збереженні даних, якщо ім'я файлу, яке ви обрали, містить в собі крапку, дані не збережуться. Дані будуть також втрачені, й користувач ніколи не дізнається, що проблема була в простій крапці в імені файлу.

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

Перехоплення конкретних винятків

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

try:
    file = open('my_file.dat')
    data = file.readfile()
    print('Data Loaded')
except FileNotFoundError:
    print('This file doesn\\'t exist')

Якщо ви запустите скрипт, а my_file.dat не існує, тоді на екран виведеться інформація про те, що файл не існує, але програма продовжить працювати. Однак, якщо файл існує, ви побачите виняток з readfile. У випадку відсутнього файлу, його легко створити:

try:
    file = open('my_file.dat')
    data = file.readfile()
    print('Data Loaded')
except FileNotFoundError:
    file = open('my_file.dat', 'w')
    print('File created')
file.close()

При першому запуску скрипта, ви побачите, що файл створився. Проте, при наступних запусках ви вже бачитимите виняток через readfile. Уявіть, що ви не вказали, який виняток ви перехоплюєте, і у вас є наступний код. Що станеться, коли ви його запустите?

try:
    file = open('my_file.dat')
    data = file.readfile()
    print('Data Loaded')
except:
    file = open('my_file.dat', 'w')
    print('File created')

Якщо ви уважно подивитесь, то зрозумієте, що навіть якщо файл my_file.dat існує, спрацює виняток через метод readfile. Потім буде виконаний блок except. У цьому блоці програма створить новий my_file.dat, навіть якщо він вже існує, тому ви втратите інформацію, яка в ньому зберігалася.

Повторне підняття винятків

Дуже розповсюдженим сценарієм є те, що коли з'являється виняток, ви хочете щось зробити, але піднімаєте одну й ту саму помилку. Це дуже розповсюджений випадок при запису в базу даних або в різні файли. Уявіть — ви зберігаєте інформацію в двох файлах, у першому зберігаєте спектри, а в другому — температуру, при якій ви отримуєте кожне значення спектру. Спершу зберігаються спектри, а потім температура, так що кожний рядок в першому файлі відповідає рядку в другому файлу.

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

[data already saved]

try:
    temp = instrument.readtemp()
except:
    remove_last_line(data_file)
    raise
save_temperature(temp)

Тут ви можете побачити, як ми намагаємося зчитати температуру, і якщо виникне якась помилка, ми її перехопимо. Ми видаляємо останній рядок з нашого файлу й далі просто викликаємо raise. Ця команда буде повторно піднімати будь-що, що було перехоплено except. За допомогою цієї стратегії ми можемо бути певні, що у нас є відповідні дані, що програма не продовжить виконуватися, і що користувач побачить правильну інформацію відносно того, що пішло не так.

Винятки у винятках

Уявіть, що код є частиною більшої функції, відповідальної за відкриття файлу, завантаження його вмісту або створення нового файлу (у випадку того, якщо вказане ім'я файлу не існує). Скрипт буде виглядати так само як раніше, з різницею лиш в тому, що ім'я файлу було змінною:

try:
    file = open(filename)
    data = file.readfile()
except FileNotFoundError:
    file = open(filename, 'w')

Для запуску вищевказаного коду, все що вам знадобиться — вказати ім'я файлу, наприклад:

filename = 'my_data.dat'
try:
    [...]

Якщо ви запустите цей код, то помітите, що він веде точно так само, як очікувалось. Однак, якщо ви вкажете порожнє ім'я файлу:

filename = ''
try:
    [...]

То побачите, що на екран вивелась значно довша помилка з одним важливим рядком:

During handling of the above exception, another exception occurred:

Якщо ви уважно подивитесь на помилку, то помітите, що вона виводить інформацію відносно того, що помилка відбулася тоді, коли код вже обробляв іншу помилку. На жаль, це розповсюджена ситуація, особливо при роботі з користувацьким вводом. Способом обійти цю проблему було б вкладення іншого блоку try/except або перевірка цілісності входів перед викликом open.

Декілька винятків

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

file = open('my_data.dat', 'a')
file.readfile()

То отримаєте це повідомлення:

AttributeError: '_io.TextIOWrapper' object has no attribute 'readfile'

Перший рядок — тип винятку, AttributeError, тоді як друга частина — повідомлення. Один і той самий виняток може мати різні повідомлення, які краще описують те, що сталося. Ми хочемо перехопити AttributeError, а також FileNotFound. Тому наш код виглядатиме наступним чином:

filename = 'my_data.dat'

try:
    file = open(filename)
    data = file.readfile()
except FileNotFoundError:
    file = open(filename, 'w')
    print('Created file')
except AttributeError:
    print('Attribute Error')

Тепер ви маєте справу с кількома винятками. Пам'ятайте, що коли виняток піднімається всередині блоку try, решта коду не буде виконуватись, а Python пройде крізь різні блоки except. Тому за раз піднімається тільки один виняток. У випадку, коли файл не існує, код працюватиме тільки з FileNotFoundError.

Звичайно, ви можете додати кінцевий виняток для перехоплення всіх можливих помилок в програмі, на зразок цього:

filename = 'my_data.dat'

try:
    file = open(filename)
    data = file.read()
    important_data = data[0]
except FileNotFoundError:
    file = open(filename, 'w')
    print('Created file')
except AttributeError:
    print('Attribute Error')
except:
    print('Unhandled exception')

Що виведе наступне повідомлення:

Unhandled exception
string index out of range

Виняток також має type, який ви можете використати. Наприклад:

filename = 'my_data.dat'

try:
    file = open(filename)
    data = file.read()
    important_data = data[0]
except Exception as e:
    print('Unhandled exception')
    if isinstance(e, IndexError):
        print(e)
        data = 'Information'
        important_data = data[0]

print(important_data)

Що надрукує першу літеру Information, тобто I. Вищенаведений приклад має дуже важливий недолік — в кінцевому підсумку important_data може не визначитись. Наприклад, якщо файл my_data.dat не існує, ми отримаємо іншу помилку:

NameError: name 'important_data' is not defined

Твердження Finally

Щоб запобігти тому, що ми бачили в минулому розділі, можна додати ще один блок у послідовність: finally. Цей блок завжди буде виконуватись, незалежно від того, був піднятий виняток чи ні. Наприклад:

filename = 'my_data.dat'

try:
    file = open(filename)
    data = file.read()
    important_data = data[0]
except Exception as e:
    if isinstance(e, IndexError):
        print(e)
        data = 'Information'
        important_data = data[0]
    else:
        print('Unhandled exception')
finally:
    important_data = 'A'

print(important_data)

Це, врешті-решт, доволі безглуздий приклад, оскільки ми присвоюємо important_data особливе значення, але я сподіваюсь, що ви зрозуміли як використовується finally. Якщо є щось, у виконанні чого ви абсолютно впевнені, ви можете додати це у твердження finally.

finally дуже корисний, щоб впевнитись в тому, що ви припиняєте зв'язок, завершуєте спілкування з пристроєм, закриваєте файл тощо — в цілому, звільнення ресурсів. Finally має дуже цікаву поведінку, оскільки він не завжди виконується в ту ж мить. Розгляньмо наступний код:

filename = 'my_data.dat'

try:
    print('In the try block')
    file = open(filename)
    data = file.read()
    important_data = data[0]
except FileNotFoundError:
    print('File not found, creating one')
    file = open(filename, 'w')
finally:
    print('Finally, closing the file')
    file.close()
    important_data = 'A'

print(important_data)

Спершу запустіть код, коли файл my_data.dat не існує. Ви маєте побачити наступний вивід:

In the try block
File not found, creating one
Finally, closing the file

Отже, як ви бачите, ви пройшли від try до except, й нарешті до finally. Якщо ви знову запустите код, коли файл існуватиме, вивід буде зовсім іншим:

In the try block
Finally, closing the file
Traceback (most recent call last):
  File "JJ_exceptions.py", line 7, in <module>
    important_data = data[0]
IndexError: string index out of range

Тут ви можете бачити, що коли виникає необроблений виняток, першим виконується блок finally. Ви негайно закриваєте файл, а потім помилка повторно піднімається. Це дуже зручно, оскільки такий підхід запобігає будь-якому конфлікту з кодом нижче. Ви відкриваєте, закриваєте файл, а решта програми має взаємодіяти з проблемою IndexError. Якщо ви хочете подивитись на програму без винятків — просто напишіть щось у my_data.dat, і ви побачите вивід.

Блок else

У шаблоні обробки винятків залишився тільки один блок, який слід обговорити — блок else. Його основна ідея полягає в тому, що він виконується, якщо винятків немає в блоці try. Дуже легко зрозуміти, як це працює. Наприклад, ви могли б зробити наступне:

filename = 'my_data.dat'

try:
    file = open(filename)
except FileNotFoundError:
    print('File not found, creating one')
    file = open(filename, 'w')
else:
    data = file.read()
    important_data = data[0]

Найскладніше в блоці else — зрозуміти його корисність. В принципі, код, який ви включили в блок else, міг би також бути розміщений після відкриття файлу, як ми робили це раніше. Проте, ми можемо використати блок else для запобігання перехоплення винятків, що не належать до блоку try. Це трохи надумані приклади, але уявіть, що вам потрібно прочитати ім'я файлу з файлу й відкрити його. Код виглядав би якось так:

try:
    file = open(filename)
    new_filename = file.readline()
except FileNotFoundError:
    print('File not found, creating one')
    file = open(filename, 'w')
else:
    new_file = open(new_filename)
    data = new_file.read()

Через те, що ми відкрили два файли, цілком можливо, що проблемою є те, що другого файлу не існує. Якби ми помістили цей код в блок try, все закінчилось би викликом except для другого файлу, навіть якщо ми цього не хотіли. Це не очевидне використання блоку else, але це може бути дуже корисним, і тому важливо, щоб ви знали, що воно існує.

Звичайно, можна поєднати все, що ви вивчили дотепер:

try:
    file = open(filename)
    new_filename = file.readline()
except FileNotFoundError:
    print('File not found, creating one')
    file = open(filename, 'w')
else:
    new_file = open(new_filename)
    data = new_file.read()
finally:
    file.close()

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

Але це ще не все. Ще є багато речей, які можна зробити за допомогою винятків.

Tрасування (Traceback)

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

Traceback (most recent call last):
  File "P_traceback.py", line 13, in <module>
    file = open(filename)
FileNotFoundError: [Errno 2] No such file or directory: 'my_data.dat'

Інтерпретація повідомлення може потребувати трохи практики, але для простих прикладів повідомлення доволі зрозумілі. Спочатку воно говорить вам, що ви бачите трасування, простіше кажучи — історію того, що призвело до винятку. Ви можете чітко бачити, що спричинило проблему та рядок її виникнення. Якщо ви відкриєте цей файл та перейдете до цього рядку, то побачите, що це саме file = open(filename). Нарешті, ви бачите виняток.

Це останнє повідомлення — це те, яке ми виводили на екран, проте ми нехтували трасуванням, яке дозволило б нам знайти справжнє джерело винятку та діяти відповідно. На щастя, Python дозволяє легко отримати доступ до трасування. Трохи змінивши приклад відкриття файлу, ми отримали б:

import traceback

filename = 'my_data.dat'

try:
    file = open(filename)
    data = file.read()
except FileNotFoundError:
    traceback.print_exc()

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

Підняття кастомних винятків

Коли ви розробляєте ваші власні пакети, часто корисно визначати деякі загальні винятки. Це надає велику гнучкість, бо дозволяє іншим розробникам обробляти ті винятки, які вони знаходять доречними. Розгляньмо приклад. Уявіть, що ви хочете написати функцію, яка підраховує середнє значення між двома числами, проте, вам потрібно, щоб обидва числа були додатними. Ми почнемо з визначення функції:

def average(x, y):
    return (x + y)/2

Тепер ми підніматимемо Exception, якщо будь-яке з введених чисел від'ємне. Ми можемо зробити наступне:

def average(x, y):
    if x<=0 or y<=0:
        raise Exception('Both x and y should be positive')
    return (x + y)/2

Якщо ви спробуєте ввести від'ємне число, то побачите наступне:

Exception: Both x and y should be positive

Що чудово. Навіть вказується номер рядка з проблемою. Однак, якщо ви створюєте модуль й очікуєте на те, що інші будуть його використовувати, набагато краще визначити кастомний виняток, який може бути явно перехоплений. Це просто:

class NonPositiveError(Exception):
    pass

def average(x, y):
    if x <= 0 or y <= 0:
        raise NonPositiveError('Both x and y should be positive')
    return (x + y) / 2

Виняток — клас, і тому він повинен наслідувати із загального класу Exception. Насправді, на цій стадії нам не потрібно нічого кастомізувати, ми просто вводимо pass в тіло запиту. Якщо ми запустимо вищевказаний код з від'ємним значенням, то отримаємо:

NonPositiveError: Both x and y should be positive

Якщо ви хочете перехопити цей виняток в коді нижче, то можете робити це, як робили завжди. Єдина різниця в тому, що кастомні винятки не доступні за замовчуванням, і тому їх треба імпортувати. Наприклад, так:

from exceptions import NonPositiveError
from tools import average

try:
    avg = average(1, -2)
except NonPositiveError:
    avg = 0

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

Найкращі практики для кастомних винятків

Коли ви розробляєте пакет, є дуже зручним визначити винятки, ексклюзивні для нього. Це спрощує обробку різних поведінок й надає розробникам дуже ефективний спосіб фільтрації, незалежно від того, у вашому пакеті проблеми, чи в іншому. Уявіть, наприклад, що ви працюєте з комплексним пакетом й хочете записувати у файл кожного разу, коли з'являється виняток з цього конкретного пакета.

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

class MyException(BaseException):
    pass

class NonPositiveIntegerError(MyException):
    pass

class TooBigIntegerError(MyException):
    pass

def average(x, y):
    if x<=0 or y<=0:
        raise NonPositiveIntegerError('Either x or y is not positive')

    if x>10 or y>10:
        raise TooBigIntegerError('Either x or y is too large')
    return (x+y)/2

try:
    average(1, -1)
except MyException as e:
    print(e)

try:
    average(11, 1)
except MyException as e:
    print(e)

try:
    average('a', 'b')
except MyException as e:
    print(e)

print('Done')

Спершу ми визначимо виняток MyException, який буде нашим базовим винятком. Потім ми визначимо дві помилки NonPositiveIntegerError та TooBigIntegerError, які наслідуватимуть від MyException. Ми знову визначимо функцію average, але цього разу підніматимемо два різних винятки, якщо одне з чисел від'ємне або більше за 10.

Коли ви побачите різноманітні варіанти використання нижче, то помітите, що в блоці try/except ми завжди перехоплюємо MyException, а не жодну з конкретних помилок. У перших двох прикладах при передачі як аргументів -1 та 11 ми успішно вивели на екран повідомлення про помилку й програма продовжила працювати. Проте, коли ми намагаємось обчислити середнє між двома буквами, Exception матиме інакший характер й не буде перехоплене Except. На екрані ви маєте побачити наступне:

TypeError: '<=' not supported between instances of 'str' and 'int'

Додавання аргументів у винятки

Іноді буває зручно додавати аргументи у винятки, щоб дати користувачам кращий контекст. На прикладі середнього значення, визначмо складніший виняток:

class MyException(BaseException):
    pass

class NonPositiveIntegerError(MyException):
    def __init__(self, x, y):
        super(NonPositiveIntegerError, self).__init__()
        if x<=0 and y<=0:
            self.msg = 'Both x and y are negative: x={}, y={}'.format(x, y)
        elif x<=0:
            self.msg = 'Only x is negative: x={}'.format(x)
        elif y<=0:
            self.msg = 'Only y is negative: y={}'.format(y)

    def __str__(self):
        return self.msg


def average(x, y):
    if x<=0 or y<=0:
        raise NonPositiveIntegerError(x, y)
    return (x+y)/2

try:
    average(1, -1)
except MyException as e:
    print(e)

Ви бачите, що виняток приймає два аргументи — x та y — й генерує на їх основі повідомлення. Вони обидва можуть бути від'ємними, або тільки один з них. Повідомлення надає вам не тільки цю інформацію, але й фактично відображає значення, яке створювало проблеми. Це дуже зручно для розуміння, що насправді пішло не так. Найважливіша частина в кінці класу: метод __str__. Цей метод відповідає за те, що з'являється на екрані, коли ви робите print(e) в блоці except. У цьому випадку ми лише повертаємо повідомлення, згенероване в __init__, проте багато розробників генерують повідомлення в цьому методі на основі параметрів, що були передані на початку.

Висновок

Винятки — те, що ніхто не хоче бачити, але вони практично неминучі. Можливо, ви намагаєтесь прочитати файл, який не існує; користувач вашого коду обрав значення, що не існують; матриця, яку ви аналізуєте, має розміри відмінні від тих, що очікувались. Обробка винятків — чутлива тема, оскільки вона може привести до навіть більших проблем у коді. Виняток — чітке повідомлення про те, що щось не так, і якщо ви не виправите це належним чином, то стане ще гірше.

Обробка винятків може допомогти вам уникнути суперечливих даних, не закриваючи такі ресурси, як пристрої, зв'язки або файли тощо. Однак, неправильна обробка винятків може призвести пізніше до навіть більших проблем. Блок try/except дуже зручний, коли ви знаєте, які типи винятків можуть з'явитися та знаєте, як їх обробляти. Уявіть, що ви виконуєте кілька кроків складної операції, як наприклад запис у базу даних. Якщо виникне помилка, то ви можете відновити всі зміни й уникнути невідповідностей.

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

Помітили помилку? Повідомте автору, для цього достатньо виділити текст з помилкою та натиснути Ctrl+Enter
Codeguida 5.8K
Приєднався: 8 місяців тому
Коментарі (0)

    Ще немає коментарів

Щоб залишити коментар необхідно авторизуватися.

Вхід / Реєстрація