Примечание
ЭТО АРХИВНАЯ ВЕРСИЯ КУРСА!
Материалы предназначаются для пересдающих дисциплину "ОП.04 - Основы алгоритмизации и программирования" в соответствии с учебными планами СПО годов набора ДО 2023-го.
Материалы были перенесены со старого сайта с минимальной доработкой, поэтому не все возможности курса могут работать как ожидается, где-то может слететь форматирование.
Домашние задания в рамках курса проверяться не будут!
ОП.04 - 14 - Порядок выполнения и прерывания. Виды ошибок в программировании и способы отладки
Код примера для практической работы
Порядок выполнения и прерывания
Порядок выполнения — это способ упорядочения инструкций программы в процессе её выполнения.
Инструкции, входящие в программу, могут исполняться как последовательно, одна за другой, так и одновременно (параллельно); как однократно, так и многократно; последовательность исполнения инструкций может совпадать с последовательностью их расположения в записи программы или не совпадать, а также зависеть как от текущего состояния вычислений, так и от внешних событий, образовывая, таким образом, разнообразные порядки выполнения инструкций.
Организация желаемого порядка выполнения может быть осуществлена с помощью различных механизмов, таких как специализированные инструкции или управляющие конструкции высокоуровневых языков программирования или встроенные в вычислитель механизмы для прерывания, сохранения и восстановления состояния, модификация и генерация инструкций программы и пр.
Характеристика порядка выполнения и контроля за этим порядком характерна для императивного программирования.
Императивное программирование (англ. imperative — приказ, повелительное наклонение) — это парадигма программирования (стиль написания исходного кода компьютерной программы), для которой характерно следующее:
- в исходном коде программы записываются инструкции (команды);
- инструкции должны выполняться последовательно;
- данные, получаемые при выполнении предыдущих инструкций, могут читаться из памяти последующими инструкциями;
- данные, полученные при выполнении инструкции, могут записываться в память.
Императивному подходу в программировании противопоставляется декларативный подход, в рамках которого описывается набор условий и результат а не путь его достижения. В рамках декларативного подхода объединены такие множество других, наиболее популярные из которых — объектно-ориентированное программирование и функциональное программирование. Однако, в рамках данного курса данные подходы не рассматриваются.
В императивном программировании предполагается, что процесс выполнения программы заключается в выполнении её инструкций вычислителем. В момент выполнения инструкции говорят, что она управляет вычислителем, переход к выполнению следующей называется передача управления или просто переход. Последовательность передач управления в процессе выполнения программы формирует её поток управления (также поток выполнения).
Способность вычислителя выбирать инструкции для выполнения в зависимости от своего состояния, а также возможность одновременного выполнения нескольких инструкций порождают существование разветвлённых (которые могут быть выполнены при определённых условиях) и параллельных (выполняющихся одновременно) связанных и взаимодействующих между собой потоков управления в одной программе.
Совокупность потоков программы, образующих разнообразные порядки, может быть изображена в виде ориентированного графа, где узлы соответствуют инструкциям программы, а ребра — переходам между ними.
Изображение порядка выполнения инструкций программы в виде графа
Порядок выполнения инструкций отражает структуру алгоритма, реализуемого программой.
Каждой базовой алгоритмической конструкции соответствует свой порядок выполнения, как правило, с таким же названием.
Простейшим порядком выполнения является последовательный или естественный порядок, когда инструкции выполняются последовательно, одна за другой, в порядке их появления в записи программы. Естественный порядок образуется при реализации алгоритмической конструкции «следование».
Отклонение от естественного для применяемого способа записи порядка называется переход. В этом случае после окончания выполнения текущей инструкции вычислитель переходит не к следующей в записи, а к некоторой другой, определённым образом заданной инструкции. При безусловном переходе инструкция перехода выбирается без учета состояния вычислителя, при условном переходе — в зависимости от состояния вычислителя, путём проверки условия.
Условный переход позволяет организовывать ветвление потока управления, образуя ветвящийся порядок, при котором исполнению подлежит только одна из двух или более внутренних фрагментов-ветвей программы. Ветвящийся порядок реализует алгоритмическую конструкцию «ветвление».
Переход к ранее выполненной инструкции позволяет организовать многократное исполнение набора инструкций, образуя циклический порядок их выполнения (цикл) и реализуя алгоритмическую конструкцию «цикл».
Другой способ организовать повторное выполнение набора инструкций в программе состоит в выделении предназначенных к повтору инструкций в обособленную часть программы, называемую подпрограмма, с возможностью многократной передачи управления (называемой вызов) в подпрограмму и последующим возвратом в место вызова.
Вычислитель может иметь возможность прервать выполнение программы и передать управление определённой инструкции в зависимости от своего состояния или сигналов внешних устройств, образуя прерывание. После его обработки выполнение программы может быть продолжено с места прерывания.
Прерывание (англ. interrupt) — сигнал от программного или аппаратного обеспечения, сообщающий процессору о наступлении какого-либо события, требующего немедленного внимания. Прерывание извещает процессор о наступлении высокоприоритетного события, требующего прерывания текущего кода, выполняемого процессором.
Прерывание — одна из базовых концепций вычислительной техники, которая заключается в том, что при наступлении какого-либо события происходит передача управления специальной процедуре, называемой обработчиком прерываний. В отличие от условных и безусловных переходов, прерывание может быть вызвано в любом месте программы, в том числе если выполнение программы приостановлено, и обусловлено обычно внешними по отношению к программе событиями.
Передача управления заранее подготовленному набору инструкций для определённой, как правило необычной или ошибочной (исключительной), ситуации без возможности возврата в место возникновения образует обработку исключений.
Обработка исключительных ситуаций (англ. exception handling) — механизм языков программирования, предназначенный для описания реакции программы на ошибки времени выполнения и другие возможные проблемы (исключения), которые могут возникнуть при выполнении программы и приводят к невозможности (бессмысленности) дальнейшей отработки программой её базового алгоритма. В русском языке также применяется более короткая форма термина: «обработка исключений».
Во время выполнения программы могут возникать ситуации, когда состояние внешних данных, устройств ввода-вывода или компьютерной системы в целом делает дальнейшие вычисления в соответствии с базовым алгоритмом невозможными или бессмысленными.
Классические примеры подобных ситуаций приведены ниже.
- Целочисленное деление на ноль. Конечного результата у данной операции быть не может, поэтому ни дальнейшие вычисления, ни попытка использования результата деления не приведут к решению задачи.
- Ошибка при попытке считать данные с внешнего устройства. Если данные не удаётся получить, любые дальнейшие запланированные операции с ними бессмысленны.
- Исчерпание доступной памяти. Если в какой-то момент система оказывается не в состоянии выделить достаточный для прикладной программы объём оперативной памяти, программа не сможет работать нормально.
- Появление сигнала аварийного отключения электропитания системы. Прикладную задачу, по всей видимости, решить не удастся, в лучшем случае (при наличии какого-то резерва питания) прикладная программа может позаботиться о сохранении данных.
Обработка исключительных ситуаций самой программой заключается в том, что при возникновении исключительной ситуации управление передаётся некоторому заранее определённому обработчику — блоку кода, процедуре, функции, которые выполняют необходимые действия.
Виды ошибок в программировании и способы отладки
Программная ошибка (арго баг от англ. bug — «жук») — означает ошибку в программе или в системе, из-за которой программа выдает неожиданное поведение и, как следствие, результат.
Большинство программных ошибок возникают из-за ошибок, допущенных разработчиками программы в её исходном коде, либо в её дизайне. Также некоторые ошибки возникают из-за некорректной работы инструментов разработчика, например из-за компилятора, вырабатывающего некорректный код.
Термин «программная ошибка» обычно употребляется для обозначения ошибок, проявляющих себя на стадии работы программы, в отличие, например, от ошибок проектирования или синтаксических ошибок. Отчет, содержащий информацию об ошибке также называют отчетом о проблеме (англ. bug report). Отчет о критической проблеме (англ. crash), вызывающей аварийное завершение программы, называют крэш-репортом (англ. crash report).
Программные ошибки локализуются и устраняются в процессе тестирования и отладки программы.
Значение и классификация ошибок программного обеспечения
Чаще всего вне зависимости от более точных классификаций, выделяют три основных типа ошибок программирования (в зависимости от этапа разработки ПО, на котором выявляется ошибка):
- синтаксические ошибки
В любом языке программирования каждое выражение строится по определенным правилам. Когда в программе встречаются выражения или условия, которые нарушает эти правила, то говорят о наличии синтаксической ошибки. Синтаксическая ошибка легко обнаруживается компиляторами или интерпретаторами языка и легко исправляется. - ошибки выполнения (ошибки времени выполнения)
Такие ошибки, как правило, возникают в процессе выполнения программы и называются исключительными ситуациями. Их причины отличаются от других типов ошибок. Если в программе происходит исключение, это свидетельствует о непредвиденном событии: например, программе передали некорректные данные или была предпринята попытка деления на ноль, что математически недопустимо. Исключение также может возникнуть, если операционная система требует немедленного завершения программы. Хотя такие ошибки выполнения легко выявить, устранение их причин может быть сложной задачей. - семантические ошибки (структурные или смысловые)
Например, применение операторов, которые не дают нужного эффекта (например,(a-b)вместо(a+b)), ошибка в структуре алгоритма, в логической взаимосвязи его частей, в применении алгоритма к тем данным, к которым он неприменим и т.д. Правила семантики не формализуемы. Поэтому поиск и устранение семантической ошибки и составляет основу отладки.
По важности:
- Критические;
- Серьёзные;
- Незначительные;
- Косметические.
По времени появления:
- Постоянно, при каждом запуске;
- Иногда («плавающий» тип);
- Только на машине у клиента (зависит от локальных настроек у клиента).
По месту и направлению:
- Ошибки пользовательского интерфейса;
- Системы обработки ошибок;
- Ошибки, связанные с граничными условиями (например, некорректная обработка пустой строки или максимального числового значения);
- Ошибки вычислений;
- Ошибки управления потоками;
- Ошибки обработки или интерпретации данных;
- Повышение нагрузки;
- Ошибки контроля версии и идентификаторов;
- Ошибки тестирования.
В зависимости от характера ошибки, программы и среды исполнения, ошибка может проявляться сразу или наоборот — долгое время оставаться незамеченной (например Проблема 2038 года которую затрагивали на прошлой лекции).
Также ошибка может проявляться в виде уязвимости, делающей возможным несанкционированный доступ к системе или целевую атаку.
Работа с ошибками в JavaScript
Неважно, насколько мы хороши в программировании, иногда наши скрипты содержат ошибки. Они могут возникать из-за наших промахов, неожиданного ввода пользователя, неправильного ответа сервера и по тысяче других причин.
Обычно скрипт в случае ошибки «падает» (сразу же останавливается), с выводом ошибки в консоль.
Но есть синтаксическая конструкция try..catch, которая позволяет «ловить» ошибки и вместо падения делать что-то более осмысленное.
Конструкция try..catch состоит из двух основных блоков: try, и затем catch:
try {
// код...
} catch (err) {
// обработка ошибки
}
Работает она так:
- Сначала выполняется код внутри блока
try {...}. - Если в нём нет ошибок, то блок
catch(err)игнорируется: выполнение доходит до концаtryи потом далее, полностью пропускаяcatch. - Если же в нём возникает ошибка, то выполнение
tryпрерывается, и поток управления переходит в началоcatch(err). Переменнаяerr(можно использовать любое имя) содержит объект ошибки с подробной информацией о произошедшем.
Таким образом, при ошибке в блоке try {...} скрипт не «падает», и мы получаем возможность обработать ошибку внутри catch.
Давайте рассмотрим примеры.
- Пример без ошибок: выведет
alert(1) и (2):
try {
alert('Начало блока try'); // (1) <--
// ...код без ошибок
alert('Конец блока try'); // (2) <--
} catch(err) {
alert('Catch игнорируется, так как нет ошибок'); // (3)
}
- Пример с ошибками: выведет (1) и (3):
try {
alert('Начало блока try'); // (1) <--
lalala; // ошибка, переменная не определена!
alert('Конец блока try (никогда не выполнится)'); // (2)
} catch(err) {
alert(`Возникла ошибка!`); // (3) <--
}
[!WARNING]
try..catchработает только для ошибок, возникающих во время выполнения кода
Чтобы try..catch работал, код должен быть выполнимым. Другими словами, это должен быть корректный JavaScript-код. Он не сработает, если код синтаксически неверен, например, содержит несовпадающее количество фигурных скобок:
try {
{{{{{{{{{{{{
} catch(e) {
alert("Движок не может понять этот код, он некорректен");
}
JavaScript-движок сначала читает код, а затем исполняет его. Ошибки, которые возникают во время фазы чтения, называются ошибками парсинга. Их нельзя обработать (изнутри этого кода), потому что движок не понимает код.
Таким образом, try..catch может обрабатывать только ошибки, которые возникают в корректном коде. Такие ошибки называют «ошибками во время выполнения», а иногда «исключениями».
Когда возникает ошибка, JavaScript генерирует объект, содержащий её детали. Затем этот объект передаётся как аргумент в блок catch. Для всех встроенных ошибок этот объект имеет два основных свойства name и message и одно дополнительное stack (зависит от окружения в котором запускается код):
nameИмя ошибки. Например, для неопределённой переменной это
"ReferenceError".messageТекстовое сообщение о деталях ошибки.
stackТекущий стек вызова: строка, содержащая информацию о последовательности вложенных вызовов, которые привели к ошибке. Используется в целях отладки.
Например:
try {
lalala; // ошибка, переменная не определена!
} catch(err) {
alert(err.name); // ReferenceError
alert(err.message); // lalala is not defined
alert(err.stack); // ReferenceError: lalala is not defined at (...стек вызовов)
// Можем также просто вывести ошибку целиком
// Ошибка приводится к строке вида "name: message"
alert(err); // ReferenceError: lalala is not defined
}
Примеры использования
Давайте рассмотрим реальные случаи использования try..catch.
JavaScript поддерживает метод JSON.parse(str) для чтения данных в особом формате JSON, который обычно используется для декодирования данных, полученных по сети, от сервера или из другого источника. Формат JSON в рамках данного курса подробно разбирать мы не будем, однако стоит сказать что он очень похож на то как в JavaScript задаются объекты данных.
Мы получаем их и вызываем JSON.parse вот так:
let json = '{"name":"John", "age": 30}'; // данные с сервера
let user = JSON.parse(json); // преобразовали текстовое представление в JS-объект
// теперь user - объект со свойствами из строки
alert( user.name ); // John
alert( user.age ); // 30
[!TIP]
Вы можете найти более детальную информацию оJSONпо ссылке: Формат JSON, метод toJSON.
Если json некорректен, JSON.parse генерирует ошибку, то есть скрипт «падает».
Устроит ли нас такое поведение? Конечно нет!
Получается, что если вдруг что-то не так с данными, то посетитель никогда (если, конечно, не откроет консоль) об этом не узнает. А люди очень не любят, когда что-то «просто падает» без всякого сообщения об ошибке.
Давайте используем try..catch для обработки ошибки:
let json = "{ некорректный JSON }";
try {
let user = JSON.parse(json); // <-- тут возникает ошибка...
alert( user.name ); // не сработает
} catch (e) {
// ...выполнение прыгает сюда
alert( "Извините, в данных ошибка, мы попробуем получить их ещё раз." );
alert( e.name );
alert( e.message );
}
Здесь мы используем блок catch только для вывода сообщения, но мы также можем сделать гораздо больше: отправить новый сетевой запрос, предложить посетителю альтернативный способ, отослать информацию об ошибке на сервер для логирования, всё лучше, чем просто «падение».
Генерация собственных ошибок
Что если json синтаксически корректен, но не содержит необходимого свойства name?
Например, так:
let json = '{ "age": 30 }'; // данные неполны
try {
let user = JSON.parse(json); // <-- выполнится без ошибок
alert( user.name ); // нет свойства name!
} catch (e) {
alert( "не выполнится" );
}
Здесь JSON.parse выполнится без ошибок, но на самом деле отсутствие свойства name для нас ошибка.
Для того, чтобы унифицировать обработку ошибок, мы воспользуемся оператором throw.
Оператор «throw»
Оператор throw генерирует ошибку.
Синтаксис:
throw <объект ошибки>
Технически в качестве объекта ошибки можно передать что угодно. Это может быть даже примитив, число или строка, но всё же лучше, чтобы это был объект, желательно со свойствами name и message (для совместимости со встроенными ошибками).
В JavaScript есть множество встроенных конструкторов для стандартных ошибок: Error, SyntaxError, ReferenceError, TypeError и другие. Можно использовать и их для создания объектов ошибки.
Их синтаксис:
let error = new Error(message);
// или
let error = new SyntaxError(message);
let error = new ReferenceError(message);
// ...
Для встроенных ошибок (не для любых объектов, только для ошибок), свойство name — это в точности имя конструктора. А свойство message берётся из аргумента.
Например:
let error = new Error(" Ого, ошибка! o_O");
alert(error.name); // Error
alert(error.message); // Ого, ошибка! o_O
Давайте посмотрим, какую ошибку генерирует JSON.parse:
try {
JSON.parse("{ bad json o_O }");
} catch(e) {
alert(e.name); // SyntaxError
alert(e.message); // Unexpected token b in JSON at position 2
}
Как мы видим, это SyntaxError.
В нашем случае отсутствие свойства name — это ошибка, ведь пользователи должны иметь имена.
Сгенерируем её:
let json = '{ "age": 30 }'; // данные неполны
try {
let user = JSON.parse(json); // <-- выполнится без ошибок
if (!user.name) {
throw new SyntaxError("Данные неполны: нет имени"); // (1)
}
alert( user.name );
} catch(e) {
alert( "JSON Error: " + e.message ); // JSON Error: Данные неполны: нет имени
}
В строке (1) оператор throw генерирует ошибку SyntaxError с сообщением message. Точно такого же вида, как генерирует сам JavaScript. Выполнение блока try немедленно останавливается, и поток управления прыгает в catch.
Теперь блок catch становится единственным местом для обработки всех ошибок и для JSON.parse и для других случаев.
Проброс исключения
В примере выше мы использовали try..catch для обработки некорректных данных. А что, если в блоке try {...} возникнет другая неожиданная ошибка? Например, программная (неопределённая переменная) или какая-то ещё, а не ошибка, связанная с некорректными данными.
Пример:
let json = '{ "age": 30 }'; // данные неполны
try {
user = JSON.parse(json); // <-- забыл добавить "let" перед user
// ...
} catch(err) {
alert("JSON Error: " + err); // JSON Error: ReferenceError: user is not defined
// (не JSON ошибка на самом деле)
}
Конечно, возможно все! Программисты совершают ошибки. Даже в утилитах с открытым исходным кодом, используемых миллионами людей на протяжении десятилетий — вдруг может быть обнаружена ошибка, которая приводит к ужасным взломам.
В нашем случае try..catch предназначен для выявления ошибок, связанных с некорректными данными. Но по своей природе catch получает все свои ошибки из try. Здесь он получает неожиданную ошибку, но всё также показывает то же самое сообщение "JSON Error". Это неправильно и затрудняет отладку кода.
К счастью, мы можем выяснить, какую ошибку мы получили, например, по её свойству name:
try {
user;
} catch(e) {
alert(e.name); // "ReferenceError" из-за неопределённой переменной
}
Есть простое правило:
Блок
catchдолжен обрабатывать только те ошибки, которые ему известны, и «пробрасывать» все остальные.
Техника «проброс исключения» выглядит так:
- Блок
catchполучает все ошибки. - В блоке
catch(err) {...}мы анализируем объект ошибкиerr. - Если мы не знаем как её обработать, тогда делаем
throw err.
В коде ниже мы используем проброс исключения, catch обрабатывает только SyntaxError:
let json = '{ "age": 30 }'; // данные неполны
try {
let user = JSON.parse(json);
if (!user.name) {
throw new SyntaxError("Данные неполны: нет имени");
}
blabla(); // неожиданная ошибка
alert( user.name );
} catch(e) {
if (e.name == "SyntaxError") {
alert( "JSON Error: " + e.message );
} else {
throw e; // проброс (1)
}
}
Ошибка в строке (1) из блока catch «выпадает наружу» и может быть поймана другой внешней конструкцией try..catch (если есть), или «убьёт» скрипт.
Таким образом, блок catch фактически обрабатывает только те ошибки, с которыми он знает, как справляться, и пропускает остальные.
Пример ниже демонстрирует, как такие ошибки могут быть пойманы с помощью ещё одного уровня try..catch:
function readData() {
let json = '{ "age": 30 }';
try {
// ...
blabla(); // ошибка!
} catch (e) {
// ...
if (e.name != 'SyntaxError') {
throw e; // проброс исключения (не знаю как это обработать)
}
}
}
try {
readData();
} catch (e) {
alert( "Внешний catch поймал: " + e ); // поймал!
}
Здесь readData знает только, как обработать SyntaxError, тогда как внешний блок try..catch знает, как обработать всё.
try..catch..finally
Подождите, это ещё не всё.
Конструкция try..catch может содержать ещё одну секцию: finally.
Если секция есть, то она выполняется в любом случае:
- после
try, если не было ошибок, - после
catch, если ошибки были.
Расширенный синтаксис выглядит следующим образом:
try {
... пробуем выполнить код...
} catch(e) {
... обрабатываем ошибки ...
} finally {
... выполняем всегда ...
}
Попробуйте запустить такой код:
try {
alert( 'try' );
if (confirm('Сгенерировать ошибку?')) BAD_CODE();
} catch (e) {
alert( 'catch' );
} finally {
alert( 'finally' );
}
У кода есть два пути выполнения:
- Если вы ответите на вопрос «Сгенерировать ошибку?» утвердительно, то
try->catch->finally. - Если ответите отрицательно, то
try->finally.
Секцию finally часто используют, когда мы начали что-то делать и хотим завершить это вне зависимости от того, будет ошибка или нет.
Например, мы хотим измерить время, которое занимает функция чисел Фибоначчи fib(n). Естественно, мы можем начать измерения до того, как функция начнёт выполняться и закончить после. Но что делать, если при вызове функции возникла ошибка? В частности, реализация fib(n) в коде ниже возвращает ошибку для отрицательных и для нецелых чисел.
Секция finally отлично подходит для завершения измерений несмотря ни на что.
Здесь finally гарантирует, что время будет измерено корректно в обеих ситуациях — и в случае успешного завершения fib и в случае ошибки:
let num = +prompt("Введите положительное целое число?", 35)
let diff, result;
function fib(n) {
if (n < 0 || Math.trunc(n) != n) {
throw new Error("Должно быть целое неотрицательное число");
}
return n <= 1 ? n : fib(n - 1) + fib(n - 2);
}
let start = Date.now();
try {
result = fib(num);
} catch (e) {
result = 0;
} finally {
diff = Date.now() - start;
}
alert(result || "возникла ошибка");
alert( `Выполнение заняло ${diff}ms` );
Вы можете это проверить, запустив этот код и введя 35 в prompt — код завершится нормально, finally выполнится после try. А затем введите -1 — незамедлительно произойдёт ошибка, выполнение займёт 0ms. Оба измерения выполняются корректно.
Другими словами, неважно как завершилась функция: через return или throw. Секция finally срабатывает в обоих случаях.
[!INFO]
Переменные внутриtry..catch..finallyлокальныОбратите внимание, что переменные
resultиdiffв коде выше объявлены доtry..catch. Если переменную объявить в блоке, например, вtry, то она не будет доступна после него.
Что почитать по теме
- Статья на Википедии - Порядок выполнения
- Статья на Википедии - Прерывание
- Статья на Википедии - Поток выполнения
- Статья на Википедии - Императивное программирование
- Статья на Википедии - Обработка исключений
- Статья на Википедии - Программная ошибка
- Современный учебник JavaScript - Обработка ошибок
- W3Schools - JavaScript Errors