Лямбда-выражения Java 8 — это замыкания?

в 7:10, , рубрики: functional programming, java, lambdas, Блог компании Издательский дом «Питер», книги, Профессиональная литература, функциональное программирование

Развернутый ответ на вопрос, вынесенный в заглавие поста, приводится в статье Брюса Эккеля в редакции от 25 ноября 2015 года. Мы решили разместить здесь перевод этой статьи и поинтересоваться, что вы думаете о функциональном программировании в Java, а также об актуальности такой книги:

Лямбда-выражения Java 8 — это замыкания? - 1

Приятного чтения!

Если кратко – конечно да.

Чтобы дать более развернутый ответ, давайте все-таки разберемся: а зачем мы с ними работаем?

Абстракция поведения

В сущности, лямбда-выражения нужны потому, что они описывают, какие вычисления должны быть выполнены, а не как их выполнять. Традиционно мы работали с внешней итерацией, при которой четко указывали всю последовательность операций, а также как они делаются.

// InternalVsExternalIteration.java
import java.util.*;

interface Pet {
    void speak();
}

class Rat implements Pet {
    public void speak() { System.out.println("Squeak!"); }
}

class Frog implements Pet {
    public void speak() { System.out.println("Ribbit!"); }
}

public class InternalVsExternalIteration {
    public static void main(String[] args) {
        List<Pet> pets = Arrays.asList(new Rat(), new Frog());
        for(Pet p : pets) // External iteration
            p.speak();
        pets.forEach(Pet::speak); // Internal iteration
    }
}

Внешняя итерация выполняется в цикле for, причем этот цикл в точности указывает, как она делается. Такой код избыточен и неоднократно воспроизводится в программах. Однако с циклом forEach мы приказываем программе вызвать speak (здесь – при помощи ссылки на метод, которая более лаконична, чем лямбда) для каждого элемента, но нам не приходится описывать, как работает цикл. Итерация обрабатывается внутрисистемно, на уровне цикла forEach.

Такая мотивация “что, а не как” в случае лямбда-выражений является основной. Но, чтобы понять замыкания, нужно подробнее рассмотреть мотивацию функционального программирования как такового.

Функциональное программирование

Лямбда-вырражения/замыкания призваны упростить функциональное программирование. Java 8 – конечно, не функциональный язык, но в нем (как и в Python) теперь обеспечивается некоторая поддержка функционального программирования, эти возможности надстроены над базисной объектно-ориентированной парадигмой.
Основная идея функционального программирования заключается в том, что можно создавать функции и манипулировать ими, в частности, создавать функции во время исполнения. Соответственно, ваша программа может оперировать не только данными, но и функциями. Представьте, какие возможности открываются перед программистом.

В чисто функциональном языке программирования есть и другие ограничения, в частности — инвариантность данных. То есть, у вас нет переменных, только неизменяемые значения. На первый взгляд это ограничение кажется чрезмерным (как вообще работать без переменных?), но оказывается, что, в сущности, при помощи значений достижимо все то же самое, что и с переменными (хотите убедиться – попробуйте Scala, этот язык не является чисто функциональным, но предусматривает возможность везде пользоваться значениями). Инвариантные функции принимают аргументы и выдают результат, не изменяя окружения; поэтому ими значительно проще пользоваться при параллельном программировании, ведь инвариантная функция не блокирует разделяемые ресурсы.
До выхода Java 8 можно было создавать функции во время выполнения только одним способом: генерировать и загружать байт-код (это довольно запутанная и сложная работа).

Для лямбда-выражений характерны две следующие черты:

  1. Более лаконичный синтаксис при создании функций
  2. Возможность создавать функции во время исполнения; затем эти функции могут передаваться другому коду, либо другой код может ими оперировать.

Замыкания касаются именно второй возможности

Что такое замыкание?

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

# Closures.py

def make_fun():
    # вне области видимости возвращенной функции:
    n = 0

    def func_to_return(arg):
        nonlocal n
        # Без 'nonlocal' n += arg дает:
        # ссылка на локальную переменную 'n' стоит до присваивания
        print(n, arg, end=": ")
        arg += 1
        n += arg
        return n

    return func_to_return

x = make_fun()
y = make_fun()

for i in range(5):
    print(x(i))

print("=" * 10)

for i in range(10, 15):
    print(y(i))

""" Вывод:
0 0: 1
1 1: 3
3 2: 6
6 3: 10
10 4: 15
==========
0 10: 11
11 11: 23
23 12: 36
36 13: 50
50 14: 65
"""

Обратите внимание, что func_to_return работает с двумя полями, не входящими в ее область видимости: n и arg (в зависимости от конкретного случая, arg может быть копией либо ссылаться на что-либо вне области видимости). Объявление nonlocal является обязательным в силу самого устройства Python: если вы только начинаете работать с переменной, то предполагается, что эта переменная локальна. Здесь компилятор (да, в Python есть компилятор и да, он выполняет кое-какую – признаться, весьма ограниченную – проверку статических типов) видит, что n += arg использует n, которое не было инициализировано в области видимости func_to_return, поэтому генерируется сообщение об ошибке. Но если мы скажем, что n является nonlocal, Python догадается, что мы используем n, определенное вне области видимости функции, и которое было инициализировано, так что все в порядке.

