Страницы

четверг, 29 февраля 2024 г.

Обработка ошибок

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

Возникающие при работе кода ошибки по своему происхождению можно разделить на:

  1. ошибки ввода и вывода
  2. ошибки самого кода
  3. ошибки исполнителя кода

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

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

песочница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 CodeError*; VAR i: INTEGER;
BEGIN
 i := -2;(* причина ошибки находится внутри самого кода *)
 WHILE i < LEN(ar) DO
  INC(i);
  Out.Int(ar[i], 0) (* проявление ошибки через обнаружение нарушения правила языка *)
 END
END CodeError;

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] Подробности ошибки в глобальных переменных не подходят также системам с разновидностью многопоточности, несовместимой с однопоточным режимом.

Дополнительно:

Ошибки кода можно не только обрабатывать, но и сокращать их количество — повышение надёжности по модели проявления ошибок в коде.

Комментариев нет:

Отправить комментарий

Обработка ошибок

Тема корректной обработки ошибок в программе является довольно сложным вопросом в программировании. Отчасти от того, что и она сама являет...