О разнице между лямбдами и обычными функциями и о имплементации лямбд в некторых языках программирования

от автора

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

В заключение, мы увидели, что:

  1. Функции в Питоне (все, а не только лямбды) — это не просто последовательности инструкций байт-кода, а объекты, содержащие некоторые данные.

  2. Поддержка по захвату переменных снаружи встроена прямо в виртуальную машину Питона.

  3. Программа на Питоне может сгенерировать произвольное количество функций.

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 происходит следующее:

  1. Для лямбда-функции, компилятор Java генерирует другую функцию, которая принимает и переменные, захватываемые снаружи, и аргументы собственно лямбда-функции.

  2. В рантайме JVM генерирует класс, который может хранить то, что лямбда захватывает снаружи и реализует нужный функциональный интерфейс, вызывая лямбда функцию с хранимыми значениями и аргументами.

  3. Объект этого сгенерированного класса создаётся и передаётся в функцию, которая принимает функциональный интерфейс.

Из этого видно, почему лямбда функции могут захватывать только final и effectively final переменные — она никак не может заменить объект, захваченный снаружи, на другой.

Обратите внимание, что то, что происходит в Java — это почти то же, что мы описывали выше как теоритический вариант, предложенный для С++, который не полагается так сильно на шаблоны — это неудивительно, так как в Java шаблонов нет.


ссылка на оригинал статьи https://habr.com/ru/articles/732434/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *