Цель настоящей статьи - изучить лямбда функции: чем они отличаются от обычных функций и изучить, как они реализованы в С++, 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 шаблонов нет.