Статья
Версия для печати
Обсудить на форуме (42)
Обработка исключений на языке C

(C) Dale, 01.02.2011 — 02.02.2011.

Язык C сегодня — довольно неоднозначное явление. Ему уже около 40 лет, а он до сих пор в строю. Второго такого долгожителя вряд ли сыщешь — даже Fortran-IV сдал позиции. Этот язык стал родоначальником множества других, включая столь популярные сегодня C++, Java и C#, по при этом не торопится на пенсию — хотя потомки изрядно его потеснили, но так и не вытеснили полностью.
Конечно, язык, основы которого закладывались еще в конце 1960-х годов, не может быть безупречным по сегодняшним меркам. Хотя он и развивался все это время, о чем свидетельствует последовательность стандартов языка, но в узких рамках совместимости с предыдущими версиями (не забываем, что главный конек C — это совместимость и переносимость), то есть умеренно. Поэтому у него до сих пор есть ряд очевидных слабостей, которые приходится преодолевать программистам.
Сегодня использование C можно было бы рассматривать как архаизм, если бы не одна ниша, в которой ему просто нет равных — программирование микропроцессоров. Сочетание машинной независимости с гибкостью непосредственной работы с аппаратными ресурсами и эффективностью объектного кода делают его незаменимым инструментом разработки firmware, а недостатки преодолеваются различными трюками. Один из таких трюков мы рассмотрели подробно в статье «Сопрограммы в языке программирования C», а сегодня познакомимся поближе с другим.
Один из наиболее скользких вопросов программирования на C — это, безусловно, обработка ошибок. Вообще говоря, обработка ошибок — это показатель мастерства и профессиональной зрелости программиста. Плохие программисты пишут программы, которые работают неправильно. Посредственные — пишут программы, которые работают правильно, но в тепличных условиях (оборудование исправно, все файлы на своем месте, данные имеют корректный формат и т.д.). Настоящие мастера пишут программы, которые надежны в любых условиях — если все гладко, они выдают правильный результат, а если по ходу работы возникли проблемы, они либо пытаются бороться с ними по мере возможностей, либо выдают внятную и недвусмысленную диагностику с описанием причин, по которым работа невозможна.
Прямо скажем, «чистый» C — плохой помощник разработчику по части обработки ошибок. В самом языке для этого нет никаких средств, а в стандартных библиотеках нет единодушия по вопросу, каким образом функция должна сообщать клиенту об ошибках при выполнении. Иногда функция возвращает код завершения, он же код ошибки, если имеет ненулевое значение; в других случаях часть диапазона значений функций выделена под коды ошибки; и апофеоз — errno.
Тут впору вспомнить Дейкстру: «...когда я начинаю анализировать свои собственные мыслительные привычки и привычки моих друзей, я прихожу, нравится мне это или нет, к совершенно иному заключению, а именно: инструменты, которые мы пытаемся использовать, и язык и обозначения, которые мы используем для выражения или записи наших мыслей, являются основным фактором, который определяет, о чем мы вообще можем мыслить и что можем выразить! Анализ влияния, которое язык программирования оказывает на своих пользователей, и признание факта, что к настоящему времени мощь нашего мозга является наиболее скудным ресурсом, совместно дают нам новый набор эталонов для сравнения относительных достоинств различных языков программирования» (из статьи «Смиренный программист»). Язык C — не исключение: вызвав потенциально чреватую ошибками функцию, следует проверить код ее завершения (или errno) и запрограммировать действия в случае ошибки; поскольку эти действия сами могут вызвать ошибку, их результат также следует проверить, и... В результате корректная программа оказывается загроможденной кодом обработки ошибок. Менее прилежные программисты, утомившись, просто бросают это дело, и результат в виде вылетов программы, неожиданных результатов и прочих сюрпризов не заставляет себя ждать.
Наследники C, учитывая отрицательный опыт родителя, обзавелись средствами, позволяющими облегчить жизнь программиста при обработке ошибок. В C++ появились исключения, пусть и не слишком изящные, затем в Java и C# эта концепция была доведена до практически приемлемого уровня. Применение механизма исключений позволяет писать код, который решает свою задачу, а в случае, когда продолжение работы невозможно, выбрасывает исключение. В подходящем месте программы размещается перехватчик исключений, который предпринимает соответствующие действия. Структура программы улучшается, т.к. обработка ошибок производится не в том месте, где они возникают, а в том, где это наиболее целесообразно.
К счастью, C располагает ценным средством для компенсации своих многочисленных недостатков и слабостей — препроцессором. Инструмент этот довольно опасен, но при умелом использовании дает замечательные результаты. Сегодня мы рассмотрим одно из таких успешных применений макросов — реализацию механизма исключений на языке ANSI C. А для этого мы попробуем решить очень простую, но весьма типичную задачу.

