Pull to refresh

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

Level of difficultyMedium
Reading time17 min
Views22K

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

Tags:
Hubs:
Total votes 19: ↑16 and ↓3+15
Comments41

Articles