Реализация exceptions на plain C

в 20:18, , рубрики: c++, exception, метки: ,

Продолжение вот этой статьи habrahabr.ru/post/131212/, где я собирался показать, как «и ошибки удобно обрабатывать и exceptions при этом не использовать», да всё руки не доходили.

Итак, будем считать, что у нас ситуация, что «настоящие C++ exceptions» использовать нельзя — например, языком разработки является C или компилятор С++ для нашей платформы не поддерживает exceptions (или формально поддерживает, а реально этим пользоваться нельзя). Это, конечно, нетипично для desktop приложений, но вполне обычно для embedded разработки.

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

// инициализация подсистемы
boolean subsystem_init(subsystem* self) {
  // семафоры
  boolean ok = true;
  ok = ok && sema_create(&self->sema1, 0);
  ok = ok && sema_create(&self->sema2, 0);
  ok = ok && sema_create(&self->sema3, 1);

  // память
  ok = ok && ((self->buffer1 = malloc(self->buff_size1)) != NULL);
  ok = ok && ((self->buffer2 = malloc(self->buff_size2)) != NULL);

  // фильтры
  ok = ok && filter1_init(&self->f1, &self->x, &self->y, self->buffer1);
  ok = ok && filter2_init(&self->f2, &self->level, self->buffer2);

  return ok;
}

Чем хорош данный код? Самое главное его качество — его логика линейна. В нём нет ни одного (явного) if, программист просто пишет последовательность инициализации. Другое такое же по важности качество — происходит перехват всех ошибок. Каждая используемая в этом примере функция может завершиться с ошибкой — однако информация об этом не теряется, а будет передана на верхний уровень. Заметим, также, что при возникновении исключительной ситуации (например, не удалось выделить память для массива buffer2) система не пойдёт в разнос (т.е. не будет попыток создать filter2 подсовывая ему невалидный указатель на буфер). Вообще ни одна из последующих функций не будет вызвана, а subsystem_init по завершению вернёт ошибку. Более того, инициализацию данной подсистемы можно легко встраивать в инициализацию системы верхнего уровня — всё, что требуется, чтобы этот подход использовался и там.

Уже, в принципе, всё хорошо — сама идея крайне проста, дополнительных накладных расходов на реализацию нет (проверять, успешно ли вызвался метод, надо в любом случае), никаких хитрых трюков не используется. Единственное требование — чтобы все функции, которые могут выполниться с ошибкой, укладывались в этот шаблон (это не трудно, на примере использования malloc видно, как это делается).

Но мы на этом останавливаться не будем, мы дальше пойдём.

Допустим, мы попытались проинициализировать эту подсистему, а получили ошибку. Было бы неплохо знать, где именно произошла внештатная ситуация — одному из фильтров не понравились его параметры или же ОС почему-то не хочет создавать семафоры. Булевского типа здесь уже не хватает. Хочется точно идентифицировать проблемную строку, а в идеале — иметь нормальный человеческий call stack (например, фильтру не понравились входные параметры, а у нас в программе десяток фильтров такого типа, какому именно не понравились — без call stack не ясно).

Не проблема. Всё, что нам потребуется — один дополнительный параметр для каждой функции, примерно такого вида:

typedef struct err_info {
  int count;
  int32_t stack[MAX_STACK]; // стек не бесконечный, да
};

В случае возникновения ошибки все функции должны делать следующее:

  • добавить уникальный ID к стеку (если есть ещё место),
  • выйти из функции, вернув false.

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

// инициализация подсистемы
boolean subsystem_init(subsystem* self, err_info* e) {
  // семафоры
  REQ(sema_create(&self->sema1, 0), 0x157DF5F3);
  REQ(sema_create(&self->sema2, 0), 0x601414A4);
  REQ(sema_create(&self->sema3, 1), 0x7D8E585D);

  // память
  REQ(self->buffer1 = malloc(self->buff_size1), 0x5DEB6FC7);
  REQ(self->buffer2 = malloc(self->buff_size2), 0x7939EDC5);

  // фильтры
  REQ(filter1_init(&self->f1, &self->x, &self->y, self->buffer1, e), 0x4D83E154);
  REQ(filter2_init(&self->f2, &self->level, self->buffer2, e), 0x5B4D8F8D);

  return true;
}

(ID генерируется не руками, конечно же, а любимой IDE по нажатию соответствующих клавиш)

А сам макрос REQ можно определить, допустим, таким образом:

#define REQ(X, ID)                                 
  if (X)       
    ;          
  else {       
    if (e->count < MAX_STACK)      
      e->stack[e->count++] = ID;   
    return false;                  
  }

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

Резюмируя:

  • остались в рамках ANSI C (никаких нестандартных расширений не использовали),
  • получили (практически бесплатный) call stack,
  • исключительные ситуации отлавливаются,
  • код линеен, не замусоривается постоянными проверками возвращаемых значений, в которых легко ошибиться,
  • если у нас «С++ без exceptions», точно также получаем вызовы деструкторов уже созданных локальных переменных, как и при обычном генерировании exceptions.

ps: да, по сути — это реализация монады Maybe.

Автор: qehgt


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js