Към съдържанието
wannacode

Хвърляне и свои изключения

Проблем

Банкоматът казва „вземи парите“

Сметката пази баланса си. Ако сумата за теглене е отрицателна или не стига, методът просто не прави нищо:

def withdraw(self, amount):
if amount > 0 and amount <= self.balance:
self.balance -= amount

Банкоматът го ползва така:

account = Account(100)
account.withdraw(500)
print("Вземи парите си.")
print(f"Баланс: {account.balance} €")
Изход
Вземи парите си.
Баланс: 100 €

Клиентът иска 500 €, а има 100 €. Методът тихо отказва, а банкоматът не разбира и казва „Вземи парите си.“ Грешката се губи. Методът може да върне True или False, но викащият лесно забравя да провери, а и False не казва защо.

Идеята

raise: отказвам и казвам защо

Вместо да мълчи, методът хвърля изключение:

def withdraw(self, amount):
if amount <= 0:
raise ValueError("Сумата трябва да е положителна")
if amount > self.balance:
raise ValueError("Не стигат парите")
self.balance -= amount

raise прекъсва метода като return. Редът self.balance -= amount не се стига. Но вместо стойност методът изпраща изключение и викащият не може да не го забележи:

account = Account(100)
try:
account.withdraw(500)
print("Вземи парите си.")
except ValueError as e:
print(f"Отказано: {e}")
print(f"Баланс: {account.balance} €")
Изход
Отказано: Не стигат парите
Баланс: 100 €
  • ValueError("Не стигат парите") създава обекта на изключението, както всеки обект в Python. Съобщението отива в конструктора му, а print(e) и f-низът f"{e}" го връщат.
  • ValueError е готовото изключение за „стойността на аргумента е грешна“. Ползва се точно в такива проверки.
По-точно, за любопитните
  • Друго често готово изключение е TypeError: стойността е от грешен тип, например текст вместо число. За действие, за което обектът не е в състояние (теглене от закрита сметка), Python няма отделно готово изключение. Тогава се прави свое, както ще видиш след малко.
  • raise работи само с обект от наследник на BaseException. Текст не може да се хвърли: raise "Не стигат парите" спира с TypeError: exceptions must derive from BaseException.

Виж го

Изключението пропада

def withdraw(self, amount):
if amount > self.balance:
raise ValueError("Не стигат парите")
self.balance -= amount
def pay_rent(account):
account.withdraw(800)
print("Наемът е платен.")
try:
pay_rent(Account(500))
except ValueError as e:
print(f"Отказано: {e}")
Стъпка по стъпка, с думи
  1. Програмата вика pay_rent за сметка с 500 €.
  2. pay_rent вика withdraw(800): нова рамка отгоре.
  3. 800 > 500: методът хвърля изключение с raise.
  4. Балансът не се променя: рамката се изхвърля, без да довърши редовете си.
  5. pay_rent няма except: „Наемът е платен.“ не се печата, рамката се изхвърля.
  6. except в програмата хваща изключението и тя продължава оттук.

Спомни си стека от извиквания от урока за рекурсията. Най-долу е програмата: кодът на най-горно ниво. Тя вика pay_rent, а тя вика withdraw. Рамките са една над друга.

Когато withdraw хвърли, изключението търси except, като слиза надолу по стека. Рамка без подходящ except се изхвърля веднага, без да довърши редовете си. Така е и с pay_rent. Чак в програмата има except и тя продължава оттам.

Ако и там нямаше except, програмата щеше да спре и да напечата stack trace: точно тези рамки.

По-точно, за любопитните

Ето същата програма, но никой не хваща (файлът rent.py):

class Account:
def __init__(self, balance):
self.balance = balance
def withdraw(self, amount):
if amount > self.balance:
raise ValueError("Не стигат парите")
self.balance -= amount
def pay_rent(account):
account.withdraw(800)
print("Наемът е платен.")
pay_rent(Account(500))
Грешка
Traceback (most recent call last):
File "/projects/rent/rent.py", line 16, in <module>
pay_rent(Account(500))
File "/projects/rent/rent.py", line 12, in pay_rent
account.withdraw(800)
File "/projects/rent/rent.py", line 7, in withdraw
raise ValueError("Не стигат парите")
ValueError: Не стигат парите

