Pull to refresh
1022.84
OTUS
Цифровые навыки от ведущих экспертов

Красота замыканий

Reading time14 min
Views25K
Original author: csharpindepth.com

До того, как Java 7 вышел, я хотел написать в своем блоге статью о различных предложениях для Java 7, касающихся замыканий. Однако, когда я начал писать эту статью, я обнаружил, что начать ее без какого-либо введения в замыкания очень трудно. Со временем введение стало настолько длинным, что я опасался утратить интерес большинство читателей еще до того, как я доберусь до темы Java 7. Я решил, что вместо этого стоит написать отдельную статью о замыканиях в целом. В итоге получилось, что статья о Java 7 в моем блоге так и не появилась.

Большинство статей о замыканиях написаны с точки зрения функциональных языков, поскольку именно они, как правило, могут похвастаться лучшей поддержкой замыканий. Однако именно поэтому я счел полезным написать статью о том, как они проявляются в более традиционных объектно-ориентированных языках. Скорее всего, если вы пишете на функциональном языке, вы уже знаете о них все, что вам нужно. В этой статье речь пойдет о C# (версии 1, 2 и 3) и Java (до версии 7).

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

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

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

Пример: фильтрация списка

Достаточно часто возникает необходимость отфильтровать список по какому-либо критерию. Это довольно легко сделать в "inline" манере, просто создав новый список, пройти по исходному списку и добавить соответствующие элементы в новый список. И хоть это требует всего несколько строк кода, все равно приятно выделить эту логику в одно место. Самое сложное — это определить, какие элементы включать в список. Здесь на помощь приходят замыкания.

Хотя в описании я использовал слово "фильтр", это несколько двусмысленно — фильтровать элементы в новый список и отфильтровывать элементы из исходного списка. Например, сохраняет ли "фильтр четных чисел" четные числа или отбрасывает их? Мы будем использовать немного другой термин — предикат. Предикат здесь — это условие отбора, которому соответствует или не соответствует заданный элемент. В нашем примере будет создан новый список, содержащий все элементы исходного списка, которые соответствуют заданному предикату.

В C# естественным способом представления предиката является делегат, и действительно, в .NET 2.0 даже есть тип Predicate<T>. (Примечание: по какой-то причине LINQ предпочитает Func<T,bool>; я лично не понимаю, почему, учитывая, что он менее нагляден. Функционально эти два типа эквивалентны.) В Java нет такого понятия, как делегат, поэтому мы будем использовать интерфейс с единственным методом. Конечно, мы могли бы использовать интерфейс и в C#, но это было бы значительно сложнее и не позволило бы нам использовать анонимные методы и лямбда-выражения — именно те функции, которые реализуют замыкания в C#. Вот как выглядят эти интерфейс и делегат:

// Объявление для System.Predicate<T>
public delegate bool Predicate<T>(T obj)

// Predicate.java
public interface Predicate<T>
{
    boolean match(T item);
}

Код, используемый для фильтрации списка, очень прост в обоих языках. На этом этапе я должен отметить, что собираюсь избегать методов расширения в C#, чтобы упростить пример, но всем, кто использовал LINQ, стоит вспомнить о методе расширения Where. (Есть некоторые различия касательно отложенного выполнения, но я пока не буду их затрагивать).

// В ListUtil.cs
static class ListUtil
{
    public static IList<T> Filter<T>(IList<T> source, Predicate<T> predicate)
    {
        List<T> ret = new List<T>();
        foreach (T item in source)
        {
            if (predicate(item))
            {
                ret.Add(item);
            }
        }
        return ret;
    }
}
// В ListUtil.java
public class ListUtil
{
    public static <T> List<T> filter(List<T> source, Predicate<T> predicate)
    {
        ArrayList<T> ret = new ArrayList<T>();
        for (T item : source)
        {
            if (predicate.match(item))
            {
                ret.add(item);
            }
        }
        return ret;
    }
}

(В обоих языках я включил в один и тот же класс метод Dump, который просто выводит заданный список d консоль).

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

Фильтр 1: отбор коротких строк (фиксированной длины)

