Хвърляне и свои изключения
Проблем
Банкоматът казва „вземи парите“
Сметката пази баланса си. Ако сумата за теглене е отрицателна или не стига, методът просто не прави нищо:
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 -= amountraise прекъсва метода като 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}")Стъпка по стъпка, с думи
- Програмата вика pay_rent за сметка с 500 €.
- pay_rent вика withdraw(800): нова рамка отгоре.
- 800 > 500: методът хвърля изключение с raise.
- Балансът не се променя: рамката се изхвърля, без да довърши редовете си.
- pay_rent няма except: „Наемът е платен.“ не се печата, рамката се изхвърля.
- 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} €")„Плащам поръчката“ е преди тегленето. После withdraw хвърля и рамката на checkout се изхвърля: „Поръчката е платена“ не излиза. В програмата „Пращам потвърждение“ също се пропуска, защото е в try след извикването. except печата отказа, а балансът е непокътнат.
Пробвай
Намери грешката
Наемът е 800 €, а в сметката има 500 €. Програмата казва „Наемът е платен.“ На кой ред е грешката?
Избери реда с грешката.
except хваща изключението, а pass значи „нищо“: празен блок в Python не може, затова се пише pass. Грешката изчезва, също като в банкомата от първата карта. Хваща се там, където може да се направи нещо смислено. Тук pay_rent не може да намери пари, затова не трябва да хваща. Махни try/except: изключението ще стигне до програмата, а тя ще каже на човека.По-точно, за любопитните
- Понякога функцията хваща, прави нещо, например записва в дневник, и хвърля същото изключение нататък. В 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)super().__init__(message) вика конструктора на родителя. Той пази съобщението, а str(e) го връща. Изключение само със съобщение може и без __init__: class InvalidAgeError(Exception): pass наследява конструктора на Exception. __init__ трябва, когато изключението носи и свои данни, като shortfall.
Упражнение
Изключения в класовете
Реши задачите в редактора си.
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.
Тест