C++.Бархатный путь

       

Ошибки и исключительные ситуации


Мы завершаем путь. Всё это время мы стремились не допускать ошибок в выражениях, операторах, объявлениях, определениях, макроопределениях, программах. Но до сих пор у нас нет чёткого представления о том, что такое ошибка.

В общем случае под ошибкой мы будем понимать несоответствие правилу, алгоритму. Это рабочее определение. Конечно, правила бывают нечёткими, алгоритмы - некорректными. Это неважно. В любом случае можно сказать, что "всё не так, как должно быть". И этого достаточно.

Разные ошибки проявляют себя по-разному и могут быть обнаружены в разное время, на разных стадиях жизненного цикла программы и при различных обстоятельствах.

Выявлением некорректных макроопределений, несуществующих заголовочных файлов и неверных условий компиляции занимается препроцессор. Ошибки препроцессора выявляются на ранних этапах трансляции. Сами по себе они не проявляются.

Транслятору C++ хорошо известен синтаксис языка. Поэтому нарушение правил порождения слов, выражений и предложений, в том числе и неуместное использование ключевых слов, достаточно легко обнаруживается на стадии трансляции.

Транслятор распознаёт константные выражения различной сложности. Он способен самостоятельно производить арифметические вычисления. Так что с вопросами определения статических массивов также не возникает никаких проблем.

На этапе трансляции распознаются леводопустимые выражения. Поэтому значительная часть ошибок, связанных с некорректным использованием операций присвоения также выявляется на стадии трансляции.

Многие ошибки несоответствия типов также могут быть выявлены на этапе трансляции, в ходе создания объектного кода. Здесь следует вспомнить об операции явного преобразования типа, которая отключает контроль транслятора за типами.

На этапе создания исполнительного модуля программа (или система) компоновки способна распознать объявленные и неопределённые переменные и функции, а также незавершённые объявления классов.

В ряде случаев на этапе создания объектного кода могут выявляться неопределённые и неиспользуемые переменные, и даже потенциально опасные фрагменты кода. Транслятор может предупредить об использовании в выражениях неинициализированных переменных.

Однако не все расхождения с правилами, идеальным схемами и алгоритмам могут быть обнаружены до того момента, пока программа находится в состоянии разработки.

Существует категория ошибок, которые не способны выявить даже самые изощрённые препроцессоры, трансляторы и программы сборки. К их числу относятся так называемые ошибки времени выполнения. Эти ошибки проявляются в ходе выполнения программы.

Мы не раз подчёркивали, что в C++ часто возникают ситуации, при которых ответственность за правильность выполнения операций, операторов и даже отдельных функций целиком возлагается на программиста. Арифметические вычисления (деление на нуль), преобразования типа, работа с индексами и адресами, корректная формулировка условий в операторах управления, работа с потоками ввода-вывода - это далеко не полный перечень неконтролируемых в C++ ситуаций.

Ошибки времени выполнения, возникающие непосредственно в ходе выполнения программы, в терминах объектно-ориентированного программирования называются исключительными ситуациями. Исключительные ситуации - это события, которые прерывают нормальный ход выполнения программы.

Различают синхронные и асинхронные исключительные ситуации.

Синхронная исключительная ситуация возникает непосредственно в ходе выполнения программы, причём её причина заключается непосредственно в действиях, выполняемых самой программой.

Асинхронные исключительные ситуации непосредственно не связаны с выполнением программы. Их причинами могут служить аппаратно возбуждаемые прерывания (например, сигналы от таймера), сообщения, поступающие от внешних устройств или даже от локальной сети.

Реакция на исключительную ситуацию называется исключением.

Заметим, что исключительная ситуация не всегда неожиданна. Очень часто при разработке алгоритма уже закладывается определённая реакция на вероятную ошибку.

Например, функция, размещающая целочисленные значения в массиве по определённому индексу, может самостоятельно следить за допустимыми значениями индекса. Она возвращает единицу в случае успешного размещения значения и нуль, если значение параметра, определяющего индекс, не позволяет этого сделать.


#define MAX 10 int PushIntArray(int* const, int, int); void main() { int intArray[MAX]; int IndexForArray, ValueForArray; ::::: for (;;) { ::::: // Значения IndexForArray и ValueForArray меняются в цикле. if (!PushIntArray(intArray, IndexForArray, ValueForArray)) { cout " Некорректное значение индекса" endl; IndexForArray = 0; } ::::: } ::::: } int PushIntArray(int* const keyArray, int index, int keyVal) { if (index = 0 index MAX) { keyArray[index] = keyVal;// Спрятали значение и сообщили об успехе. return 1; } else return 0; // Сообщили о неудаче. }

Перед нами самый простой вариант исключения как формы противодействия синхронной исключительной ситуации. Из функции main вызывается функция, PushIntArray, которой в качестве параметров передаются адрес массива, значение индекса и значение, предназначенное для сохранения в массиве.