Наш пример будет очень простым, но я надеюсь, что вы все равно поймете его важность. Мы возьмем список строк, а затем создадим другой список, который будет содержать только "короткие" строки из исходного списка. Построить сам список очень просто — сложнее будет сформировать предикат.

В C# 1 мы должны иметь метод, представляющий логику нашего предиката. Экземпляр делегата создается путем указания имени метода. (Конечно, этот код не совсем подходит для C# 1 из-за использования дженериков, но сосредоточьтесь на том, как создается экземпляр делегата — это самое важное здесь).

// В Example1a.cs
static void Main()
{
    Predicate<string> predicate = new Predicate<string>(MatchFourLettersOrFewer);
    IList<string> shortWords = ListUtil.Filter(SampleData.Words, predicate);
    ListUtil.Dump(shortWords);
}

static bool MatchFourLettersOrFewer(string item)
{
    return item.Length <= 4;
}

В C# 2 у нас есть три варианта. Мы можем использовать точно такой же код, как и раньше, или немного упростить его, используя новые преобразования групп методов, или использовать анонимный метод, чтобы задать логику предиката в "inline" манере. Вариант улучшения с помощью преобразования групп методов не потребует на себя много времени — это просто замена new Predicate<string>(MatchFourLettersOrFewer) на MatchFourLettersOrFewer. Впрочем, он доступен в скачиваемом кодеExample1b.cs), если вам интересно. Вариант с анонимным методом гораздо интереснее:

static void Main()
{
    Predicate<string> predicate = delegate(string item)
        {
            return item.Length <= 4;
        };
    IList<string> shortWords = ListUtil.Filter(SampleData.Words, predicate);
    ListUtil.Dump(shortWords);
}

У нас больше нет лишнего метода, а поведение предиката очевидно в момент использования. Просто замечательно. Как это работает за кулисами? Если вы воспользуетесь ildasm или Reflector, чтобы посмотреть на сгенерированный код, то увидите, что он практически такой же, как и в предыдущем примере: компилятор просто сделал часть работы за нас. Позже мы увидим, что он способен сделать гораздо больше...

В C# 3 у вас есть все те же возможности, что и раньше, а также лямбда-выражения. В отношении темы этой статьи лямбда-выражения — это просто анонимные методы в лаконичной форме. (Большая разница между ними, когда речь идет о LINQ, заключается в том, что лямбда-выражения можно преобразовывать в деревья выражений, но здесь это не имеет особого значения). При использовании лямбда-выражения код выглядит следующим образом:

static void Main()
{
    Predicate<string> predicate = item => item.Length <= 4;
    IList<string> shortWords = ListUtil.Filter(SampleData.Words, predicate);
    ListUtil.Dump(shortWords);
}

Не обращайте внимания на то, что благодаря использованию <= кажется, будто большая стрелка указывает на item.Length — я оставил это так для единообразия, но с тем же успехом это можно было бы написать как Predicate<string> predicate = item => item.Length < 5;

В Java нам не нужно создавать делегат — нам нужно реализовать интерфейс. Самый простой способ — создать новый класс для реализации интерфейса, например, как здесь:

// В FourLetterPredicate.java
public class FourLetterPredicate implements Predicate<String>
{
    public boolean match(String item)
    {
        return item.length() <= 4;
    }
}

// В Example1a.java
public static void main(String[] args)
{
    Predicate<String> predicate = new FourLetterPredicate();
    List<String> shortWords = ListUtil.filter(SampleData.WORDS, predicate);
    ListUtil.dump(shortWords);
}

В этом случае не используются никакие причудливые возможности языка, но для выражения небольшой логики требуется целый отдельный класс. В соответствии с соглашениями Java, этот класс, скорее всего, будет находиться в другом файле, что затруднит чтение кода, который его использует. Вместо этого мы можем сделать его вложенным классом, но логика все равно будет находиться в стороне от кода, который ее использует, — по сути, это более многословная версия решения C# 1. (Опять же, я не буду показывать здесь версию с вложенным классом, но она есть в скачиваемом коде в виде Example1b.java). Однако Java позволяет выразить код в inline манере, используя анонимные классы. Вот код во всей его красе:

