Информация об изменениях

Сообщение Re: Отличительные свойства лямбды и замыкания от 08.02.2022 8:12

Изменено 10.02.2022 19:47 netch80

Re: Отличительные свойства лямбды и замыкания
Здравствуйте, vaa, Вы писали:

vaa>Не уверен в правильности выбора раздела, но все же.

vaa>Есть четкое определение отличительны свойств лямбды и замыкания?
vaa>Или то и другое просто анонимные функции? В чем их ключевые различия?
vaa>Что может одна и не может другая конструкция?

Нужно различать, как минимум:
1) лямбду как концепцию теории лямбда-исчисления.
2) лямбду как языковую конструкцию.
3) замыкание как технологию в программировании.
Может быть, ещё и замыкание в исходном смысле имени Haskell Curry, но я тут не в курсе.

Скорее всего, у тебя вопрос про соотношение (2) и (3). (1) мало смысла учитывать, потому что при его учёте можно любую функцию считать лямбдой, это неэффективно.

В среднем по наиболее ходовым языкам на данный момент: лямбда может ничего не замыкать, а замыкание может не быть оформлено как лямбда. Возьмём Питон:
(простите за изъезженность примера с make_adder, но он универсальный и таки понятный)

func1 = lambda x: x+1
def func2(x):
  return x+1


Первое — лямбда в смысле собственно языка, второе — именованная функция. Делают одинаковые вещи. В обоих случаях это функция первого класса, но не замыкание (или, математически, можно назвать замыканием на 0 параметров, тривиальный вырожденный случай — но для обычного разговора это неинтересно).

Порождаем замыкание на 1 параметр:

def make_adder_1(y):
  return lambda x: x+y
def make_adder_2(y):
  def adder(x):
    return x+y
  return adder


make_adder(3), например, создаст функцию с 1 аргументом, которая возвращает этот аргумент, увеличенный на 3.

Такие же две функции внутри, которые стали замыканиями из-за использования параметра — переменной из внешнего контекста, и две снаружи, которые порождают такие замыкания. (Из-за свойства Python, что замыкание тянет за собой весь внешний фрейм по ссылке, лучше генерацию замыкания выносить в отдельную функцию с минимальным количеством локальных переменных. Или я тут отстал и это уже не актуально?)

Теперь C++. Именованных вложенных функций там нет, всё делается через синтаксис, который называется Lambda expressions. Но есть большая разница, созданная им вложенная функция будет хранить какой-то контекст или нет (компилятор может найти причины соптимизировать это).
Например:

auto f = [] {
  printf("Hello from lambda\n");
};
f();


Тут просто выделение части кода в отдельный блок (иногда удобно чтобы не распространять локальный контекст). И снова это то тривиальное замыкание на 0 параметров, которое простой компилятор может создать полноценную функцию первого класса в виде std::function<сигнатура> (или аналога, дальше не уточняю). Более умный наверняка имеет средства отбросить подобное усложнение (для GCC, Clang это точно так).

auto f = [] {
  printf("Hello from lambda\n");
};
foo(f);


Если foo() принимает коллбэк, это объект типа std::function<>, но он ничего не сохранил из контекста, внутри объекта только адрес для вызова.

int x;
auto f = [&] {
  printf("Hello %d%% from lambda\n", x);
};
x = 100;
f();
x = 200;
f();


С одной стороны, x запомнено по значению, с другой, компилятор может не создавать полноценный std::function, а иначе сделать — неважно как, лишь бы x передавался в f. Оно расскажет про 100% и 200%.

int x;
auto f = [&] {
  printf("Hello %d%% from lambda\n", x);
};
x = 100;
foo(f);
x = 200;
foo(f);


А тут без контекста (хранящего адрес f) не обойтись. В foo() уходит объект контекста с одним дополнительно запомненным параметром — &x (и, естественно, кого вызывать с этим параметром).
Точно так же, вызвать — расскажет про 100% и 200%.

int x = 0;
auto f = [=] {
  printf("Hello %d%% from lambda\n", x);
};
x = 100;
foo(f);
x = 200;
foo(f);


Тут будет 0% в обоих случаях, запомнено будет значение x в момент создания замыкания, а не его адрес. Это уже особенности C++.

С третьей стороны, ничто не мешает сделать замыкание и не через лямбду языка. Например, libjit — популярный вариант до появления готового аналога C++11, или и сейчас в C без C++. Лямбды C++ переложили заботу о генерации кода для всех платформ на компилятор, а тут — авторы библиотеки.

Вдогонку: вспомнил, как мы ещё в 90-х творили подобные штуки на Фортране С соглашениями вызова, принятых в x86-{16,32}, параметры передаются на стеке. Генератор "замыкания" аллоцировал память под кусочек кода, который писал по байтам, типа такого:

pop cx ; адрес возврата. не помню, какой регистр был свободен, пусть это cx
push <константа>
push cx
jmp <функция вокруг которой делалось замыкание>


У внутренней функции первым параметром было cookie, которое она дальше разбирала (обычно — указатель на структуру).

