Тема правильной обработки отказов и ошибок в программе является довольно сложным вопросом в программировании. Отчасти от того, что и она сама является источником постоянных недоразумений и ошибок проектирования из-за коварной рекурсии в природе ошибок. Другую часть сложностей, как это водится, программисты создают себе сами, вводя ненужную путаницу.
Возникающие при работе кода отказы по своему происхождению можно разделить на:
- отказы ввода и вывода
- ошибки самого кода
- ошибки исполнителя кода
Ошибки исполнителя, в частности, аппаратные сбои тоже можно учитывать в программном коде, но далеко не всегда это целесообразно из-за сравнительной редкости в типичных условиях. Здесь ошибки исполнителя не рассматриваются. Отказы вывода можно свести к отказам ввода, потому что сведения о неуспешности вывода по сути должны быть введены.
Природа отказов ввода и ошибок кода принципиально отличается, как и качества подходов к их обработке. Но между ними есть связь, которая часто приводит к путанице. Неправильная обработка отказов ввода является разновидностью ошибки программного кода. Некоторые ошибки кода могут привести к дополнительным отказам ввода. В других случаях ввод может быть внутренней частью некоторого кода, из-за чего он не является произвольным, и отказы такого ввода будут проявлением ошибок кода. Наконец, когда вводом программы в том или ином виде тоже является код, то ошибки этого кода оказываются ошибками-отказами ввода для читающей программы. С наиболее широкой точки зрения любой код является результатом ввода. Всё это приводит многих к мысли, что отказы ввода и ошибки кода следует обрабатывать одинаково. Но для получения более надёжных решений нужно избегать смешивания этих понятий.
песочницаVAR ar: ARRAY 15 OF INTEGER;
PROCEDURE InputError*; VAR i: INTEGER;
BEGIN
(* отказ ввода происходит во время работы кода, но её причина находится вне его *)
In.Int(i );
(* это может быть и отсутствие данных *)
IF ~In.Done THEN
Out.String("Число не введено.")
(* и ошибочность данных относительно предъявляемых к ним требований *)
ELSIF (i < 0) OR (i >= LEN(ar)) THEN
Out.String("Индекс выходит за пределы массива.")
ELSE
Out.Int(ar[i], 0)
END;
Out.Ln
END InputError;
PROCEDURE CodeMistake*; VAR i: INTEGER;
BEGIN
i := -2;(* причина ошибки находится внутри самого кода *)
WHILE i < LEN(ar) DO
INC(i);
Out.Int(ar[i], 0) (* проявление ошибки через обнаружение нарушения правила языка *)
END
END CodeMistake;
PROCEDURE InputAndCodeError*; VAR i: INTEGER;
BEGIN
In.Int(i );
(* Ошибка находится внутри кода, но заключается не в наличии ошибочного кода,
а в отсутствии правильного кода, ответственного за обработку отказов ввода.
Если отказ при работе кода не произойдёт, то и ошибка кода не проявится. *)
Out.Int(ar[i], 0)
END InputAndCodeError;
PROCEDURE LogicalError*; VAR i: INTEGER;
BEGIN
i := 0;
In.Int(i );
(* Здесь из-за особенности логики при отказе ввода проявится и ошибка кода,
что приведёт к неправильному результату, но не выявлению ошибки. *)
Out.Int(ar[i MOD 10], 0)
END LogicalError;
Отказы ввода принципиально неустранимы, потому что в отчуждаемом коде автору неподконтролен ввод, который может отсутствовать или быть произвольным, а код может предъявлять ряд требований к данным, несоблюдение которых и трактуется как ошибка. Отказы ввода в правильном коде должны быть обработаны во время его исполнения. Тогда отказы не приводят к ошибкам кода.
Все ошибки кода, напротив, потенциально устранимы, поскольку код находится в распоряжении разработчика. А сохраняются они в коде в основном потому, что остаются неизвестными. И хотя обнаружить ошибки можно даже без прямого запуска программы, в общем случае эта задача трудно разрешима. Поэтому выявление части ошибок кода может перекладываться на время работы кода (ошибки времени исполнения), а для другой части может отсутствовать полностью (логические ошибки).
Из-за того, что ошибки кода неизвестны, обработать их напрямую, так же, как отказы ввода, невозможно. Их можно лишь косвенно выявлять по их следствиям проверкой проектно неизменных свойств кода (инвариантов). Если свойство не выполняется, то есть не соблюдается его неизменность, значит где-то произошла ошибка кода. Часть таких свойств может быть проверена автоматически, потому что они выводимы из правил самого языка, как, например, нахождение индекса массива в пределах его размера. Проверка другой части может задаваться лишь самим программистом с помощью утверждений (assertions).
TYPE Range = RECORD min, max: INTEGER END;[1]
PROCEDURE Init*(VAR r: Range; min, max: INTEGER);
BEGIN
ASSERT(min <= max);
r.min := min;
r.max := max
END Init;
Неизменные свойства кода являются частью или следствием его смысла и только его. Правильно составленные ASSERT не задают эти свойства, а лишь формализуют, вводя жёсткие проверки соответствующих логических выражений.
Проверка в ASSERT не подразумевает дальнейшее выполнение кода в случае обнаружения нарушения заданного условия, но, строго говоря, и не гарантирует прерывание в общем случае [2]. ASSERT следует применять исключительно для проверки неизменных свойств. Свойства вводимых данных такими не являются и такая их проверка является целенаправленным созданием ошибок кода, что в свою очередь является ошибкой проектирования. Наличие жёстких проверок неизменных свойств не должно подменять собой мягкие проверки произвольных данных.
In.Int(min );
(* Форма проверки входных данных на ошибки может отличаться от
проверки неизменных свойств кода в зависимости от контекста *)
IF In.Done & (min <= IntMax - (N-1)) THEN
Range.Init(r , min, min + (N-1))
ELSE
HandleError
END
В полностью правильном коде при любых входных данных все проверки неизменности свойств оказываются истинными, поэтому программисты любят отключать или даже не включать проверки. Правда, полностью правильный код мало-мальски большого размера составляет исчезающе малую часть от всего используемого кода. В то же время отключение проверок является важной частью тестирования надёжных систем, потому что без такого контроля эффекты поиска и обнаружения ошибок вместе с самими ошибками могут непреднамеренно стать частью основной логики, что вредно.
Чёткое разделение — жёсткие проверки для неизменных свойств и мягкие для произвольных данных делает удобной отладку кода после АВОСТ (postmortem — посмертная отладка). Только нарушение жёстких проверок, связываемых с ошибками кода, приводит к аварийной остановке, из-за чего она может быть автоматически обработана отладчиком с выдачей нужных сведений. Чем больше будет жёстких проверок как со стороны языка, так и со стороны разработчика, тем легче будет отладка. В случае использования механизма исключений прерывание потока выполнения может быть вызвано как ошибками кода, так и отказами ввода и таким образом быть частью условно «нормального» потока выполнения. Поэтому сложно автоматически обнаружить время и место, когда необходима остановка в отладчике, кроме тех счастливых случаев, когда исключение от ошибки кода не была искажено ни одним из обработчиков исключений. Если же пытаться останавливаться на любых выбросах исключений, то в случае изобилия отказов ввода можно утонуть в спаме.
В тех случаях, когда код часто имеет дело с произвольными данными или содержит проверки, сложно отделимые от основных действий, или сами проверки довольно сложны, может быть удобным сделать основные проверки мягкими. Это позволит применять их как с недоверенными, так и с доверенными данными. В последнем случае достаточно проверять результат таких процедур в ASSERT, ведь свойства проверенных данных относятся к неизменным, и результат их проверки должен быть всегда истинным.
PROCEDURE Init*(VAR r: Range; min, max: INTEGER): BOOLEAN;
VAR ok: BOOLEAN;
BEGIN
ok := min <= max;
IF ok THEN r.min := min; r.max := max END
RETURN
ok
END Init;
...
In.Int(m0 ); In.Int(m1 );
ok := In.Done
& Range.Init(r0 , m0, m1);
...
(* постоянные параметры являются тривиально доверенными *)
ASSERT(Range.Init(r1 , 20, 40));
In.Int(n);
IF In.Done THEN
(* хотя n произвольно, ABS(n DIV 2) в этом контексте является доверенным *)
ASSERT(Range.Init(r2 , 0, ABS(n DIV 2)))
END
Вызовы процедур, возвращающих правильность своего выполнения в качестве логического значения, удобно связывать в цепочки проверок, что возможно за счёт краткой схемы вычисления логических выражений:
ok := Range.Init(r0 , min0, max0)
& Range.Init(r1 , min1, max1)
& Range.Union(r2 , r0, r1);
Если нужны подробности отказа, что характерно при наличии сложных проверок, то всё равно удобней оставить возврат правильности логическим значением. Его учёт удобен и с ним трудней запутаться, а дополнительные сведения можно поместить в отдельный выходной параметр.
TYPE Error* = POINTER TO RECORD(Errors.R) importantDetails*: INTEGER END;
(* mark — метка конкретной ошибки в контексте (под)программы,
идентификация по типу записи либо недостаточна, либо ресурсоёмка *)
PROCEDURE ErrorNew(VAR err: Errors.T; mark: INTEGER; details: INTEGER);
VAR e: Error;
BEGIN
NEW(e );
(* В неудачном сценарии поток ошибок может перегрузить систему выделения,
поэтому начализация может подменять объект на предвыделенный без деталей *)
IF Errors.Init(e , mark, err ) THEN
e.importantDetails := details
END
END ErrorNew;
PROCEDURE InitOrErr*(VAR r: Range; min, max: INTEGER; VAR err: Errors.T; mark: INTEGER): BOOLEAN;
BEGIN
IF min <= max THEN
r.min := min; r.max := max;
err := Errors.No
ELSE
ErrorNew(err , mark, min DIV 2 + max DIV 2)
END
RETURN
err = Errors.No
END InitOrErr;
(*...*)
VAR r0, r1, r2: Range.T; err: Errors.T;
BEGIN
ok := Range.InitOrErr(r0 , min0, max0, err , ErrRange0)
& Range.InitOrErr(r1 , min1, max1, err , ErrRange1)
& Range.UnionOrErr(r2 , r0, r1, err , ErrUnion);
(*...*)
CASE Errors.Mark(err) OF
| Errors.None: ;
| Errors.Some: Out.String("Ошибка без подробностей")
| ErrRange0: Out.String("Неправильные параметры 1-го отрезка: ");
Out.Int(err(Range.Error).importantDetails, 0)
| ErrRange1: Out.String("Неправильный 2-й отрезок")
| ErrUnion: Out.String("Невозможно объединить отрезки")
END;
Out.Ln
Если вызываемая процедура не возвращает объект отказа общего типа, потому что для неё это избыточно и нежелательно[3], то такой объект всё равно можно создать отдельно, не разрывая цепочки проверки правильности.
PROCEDURE ErrorWrap(VAR err: Errors.T; mark: INTEGER; details: INTEGER): BOOLEAN;
VAR e: Error;
BEGIN
NEW(e ); IF Errors.Init(e , mark, err ) THEN e.importantDetails := details END
RETURN (* возвращаемое значение нужно только чтобы вписаться в выражение *)
FALSE
END ErrorWrap;
...
VAR r0, r1, r2: Range.T; err: Errors.T; ierr: INTEGER;
BEGIN
ok := ( Range.Init(r0 , min0, max0, ierr ) OR ErrorWrap(err , ErrRange0, ierr) )
& ( Range.Init(r1 , min1, max1, ierr ) OR ErrorWrap(err , ErrRange1, ierr) )
& ( Range.Union(r2 , r0, r1, ierr ) OR ErrorWrap(err , ErrUnion, ierr) );
Можно обойтись и без логических выражений, используя многоветочный IF. Этот подход имеет свои достоинства и недостатки.
IF ~Range.Init(r0 , min0, max0, ierr ) THEN err := ErrorNew(ErrRange0, ierr)
ELSIF ~Range.Init(r1 , min1, max1, ierr ) THEN err := ErrorNew(ErrRange1, ierr)
ELSIF ~Range.Union(r2 , r0, r1, ierr ) THEN err := ErrorNew(ErrUnion, ierr)
ELSE err := Errors.No
END
Если подробности отказа нужны редко, то закономерно желание составить основную процедуру вообще без выходных параметров для сведений об отказах, заключив их в качестве глобальной переменной в модуле, содержащем процедуру. Это гибкий подход, позволяющий избежать создания разных версий процедур, но теряющий в очевидности, и требующий бо́льшей осторожности[4]. Но главное, чтобы процедура продолжала явный возврат состояния правильности.
(* Когда подробности не нужны *)
ok := Range.Init(r0 , min0, max0)
& Range.Init(r1 , min1, max1)
& Range.Union(r2 , r0, r1);
VAR err: Errors.T;
BEGIN
(* Когда подробности необходимы *)
ok := ( Range.Init(r0 , min0, max0) OR Range.Error(err , ErrRange0) )
& ( Range.Init(r1 , min1, max1) OR Range.Error(err , ErrRange1) )
& ( Range.Union(r2 , r0, r1) OR Range.Error(err , ErrUnion) );
(* Когда нужны не слишком детальные подробности *)
ok := ( Range.Init(r0 , min0, max0)
& Range.Init(r1 , min1, max1)
& Range.Union(r2 , r0, r1)
)
OR Range.Error(err , ErrUnion));
Примечания
[1] Стоит отметить, что применение в ряде языков структурных литералов (значения записей и массивов) вместо процедур инициализации (включая конструкторы), не способствует проверкам правильности при отсутствии дополнительных механизмов.[2] Возможность отсутствия прерывания выполнения кода после ASSERT c ложным условием является расширением по отношению к оригинальной спецификации Oberon-07, в которой лаконично указано, что ASSERT(b) — abort, if ~b
[3] Как можно более быстрое отсечение ошибочных ситуаций может быть важно для программ, которые могут быть перегружены ошибочными данными.
[4] Подробности отказа в глобальных переменных не подходят также системам с разновидностью многопоточности, несовместимой с однопоточным режимом.
Комментариев нет:
Отправить комментарий