Задача

Рассмотрим простую задачу. Треугольник задан тремя сторонами a, b, c. Нам нужно определить его площадь.
В принципе задача довольно легко решаема:

Код: (C)
#include <math.h>

double triangleArea(double a, double b, double c)
{
  double p = (a + b + c) / 2.0;
  double result = sqrt(p * (p - a) * (p - b) * (p - c));
  return result;
}

Впрочем, идеальным это решение не назовешь. Функция реализует вычисление площади по формуле Герона, не заботясь о корректности значений переданных ей параметров. Во-первых, отрицательное значение длины стороны треугольника лишено физического смысла. Во-вторых, не каждые три отрезка положительной длины образуют треугольник. Помимо бессмысленного результата, можно получить и более серьезную проблему в виде отрицательного значения под корнем.
Самое простое — возложить ответственность за корректность параметров на клиента, вызывающего нашу функцию. Но такое решение сродни заметанию мусора под ковер. К тому же, если функция будет вызываться достаточно часто (а ведь именно для этого и придуманы функции), проверку параметров перед вызовом придется производить во многих местах. И даже это не дает гарантии: достаточно забыть проверить параметры в одном месте программы — и благодатная почва для потенциального сбоя подготовлена.
Лучше, конечно, проверить параметры внутри функции, до начала вычислений.

Код: (C)
#include <math.h>

double triangleArea(double a, double b, double c)
{
  if (   (a <= 0.0)
      || (b <= 0.0)
      || (c <= 0.0)
      || (a <= fabs(b - c))
      || (a >= (b + c))
      )
      ;  // do something?
  double p = (a + b + c) / 2.0;
  double result = sqrt(p * (p - a) * (p - b) * (p - c));
  return result;
}

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

Код: (C)
#include <math.h>

double triangleArea(double a, double b, double c)
{
  if (   (a <= 0.0)
      || (b <= 0.0)
      || (c <= 0.0)
      || (a <= fabs(b - c))
      || (a >= (b + c))
      )
       return -1.0;
  double p = (a + b + c) / 2.0;
  double result = sqrt(p * (p - a) * (p - b) * (p - c));
  return result;
}

Как вариант, можно вообще прекратить выполнение программы при ошибке, чтобы заведомо избежать нежелательных последствий:

Код: (C)
#include <math.h>
#include <stdlib.h>

double triangleArea(double a, double b, double c)
{
  if (   (a <= 0.0)
      || (b <= 0.0)
      || (c <= 0.0)
      || (a <= fabs(b - c))
      || (a >= (b + c))
      )
       exit(EXIT_FAILURE);
  double p = (a + b + c) / 2.0;
  double result = sqrt(p * (p - a) * (p - b) * (p - c));
  return result;
}

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

Основы CException

Пользоваться CException очень просто. Его основу образуют три макроса: Try, Catch и Throw, а также тип CEXCEPTION_T.
CEXCEPTION_T

Для того, чтобы обработчик ошибки мог выяснить, какая именно ошибка произошла, каждый тип ошибки должен иметь свой идентификатор. Тип этого идентификатора должен быть CEXCEPTION_T. По умолчанию он соответствует unsigned int.
Try

Этот макрос начинает защищенный блок. Синтаксис его подобен синтаксису обычного if: за ним следует либо единичный оператор, либо блок. За ним обязательно должен следовать блок Catch (если вы забудете об этом, получите ошибку компиляции).
Блоки Try могут быть вложены друг в друга. При необходимости можно повторно выбросить исключение, чтобы обработать его во внешнем блоке Try.

Catch

