Пишем try-catch в C не привлекая внимания санитаров +47


Всё началось с безобидного пролистывания GCC расширений для C. Мой глаз зацепился за вложенные функции. Оказывается, в C можно определять функции внутри функций:

int main() {
    void foo(int a) {
        printf("%d\n", a);
    }
    for(int i = 0; i < 10; i ++)
        foo(i);
    return 0;
}

Более того, во вложенных функциях можно менять переменные из внешней функции и переходить по меткам из неё, но для этого необходимо, чтобы переменные были объявлены до вложенной функции, а метки явно указаны через __label__

int main() {
    __label__ end;
    int i = 1;

    void ret() {
        goto end;
    }
    void inc() {
        i ++;
    }
    
    while(1) {
        if(i > 10)
            ret();
        printf("%d\n", i);
        inc();
    }

  end:
    printf("Done\n");
    return 0;
}

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

Приступим к написанию try-catch. Определим вспомогательные типы данных:

// Данными, как и выкинутой ошибкой может быть что угодно
typedef void *data_t;
typedef void *err_t;

// Определяем функцию для выкидывания ошибок
typedef void (*throw_t)(err_t);

// try и catch. Они тоже будут функциями
typedef data_t (*try_t)(data_t, throw_t);
typedef data_t (*catch_t)(data_t, err_t);

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

data_t try_catch(try_t try_, catch_t catch_, data_t data) {
    __label__ fail;
    err_t err;
    // Объявляем функцию выбрасывания ошибки
    void throw_(err_t e) {
        err = e;
        goto fail;
    }
    // Передаём в try данные и callback для ошибки
    return try_(data, throw_);
    
  fail:
    // Если есть catch, передаём данные, над которыми 
    // работал try и ошибку, которую он выбросил
    if(catch_ != NULL)
        return catch_(data, err);
    // Если нет catch, возвращаем пустой указатель
    return NULL;
}

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

data_t try_sqrt(data_t ptr, throw_t throw_) {
    float *arg = (float *)ptr;
    if(*arg < 0)
        throw_("Error, negative number\n");
  
    // Выделяем кусок памяти для результата
    float *res = malloc(sizeof(float));
    *res = sqrt(*arg);
    return res;
}

data_t catch_sqrt(data_t ptr, err_t err) {
    // Если возникла ошибка, печатает её и ничего не возвращаем
    fputs(err, stderr);
    return NULL;
}

Добавляем функцию main, посчитаем в ней корень от 1 и от -1

int main() {
    printf("------- sqrt(1) --------\n");
    float a = 1;
    float *ptr = (float *) try_catch(try_sqrt, catch_sqrt, &a);

    if(ptr != NULL) {
        printf("Result of sqrt is: %f\n", *ptr);
        // Не забываем освободить выделенную память
        free(ptr);
    } else
        printf("An error occured\n");
    

    printf("------- sqrt(-1) -------\n");
    a = -1;
    ptr = (float *)try_catch(try_sqrt, catch_sqrt, &a);

    if(ptr != NULL) {
        printf("Result of sqrt is: %f\n", *ptr);
        // Аналогично
        free(ptr);
    } else
        printf("An error occured\n");
  
    return 0;
}

И, как и ожидалось, получаем

------- sqrt(1) --------
Result of sqrt is: 1.000000
------- sqrt(-1) -------
Error, negative number
An error occured

Try-catch готов, господа.

На этом статью можно было бы и закончить, но тут внимательный читатель заметит, что функция throw остаётся валидной в блоке catch. Можно вызвать её и там, и тогда мы уйдём в рекурсию. Заметим также, что функция throw, это не обычная функция, она noreturn и разворачивает стек, поэтому, даже если вызвать её в catch пару сотен раз, на стеке будет только последний вызов. Мы получаем хвостовую оптимизацию рекурсии.

Попробуем посчитать факториал на нашем try-catch. Для этого передадим указатель на функцию throw в функцию catch. Сделаем это через структуру, в которой также будет лежать аккумулятор вычислений.

struct args {
    uint64_t acc;
    throw_t throw_;
};

В функции try инициализируем поле throw у структуры, и заводим переменную num для текущего шага рекурсии.

data_t try_(data_t ptr, throw_t throw_) {
    struct args *args = ptr;
    // Записываем функцию в структуру, чтобы catch мог её pf,hfnm
    args->throw_ = throw_;
  
    // Заводим переменную для хранения текущего шага рекурсии
    uint64_t *num = malloc(sizeof(uint64_t));
    // Изначально в acc лежит начальное число, в нашем случае 10
    *num = args->acc; 
    // Уменьшаем число
    (*num) --;
    // Уходим в рекурсию
    throw_(num);
}

В функции catch будем принимать структуру и указатель на num, а дальше действуем как в обычном рекурсивном факториале.

