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

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

Проблем

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

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

withdraw(amount) {
if (amount > 0 && amount <= this.balance) {
this.balance -= amount;
}
}

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

const account = new Account(100);
account.withdraw(500);
console.log("Вземи парите си.");
console.log(`Баланс: ${account.balance} €`);
Изход
Вземи парите си.
Баланс: 100 €

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

Идеята

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

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

withdraw(amount) {
if (amount <= 0) {
throw new RangeError("Сумата трябва да е положителна");
}
if (amount > this.balance) {
throw new RangeError("Не стигат парите");
}
this.balance -= amount;
}

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

const account = new Account(100);
try {
account.withdraw(500);
console.log("Вземи парите си.");
} catch (e) {
console.log(`Отказано: ${e.message}`);
}
console.log(`Баланс: ${account.balance} €`);
Изход
Отказано: Не стигат парите
Баланс: 100 €
  • new създава обекта на изключението, както всеки обект. Съобщението отива в конструктора му, а e.message го връща.
  • RangeError е готовото изключение за „стойността е извън позволеното“. Тук сумата е такава: под нула или над баланса.
  • catch (e) в JavaScript няма вид в скобите и хваща всяко изключение. Тук изключението е само едно, затова е достатъчно.
По-точно, за любопитните
  • Друго често готово изключение е TypeError: стойността е от грешен тип. За всичко друго се ползва Error или свое изключение.
  • throw в JavaScript приема всяка стойност, дори текст: throw "Не стигат парите" работи. Но тогава в catch няма e.message и e.stack (и двете са undefined), а stack trace липсва. Затова хвърляй само Error или негов наследник.

Виж го

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

withdraw(amount) {
if (amount > this.balance) {
throw new RangeError("Не стигат парите");
}
this.balance -= amount;
}
function payRent(account) {
account.withdraw(800);
console.log("Наемът е платен.");
}
try {
payRent(new Account(500));
} catch (e) {
console.log(`Отказано: ${e.message}`);
}
Стъпка по стъпка, с думи
  1. Програмата вика payRent за сметка с 500 €.
  2. payRent вика withdraw(800): нова рамка отгоре.
  3. 800 > 500: методът хвърля изключение.
  4. Балансът не се променя: рамката се изхвърля, без да довърши редовете си.
  5. payRent няма catch: „Наемът е платен.“ не се печата, рамката се изхвърля.
  6. catch в програмата хваща изключението и тя продължава оттук.

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

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

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

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

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

class Account {
constructor(balance) {
this.balance = balance;
}
withdraw(amount) {
if (amount > this.balance) {
throw new RangeError("Не стигат парите");
}
this.balance -= amount;
}
}
function payRent(account) {
account.withdraw(800);
console.log("Наемът е платен.");
}
payRent(new Account(500));
Грешка
/projects/rent/rent.js:8
throw new RangeError("Не стигат парите");
^
RangeError: Не стигат парите
at Account.withdraw (/projects/rent/rent.js:8:13)
at payRent (/projects/rent/rent.js:15:11)
at Object.<anonymous> (/projects/rent/rent.js:19:1)
at Module._compile (node:internal/modules/cjs/loader:1705:14)
at Object..js (node:internal/modules/cjs/loader:1838:10)
at Module.load (node:internal/modules/cjs/loader:1441:32)
at Function._load (node:internal/modules/cjs/loader:1263:12)
at TracingChannel.traceSync (node:diagnostics_channel:328:14)
at wrapModuleLoad (node:internal/modules/cjs/loader:237:24)
at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:171:5)
Node.js v22.22.2

Node показва реда, на който е хвърлено, после вида и съобщението и рамките отвътре навън, както Java: Account.withdraw, payRent, а Object.<anonymous> е програмата. Редовете с node:internal са кодът на самия Node.

Пробвай

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

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

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

function checkout(account) {
console.log("Плащам поръчката");
account.withdraw(120);
console.log("Поръчката е платена");
}
const account = new Account(50);
try {
checkout(account);
console.log("Пращам потвърждение");
} catch (e) {
console.log(`Отказано: ${e.message}`);
}
console.log(`Баланс: ${account.balance} €`);
Отговор

Пробвай

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

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

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

По-точно, за любопитните
  • Понякога функцията хваща, прави нещо, например записва в дневник, и хвърля същото изключение нататък. В JavaScript това е throw e; в catch. Stack trace-ът остава оригиналният: той се записва, когато Error се създаде, а не когато се хвърли.