Функция PushIntArray проверяет значение индекса и возвращает соответствующее сообщение. Эта функция выявляет возможные ошибки и уведомляет о них вызывающую функцию. Подобное сообщение о неудаче можно рассматривать как прообраз генерации (или возбуждения) исключения.

Вызывающая функция может корректировать значение индекса: исправление выявленных ошибок (то есть реакция на исключение) - компетенция вызывающей функции.

Очевидно, что не всегда исключение может быть возбуждено по такой простой схеме. Например, функция, возвращающая результат деления двух действительных чисел, должна предусматривать вероятность возникновения ситуации, при которой делитель оказывается равным нулю. Ожидаемая исключительная ситуация также может сопровождаться определённой реакцией, нейтрализующей возможные последствия вероятной ошибки.

#include iostream.h #define EXDIVERROR 0.0 /* Здесь может быть определено любое значение. Это не меняет сути дела. Так кодируется значение, предупреждающее об ошибке. Не самая хорошая идея: некоторые корректные значения всегда будут восприниматься как уведомления об ошибке. */ float exDiv (float, float); void main() { float val1, val2; ::::: if (exDiv(val1, val2) == EXDIVERROR) { ::::: cout "exDiv error…"; // Здесь можно попытаться исправить ситуацию. ::::: } } float exDiv (float keyVal1, float keyVal2) { if (val2) return keyVal1/keyVal2; return EXDIDERROR; }



Функция exDiv может быть модифицирована следующим образом: возвращаемое целочисленное значение сообщает о ходе вычисления, а непосредственно сам результат вычисления передаётся по ссылке.

Подобная схема противодействия исключительным ситуациям уже применялась при работе со стеком.

#include iostream.h int exDiv (float, float, float); void main() { float val1, val2, resDiv; ::::: if (!exDiv(val1, val2, resDiv)) { ::::: cout "exDiv error…"; ::::: } } int exDiv (float keyVal1, float keyVal2, float keyRes) { if (val2) {keyRes = keyVal1/keyVal2; return 1;} return 0; }

Ещё один возможный вариант обратной связи между вызываемой и вызывающей функциями заключается в определении специального класса, объединяющего в одном объекте возвращаемое значение и служебную информацию о результате выполнения функции. В таком случае возвращаемое значение превращается в исключение лишь в случае возникновения исключительной ситуации.

#include iostream.h class DivAnsver { public: int res; float fValue; // Конструктор. DivAnsver(): res(1), fValue(0.0) {}; // ctorИнициализаторы в действии! }; DivAnsver exDiv (float, float); void main() { DivAnsver Ansver; Ansver = exDiv(0.025, 0.10); cout Ansver.fValue "..." Ansver.res endl; Ansver = exDiv(0.025, 0.0); cout Ansver.fValue "..." Ansver.res endl; } DivAnsver exDiv (float val1, float val2) { DivAnsver Ans; if (val2) Ans.fValue = val1/val2; else Ans.res = 0; return Ans; }

Функция exDiv возвращает значение объекта Ans (предопределённый конструктор копирования об этом позаботится). При этом, если деление возможно, значение данного-члена res оказывается равным единице, а fValue принимает значение частного от деления. В противном случае res устанавливается в нуль и объект Ans становится исключением.

Подобным изменениям можно подвергнуть объявление класса, реализующего стек: возвращаемое функцией pop() значение объекта-представителя шаблонного класса мог бы содержать результат выполнения функции и значение содержимого стека.

И опять в этом случае применима традиционная схема: вызывающая функция обращается к вызываемой функции с предложением выполнить конкретное действие. Последняя принимает решение относительно возможности выполнения этого действия и если возможно, выполняет его. В любом случае вызывающая функция уведомляется о проделанной работе, получая либо результат выполнения, либо исключение.

На основе получаемой информации, вызывающая функция принимает соответствующее решение, которое может состоять в продолжении выполнения программы, попытке исправления ошибочной ситуации, либо аварийной остановке выполнения программы.

Казалось бы, всё хорошо и на этом можно было бы остановиться. Однако, нет пределов совершенству!

Существует целый ряд проблем, связанных с подобным способом организации программного кода. Рассмотрим некоторые из них.

Структура вызывающей функции определяется множеством значений, которые может возвратить вызываемая функция. Каждое возвращаемое значение, как правило, сопровождается определённой реакцией. Чем больше вариантов возвращаемых значений и исключений, тем менее наглядным, понятным и легкочитаемым оказывается программный код вызывающей функции.

С ростом числа вариантов возвращаемых значений становится всё более актуальной проблема разделения "положительных" и "отрицательных" ответов.

И вообще, если вызываемая функция возвращает несколько вариантов исключений, то программный код, необходимый для адекватной реакции на ошибки, может превысить объём кода, реализующего основную логику программы.

Наконец, конструкторы и деструкторы вообще не возвращают никаких значений. Поэтому они не способны сообщить о своих проблемах общепринятым способом. Для них приходится специально изобретать особые нестандартные средства взаимодействия.


Содержание раздела