data_t catch_(data_t ptr, err_t err) {
    struct args *args = ptr;
    // В err на самом деле лежит num
    uint64_t *num = err;
    // Печатаем num, будем отслеживать рекурсию
    printf("current_num: %"PRIu64"\n", *num);
    
    if(*num > 0) {
        args->acc *= *num;
        (*num) --;
        // Рекурсивный вызов
        args->throw_(num);
    }
    // Конец рекурсии
    // Не забываем осовободить выделенную память
    free(num);
    
    // Выводим результат
    printf("acc is: %"PRIu64"\n", args->acc);
    return &args->acc;
}
int main() {
    struct args args = { .acc = 10 };
    try_catch(try_, catch_, &args);

    return 0;
}

Вызываем, и получаем, как и ожидалось:

current_num: 9
current_num: 8
current_num: 7
current_num: 6
current_num: 5
current_num: 4
current_num: 3
current_num: 2
current_num: 1
current_num: 0
acc is: 3628800
main.c
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <inttypes.h>
#include <stdnoreturn.h>

typedef void *err_t;
typedef void *data_t;
typedef void (*throw_t)(err_t);
typedef data_t (*try_t)(data_t, throw_t);
typedef data_t (*catch_t)(data_t, err_t);


data_t try_catch(try_t try, catch_t catch, data_t data) {
    __label__ fail;
    err_t err;
    void throw(err_t e) {
        err = e;
        goto fail;
    }

    return try(data, throw);
    
  fail:
    if(catch != NULL)
        return catch(data, err);
    return NULL;
}

struct args {
    uint64_t acc;
    throw_t throw_;
};

data_t try_(data_t ptr, throw_t throw_) {
    struct args *args = ptr;
    args->throw_ = throw_;

    uint64_t *num = malloc(sizeof(uint64_t));
    *num = args->acc;
    (*num) --;
    
    throw_(num);
}

data_t catch_(data_t args_ptr, err_t num_ptr) {
    struct args *args = args_ptr;
    uint64_t *num = num_ptr;
    
    printf("current_num: %"PRIu64"\n", *num);
    
    if(*num > 0) {
        args->acc *= *num;
        (*num) --;
        args->throw_(num);
    }
    free(num);
    printf("acc is: %"PRIu64"\n", args->acc);
    return &args->acc;
}

int main() {
    struct args args = { .acc = 10 };
    try_catch(try_, catch_, &args);

    return 0;
}

Спасибо за внимание.

P.S. Текст попытался вычитать, но, так как русского в школе не было, могут быть ошибки. Прошу сильно не пинать и по возможности присылать всё в ЛС, постараюсь реагировать оперативно.




