Всё началось с безобидного пролистывания 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)
Apoheliy
24.11.2022 17:45+2Возможно, я ошибаюсь и ничего не понял, но такие вещи могут странно работать на разных моделях вызова (разная обработка стэка): у вас вызывается void throw(err_t e) который возвращает void, а вместо этого вы возвращаете data_t из функции try_catch (хоть NULL, хоть возврат от catch). В принципе, это можно обойти, если добавить некий контекст, там сохранить колбэк на catch и в throw сразу вызывать этот обработчик.
Ещё одна проблема/пожелалка: вы выделяете память. Даже так: когда всё хорошо, то вы не выделяете память; когда есть проблемы, то вы выделяете и освобождаете память. Это есть не очень хорошо для маленьких процессоров (где с памятью не всё хорошо) и для режима ядра, где работа с памятью - это ЛОК. В принципе, это можно обойти, если заранее закладывать поля в некую структуру "контекст прерывания".
Ещё есть идея try/catch загнать в препроцессор и сделать типа обвязки на кодом: пишем try, пишем код, пишем catch.
В общем, можно ещё добавить ненормальности, можно.
GRaAL
24.11.2022 18:40+2Мне нравятся такие эксперименты, продолжайте )
А возможно ли тут как-то сделать пробрасывание? Ну, если catch не определён, то вызвать throw вышестоящего метода, или типа того...
orenty7 Автор
24.11.2022 19:09+1Можно завести стек, в функции try_catch добавлять в него текущий throw, а последний throw сделать глобальной функцией. Тогда выбросить ошибку можно будет почти отовсюду и она будет пробрасываться вверх по стеку, пока не встретит функцию catch. Более того, можно сделать коды (типы) ошибок и catch-ем ловить только те ошибки, которые он может обработать
event1
24.11.2022 18:46+5Как верно подметил коллега выше, в стандартной библиотеке есть setjmp и longjmp. Пример использования этих вызовов для обработки исключений есть в известной утилите uci. Без никаких gcc 12 extensions
AndreyHenneberg
26.11.2022 19:09Вот спасибо! В следующий раз не придётся изобретать велосипед или делать всё руками.
ikle
24.11.2022 23:06+1Оказывается, в C можно определять функции внутри функций
Можно, вот только в Си (стандартном) они имеют область видимости файла, а не блока, где определены. (В отличие от Pascal и прочих Виртовских языков, например.)
Более того, во вложенных функциях можно менять переменные из внешней функции и переходить по меткам из неё, но для этого необходимо, чтобы переменные были объявлены до вложенной функции, а метки явно указаны через __label__
А вот это уже совсем не Си (стандартный), а GCC диалект. Так что стоит заменить в заголовке «в C» на «в GCC Си».
ptr = (float *) try_catch
Указатель на void в Си преобразуем к указателю на любой другой тип: не нужно лишних явных преобразований.
Вообще нужно стараться избегать явных преобразований там, где без них можно обойтись: это корень зла — метод приказать компилятору заткнуться, повиноваться и делать, как программист сказал. Это бомба замедленного действия.
aamonster
24.11.2022 23:51+2Паскалем повеяло... Доступ к переменным внешней функции (учитывая возможность рекурсии) – вообще говоря, нетривиальная задача (если по простому – вложенной функции нужен указатель на stack frame родительской), неудивительно, что это только в расширениях появилось.
firehacker
25.11.2022 01:05+4Оказывается, в C можно определять функции внутри функций
Ну, оказывается, не в Си, а в одном из расширений для Си в составе gcc. В чистом стандартном Си ничего подобного нет. Если уж расширить выборку до всех на свете расширений языка Си, то в Microsoft-овском компиляторе Си есть расширение в виде ключевых слов _try/_except, которые дают готовый механизм обработки исключений, основанный на SEH.
Если же брать чистый стандартный Си, то механизм исключений может быть заполучен как результат использования стандартных функций setjmp/longjmp. Причем, если их обернуть в соответствующие макросы, внешне для программиста это будет выглядеть как типичное try/except.
Именно так, к примеру, сделано в исходниках VB/VBA, а значит этот механизм является частью VBA в составе Офисов, частью VB IDE и VB-рантайм-библиотеки.
pinbraerts
25.11.2022 01:28Четверной лутц за один только заголовок статьи.
По моему мнению, исключения существуют только потому что существуют. Они приводят к дичайшему усложнению грамматики, ABI, проблемам при стандартизации и багам компиляторов и интерпретаторов. Если есть какой-то нерассмотренный случай, программа должна максимально быстро и информативно сообщить об этом разработчику, то есть с грохотом упасть, чтобы он этот случай рассмотрел и уже сам решил, как программа должна на него реагировать.
Одной системой типов можно заставить программиста явно рассматривать все актуальные локальные случаи ещё до компиляции. Потом за счёт анализа потока данных и тестов можно указать на ещё более редкие и пограничные случаи, хотя вся их редкость заключается в комбинации множества совершенно обычных случаев.
Безусловно должен быть способ как-то выкинуть исключение с пользовательским описанием, так же должен быть способ как-то его поймать, чтобы можно было написать свой дампер. Но использовать это как if-else или выход из глубокой рекурсии -- ремонт часов кувалдой.
Первый пример всё равно сводится к if-else. Если вам нужны именно локальные исключения, чем они отличаются от if-else? Эти вопросы всегда упираются в грамматику и возможности языка. Удобные optional или variadic на С будут намного полезнее.
rsashka
25.11.2022 11:05Тут наверно проблема в том, что необходимость в "исключениях" появились позже чем появились функции.
Если бы эти понятия придумали одновременно, то думаю, что все было бы гораздо прозаичнее. Не нужно было бы разделять понятия "исключение" и "возврат", а была бы одна универсальная сущность, которая бы прерывала выполнение кода и разматывала стек до нужного места с возвращением результата.
me21
27.11.2022 10:27Я исключения в одной программе применяю при чтении пакета данных из последовательного порта. Прочитал заголовок кадра и попытался сконструировать объект - кадр определённого типа. Если сообщение под него не подходит, конструктор кидает исключение, можно пробовать что-то ещё.
Довольно удобно, вся логика проверки соответствия полученных данных определенному формату инкапсулирована в классе, точнее, в конструкторе.
Samhuawei
В качестве тренировки для ума пойдёт, но в принципе в языке C есть встроенные средства типа сигналов. Которые делают то же самое, но на уровне библиотеки C.
Вот первый попавшийся в выдаче Google сайт.
https://www.geeksforgeeks.org/signals-c-language/
bfDeveloper
Я не C, а C++ программист и может быть поэтому не понимаю, но как использовать сигналы вместо исключений? Для взаимодействия с другими процессами - понятно, но что толку слать сигнал самому себе? Стек после хэндлера будет тем же, выполнение продолжится со следующей после kill строки. Зачем?
vda19999
Никак не использовать. Это очень странная идея
AndreyHenneberg
Механизм, очень близкий к механизму в C есть --- setjmp()/longjmp(), заголовочный файл --- setjmp.h, стандартные библиотеки C. Кое-что приходится делать руками, например, закрывать файлы и освобождать память, но это работает. Как-то довелось использовать библиотеку для работы с PNG и вот там эти джампы активно используются и как раз для обработки ошибок.
orenty7 Автор
Сигналы не дают локальности исключений. Хотелось, чтобы как в других языках, можно было делать try-catch блоки внутри других try-catch блоков. Плюс сигналы не дают возможности удобно пробрасывать данные. В любом случае писать свою обёртку, но так нельзя всё испортить извне, например, навесив другой обработчик на этот же сигнал или вызвав setjmp где-то в коде (их предлагали использовать ниже)
AndreyHenneberg
setjmp() работает довольно неплохо, но требует несколько большей внимательности к деталям, иначе могут начать течь ресурсы. Ваш вариант безопаснее, но и несколько более запутанный. В общем, очень неплох в качестве дополнения к стандартному способу, но в качестве полной замены я бы его не рассматривал.
vda19999
Сигналы - это средство ОС Linux, а не языка С. На Windows тот же самый код работать не будет.
Кроме того, отправка сигнала - это системный вызов, а это долго - переключение контекста, сохранение регистров, работа ядра и так далее.
AndreyHenneberg
Да это всё фигня по сравнению с тем, что после обработки сигнала процесс возвращается в ту же точку, из которой его выдернули, что логично, учитывая, что сигнал --- это механизм общения процессов и сторонний процесс не может знать, в какой точке кода находится вызываемая сторона. А вот исключение C++ и других объектно-ориентированных языков, предложенный вариант и setjmp()/lonhjmp() как раз выбрасывают процесс в заданную точку, "разматывая" стэк, то есть в точку сбоя процесс уже не вернётся. В варианте setjmp()/lonhjmp() надо ещё вручную освободить ресурсы, но автоматизировать этот процесс в C невозможно.
AndreyHenneberg
Сигналы используются для межпроцессного взаимодействия, а для обработки исключений лучше посмотрите в сторону setjmp()/longjmp() --- этот механизм именно для этого и предназначен. Не так красиво, как в C++, но, при должной внимательности, работает достаточно надёжно.