Этот макрос начинает блок обработки ошибок. Он выполняется лишь в том случае, если в блоке Try возникло исключение. Исключение может возникнуть как непосредственно в коде внутри блока Try, так и в функции, вызываемой в блоке Try на любом уровне вложенности.
Catch получает идентификатор исключения типа CEXCEPTION_T, который вы можете использовать при обработке ошибки. Если в процессе обработки возникнет исключение, оно будет обработано внешним блоком Try.

Throw

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

Паттерн обработки ошибок

Располагая описанными выше примитивами, можно написать надежную программу по следующему образцу:

Код:
...
// Защищенный блок
Try
{
  ...
  потенциально_опасный код
  if (возникла_ошибка)
    Throw(e1); // e1 - код конкретной ошибки
  ...  
  вызов_потенциально_опасной_функции()
  ...
}
// Сюда мы попадаем только в случае возникновения ошибки
Catch(e) // здесь e - код возникшей ошибки
{
  ...
  код_обработки_ошибки
  ...
}
// Конец защищенного блока
...

потенциально_опасная_функция()
{
  ...
  if (возникла_ошибка)
    Throw(e2); // e2 - код конкретной ошибки
  ...
}

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

Окончательный вариант программы

Сначала определим коды возможных ошибок, чтобы наша программа не пестрила «магическими числами».

Код: (C)
// ErrorCode.h

enum ERRORCODE_T
{
  NEGATIVE_SIDE,
  BAD_TRIANGLE
};

Интерфейс функции вычисления треугольника остается неизменным:

Код: (C)
// TriangleArea.h

extern double triangleArea(double a, double b, double c);

А вот в тело функции внесем изменения с учетом того, что узнали про обработку исключений:

Код: (C)
// TriangleArea.с

#include <math.h>
#include <stdlib.h>
#include "CException.h"
#include "ErrorCode.h"
#include "TriangleArea.h"

double triangleArea(double a, double b, double c)
{
  if (   (a <= 0.0)
      || (b <= 0.0)
      || (c <= 0.0)
          )
          Throw(NEGATIVE_SIDE);
       
  if (   (a <= fabs(b - c))
      || (a >= (b + c))
          )
          Throw(BAD_TRIANGLE);
         
  double p = (a + b + c) / 2.0;
  double result = sqrt(p * (p - a) * (p - b) * (p - c));
  return result;
}

Ну и в завершение — главная программа:

Код: (C)
// main.c

#include <stdio.h>
#include <stdlib.h>
#include "TriangleArea.h"
#include "CException.h"
#include "ErrorCode.h"

int main(int argc, char *argv[])
{
  CEXCEPTION_T e;
  double a, b, c;
  a = 10.0;
  b = 2.0;
  c = 2.0;
  printf("a=%f b=%f c=%f\n", a, b, c);

  Try
  {
    double area = triangleArea(a, b, c);
    printf("area=%f\n", area);
  }
  Catch(e)
  {
    switch (e)
    {
      case NEGATIVE_SIDE:
        printf("One of triangle sides is negative.\n");
        break;
       
      case BAD_TRIANGLE:
        printf("Triangle cannot be made of these sides.\n");
        break;
       
      default:
        printf("Unknown error: %d\n", e);
    }
  }
 
  system("PAUSE");     
  return 0;
}

Улучшение структуры кода сразу бросается в глаза: функция сообщает о возникновении ошибки, а инфраструктура CException передает управление обработчику ошибок без участия программиста. Больше нет необходимости загромождать текст программы условными операторами (не забываем, что по-хорошему программу нужно еще тестировать, а наличие каждого ветвления усложняет эту процедуру).

Заключение

Конечно, CException — не панацея, и его применение не решит всех проблем. Например, CEXCEPTION_T — это всего лишь целочисленная константа, и с ее помощью невозможно сообщить обработчику все детали об ошибке. Кроме того, обработчик Catch в отличие от настоящих обработчиков, встроенных в современные языки программирования, перехватывает все исключения, поэтому вполне возможно, что некоторые из них, не предназначенные данному обработчику, придется выбросить повторно. Но все же плюсов от использования CException гораздо больше чем минусов, и программы на C, написанные с использованием механизма исключений, окажутся проще и надежнее своих старорежимных собратьев.
Скачать исходный код и документацию по CException можно здесь.
Исходный код примера из данной статьи (в виде проекта Dev-C++) находится здесь.
Версия для печати
Обсудить на форуме (42)