Комментарии (21):

  1. Samhuawei
    /#24945708

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

    Вот первый попавшийся в выдаче Google сайт.

    https://www.geeksforgeeks.org/signals-c-language/

    • bfDeveloper
      /#24945982 / +2

      Я не C, а C++ программист и может быть поэтому не понимаю, но как использовать сигналы вместо исключений? Для взаимодействия с другими процессами - понятно, но что толку слать сигнал самому себе? Стек после хэндлера будет тем же, выполнение продолжится со следующей после kill строки. Зачем?

      • vda19999
        /#24951204

        Никак не использовать. Это очень странная идея

      • AndreyHenneberg
        /#24953018

        Механизм, очень близкий к механизму в C есть --- setjmp()/longjmp(), заголовочный файл --- setjmp.h, стандартные библиотеки C. Кое-что приходится делать руками, например, закрывать файлы и освобождать память, но это работает. Как-то довелось использовать библиотеку для работы с PNG и вот там эти джампы активно используются и как раз для обработки ошибок.

    • orenty7
      /#24946070 / +2

      Сигналы не дают локальности исключений. Хотелось, чтобы как в других языках, можно было делать try-catch блоки внутри других try-catch блоков. Плюс сигналы не дают возможности удобно пробрасывать данные. В любом случае писать свою обёртку, но так нельзя всё испортить извне, например, навесив другой обработчик на этот же сигнал или вызвав setjmp где-то в коде (их предлагали использовать ниже)

      • AndreyHenneberg
        /#24953024

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

    • vda19999
      /#24951200 / +3

      Сигналы - это средство ОС Linux, а не языка С. На Windows тот же самый код работать не будет.

      Кроме того, отправка сигнала - это системный вызов, а это долго - переключение контекста, сохранение регистров, работа ядра и так далее.

      • AndreyHenneberg
        /#24953036

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

    • AndreyHenneberg
      /#24953020

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

  2. RekGRpth
    /#24945764 / +4

    лучше использовать setjmp и longjmp

  3. Apoheliy
    /#24946264 / +2

    Возможно, я ошибаюсь и ничего не понял, но такие вещи могут странно работать на разных моделях вызова (разная обработка стэка): у вас вызывается void throw(err_t e) который возвращает void, а вместо этого вы возвращаете data_t из функции try_catch (хоть NULL, хоть возврат от catch). В принципе, это можно обойти, если добавить некий контекст, там сохранить колбэк на catch и в throw сразу вызывать этот обработчик.

    Ещё одна проблема/пожелалка: вы выделяете память. Даже так: когда всё хорошо, то вы не выделяете память; когда есть проблемы, то вы выделяете и освобождаете память. Это есть не очень хорошо для маленьких процессоров (где с памятью не всё хорошо) и для режима ядра, где работа с памятью - это ЛОК. В принципе, это можно обойти, если заранее закладывать поля в некую структуру "контекст прерывания".

    Ещё есть идея try/catch загнать в препроцессор и сделать типа обвязки на кодом: пишем try, пишем код, пишем catch.

    В общем, можно ещё добавить ненормальности, можно.

  4. GRaAL
    /#24946480 / +2

    Мне нравятся такие эксперименты, продолжайте )

    А возможно ли тут как-то сделать пробрасывание? Ну, если catch не определён, то вызвать throw вышестоящего метода, или типа того...

    • orenty7
      /#24946578 / +1

      Можно завести стек, в функции try_catch добавлять в него текущий throw, а последний throw сделать глобальной функцией. Тогда выбросить ошибку можно будет почти отовсюду и она будет пробрасываться вверх по стеку, пока не встретит функцию catch. Более того, можно сделать коды (типы) ошибок и catch-ем ловить только те ошибки, которые он может обработать

  5. event1
    /#24946494 / +5

    Как верно подметил коллега выше, в стандартной библиотеке есть setjmp и longjmp. Пример использования этих вызовов для обработки исключений есть в известной утилите uci. Без никаких gcc 12 extensions

    • AndreyHenneberg
      /#24953050

      Вот спасибо! В следующий раз не придётся изобретать велосипед или делать всё руками.

  6. ikle
    /#24947182 / +1

    Оказывается, в C можно определять функции внутри функций

    Можно, вот только в Си (стандартном) они имеют область видимости файла, а не блока, где определены. (В отличие от Pascal и прочих Виртовских языков, например.)

    Более того, во вложенных функциях можно менять переменные из внешней функции и переходить по меткам из неё, но для этого необходимо, чтобы переменные были объявлены до вложенной функции, а метки явно указаны через __label__

    А вот это уже совсем не Си (стандартный), а GCC диалект. Так что стоит заменить в заголовке «в C» на «в GCC Си».

    ptr = (float *) try_catch

    Указатель на void в Си преобразуем к указателю на любой другой тип: не нужно лишних явных преобразований.

    Вообще нужно стараться избегать явных преобразований там, где без них можно обойтись: это корень зла — метод приказать компилятору заткнуться, повиноваться и делать, как программист сказал. Это бомба замедленного действия.

  7. aamonster
    /#24947264 / +2

    Паскалем повеяло... Доступ к переменным внешней функции (учитывая возможность рекурсии) – вообще говоря, нетривиальная задача (если по простому – вложенной функции нужен указатель на stack frame родительской), неудивительно, что это только в расширениях появилось.

  8. firehacker
    /#24947414 / +4

    Оказывается, в C можно определять функции внутри функций

    Ну, оказывается, не в Си, а в одном из расширений для Си в составе gcc. В чистом стандартном Си ничего подобного нет. Если уж расширить выборку до всех на свете расширений языка Си, то в Microsoft-овском компиляторе Си есть расширение в виде ключевых слов _try/_except, которые дают готовый механизм обработки исключений, основанный на SEH.

    Если же брать чистый стандартный Си, то механизм исключений может быть заполучен как результат использования стандартных функций setjmp/longjmp. Причем, если их обернуть в соответствующие макросы, внешне для программиста это будет выглядеть как типичное try/except.

    Именно так, к примеру, сделано в исходниках VB/VBA, а значит этот механизм является частью VBA в составе Офисов, частью VB IDE и VB-рантайм-библиотеки.

  9. pinbraerts
    /#24947430

    Четверной лутц за один только заголовок статьи.

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

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

    Безусловно должен быть способ как-то выкинуть исключение с пользовательским описанием, так же должен быть способ как-то его поймать, чтобы можно было написать свой дампер. Но использовать это как if-else или выход из глубокой рекурсии -- ремонт часов кувалдой.

    Первый пример всё равно сводится к if-else. Если вам нужны именно локальные исключения, чем они отличаются от if-else? Эти вопросы всегда упираются в грамматику и возможности языка. Удобные optional или variadic на С будут намного полезнее.

    • rsashka
      /#24948510

      Тут наверно проблема в том, что необходимость в "исключениях" появились позже чем появились функции.

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

    • me21
      /#24954132

      Я исключения в одной программе применяю при чтении пакета данных из последовательного порта. Прочитал заголовок кадра и попытался сконструировать объект - кадр определённого типа. Если сообщение под него не подходит, конструктор кидает исключение, можно пробовать что-то ещё.

      Довольно удобно, вся логика проверки соответствия полученных данных определенному формату инкапсулирована в классе, точнее, в конструкторе.