В Python stack trace-ът се казва traceback и рамките са отвън навътре: най-горе е програмата (<module>), най-долу е withdraw, а последният ред е видът и съобщението. „most recent call last“ значи „последното извикване е най-отдолу“. Затова в Python започни да четеш отдолу.

Пробвай

Какво стига до екрана?

Сметката има 50 €, а поръчката е 120 €.

Какво ще отпечата програмата?

def checkout(account):
print("Плащам поръчката")
account.withdraw(120)
print("Поръчката е платена")
account = Account(50)
try:
checkout(account)
print("Пращам потвърждение")
except ValueError as e:
print(f"Отказано: {e}")
print(f"Баланс: {account.balance} €")
Отговор

Пробвай

Намери грешката

Наемът е 800 €, а в сметката има 500 €. Програмата казва „Наемът е платен.“ На кой ред е грешката?

Избери реда с грешката.

По-точно, за любопитните
  • Понякога функцията хваща, прави нещо, например записва в дневник, и хвърля същото изключение нататък. В Python това е raise без нищо след него, в except. Traceback-ът остава оригиналният.
def pay_rent(account):
try:
account.withdraw(800)
except ValueError:
print("Дневник: наемът не мина")
raise
try:
pay_rent(Account(500))
except ValueError as e:
print(f"Отказано: {e}")
Изход
Дневник: наемът не мина
Отказано: Не стигат парите
  • Може и да хвърлиш друго изключение, което по-добре казва какво е станало, а старото да остане като причина. В Python: raise RuntimeError("Наемът не е платен") from e. Старото е в e.__cause__, а traceback-ът показва и двете с ред „The above exception was the direct cause of the following exception“.
def pay_rent(account):
try:
account.withdraw(800)
except ValueError as e:
raise RuntimeError("Наемът не е платен") from e
try:
pay_rent(Account(500))
except RuntimeError as e:
print(f"{e}, защото: {e.__cause__}")
Изход
Наемът не е платен, защото: Не стигат парите

Идеята

Свое изключение

„Не стигат парите“ е текст. Ако банкоматът иска да предложи „Внеси още 30 €“, трябва да знае колко не стигат. Да вади числото от текста е крехко. По-добре направи свой клас за изключение с поле:

class InsufficientFundsError(Exception):
def __init__(self, shortfall):
super().__init__(f"Не стигат {shortfall} €")
self.shortfall = shortfall

Своето изключение е обикновен клас, който наследява Exception. Конструкторът подава съобщението на родителя с super().__init__(...). Методът хвърля него, когато парите не стигат:

def withdraw(self, amount):
if amount <= 0:
raise ValueError("Сумата трябва да е положителна")
if amount > self.balance:
raise InsufficientFundsError(amount - self.balance)
self.balance -= amount

А викащият хваща двата случая поотделно:

account = Account(100)
try:
account.withdraw(130)
except InsufficientFundsError as e:
print(e)
print(f"Внеси още {e.shortfall} € или избери по-малка сума.")
except ValueError as e:
print(f"Отказано: {e}")
Изход
Не стигат 30 €
Внеси още 30 € или избери по-малка сума.
По-точно, за любопитните
  • Имената на изключенията винаги завършват на Error. Така е и при готовите: ValueError, TypeError. Ръководството PEP 8 го иска за всяко изключение, което е грешка.
  • Своето изключение може да наследи и по-конкретен готов клас, например ValueError. Тогава except ValueError хваща и него. Тук то е отделно, затова редът на двата except няма значение.

Идеята

Свое или готово?

Проверени изключения като в Java тук няма. Никой не проверява преди пускането дали викащият хваща. Какво хвърля една функция, се пише в документацията ѝ: docstring с раздел „Raises:“.

