Базовый CS с нуля
Ошибки и исключения
До сих пор каждая программа, которую ты трассировал, доходила до конца чисто. Счётчик команд продвигался, стек вызовов клал и снимал кадры, и программа выдавала результат. Но реальные программы попадают в условия, которые они не были построены пройти: файл, которого не существует, число, делённое на ноль, значение неправильного типа.
Когда это происходит, одновременно идут две разные вещи, и новички их путают. Одна — это сама плохая ситуация — отсутствующий файл. Другая — что машина с этим делает — остановиться и пойти искать код, который умеет восстановиться. Этот урок строго разделяет эти две вещи: ошибку (условие) и исключение (механизм, отвечающий на неё).
После этого урока ты сможешь определить ошибку как условие, которое программа не может обработать в нормальном потоке, определить исключение как механизм рантайма, отвечающий на возбуждённую ошибку, объяснить, что возбуждение исключения останавливает нормальный поток управления и разматывает стек вызовов, и объяснить, что такое обработчик и что происходит, когда обработчик не найден.
Ошибка — это условие, а не событие в машине. Ошибка — это ситуация, в которую
попадает программа и которую она не может пройти своей нормальной логикой. Примеры:
открытие файла, которого не существует, деление числа на ноль, чтение свойства из
значения, у которого нет свойств, преобразование текста "hello" в число.
Ключевое слово — условие. Ошибка — это факт о состоянии программы в конкретный момент: запрошенный файл отсутствует, делитель равен нулю. Это ещё не действие. С счётчиком команд или стеком вызовов пока ничего не произошло. Ошибка просто истинна: программа теперь находится там, где у её нормального потока нет определённого следующего шага.
Исключение — это механизм, отвечающий на возбуждённую ошибку. Когда программа
обнаруживает условие ошибки, она не просто молча останавливается. Она возбуждает
исключение (в JavaScript и TypeScript ключевое слово — throw; «возбудить» и
«выбросить» означают одно и то же действие). Исключение — это механизм рантайма
(вспомни рантайм из Блока 04 — вспомогательный код, управляющий программой во время её
выполнения), который берёт управление на себя в момент возбуждения ошибки.
Итак, ошибка — это условие («файл отсутствует»). Исключение — это ответ («остановить нормальный поток и начать искать код, который может это обработать»). Одно — факт, другое — процесс. Программа может обнаружить ошибку и решить возбудить о ней исключение — или обнаружить ту же ошибку и обработать её прямо на месте вообще без исключения. Ошибка не вынуждает механизм; механизм — это один из возможных ответов на неё.
Возбуждение исключения останавливает нормальный поток управления. Вспомни из Блока 07, что нормальный поток управления — это упорядоченная последовательность инструкций, которые исполняет CPU: прямо вниз, с ветвлениями и циклами, направляющими счётчик команд. Исключение прерывает это.
В тот миг, когда возбуждается исключение, рантайм останавливает нормальный поток управления. Следующая строка текущей функции не выполняется. Счётчик команд больше не продвигается по программе так, как описывал Блок 07. Управление полностью передано механизму исключений, у которого теперь одна задача: найти код, который знает, что делать с этой ошибкой. Пока он не найдёт этот код — или не исчерпает места для поиска — ни одна обычная инструкция программы вообще не выполняется.
Рантайм разматывает стек вызовов в поиске обработчика. Вспомни стек вызовов из Блока 08: стопка кадров, по одному на каждый активный вызов функции, самый недавний сверху. Когда возбуждается исключение, рантайм идёт вниз по этой стопке, кадр за кадром, от кадра, возбудившего исключение, к точке входа программы. Этот проход называется разматыванием стека.
На каждом кадре рантайм задаёт один вопрос: есть ли у этой функции обработчик — блок
кода, написанный, чтобы перехватить это исключение и восстановиться (в JavaScript — блок
catch)? Если да, разматывание там останавливается: обработчик выполняется, нормальный
поток управления возобновляется внутри обработчика, и программа продолжается. Если нет,
рантайм снимает этот кадр (ровно снятие из Блока 08 — функция оставлена, её локальные
переменные отброшены) и переходит к кадру ниже. Поиск продолжается вниз, пока не найдётся
обработчик.
Если обработчика нет нигде, программа аварийно завершается. Допустим, рантайм размотал каждый кадр — он снял функцию, возбудившую исключение, затем её вызывающую, затем вызывающую её — вплоть до самого нижнего кадра, точки входа программы, и всё ещё не нашёл обработчик. Искать больше негде.
В этот момент программа аварийно завершается: рантайм завершает программу и печатает описание необработанного исключения, включая список кадров, через которые он размотался. Этот напечатанный список кадров — трассировка стека (stack trace), тема следующего урока. Аварийное завершение — это не поломка машины; это механизм исключений, дошедший до низа стека вызовов без обработчика и сдавшийся управляемым, отчётным образом.
Трассировка одной ошибки от условия до аварийного завершения.
Программа читает файл конфигурации и парсит из него число. Цепочка вызовов —
main → loadConfig → parseNumber.
1. Возникает условие ошибки. Внутри parseNumber текст из файла — "hello", и код
пытается преобразовать его в число. "hello" — не число; это условие ошибки. Это
просто факт: у преобразования нет определённого результата.
2. Возбуждается исключение. parseNumber отвечает на это условие, возбуждая
исключение — throw new Error("not a number"). Нормальный поток управления внутри
parseNumber немедленно останавливается; остальная часть parseNumber не выполняется.
3. Рантайм разматывает. Рантайм проверяет кадр parseNumber на обработчик — его нет.
Он снимает кадр parseNumber и переходит к кадру loadConfig. Он проверяет loadConfig
на обработчик — его нет. Он снимает кадр loadConfig и переходит к кадру main.
4а. С обработчиком. Допустим, у main есть блок catch вокруг вызова loadConfig.
Разматывание останавливается на кадре main. Обработчик выполняется — например, печатает
«используются значения по умолчанию» и продолжает. Нормальный поток управления
возобновляется внутри main. Аварийного завершения нет.
4б. Без обработчика. Допустим, у main нет блока catch. Рантайм снимает и кадр
main. Стек теперь пуст — искать больше негде. Программа аварийно завершается, печатая
необработанное исключение и кадры, через которые он размотался.
Условие ошибки было одинаковым в обоих случаях. Различалось то, нашёл ли механизм исключений обработчик, прежде чем дойти до низа стека.
Почему это работает
Зачем отделять условие от механизма? Потому что они меняются независимо. Одно и то же
условие ошибки — отсутствующий файл — может быть возбуждено как исключение в одной
программе и тихо обработано проверкой if в другой. И один и тот же механизм —
разматывание стека для поиска обработчика — отвечает на тысячи разных условий ошибок
ровно одинаково. Разделение этих двух идей позволяет рассуждать о каждой чисто: «что
пошло не так» — вопрос об условии; «что программа сделала дальше» — вопрос о механизме.
Частая ошибка
Распространённая ошибка — думать, что исключение и есть ошибка, так что «произошло исключение» означает «что-то сломано». Это не так. Исключение — лишь механизм потока управления: дисциплинированный способ остановиться и поискать обработчик. Хорошо написанная программа может возбудить исключение, перехватить его в обработчике, восстановиться и продолжить совершенно нормально. Исключение становится проблемой, только если оно доходит до низа стека, и его негде перехватить.
Ошибка — это условие; исключение — это механизм, отвечающий на неё. Деление на ноль — это что из двух? Введи 1 за условие ошибки, 2 за механизм.
Исключение возбуждается внутри parseNumber. У parseNumber нет обработчика. У её вызывающей loadConfig нет обработчика. У main есть обработчик. Сколько кадров снимает рантайм, прежде чем разматывание остановится?
main вызывает a, которая вызывает b. b возбуждает исключение. Ни у одной функции нигде нет обработчика. Сколько кадров на стеке вызовов после того, как рантайм закончит разматывание?
Когда возбуждается исключение, нормальный поток управления останавливается. Выполняется ли следующая строка функции, возбудившей исключение? Введи 1 за да, 0 за нет.
Рантайм разматывает стек вызовов и доходит до нижнего кадра (точки входа программы), не найдя обработчик. Что происходит с программой? Введи 1 — она аварийно завершается, 0 — продолжается нормально.
В чём разница между ошибкой и исключением?
Ошибка — это условие: ситуация, в которую попадает программа и которую она не может
пройти своей нормальной логикой — отсутствующий файл, деление на ноль, плохое
преобразование типа. Исключение — это механизм: когда ошибка возбуждается
(ключевое слово throw в JavaScript), рантайм останавливает нормальный поток
управления — следующая строка не выполняется — и разматывает стек вызовов, идя кадр
за кадром от кадра, возбудившего исключение, вниз к точке входа программы. На каждом
кадре он ищет обработчик (блок catch). Если он его находит, обработчик выполняется
и нормальный поток управления возобновляется; если он размотает весь стек, не найдя
обработчик, программа аварийно завершается и печатает необработанное исключение. Условие
— это факт; исключение — управляемый ответ; держи эти две идеи раздельно.