Современные аналоги делают почти то же самое 1:1 (только с регистровыми соглашениями вызова для x86-64 добавляется сильная громоздкость этому процессу).
Re: Отличительные свойства лямбды и замыкания
Здравствуйте, vaa, Вы писали:

vaa>Не уверен в правильности выбора раздела, но все же.

vaa>Есть четкое определение отличительны свойств лямбды и замыкания?
vaa>Или то и другое просто анонимные функции? В чем их ключевые различия?
vaa>Что может одна и не может другая конструкция?

Нужно различать, как минимум:
1) лямбду как концепцию теории лямбда-исчисления.
2) лямбду как языковую конструкцию.
3) замыкание как технологию в программировании.
Может быть, ещё и замыкание в исходном смысле имени Haskell Curry, но я тут не в курсе.

Скорее всего, у тебя вопрос про соотношение (2) и (3). (1) мало смысла учитывать, потому что при его учёте можно любую функцию считать лямбдой, это неэффективно.

В среднем по наиболее ходовым языкам на данный момент: лямбда может ничего не замыкать, а замыкание может не быть оформлено как лямбда. Возьмём Питон:
(простите за изъезженность примера с make_adder, но он универсальный и таки понятный)

func1 = lambda x: x+1
def func2(x):
  return x+1


Первое — лямбда в смысле собственно языка, второе — именованная функция. Делают одинаковые вещи. В обоих случаях это функция первого класса, но не замыкание (или, математически, можно назвать замыканием на 0 параметров, тривиальный вырожденный случай — но для обычного разговора это неинтересно).

Порождаем замыкание на 1 параметр:

def make_adder_1(y):
  return lambda x: x+y
def make_adder_2(y):
  def adder(x):
    return x+y
  return adder


make_adder(3), например, создаст функцию с 1 аргументом, которая возвращает этот аргумент, увеличенный на 3.

Такие же две функции внутри, которые стали замыканиями из-за использования параметра — переменной из внешнего контекста, и две снаружи, которые порождают такие замыкания. (Из-за свойства Python, что замыкание тянет за собой весь внешний фрейм по ссылке, лучше генерацию замыкания выносить в отдельную функцию с минимальным количеством локальных переменных. Или я тут отстал и это уже не актуально?)

Теперь C++. Именованных вложенных функций там нет, всё делается через синтаксис, который называется Lambda expressions. Но есть большая разница, созданная им вложенная функция будет хранить какой-то контекст или нет (компилятор может найти причины соптимизировать это).
Например:

auto f = [] {
  printf("Hello from lambda\n");
};
f();


Тут просто выделение части кода в отдельный блок (иногда удобно чтобы не распространять локальный контекст). И снова это то тривиальное замыкание на 0 параметров, которое простой компилятор может создать полноценную функцию первого класса в виде std::function<сигнатура> (или аналога, дальше не уточняю). Более умный наверняка имеет средства отбросить подобное усложнение (для GCC, Clang это точно так).

auto f = [] {
  printf("Hello from lambda\n");
};
foo(f);


Если foo() принимает коллбэк, это объект типа std::function<>, но он ничего не сохранил из контекста, внутри объекта только адрес для вызова.

int x;
auto f = [&] {
  printf("Hello %d%% from lambda\n", x);
};
x = 100;
f();
x = 200;
f();


С одной стороны, x запомнено по значению, с другой, компилятор может не создавать полноценный std::function, а иначе сделать — неважно как, лишь бы x передавался в f. Оно расскажет про 100% и 200%.

int x;
auto f = [&] {
  printf("Hello %d%% from lambda\n", x);
};
x = 100;
foo(f);
x = 200;
foo(f);


А тут без контекста (хранящего адрес x) не обойтись. В foo() уходит объект контекста с одним дополнительно запомненным параметром — &x (и, естественно, кого вызывать с этим параметром).
Точно так же, вызвать — расскажет про 100% и 200%.

int x = 0;
auto f = [=] {
  printf("Hello %d%% from lambda\n", x);
};
x = 100;
foo(f);
x = 200;
foo(f);


Тут будет 0% в обоих случаях, запомнено будет значение x в момент создания замыкания, а не его адрес. Это уже особенности C++.

С третьей стороны, ничто не мешает сделать замыкание и не через лямбду языка. Например, libjit — популярный вариант до появления готового аналога C++11, или и сейчас в C без C++. Лямбды C++ переложили заботу о генерации кода для всех платформ на компилятор, а тут — авторы библиотеки.

Вдогонку: вспомнил, как мы ещё в 90-х творили подобные штуки на Фортране С соглашениями вызова, принятых в x86-{16,32}, параметры передаются на стеке. Генератор "замыкания" аллоцировал память под кусочек кода, который писал по байтам, типа такого:

pop cx ; адрес возврата. не помню, какой регистр был свободен, пусть это cx
push <константа>
push cx
jmp <функция вокруг которой делалось замыкание>


У внутренней функции первым параметром было cookie, которое она дальше разбирала (обычно — указатель на структуру).

Современные аналоги делают почти то же самое 1:1 (только с регистровыми соглашениями вызова для x86-64 добавляется сильная громоздкость этому процессу).