def withdraw(self, amount):
"""Тегли сумата от сметката.
Raises:
ValueError: ако сумата не е положителна.
InsufficientFundsError: ако парите не стигат.
"""
if amount <= 0:
raise ValueError("Сумата трябва да е положителна")
if amount > self.balance:
raise InsufficientFundsError(amount - self.balance)
self.balance -= amount

Това е само навик за хората и за редактора. Програма, която вика withdraw без try, тръгва и работи, докато изключението не бъде хвърлено наистина. Въпросът е друг: нужно ли е свое изключение изобщо?

Готово изключение, когато смисълът съвпада: ValueError за грешна стойност, TypeError за грешен тип. Свое, когато викащият трябва да го различи от другите и да реагира по свой начин, когато изключението трябва да носи данни, като shortfall, или когато готово с този смисъл няма, като за теглене от закрита сметка.

Пробвай

Попълни празнината

Изключение за невалидна възраст. Какво трябва да пише на празното място, за да стигне съобщението до родителя?

class InvalidAgeError(Exception):
    def __init__(self, message):
        ().__init__(message)

Упражнение

Изключения в класовете

Реши задачите в редактора си.

1. Музикален магазин. Магазинът пуска инструменти на демо, но не всеки може да се пусне.

  • Клас MusicalInstrument с име, марка и цена и __init__.
  • Абстрактен клас Playable(ABC) с абстрактен метод play() (@abstractmethod). Това е Python-ският начин за интерфейс.
  • Guitar и Piano наследяват и MusicalInstrument, и Playable, и могат да свирят. Drum наследява само инструмента.
  • class NotPlayableError(Exception).
  • MusicShop с метод demo_instrument(self, instrument): ако инструментът може да свири (isinstance(instrument, Playable)), пуска го, иначе хвърля NotPlayableError.
  • В програмата пусни на демо китара Fender Stratocaster (1299 €), барабани Pearl Export (849 €) и пиано Yamaha P-45 (499 €) от масив. Хвани изключението за всеки инструмент поотделно, за да продължи демото.
Изход
Fender Stratocaster, 1299.00 €
Китарата свири акорд.
Pearl Export, 849.00 €
Pearl Export не може да се пусне на демо.
Yamaha P-45, 499.00 €
Пианото свири гама.

2. Сметка с правила. Добави към Account метод deposit(self, amount), който хвърля ValueError при сума 0 или по-малко. Тегленето хвърля InsufficientFundsError от урока. Сметката започва със 100 €. Прочети брой операции и после самите операции, всяка на свой ред: „внеси 50“ или „изтегли 500“. След всяка отпечатай баланса или защо операцията е отказана.

Вход
4
внеси 50
изтегли 500
изтегли -5
изтегли 120
Изход
Баланс: 150 €
Не стигат 350 €. Баланс: 150 €
Сумата трябва да е положителна. Баланс: 150 €
Баланс: 30 €

3. Невалидна възраст. Person има име и възраст. Свойството age (@property с setter) хвърля InvalidAgeError от картата преди тази, ако възрастта е под 0 или над 150. Конструкторът също минава през self.age = age, за да не може да се създаде човек с невалидна възраст. Създай Мария (19), Петър (−3) и Иван (200).

Изход
Мария, 19 г.
Петър: Невалидна възраст: -3
Иван: Невалидна възраст: 200

Още задачи

  • Резервация на маса. Клас Restaurant с брой свободни маси и метод за резервация. Ако масите са свършили, хвърли свое NoTablesError. Ако броят на гостите е 0 или по-малко, хвърли готовото изключение за грешен аргумент.
  • Продукт със склад. Клас Product с наличност и метод sell(self, qty). Ако наличността не стига, хвърли OutOfStockError, което носи колко бройки има. Магазинът хваща изключението и предлага да продаде наличните.
  • Музикален магазин с причина. Добави към NotPlayableError поле с инструмента, който не може да свири, и отпечатай цената му в except.

Тест

Провери се

1. Какво прави raise ValueError("...")?
2. Програмата вика a, a вика b, b хвърля изключение. Само кодът на най-горно ниво има except. Какво става с редовете в a след извикването на b?
3. Къде е най-добре да хванеш изключението?
4. От кой клас наследява свое изключение в Python?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·