Итак, мы сталкиваемся с такой проблемой: если просто вернуть func_to_return, что будет с n, находящимся вне области видимости func_to_return? Как правило, следовало бы ожидать, что n выйдет из области видимости и станет недоступным, но если это произойдет, то func_to_return не будет работать. Для поддержки динамического создания функций func_to_return должна “замкнуться” вокруг n и обеспечить, чтобы оно «дожило» до возврата функции. Отсюда и термин «замыкание».

Чтобы протестировать make_fun(), мы дважды его вызываем и сохраняем результат функции в x и y. Тот факт, что x и y дают совершенно разные результаты, демонстрирует, что что при каждом вызове make_fun() возникает совершенно самостоятельная функция func_to_return с собственным замкнутым хранилищем для n.

Лямбда-выражения в Java 8

Рассмотрим тот же пример на Java с использованием лямбда-выражений:

// AreLambdasClosures.java
import java.util.function.*;

public class AreLambdasClosures {
    public Function<Integer, Integer> make_fun() {
        // вне области видимости возвращенной функции:
        int n = 0;
        return arg -> {
            System.out.print(n + " " + arg + ": ");
            arg += 1;
            // n += arg; // выдает сообщение об ошибке
            return n + arg;
        };
    }
    public void try_it() {
        Function<Integer, Integer>
            x = make_fun(),
            y = make_fun();
        for(int i = 0; i < 5; i++)
            System.out.println(x.apply(i));
        for(int i = 10; i < 15; i++)
            System.out.println(y.apply(i));
    }
    public static void main(String[] args) {
        new AreLambdasClosures().try_it();
    }
}
/* Output:
0 0: 1
0 1: 2
0 2: 3
0 3: 4
0 4: 5
0 10: 11
0 11: 12
0 12: 13
0 13: 14
0 14: 15
*/

Неоднозначная штука: мы действительно можем обратиться к n, но как только попытаемся изменить n, начнутся проблемы. Сообщение об ошибке таково: local variables referenced from a lambda expression must be final or effectively final (локальные переменные, на которые ставится ссылка из лямбда-выражения, должны быть финальными или фактически финальными).

Оказывается, что лямбда-выражения в Java замыкаются только вокруг значений, но не вокруг переменных. Java требует, чтобы эти значения были неизменны, как если бы мы объявили их final. Итак, они должны быть final независимо от того, объявляли вы их таким образом или нет. То есть, «фактически финальными». Поэтому в Java есть «замыкания с ограничениями», а не «полноценные» замыкания, которые, тем не менее, довольно полезны.

Если мы создаем объекты, расположенные не в куче, то можем изменять такие объекты, поскольку компилятор следит лишь за тем, чтобы не изменялась сама ссылка. Например:

// AreLambdasClosures2.java
import java.util.function.*;

class myInt {
    int i = 0;
}

public class AreLambdasClosures2 {
    public Consumer<Integer> make_fun2() {
        myInt n = new myInt();
        return arg -> n.i += arg;
    }
}

Все компилируется без проблем, чтобы в этом убедиться можете просто поставить ключевое слово final в определение n. Разумеется, если применить такой ход с любой конкуренцией, то возникнет проблема с изменяемым разделяемым состоянием.

Лямбда-выражения – как минимум, отчасти – позволяют достичь желаемой цели: теперь можно создавать функции динамически. Если вы выйдете за границы, то получите сообщение об ошибке, но обычно подобные проблемы решаемы. Выход будет не таким прямолинейным, как на Python, но ведь это все-таки Java. А конечный результат, не лишенный некоторых ограничений (давайте признаем, любой результат в Java не лишен некоторых ограничений) не так уж и плох.

Я поинтересовался, почему же эти структуры назвали «лямбдами», а не просто «замыканиями» — ведь по всем признакам это чистые замыкания. Мне ответили, что «замыкание» — неудачный и перегруженный термин. Когда кто-то говорит «настоящее замыкание», то зачастую имеет в виду такие «замыкания», которые попались ему в первом освоенном языке программирования, где имелись сущности, именуемые «замыканиями».

Я не усматриваю здесь спора «ООП против ФП», впрочем, и не собирался его устраивать. Более того, я даже «против» здесь не вижу. ООП хорошо подходит для абстрагирования данных (и пусть даже Java вынуждает вас работать с объектами, это еще не означает, что любая задача решаема при помощи объектов), а ФП — для абстрагирования поведений. Обе парадигмы полезны, и, на мой взгляд, тем более полезны, если их смешивать — и в Python, и в Java 8. Недавно мне довелось поработать с Pandoc — конвертером, написанном на чисто функциональном языке Haskell, причем у меня остались от этого самые положительные впечатления. Итак, чисто функциональные языки, также заслуживают места под солнцем.

Автор: Издательский дом «Питер»

Источник

Поделиться новостью

* - обязательные к заполнению поля