// В Example 1c.java
public static void main(String[] args)
{
    Predicate<String> predicate = new Predicate<String>()
    {
        public boolean match(String item)
        {
            return item.length() <= 4;
        }
    };
    
    List<String> shortWords = ListUtil.filter(SampleData.WORDS, predicate);
    ListUtil.dump(shortWords);
}

 Как видите, здесь много синтаксического шума по сравнению с решениями на C# 2 и 3, но, по крайней мере, весь код виден в нужном месте. Это текущая поддержка замыканий в Java... что плавно подводит нас ко второму примеру.

Фильтр 2: отбор коротких строк (переменной длины)

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

Для начала вернемся к C# 1. В нем нет никакой реальной поддержки замыкания — нет места, где было бы удобно хранить нужную нам часть информации. Да, мы могли бы просто использовать переменную в текущем контексте метода (например, статическую переменную в классе main из нашего первого примера), но это явно не лучшее решение — во-первых, оно сразу лишает нас потокобезопасности. Ответ заключается в том, чтобы отделить требуемое состояние от текущего контекста, создав новый класс. На данный момент он очень похож на оригинальный код Java, только с делегатом вместо интерфейса:

// В VariableLengthMatcher.cs
public class VariableLengthMatcher
{
    int maxLength;

    public VariableLengthMatcher(int maxLength)
    {
        this.maxLength = maxLength;
    }

    /// <summary>
    /// Метод, используемый в качестве экшена делегата
    /// </summary>
    public bool Match(string item)
    {
        return item.Length <= maxLength;
    }
}

// В Example2a.cs
static void Main()
{
    Console.Write("Maximum length of string to include? ");
    int maxLength = int.Parse(Console.ReadLine());

    VariableLengthMatcher matcher = new VariableLengthMatcher(maxLength);
    Predicate<string> predicate = matcher.Match;
    IList<string> shortWords = ListUtil.Filter(SampleData.Words, predicate);
    ListUtil.Dump(shortWords);
}

Изменения в коде как для C# 2, так и для C# 3 проще: мы просто заменяем захардкоженное ограничение параметром в обоих случаях. Пока не стоит беспокоиться о том, как именно это работает — мы рассмотрим это через минуту, когда увидим Java-код.

// В Example2b.cs (C# 2)
static void Main()
{
    Console.Write("Maximum length of string to include? ");
    int maxLength = int.Parse(Console.ReadLine());

    Predicate<string> predicate = delegate(string item)
    {
        return item.Length <= maxLength;
    };
    IList<string> shortWords = ListUtil.Filter(SampleData.Words, predicate);
    ListUtil.Dump(shortWords);
}

// В Example2c.cs (C# 3)
static void Main()
{
    Console.Write("Maximum length of string to include? ");
    int maxLength = int.Parse(Console.ReadLine());

    Predicate<string> predicate = item => item.Length <= maxLength;
    IList<string> shortWords = ListUtil.Filter(SampleData.Words, predicate);
    ListUtil.Dump(shortWords);
}

Изменения в коде Java (версия с использованием анонимных классов) аналогичны, но с одной маленькой изюминкой — мы должны сделать параметр final. Это звучит странно, но в безумии Java есть свой порядок. Давайте сперва посмотрим на код, прежде чем разбираться, что он делает:

// В Example2a.java
public static void main(String[] args) throws IOException
{
    System.out.print("Maximum length of string to include? ");
    BufferedReader console = new BufferedReader(new InputStreamReader(System.in));
    final int maxLength = Integer.parseInt(console.readLine());
    
    Predicate<String> predicate = new Predicate<String>()
    {
        public boolean match(String item)
        {
            return item.length() <= maxLength;
        }
    };
    
    List<String> shortWords = ListUtil.filter(SampleData.WORDS, predicate);
    ListUtil.dump(shortWords);
}

Итак, в чем же разница между кодом на Java и C#? В Java значение переменной было захвачено анонимным классом. В C# сама переменная была захвачена делегатом. Чтобы доказать, что C# захватывает переменную, давайте изменим код C# 3, чтобы он изменял значение параметра после того, как список был отфильтрован один раз, а затем отфильтруем его снова:

