Введение
При взятии адреса вложенной функции GCC создаёт трамплин во время выполнения программы. Такой трамплин размещается на стеке, а значит, стек должен быть исполняемым. Это проблематично, поскольку неисполняемый стек — важная функция безопасности. Некоторое время назад в GCC появилась возможность размещать трамплин в куче, что лучше, но всё ещё не идеально: выделение памяти обходится дороже, а трамплин может утечь при использовании longjmp.
В предыдущей заметке было показано, как этого можно избежать с помощью нового типа широкого указателя, а также рассказано о предварительном патче для GCC, реализующем часть базовой функциональности, необходимой для этого.
Здесь речь пойдёт о двух обновлениях.
GCC 16: вложенные функции без захвата
Во-первых, вышел GCC 16. В нём гарантируется, что вложенная функция, не обращающаяся к переменным родительской функции, не потребует трамплина. На практике это уже обеспечивалось при оптимизации, но теперь гарантируется и без оптимизации, а также задокументировано. Это также означает, что такую функцию можно безопасно возвращать из родительской, и при попытке вернуть функцию, которая действительно обращается к локальному контексту, компилятор выдаст предупреждение (по крайней мере в простых случаях, очевидных для него), как в следующем примере (пример на Godbolt).
typedef int cb_f(int);
cb_f *foo(int x)
{
int worker(int y)
{
return x + y; // capture
}
return worker;
}
cb_f *bar(int _x)
{
static int x;
x = _x;
int worker(int y)
{
return x + y; // no capture
}
return worker;
}
Вложенные функции, не обращающиеся к переменным родительской функции, всё же могут обращаться к статическим переменным, именованным константам или типам (если те не изменяются переменно). Это полезно для написания функций обратного вызова (пример на Godbolt).
typedef int cb_f(void *, int y);
int process(cb_f *cb, void *data)
{
return cb(data, 5);
}
int foo(int x)
{
struct { int x; } data = { .x = x };
int worker(void *_data, int y)
{
typeof(data) *data = _data;
return data->x + y;
}
return process(worker, &data);
}
Тем не менее, истинная сила вложенных функций — в возможности напрямую обращаться к переменным родительской функции. Кроме того, приведённый выше код выглядит так, будто такие функции просто имитируются вручную добавленным шаблонным кодом — и компилятор наверняка мог бы помочь с этим. Об этом — второе обновление.
GCC 17: вложенные функции с захватом
Недавно в ветку разработки GCC была принята версия патча, реализующая две новые встроенные функции (builtins), которые вместе с уже существующей __builtin_call_with_static_chain позволяют написать следующий пример (пример на Godbolt) без необходимости в трамплинах.
typedef int cb_f(int y);
static int process(cb_f *cb, void *data)
{
return __builtin_call_with_static_chain(cb(5), data);
}
int main()
{
int x = 7;
int worker(int y)
{
return x + y;
}
return process(__builtin_call_code_address(worker),
__builtin_call_static_chain(worker));
}
На самом деле, если убрать трамплины из уравнения, всё, что делает GCC под капотом — это автоматически преобразует исходный код в пример выше! Единственное отличие от вручную написанной версии заключается в том, что указатель на данные передаётся через специальный регистр. Этот регистр используется многими другими языками программирования для передачи статической цепочки и потому уже зарезервирован для этой цели в ABI.
Даже без полноценной поддержки на уровне языка, пример можно сделать немного изящнее, определив обобщённый тип широкого указателя и обернув встроенные функции макросами. (пример на Godbolt)
#define wide(T) struct wide_##T { typeof(T) *code; void *chain; }
typedef int cb_f(int y);
int baz(wide(cb_f) p, int x)
{
return CALL(p, (x));
}
int foo(int k)
{
int bar(int x) { return k + x; }
return baz(CLOSURE(cb_f, bar), 2 * k);
}
Компилятор без труда оптимизирует это до одной ассемблерной инструкции, реализующей умножение на три.
foo:
lea eax, [rdi+rdi*2]
ret
Перспективы
Хотя вложенные функции поддерживают и другие компиляторы, помимо GCC, эта возможность не вполне переносима между ними. В дальнейшем планируется рассмотреть, как использовать вложенные функции одновременно с clang и GCC, а также как с помощью небольшого трюка избежать создания трамплинов даже в более старых версиях GCC.