Цель настоящей статьи - изучить лямбда функции: чем они отличаются от обычных функций и изучить, как они реализованы в С++, Python 3 и Java.
На протяжении этой статьи я буду использовать godbolt.org, чтобы компилировать код и изучать машинный код или байт код. Я думаю, что при чтении статьи может быть удобнее смотреть не на приведённый машинный код в статье, а на этом сайте.
C++
Что такое функция? Можно сказать, что функция - это набор выражений языка программирования, в данном случае С++, перед которым следует декларация функции из её названия, аргументов, типов и так далее. С точки зрения случая, когда вы передаёте одну функцию в другую, функция - это просто напросто указатель на начало функции в исполняемом файле. С точки зрения же машинного кода, функция - это последовательность инструкций, предварённая меткой с названием функции, такая, что эта последовательность соответствует calling conventions.
Окромя обыкновенных функция, в С++ есть и лямбда функции, которые можно объявлять внутри других функций. Как и обычные функции, они могут принимать аргументы и возвращать значения, но, в отличие от обычных функций, они могут "захватывать" значения из того контекста, в котором объявлены, по значению или по ссылке (иными словами, лямбда функция может использовать значения снаружи или указатели на них). Давайте посмотрим на несколько примеров с лямбдами на С++ и на то, в какой ассемблерный код они компилируются. Я буду использовать gcc 12.2 с флагом -Os (чем меньше ассемблерного кода будет сгенерировано, тем проще его читать).
#include <vector> #include <cstdio> // extern so that it won`t be inlined // функция объявлена как extern, чтобы её нельзя было заинлайнить extern void for_each(const std::vector<int>& vec, void (*func) (int)); void example(const std::vector<int>& vec) { for_each(vec, [] (int n) { printf("%d\n", n); }); }
.LC0: .string "%d\n" example(std::vector<int, std::allocator<int> > const&)::{lambda(int)#1}::_FUN(int): movl %edi, %esi xorl %eax, %eax movl $.LC0, %edi jmp printf example(std::vector<int, std::allocator<int> > const&): movl $example(std::vector<int, std::allocator<int> > const&)::{lambda(int)#1}::_FUN(int), %esi jmp for_each(std::vector<int, std::allocator<int> > const&, void (*)(int)) // note that using jmp instead of call is an optimization; the called // function will return to the callee of the current function, skipping `example`
В примере выше мы ничего особенного не видим: лямбда функция, которую мы объявили, просто напросто была скомпилирована как обычная функция (то есть, если мы бы объявили обычную функцию такого же типа и с таким же телом, ассемблер был бы такой же). Давайте посмотрим на более интересный пример, где лямбда захватит переменную снаружи.
#include <vector> #include <cstdio> extern void for_each(const std::vector<int>& vec, void (*func) (int)); void example(const std::vector<int>& vec, int threshold) { for_each(vec, [threshold] (int n) { if (n > threshold) { printf("%d\n", n); } }); }
Впрочем, это не скомпилируется:
<source>: In function 'void example(const std::vector<int>&, int)': <source>:7:19: error: cannot convert 'example(const std::vector<int>&, int)::<lambda(int)>' to 'void (*)(int)' 7 | for_each(vec, [threshold] (int n) { | ^~~~~~~~~~~~~~~~~~~~~ | | | example(const std::vector<int>&, int)::<lambda(int)> 8 | if (n > threshold) { | ~~~~~~~~~~~~~~~~~~~~ 9 | printf("%d\n", n); | ~~~~~~~~~~~~~~~~~~ 10 | } | ~ 11 | }); | ~ <source>:4:58: note: initializing argument 2 of 'void for_each(const std::vector<int>&, void (*)(int))' 4 | extern void for_each(const std::vector<int>& vec, void (*func) (int)); | ~~~~~~~^~~~~~~~~~~ Compiler returned: 1
Выясняется, что лямбда функция, объявленная внутри другой функции и захватывающая переменные снаружи, имеет более сложный тип, чем обычная функция, в данной случае, example(const std::vector<int>&, int)::<lambda(int)>, то есть тип лямбда функции будет включать информацию о том, где она объявлена.
Из этого можно сделать два важных вывода. Во-первых, произвольная лямбда функция не может быть скомпилирована просто в ассемблерную функцию (как описано выше), это на самом деле объект, имеющий некоторое состояние. И во-вторых, лямбда функция, захватывающая аргументы снаружи, может быть передана только в шаблонную функцию, у которой тип принимаемой функции является шаблонным параметром (шаблонная функция - это шаблон функции, позволяющий компилятору генерировать функции для каждой подстановки параметров шаблона). Это означает, что для каждой новой лямбды, которую вы захотите передать в одну и ту же шаблонную функцию, компилятор будет вынужден сгенерировать новую версию шаблонной функции. Давайте посмотрим на пример:
#include <vector> #include <cstdio> template <typename Callable> void __attribute__ ((noinline)) for_each(const std::vector<int>& vec, Callable func) { for (const auto& elem : vec) { func(elem); } } void example(const std::vector<int>& vec, int threshold) { for_each(vec, [threshold] (int n) { if (n > threshold) { printf("%d\n", n); } }); }
.LC0: .string "%d\n" void for_each<example(std::vector<int, std::allocator<int> > const&, int)::{lambda(int)#1}>(std::vector<int, std::allocator<int> > const&, example(std::vector<int, std::allocator<int> > const&, int)::{lambda(int)#1}): pushq %r12 pushq %rbp movl %esi, %ebp pushq %rbx movq 8(%rdi), %r12 movq (%rdi), %rbx .L2: cmpq %rbx, %r12 je .L7 movl (%rbx), %esi cmpl %ebp, %esi jle .L3 movl $.LC0, %edi xorl %eax, %eax call printf .L3: addq $4, %rbx jmp .L2 .L7: popq %rbx popq %rbp popq %r12 ret example(std::vector<int, std::allocator<int> > const&, int): jmp void for_each<example(std::vector<int, std::allocator<int> > const&, int)::{lambda(int)#1}>(std::vector<int, std::allocator<int> > const&, example(std::vector<int, std::allocator<int> > const&, int)::{lambda(int)#1})
Как можно видеть, реализация функции for_each была сгенерирована, и наша лямбда туда заинлайнилась - а for_each на самом деле оказалось функцией, принимающей вектор и число.
Это означает, что реализация лямбда функций в С++ основана на механизме шаблонов, и в большинстве случаев, лямбда функция будет инлайнится в шаблонную функцию.
Но не всегда! Мы (вроде) не может попросить компилятор не инлайнить лямбды, но мы можем сделать это дорогим. Поскольку у нас используется опция -Os, то компилятор должен постараться не инлайнить её в случае, если лямбда становится большой и передаётся сразу в несколько функций:
#include <vector> #include <cstdio> template <typename Callable> void __attribute__ ((noinline)) for_each(const std::vector<int>& vec, Callable func) { for (const auto& elem : vec) { func(elem); } } template <typename Callable> void __attribute__ ((noinline)) for_each2(const std::vector<int>& vec, Callable func) { for (auto iter = vec.rbegin(); iter != vec.rend(); iter++) { func(*iter); } } void example(const std::vector<int>& vec, int threshold) { auto lambda = [threshold] (int n) { if (n > threshold) { printf("%d\n", n); printf("number: %d", n); printf("previous number: %d", --n); printf("next number: %d", n + 2); } }; for_each(vec, lambda); for_each2(vec, lambda); }
.LC0: .string "%d\n" .LC1: .string "number: %d" .LC2: .string "previous number: %d" .LC3: .string "next number: %d" example(std::vector<int, std::allocator<int> > const&, int)::{lambda(int)#1}::operator()(int) const [clone .part.0]: pushq %rbx movl %edi, %esi movl %edi, %ebx xorl %eax, %eax movl $.LC0, %edi call printf movl %ebx, %esi movl $.LC1, %edi xorl %eax, %eax call printf leal -1(%rbx), %esi movl $.LC2, %edi xorl %eax, %eax call printf leal 1(%rbx), %esi movl $.LC3, %edi xorl %eax, %eax popq %rbx jmp printf void for_each<example(std::vector<int, std::allocator<int> > const&, int)::{lambda(int)#1}>(std::vector<int, std::allocator<int> > const&, example(std::vector<int, std::allocator<int> > const&, int)::{lambda(int)#1}): pushq %r12 pushq %rbp movl %esi, %ebp pushq %rbx movq 8(%rdi), %r12 movq (%rdi), %rbx .L4: cmpq %rbx, %r12 je .L9 movl (%rbx), %edi cmpl %ebp, %edi jle .L5 call example(std::vector<int, std::allocator<int> > const&, int)::{lambda(int)#1}::operator()(int) const [clone .part.0] .L5: addq $4, %rbx jmp .L4 .L9: popq %rbx popq %rbp popq %r12 ret void for_each2<example(std::vector<int, std::allocator<int> > const&, int)::{lambda(int)#1}>(std::vector<int, std::allocator<int> > const&, example(std::vector<int, std::allocator<int> > const&, int)::{lambda(int)#1}): pushq %r12 movl %esi, %r12d pushq %rbp movq %rdi, %rbp pushq %rbx movq 8(%rdi), %rbx .L11: cmpq %rbx, 0(%rbp) je .L15 movl -4(%rbx), %edi cmpl %r12d, %edi jle .L12 call example(std::vector<int, std::allocator<int> > const&, int)::{lambda(int)#1}::operator()(int) const [clone .part.0] .L12: subq $4, %rbx jmp .L11 .L15: popq %rbx popq %rbp popq %r12 ret example(std::vector<int, std::allocator<int> > const&, int): subq $24, %rsp movl %esi, 12(%rsp) movq %rdi, (%rsp) call void for_each<example(std::vector<int, std::allocator<int> > const&, int)::{lambda(int)#1}>(std::vector<int, std::allocator<int> > const&, example(std::vector<int, std::allocator<int> > const&, int)::{lambda(int)#1}) movl 12(%rsp), %esi movq (%rsp), %rdi addq $24, %rsp jmp void for_each2<example(std::vector<int, std::allocator<int> > const&, int)::{lambda(int)#1}>(std::vector<int, std::allocator<int> > const&, example(std::vector<int, std::allocator<int> > const&, int)::{lambda(int)#1})
Лямбда скомпилировалась в функцию, которая принимает число и печатает его несколько раз. Сама же проверка n > threshold заинлайнилась в функции for_each и for_each2! При этом, подстрока operator()(int) const в названии функции, соответствующей лямбда-функции, намекает на то, что компилятор представляет лямбда-функцию как структруру, которая может хранить то, что лямбда захватила и имеет оператор () [круглые скобки]. Ещё немного, и мы можем заставить компилятор ничего от лямбды не инлайнить:
#include <vector> #include <cstdio> extern bool predicate(int a, int threshold); template <typename Callable> void __attribute__ ((noinline)) for_each(const std::vector<int>& vec, Callable func) { for (const auto& elem : vec) { func(elem); } } template <typename Callable> void __attribute__ ((noinline)) for_each2(const std::vector<int>& vec, Callable func) { for (auto iter = vec.rbegin(); iter != vec.rend(); iter++) { func(*iter); } } void example(const std::vector<int>& vec, int threshold) { auto lambda = [threshold] (int n) { if (predicate(n, threshold)) { printf("%d\n", n); printf("number: %d", n); printf("previous number: %d", --n); printf("next number: %d", n + 2); } }; for_each(vec, lambda); for_each2(vec, lambda); }
.LC0: .string "%d\n" .LC1: .string "number: %d" .LC2: .string "previous number: %d" .LC3: .string "next number: %d" // first argument in always passed in %edi, second in %esi, third in %edx example(std::vector<int, std::allocator<int> > const&, int)::{lambda(int)#1}::operator()(int) const [clone .isra.0]: pushq %rbx movl %esi, %ebx movl %edi, %esi movl %ebx, %edi call predicate(int, int) testb %al, %al je .L1 movl %ebx, %esi movl $.LC0, %edi xorl %eax, %eax call printf movl %ebx, %esi movl $.LC1, %edi xorl %eax, %eax call printf leal -1(%rbx), %esi movl $.LC2, %edi xorl %eax, %eax call printf leal 1(%rbx), %esi movl $.LC3, %edi xorl %eax, %eax popq %rbx jmp printf .L1: popq %rbx ret void for_each2<example(std::vector<int, std::allocator<int> > const&, int)::{lambda(int)#1}>(std::vector<int, std::allocator<int> > const&, example(std::vector<int, std::allocator<int> > const&, int)::{lambda(int)#1}): pushq %r12 movl %esi, %r12d pushq %rbp movq %rdi, %rbp pushq %rbx movq 8(%rdi), %rbx .L6: cmpq %rbx, 0(%rbp) je .L9 movl -4(%rbx), %esi movl %r12d, %edi subq $4, %rbx call example(std::vector<int, std::allocator<int> > const&, int)::{lambda(int)#1}::operator()(int) const [clone .isra.0] jmp .L6 .L9: popq %rbx popq %rbp popq %r12 ret void for_each<example(std::vector<int, std::allocator<int> > const&, int)::{lambda(int)#1}>(std::vector<int, std::allocator<int> > const&, example(std::vector<int, std::allocator<int> > const&, int)::{lambda(int)#1}): pushq %r12 pushq %rbp movl %esi, %ebp pushq %rbx movq 8(%rdi), %r12 movq (%rdi), %rbx .L11: cmpq %rbx, %r12 je .L14 movl (%rbx), %esi movl %ebp, %edi addq $4, %rbx call example(std::vector<int, std::allocator<int> > const&, int)::{lambda(int)#1}::operator()(int) const [clone .isra.0] jmp .L11 .L14: popq %rbx popq %rbp popq %r12 ret example(std::vector<int, std::allocator<int> > const&, int): subq $24, %rsp movl %esi, 12(%rsp) movq %rdi, (%rsp) call void for_each<example(std::vector<int, std::allocator<int> > const&, int)::{lambda(int)#1}>(std::vector<int, std::allocator<int> > const&, example(std::vector<int, std::allocator<int> > const&, int)::{lambda(int)#1}) movl 12(%rsp), %esi movq (%rsp), %rdi addq $24, %rsp jmp void for_each2<example(std::vector<int, std::allocator<int> > const&, int)::{lambda(int)#1}>(std::vector<int, std::allocator<int> > const&, example(std::vector<int, std::allocator<int> > const&, int)::{lambda(int)#1})
В этот раз лямбда функция также является оператором круглые скобки, и она принимает первым аргументом (как бы внутри этой воображаемой структуры) threshold, а вторым - собственно - число из вектора. Аргументы меняются местами и передаются в predicate. Дальше всё понятно. Функции for_each, как и прежде, принимают вектор и threshold и передают последнее значение в лямбду.
Напследок, немного более хитрый пример:
#include <vector> #include <cstdio> template <typename Callable> void __attribute__ ((noinline)) for_each(const std::vector<int>& vec, Callable func) { for (const auto& elem : vec) { func(elem); } } auto get_filtering_lambda(int threshold) { return [threshold] (int n) { if (n > threshold) { printf("%d\n", n); } }; } void example(const std::vector<int>& vec, int threshold) { for_each(vec, get_filtering_lambda(threshold)); }
Какой ассемблер мы бы здесь ожидали увидеть? Как мы помним, for_each на самом деле не принимает функций, а на самом деле принимает то, что лямбда захватывает. Это значит, что get_filtering_lambda просто должна предоставить эти самые захватываемые переменные:
.LC0: .string "%d\n" void for_each<get_filtering_lambda(int)::{lambda(int)#1}>(std::vector<int, std::allocator<int> > const&, get_filtering_lambda(int)::{lambda(int)#1}): pushq %r12 pushq %rbp movl %esi, %ebp pushq %rbx movq 8(%rdi), %r12 movq (%rdi), %rbx .L2: cmpq %rbx, %r12 je .L7 movl (%rbx), %esi cmpl %ebp, %esi jle .L3 movl $.LC0, %edi xorl %eax, %eax call printf .L3: addq $4, %rbx jmp .L2 .L7: popq %rbx popq %rbp popq %r12 ret get_filtering_lambda(int): movl %edi, %eax ret example(std::vector<int, std::allocator<int> > const&, int): jmp void for_each<get_filtering_lambda(int)::{lambda(int)#1}>(std::vector<int, std::allocator<int> > const&, get_filtering_lambda(int)::{lambda(int)#1})
Надо отметить, что реализация, опирающаяся на шаблоны - не единственная возможная. Можно было бы в большей степени полагаться на виртуальные классы. Компилятор мог бы переводить лямбда-функцию в функцию, принимающую захватываемые аргументы и аргументы лямбда функции (примерно как мы это уже видели выше, когда лямбда становилась оператором круглые скобки некоторой структуры). Потом эту лямбду можно запаковать вместе с захватыевамыми значениями в структуру, которую уже передать в нешаблонную функцию (эта функция должна принимать указатель на объект виртуального класса).
class IIntConsumer { public: virtual ~IIntConsumer() = 0; virtual void consume(int) = 0; }; void for_reach(const vector<int>& vec, IIntConsumer* func) { for (const auto& elem : vec) { func->consume(elem); } }
Для лямбда функции, передаваемой в такую функцию, компилятор должен сгенерировать класс, имплементирующий IIntConsumer, затем создать объект такого класса и передать в функцию for_each.
void lambda_translation(int a, int captured0) { if (a > captured0) { printf("%d", a); } } class LambdaHolder: public IIntConsumer { public: LambdaHolder(int a, int b, int captured0) : captured0(captured0) {} void consume(int a) override { lambda_translation(a, captured0); } private: int captured0; };
Преимущество такого подхода было бы в том, что имплементация функции for_each будет одинаковая для всех лямбда функций, что может сократить объём генерируемого машинного кода (что может быть снизить риск iTLB промахов) и время компеиляции. Однако вызов виртуальных методов дороже вызова нормальных функций - в то время как в случае с шаблонами функции будут инлайниться, и вызовов вообще не будет. Таким образом, в большинстве случаев производительность будет хуже.
Объект виртуального класса по сути представляет из себя обычную структуру, к которой добавлен указатель на таблицу с адресами виртуальных функций. В сущности таким образом поддержку виртуальных функций можно было бы встроить и в язык программирования, в котором нет ни шаблонов, ни виртуальных классов.
Однако С++ - довольно гибкий язык, и в нём есть возможность обернуть функцию или лямбла функцию в некий объект, тип которого будет зависеть только от принимаемых и возвращаемых объектов функции, а не того, что она захватывает, где объявлена и так далее. А именно, это шаблонный класс std::function<RType(InTypes...)>. Ипользовать его можно так:
void for_each(const vector<int> &vec, function<void(int)> func) { for (const auto &elem: vec) { func(elem); } } int main() { int threshold = 1; auto func = function<void(int)>( [threshold](int n) { if (n > threshold) { printf("%d\n", n); } } ); vector<int> vec = {1, 2, 3}; for_each(vec, func); }
То есть в С++ всё-таки можно оборачивать лямбда-функцию в некий объект и передавать его в нешаблонные функции. К сожалению, код function в STL выглядит так будто его декомпилировали а потом обфусцировали (декомпилировать это в принципе невозможно, шутка) не очень понятно, поэтому я написал упрощённую реализацию:
template<typename RType, typename... Args> class IFuncHolder { public: virtual RType operator()(Args ...args) = 0; virtual ~IFuncHolder() = default; }; template<typename RType, typename Callable, typename... Args> class FuncHolderImpl : public IFuncHolder<RType, Args...> { public: FuncHolderImpl(Callable&& callable) : callable(callable) {} ~FuncHolderImpl() { } RType operator()(Args ...args) override { return callable(args...); } private: Callable callable; }; template<typename Signature> class my_function; template<typename RType, typename... Args> class my_function<RType(Args...)> { public: template<typename Callable> my_function(Callable&& f) { func = new FuncHolderImpl<RType, Callable, Args...>(std::forward<Callable>(f)); } ~my_function() { delete func; } RType operator()(Args ...args) { return func->operator()(args...); } private: IFuncHolder<RType, Args...> *func; }; void for_each(const vector<int> &vec, my_function<void(int)> func) { for (const auto &elem: vec) { func(elem); } }
Python
Давайте начнём с того, что напишем какой-нибудь код и посмотрим, в какой байт-код он компилируется. Я буду использовать Python 3.7. Вообще-то, в Питоне лямбда функции - это очень простые однострочные выражения, создающие функцию, которая не очень экспрессивна. Но в Питоне можно объявить одну функцию внутри другой, и внутренняя функция может захватывать переменные снаружи, как и лямбда функция в С++.
Но прежде чем читать байт код Питона, надо заметить три вещи. Во-первых, числа в левой колонке означают номера строк исходного кода (нумеруются с 1), которым соответствует этот блок байт-кода - таким образом, можно легко видеть, в какой байт-код транслируется каждая строка по отдельности. Во-вторых, байт-код Питона организован по-другому, чем машинный код, получающийся из С, С++ и так далее. В случае с машинным кодом, заголовок бинарного файла будет содержать адрес, откуда следует начать исполнение файла - это будет метка (функция) _start, которая объявлена в libstdc, которая уже дальше вызывает функцию main из вашего кода. В Питоне же байт код начинает выполняться с первой же инструкции - он импортирует другие модули, объявляет переменные и тому подобное. Поэтому вторая и первая строки функции окажутся в разных местах (не друг за другом) в итоговом байт-коде. И последнее, байт-код Питона исполняется стековой машиной, а машинный код - на машине с регистрами.
def func_generator(a): def inner(b): return a < b return inner print(func_generator(2)(3))
1 0 LOAD_CONST 0 (<code object func_generator at 0x55ccef231910, file "example.py", line 1>) 2 LOAD_CONST 1 ('func_generator') 4 MAKE_FUNCTION 0 6 STORE_NAME 0 (func_generator) 7 8 LOAD_NAME 1 (print) 10 LOAD_NAME 0 (func_generator) 12 LOAD_CONST 2 (2) 14 CALL_FUNCTION 1 16 LOAD_CONST 3 (3) 18 CALL_FUNCTION 1 20 CALL_FUNCTION 1 22 POP_TOP 24 LOAD_CONST 4 (None) 26 RETURN_VALUE Disassembly of <code object func_generator at 0x55ccef231910, file "example.py", line 1>: 2 0 LOAD_CLOSURE 0 (a) 2 BUILD_TUPLE 1 4 LOAD_CONST 1 (<code object inner at 0x55ccef204fc0, file "example.py", line 2>) 6 LOAD_CONST 2 ('func_generator.<locals>.inner') 8 MAKE_FUNCTION 8 10 STORE_FAST 1 (inner) 5 12 LOAD_FAST 1 (inner) 14 RETURN_VALUE Disassembly of <code object inner at 0x55ccef204fc0, file "example.py", line 2>: 3 0 LOAD_DEREF 0 (a) 2 LOAD_FAST 0 (b) 4 COMPARE_OP 0 (<) 6 RETURN_VALUE
Что же происходит в байт-коде выше? Сначала выполняется первая строка кода на Питоне: она кладёт на стек ссылку на байт-код собственно функции, название функции. Затем выполняется инструкция MAKE_FUNCTION, которая создаёт внутри интерпритатора Питона. В конце новосозданный объект сохраняется в глобальную переменную.
Далее исполняется седьмая строка из питоновского кода. Она кладёт на стек функции print и func_generator, аргумент к последней, вызывает её, затем кладёт на стек аргумент к следующей функции, вызывает её, затем вызывает print и возвращает None, чтобы следующий байт-код не выполнялся.
Когда выполняется func_generator, выполняется вторая линия питоновского кода: на стек загружается переменная a (инструкции LOAD_CLOSURE и LOAD_FAST - это одно и то же), создаёт кортеж из этой переменной, потом кладёт на стек ссылку на код функции inner и имя этой функции. Затем вызывается ин��трукция MAKE_FUNCTION - выглядит так, будто ей в данном случае передаётся кортеж из переменных, которые внутрення функция захватывает. Давайте посмотрим в документацию о том, что эта инструкция делает:

грубый перевод
MAKE_FUNCTION(флаги)
Кладёт на стек новый объект функции. Снизу вверх стек должен состоять из следующих значений, в случае, если в переданном аргументе установлены соответствующие биты:
0x1: кортеж значений по умолчанию для аргументов, кроме тех, которые только по ключу
0x2: словарь значений по умолчанию для аргументов по ключу
0x4: кортеж строк, составляющих аннотации для аргументов
0x8: кортеж, состоящий из свободных переменных, то есть тех, которые лямбда захватывает.
код функции
имя функции
В конце концов, давайте посмотрим, что делает функция inner: она просто напросто загружает аргумент на стек, затем захваченное значение (из объекта функции) и сравнивает их.
В заключение, мы увидели, что:
Функции в Питоне (все, а не только лямбды) - это не просто последовательности инструкций байт-кода, а объекты, содержащие некоторые данные.
Поддержка по захвату переменных снаружи встроена прямо в виртуальную машину Питона.
Программа на Питоне может сгенерировать произвольное количество функций.
Java
Теперь пришла пора посмотреть, что присходит в Java. Я буду использовать openjdk 19.
import java.util.ArrayList; import java.util.concurrent.atomic.*; class Example { public static void caller3(ArrayList<Integer> a, AtomicInteger b, AtomicBoolean boo) { a.forEach((i) -> { if (i > b.get()) { System.out.println(i); boo.set(true); } else { b.decrementAndGet(); boo.set(false); } }); } }
Получится следующий байт-код:
class Example { public static void caller3(java.util.ArrayList<java.lang.Integer>, java.util.concurrent.atomic.AtomicInteger, java.util.concurrent.atomic.AtomicBoolean); 0: aload_0 1: aload_1 2: aload_2 3: invokedynamic #7, 0 // InvokeDynamic #0:accept:(Ljava/util/concurrent/atomic/AtomicInteger;Ljava/util/concurrent/atomic/AtomicBoolean;)Ljava/util/function/Consumer; 8: invokevirtual #11 // Method java/util/ArrayList.forEach:(Ljava/util/function/Consumer;)V 11: return private static void lambda$caller3$0(java.util.concurrent.atomic.AtomicInteger, java.util.concurrent.atomic.AtomicBoolean, java.lang.Integer); 0: aload_2 1: invokevirtual #17 // Method java/lang/Integer.intValue:()I 4: aload_0 5: invokevirtual #23 // Method java/util/concurrent/atomic/AtomicInteger.get:()I 8: if_icmple 26 11: getstatic #28 // Field java/lang/System.out:Ljava/io/PrintStream; 14: aload_2 15: invokevirtual #34 // Method java/io/PrintStream.println:(Ljava/lang/Object;)V 18: aload_1 19: iconst_1 20: invokevirtual #40 // Method java/util/concurrent/atomic/AtomicBoolean.set:(Z)V 23: goto 36 26: aload_0 27: invokevirtual #46 // Method java/util/concurrent/atomic/AtomicInteger.decrementAndGet:()I 30: pop 31: aload_1 32: iconst_0 33: invokevirtual #40 // Method java/util/concurrent/atomic/AtomicBoolean.set:(Z)V 36: return }
Здесь можно увидеть, что, во-первых, лямбда функция стала статической функцией, которая принимает и переменные, которые лямбда захватывает снаружи, и собственные аргументы, и во вторых, что перед тем, как вызвать ArrayList::forEach, вызывается нечто, и в результате чего получается объект некоторого типа, который имплементирует Consumer<Integer>. Сейчас не столь важно, что конкретно вызывается (это скорее тема для отдельной статьи, подробнее можно посмотреть здесь), а интересно, что за класс имплементирует вышеуказанный интерфейс. Этот класс невозможно найти среди генерируемых class файлов. Выясняется, что этот класс генерируется JVM в рантайме. Мы можем его увидеть, добавив флаг -jdk.internal.lambda.dumpProxyClasses при запуске JVM. После декомпиляции этого класса получим:
import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Consumer; // $FF: synthetic class final class Lambder$$Lambda$1 implements Consumer { private final AtomicInteger arg$1; private Lambder$$Lambda$1(AtomicInteger var1) { this.arg$1 = var1; } public void accept(Object var1) { Lambder.lambda$caller2$0(this.arg$1, (Integer)var1); } }
Итак, в Java происходит следующее:
Для лямбда-функции, компилятор Java генерирует другую функцию, которая принимает и переменные, захватываемые снаружи, и аргументы собственно лямбда-функции.
В рантайме JVM генерирует класс, который может хранить то, что лямбда захватывает снаружи и реализует нужный функциональный интерфейс, вызывая лямбда функцию с хранимыми значениями и аргументами.
Объект этого сгенерированного класса создаётся и передаётся в функцию, которая принимает функциональный интерфейс.
Из этого видно, почему лямбда функции могут захватывать только final и effectively final переменные - она никак не может заменить объект, захваченный снаружи, на другой.
Обратите внимание, что то, что происходит в Java - это почти то же, что мы описывали выше как теоритический вариант, предложенный для С++, который не полагается так сильно на шаблоны - это неудивительно, так как в Java шаблонов нет.