// В Example2d.cs
static void Main()
{
    Console.Write("Maximum length of string to include? ");
    int maxLength = int.Parse(Console.ReadLine());

    Predicate<string> predicate = item => item.Length <= maxLength;
    IList<string> shortWords = ListUtil.Filter  (SampleData.Words, predicate);
    ListUtil.Dump(shortWords);

    Console.WriteLine("Now for words with <= 5 letters:");
    maxLength = 5;
    shortWords = ListUtil.Filter(SampleData.Words, predicate);
    ListUtil.Dump(shortWords);
}

Обратите внимание, что мы изменяем только значение локальной переменной. Мы не пересоздаем экземпляр делегата или что-то в этом роде. У экземпляра делегата есть доступ к локальной переменной, поэтому он может видеть, что она изменилась. Давайте сделаем еще один шаг и заставим сам предикат изменить значение переменной:

// В Example2e.cs
static void Main()
{
    int maxLength = 0;

    Predicate<string> predicate = item => { maxLength++; return item.Length <= maxLength; };
    IList<string> shortWords = ListUtil.Filter(SampleData.Words, predicate);
    ListUtil.Dump(shortWords);
}

Я не буду вдаваться в подробности того, как все это работает под капотом — читайте 5-ю главу книги C# in Depth, чтобы узнать все интересующие вас подробности. Просто ожидайте, что некоторые ваши представления о том, что такое "локальная переменная", перевернутся с ног на голову.

Посмотрев, как C# реагирует на изменения в захваченных переменных, давайте разберемся, что происходит в Java? Ну, тут все довольно просто: вы не можете изменить значение захваченной переменной. Оно должно быть final, так что вопрос неактуальный. Однако если каким-то образом вы сможете изменить значение переменной, то обнаружите, что предикат на это не реагирует. Значения захваченных переменных копируются при создании предиката и хранятся в экземпляре анонимного класса. Для ссылочных же переменных значение переменной — это только ссылка, а не текущее состояние объекта. Например, если вы захватите StringBuilder, а затем добавите к нему переменную, эти изменения будут видны в анонимном классе.

Сравнение стратегий захвата: сложность против мощности

Очевидно, что схема Java является более ограничительной, но она также значительно упрощает нам жизнь. Локальные переменные ведут себя так же, как и раньше, и во многих случаях код также легче понять. Например, посмотрите на следующий код, использующий интерфейс Java Runnable и делегат .NET Action — оба они представляют собой экшены, не принимающие никаких параметров и не возвращающие никаких значений. Сначала посмотрим на код на C#:

// В Example3a.cs
static void Main()
{
    // Сначала создаем список экшенов
    List<Action> actions = new List<Action>();
    for (int counter = 0; counter < 10; counter++)
    {
        actions.Add(() => Console.WriteLine(counter));
    }

    // Затем выполняем их
    foreach (Action action in actions)
    {
        action();
    }
}

Что получается на выходе? Ну, на самом деле мы объявили только одну переменную counter — поэтому эта же переменная-счетчик используется всеми экземплярами Action. В результате в каждой строке выводится число 10. Чтобы "исправить" код и заставить его выводить то, что ожидает большинство людей (т.е. от 0 до 9), нам нужно ввести дополнительную переменную внутри цикла:

// В Example3b.cs
static void Main()
{
    // Сначала создаем список экшенов
    List<Action> actions = new List<Action>();
    for (int counter = 0; counter < 10; counter++)
    {
        int copy = counter;
        actions.Add(() => Console.WriteLine(copy));
    }

    // Затем выполняем их
    foreach (Action action in actions)
    {
        action();
    }
}

Каждый раз, когда мы проходим через цикл, мы получаем разные экземпляры переменной copy — каждый Action захватывает разные переменные. Это вполне логично, если посмотреть, что на самом деле делает компилятор за кулисами, но изначально это противоречит интуиции большинства разработчиков (включая меня).

Java полностью запрещает первый вариант — вы вообще не можете захватить переменную counter, потому что она не является final. Чтобы использовать final переменную, нам придется написать код, подобный этому, который очень похож на код C#:

// В Example3a.java
public static void main(String[] args)
{
    // Сначала создаем список экшенов
    List<Runnable> actions = new ArrayList<Runnable>();        
    for (int counter=0; counter < 10; counter++)
    {
        final int copy = counter;
        actions.add(new Runnable()
        {
            public void run()
            {
                System.out.println(copy);
            }
        });
    }
    
    // Затем выполняем их
    for (Runnable action : actions)
    {
        action.run();
    }
}

Замысел здесь достаточно ясен благодаря семантике "захваченного значения". Получившийся код все равно менее пригляден, чем код на C#, из-за более сложного синтаксиса, но Java заставляет нам писать “корректный код” в качестве единственного варианта. Недостатком является то, что когда вы хотите реплицировать поведение оригинального кода на C# (что, безусловно, случается в некоторых ситуациях), его сложно реализовать на Java. (Можно иметь одноэлементный массив, захватить ссылку на массив, а затем менять значение элемента, когда вам это нужно, но это та еще морока).

Ну и что тут такого?

В этих примерах мы увидели лишь небольшую пользу от использования замыканий. Конечно, мы отделили структуру управления от логики, необходимой для фильтрации, но сам по себе код от этого не стал намного проще. Это знакомая ситуация — новая функция часто выглядит не слишком впечатляюще, когда используется в упрощенных примерах. Однако преимущество, которое часто приносят замыкания, заключается в композитности. Если это звучит несколько натянуто, то я с вами согласен — и это часть проблемы. Когда вы знакомы с замыканиями и, возможно, даже немного подсели на них, связь кажется вполне очевидной. До этого момента она кажется непонятной.

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

Таким образом, замыкания упрощают создание делегатов. Это означает, что становится целесообразно разрабатывать API, использующие делегаты. (Я не думаю, что это совпадение, что делегаты использовались почти исключительно для запуска потоков и обработки событий в .NET 1.1). Как только вы начинаете мыслить в терминах делегатов, способы их комбинирования становятся очевидными. Например, очень просто создать предикат Predicate<T>, который принимает два других предиката и представляет собой их логическое И / ИЛИ (или другие булевы операции, конечно).

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

Однако на этом использование композиции не заканчивается — на ней построен весь LINQ. Фильтр, который мы построили с помощью списков, — лишь один из примеров того, как одна последовательность данных может быть преобразована в другую. Другие операции включают упорядочивание, группировку, объединение с другой последовательностью и проецирование. Исторически написание каждой из этих операций от руки не было слишком болезненным, но сложность вскоре возрастает, когда ваш "конвейер данных" состоит из более чем нескольких преобразований. Кроме того, благодаря отложенному выполнению и потоковой передаче данных, обеспечиваемым LINQ для объектов, вы несете значительно меньшие затраты памяти, чем при прямолинейной реализации, когда одно преобразование выполняется после завершения другого. Сложность устраняется не тем, что отдельные преобразования особенно умны — она устраняется возможностью выражать небольшие фрагменты логики в строке с помощью замыканий и возможностью комбинировать операции с помощью хорошо продуманного API.

Заключение

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

Одна из ключевых особенностей лямбда-выражений — краткость.Если сравнить приведенный ранее код на Java с кодом на C#, то Java выглядит крайне неуклюжим и тяжеловесным. Это одна из проблем, которую пытаются решить различные предложения по замыканиям в Java. В обозримом будущем я все-таки изложу свою точку зрения на эти предложения в одном из постов.

Если unit тесты умеет писать практически каждый, то с другими практиками сталкивались далеко не все. Приглашаем всех желающих на открытый урок 17 января, на котором поговорим про тесты api, e2e и многое другое. В том числе, как жить без автоматизации и зачем все-таки автоматизировать. Записаться можно на странице онлайн-курса "C# Developer. Professional".

Tags:
Hubs:
Total votes 14: ↑10 and ↓4+7
Comments44

Articles

Information

Website
otus.ru
Registered
Founded
Employees
101–200 employees
Location
Россия
Representative
OTUS