function payRent(account) {
try {
account.withdraw(800);
} catch (e) {
console.log("Дневник: наемът не мина");
throw e;
}
}
try {
payRent(new Account(500));
} catch (e) {
console.log(`Отказано: ${e.message}`);
}
Изход
Дневник: наемът не мина
Отказано: Не стигат парите
  • Може и да хвърлиш друго изключение, което по-добре казва какво е станало, а старото да остане като причина. В JavaScript: new Error("Наемът не е платен", { cause: e }). Старото е в e.cause, а Node го печата под новото като [cause].
function payRent(account) {
try {
account.withdraw(800);
} catch (e) {
throw new Error("Наемът не е платен", { cause: e });
}
}
try {
payRent(new Account(500));
} catch (e) {
console.log(`${e.message}, защото: ${e.cause.message}`);
}
Изход
Наемът не е платен, защото: Не стигат парите

Идеята

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

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

class InsufficientFundsError extends Error {
constructor(shortfall) {
super(`Не стигат ${shortfall} €`);
this.name = "InsufficientFundsError";
this.shortfall = shortfall;
}
}

Своето изключение е обикновен клас, който наследява Error. Конструкторът подава съобщението на родителя с super(...). this.name дава на изключението неговото име: без него e.name и stack trace-ът казват само Error. Методът хвърля него, когато парите не стигат:

withdraw(amount) {
if (amount <= 0) {
throw new RangeError("Сумата трябва да е положителна");
}
if (amount > this.balance) {
throw new InsufficientFundsError(amount - this.balance);
}
this.balance -= amount;
}

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

const account = new Account(100);
try {
account.withdraw(130);
} catch (e) {
if (e instanceof InsufficientFundsError) {
console.log(e.message);
console.log(`Внеси още ${e.shortfall} € или избери по-малка сума.`);
} else if (e instanceof RangeError) {
console.log(`Отказано: ${e.message}`);
} else {
throw e;
}
}
Изход
Не стигат 30 €
Внеси още 30 € или избери по-малка сума.

catch в JavaScript е един и хваща всичко. Видът се проверява вътре с instanceof, а изключение, което не очакваш, хвърляш нататък с throw e;. Иначе празният else би го глътнал, както в задачата преди малко.

По-точно, за любопитните
  • Имената на изключенията винаги завършват на Error.
  • Своето изключение може да наследи и по-конкретен готов клас, например RangeError. Тогава и e instanceof RangeError е вярно за него. Тук то е отделно, затова редът на двете проверки няма значение.

Идеята

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

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

/**
* Тегли сумата от сметката.
* @throws {RangeError} ако сумата не е положителна
* @throws {InsufficientFundsError} ако парите не стигат
*/
withdraw(amount) {
if (amount <= 0) {
throw new RangeError("Сумата трябва да е положителна");
}
if (amount > this.balance) {
throw new InsufficientFundsError(amount - this.balance);
}
this.balance -= amount;
}

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

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

Пробвай

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

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

class InvalidAgeError extends Error {
  constructor(message) {
    (message);
    this.name = "InvalidAgeError";
  }
}

Упражнение

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

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

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

  • Клас MusicalInstrument с име, марка и цена и конструктор.
  • Guitar и Piano наследяват инструмента и имат метод play(). Drum само наследява. JavaScript няма интерфейси: „може да свири“ значи „има метод play“.
  • class NotPlayableError extends Error.
  • MusicShop с метод demoInstrument(instrument): ако инструментът може да свири (typeof instrument.play === "function"), пуска го, иначе хвърля 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(amount), който хвърля RangeError при сума 0 или по-малко. Тегленето хвърля InsufficientFundsError от урока. Сметката започва със 100 €. Прочети брой операции и после самите операции, всяка на свой ред: „внеси 50“ или „изтегли 500“. След всяка отпечатай баланса или защо операцията е отказана.

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

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

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

Още задачи

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

Тест

Провери се

1. Какво прави throw new RangeError("...")?
2. Програмата вика a, a вика b, b хвърля изключение. Само кодът на най-горно ниво има catch. Какво става с редовете в a след извикването на b?
3. Къде е най-добре да хванеш изключението?
4. Защо конструкторът на InsufficientFundsError пише this.name = "InsufficientFundsError"?

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