До того, как 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".

Комментарии (43)


  1. avshkol
    29.12.2023 06:56
    -6

    Спасибо. Если к коду на C# и Java добавить аналог на python, то аудитория статьи резко расширится ;)


  1. SergeyMax
    29.12.2023 06:56
    +1

    Например, сохраняет ли "фильтр четных чисел" четные числа или отбрасывает их?

    Ну это легкотня. Воздушный фильтр пропускает воздух, и задерживает вас остальное.


    1. dopusteam
      29.12.2023 06:56

      А предикат чётных чисел что делает? :)


  1. freeExec
    29.12.2023 06:56
    -1

    Захват переменной - понятен, замыкание - не понятно.


  1. Spyman
    29.12.2023 06:56

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


    1. rukhi7
      29.12.2023 06:56
      -1

      еще у нас есть предположение о том как появились замыкания.


  1. klimkinMD
    29.12.2023 06:56
    +1

    Если говорить простым языком, то замыкания (closures)...

    Проще не скажешь!


  1. nronnie
    29.12.2023 06:56

    как все это работает под капотом

    У "под капотом" есть интересный побочный эффект - то, что нельзя замыкать переменные типа ref struct (в частности, Span<T>), и то, что замыкание "value type" переменной приводит к её boxing/unboxing.


    1. vvdev
      29.12.2023 06:56

      нет там никакого боксинга.


      1. nronnie
        29.12.2023 06:56
        -1

        Есть. Именно поэтому Span<T> замкнуть и не получается - замкнутые value-type переменные "уезжают" в кучу (heap).


        1. vvdev
          29.12.2023 06:56
          +1

          Жаль нельзя на большие деньги поспорить, погулял бы на НГ за чужой счёт в кои-то веки.

          Повторю: боксинга нет, ref struct захватывать нельзя по другой причине (да, именно из-за отъезда в кучу).

          "Уезжать в кучу" != боксинг.


          1. nronnie
            29.12.2023 06:56

            "Уезжать в кучу" != боксинг.

            Офигеть... А что же по-вашему такое тогда боксинг? :-О

            Здесь при вызове GetMultiplyBy(42) будет боксинг?

            public Func<int> GetMultiplyBy(int x) =>  y => y *x;
            


            1. vvdev
              29.12.2023 06:56
              +1

              А что же по-вашему такое тогда боксинг? :-О

              А вот это можно почитать и в учебнике.


            1. mayorovp
              29.12.2023 06:56

              Конкретно в C# боксингом называется использование операции box. В вашем коде её нет.

              В более общем смысле, боксинг - это создание индивидуального объекта-обёртки ("коробки") для значения. Формально ваша GetMultiplyBy действительно создаёт такую коробку, но это происходит вовсе не потому что int является value типом. К примеру, вот такая функция так же создаст скоуп, попадающий под определение "коробки":

              Func<string, strting> GetConcatenatedBy(string x) => y => y + x;


              1. nronnie
                29.12.2023 06:56
                -1

                Конкретно в C# боксингом называется использование операции box.

                Нет.


              1. nronnie
                29.12.2023 06:56

                Вот, можете ознакомиться, если интересно. Там описаны случаи, когда boxing есть, но явная инструкция box в IL отсутствует.


                1. vvdev
                  29.12.2023 06:56

                  Отлично, первые шаги к изучению учебника сделаны.

                  Вот, можете ознакомиться,

                  А можно теперь конкретную цитату, которая относится к обсуждаемому случаю?

                  boxing есть, но явная инструкция box в IL отсутствует.

                  Любопытно было бы увидеть примеры, не поделитесь?


                  1. nronnie
                    29.12.2023 06:56

                    Отлично, первые шаги к изучению учебника сделаны.

                    Давайте договоримся только без личностей, хорошо? Я на .NET, не слезая, с момента его появления - совершенно не знаю ваш возраст, но, возможно, вы тогда еще вообще читать не умели :)

                    Любопытно было бы увидеть примеры, не поделитесь?

                    Foo foo = new();
                    Console.WriteLine(foo.ToString());
                    
                    internal struct Foo
                    {
                    }
                    
                        // [1 1 - 1 17]
                        IL_0000: ldloca.s     foo
                        IL_0002: initobj      Foo
                    
                        // [2 1 - 2 35]
                        IL_0008: ldloca.s     foo
                        IL_000a: constrained. Foo
                        IL_0010: callvirt     instance string [System.Runtime]System.Object::ToString()
                        IL_0015: call         void [System.Console]System.Console::WriteLine(string)
                        IL_001a: nop
                        IL_001b: ret
                    

                    Boxing возникает неявно на инструкции IL_0010 и, чтобы подобного избежать надо явно перегружать метод ToString() в структуре Foo


                    1. vvdev
                      29.12.2023 06:56

                      только без личностей, хорошо? ... не знаю ваш возраст

                      Ноль вопросов, никаких личностей, просто я (лично) считаю, что учиться никогда не поздно и не зазорно, а иногда ещё и нужно ;)

                      Boxing возникает неявно на инструкции IL_0010

                      Вообще, не сказал бы, что прям неявно:

                      https://learn.microsoft.com/en-us/dotnet/api/system.reflection.emit.opcodes.constrained?view=net-8.0&redirectedfrom=MSDN

                      When a callvirt method instruction has been prefixed by constrained thisType, the instruction is executed as follows:

                      • If thisType is a reference type (as opposed to a value type) then ptr is dereferenced and passed as the 'this' pointer to the callvirt of method.

                      • If thisType is a value type and thisType implements method then ptr is passed unmodified as the 'this' pointer to a call method instruction, for the implementation of method by thisType.

                      • If thisType is a value type and thisType does not implement method then ptr is dereferenced, boxed, and passed as the 'this' pointer to the callvirt method instruction.

                      This last case can occur only when method was defined on ObjectValueType, or Enum and not overridden by thisType. In this case, the boxing causes a copy of the original object to be made. However, because none of the methods of ObjectValueType, and Enum modify the state of the object, this fact cannot be detected.

                      Но ок, box в IL действительно нет, спасибо за напоминание.

                      Тем не менее, что насчёт обсуждаемого случая?

                      Есть там цитата?


                      1. nronnie
                        29.12.2023 06:56

                        Тем не менее, что насчёт обсуждаемого случая?

                        Ну, тут я говорил насчет того, что:

                        Конкретно в C# боксингом называется использование операции box.

                        Про замыкания, ну бокс бог с ним - не будем называть это прямо "боксингом", но факт, что (возможно, нежелательное) копирование из стека в кучу при замыкании вполне возможно и это стоит иметь в виду.


                      1. vvdev
                        29.12.2023 06:56
                        +1

                        Про замыкания, ну бокс бог с ним - не будем называть это прямо "боксингом"

                        Не "не будем", а "нельзя".

                        Боксинг - хоть в .НЕТ, хоть в абстрактном CS-определении - это не "копировние из стека в кучу" и кроме аллокации тянет за собой и вычислительные расходы.

                        В случае с захватом этого не происходит.

                        Более того, и самой локальной переменной на стеке вообще [в большинстве случаев] не окажется, она сразу будет определена в классе, созданном компилятором для лямбды.

                        но факт, что (возможно, нежелательное) копирование из стека в кучу при замыкании вполне возможно

                        Не "вполне возможно", а обязательно произойдёт, бай дизайн, так сказать.


        1. mayorovp
          29.12.2023 06:56

          "Уезжают" в кучу не просто value-type переменные, а вообще все замкнутые переменные.

          Именно потому боксингом это и не является.


  1. D7ILeucoH
    29.12.2023 06:56
    +1

    Вы можете называть замыкания в Java и C# красивыми лишь до тех пор, пока не познакомитесь с замыканиями на Kotlin. Потому что Kotlin это одно сплошное замыкание.

    Лично я в приведённом коде увидел слишком много лишнего, не относящегося к сути, что у неподготовленного читателя вызовет много вопросов.


    1. SadOcean
      29.12.2023 06:56
      +1

      Ну что в котлине действительно хорошо, так это it
      А в остальном разве что сахар для инлайн классов java хорош, а в остальном то что такого?

      Последние шарпы тоже весьма хороши.


  1. SadOcean
    29.12.2023 06:56

    На самом деле мне очень нравится ограничение final для замыканий в java
    Это явно определяет контекст и ограничения, в отличие от классической проблемы цикла в c#


    1. vvdev
      29.12.2023 06:56

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


      1. SadOcean
        29.12.2023 06:56

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


        1. vvdev
          29.12.2023 06:56

          Всё может быть чревато :)


          1. SadOcean
            29.12.2023 06:56

            Ну речь о том, чтобы уменьшить количество ситуаций, которые могут привести к проблемам.

            Можно ведь отследить когда проблемы бывают чаще - при написании статичных методов, методов с использованием полей или лямбд

            Если лямбда простая - отлично, она увеличит локальность и удобство чтения

            Но если она захватывает широкий контекст и образует сложную последовательность из изменений переменных - может и лямбду не стоит использовать, но создать объект?

            Тут я бы сказал, что если мы используем замыкание как константу в лямбде - это одно, это простая и допустимая логика.

            Но если это аккумулятор к примеру - возможно не стоит использовать тут лямбду вообще


            1. vvdev
              29.12.2023 06:56

              Возможно не стоит, а возможно и стоит - написание руками класса с полем и методом, повторяющим то, что сделал бы компилятор для лямбды - это разумно/упрощает/стоит того?

              Я склоняюсь к тому, что универсального ответат нет, зато выбор есть.


        1. vvdev
          29.12.2023 06:56

          ...но это, к примеру, легко доступная элементарная оптимизация:

          Объявляем локальную переменну, объявляем лямбду, которая её захватывает.

          В цикле n раз изменяем значение переменной и вызываем метод, в который передаём лямбду - итого одна аллокация вместо n


          1. SadOcean
            29.12.2023 06:56

            Ну это даётся ценой неясности

            Локальная переменная теперь оказывается не работает как локальная, но работает как метод.

            В этом случае можно использовать поле или вообще отдельный объект с полем - памяти уйдет столько же.


            1. vvdev
              29.12.2023 06:56

              Локальная переменная теперь оказывается не работает как локальная, но работает как метод.

              А когда локальная переменная передаётся как ref - она перестаёт работать как локальная?

              Для рантайма она больше не локальная переменная, но это скрыто и незаметно (пока не упираемся в особые случаи типа ref [struct]). Ну, чтож.


        1. nronnie
          29.12.2023 06:56
          -2

          У лямбд есть еще одна проблема - они часто создают сложности для юнит-тестов, потому что (в отличие от делегата) лямбду невозможно так просто, в случае надобности замокать. В принципе, можно попробовать создать мок-делегат, а потом уже его завернуть в лямбду, но, в общем случае, это может сработать, а может и нет.


          1. dopusteam
            29.12.2023 06:56

            А в каких кейсах может понадобиться мокать лямбду?


            1. SadOcean
              29.12.2023 06:56

              При тестировании фильтра? (Стратегии в широком смысле)


              1. dopusteam
                29.12.2023 06:56

                А есть примеры кода? Что то пока непонятно.


                1. nronnie
                  29.12.2023 06:56

                  Я вот как-то неудачно выразился - ночь поздняя была плюс алкоголь. Наверное, нужно было написать: "На лямбды тяжело писать какие-либо assert-ы".

                  Вот, например
                  using System.Diagnostics.CodeAnalysis;
                  using System.Linq.Expressions;
                  
                  var people = new[] {
                      new Person(1, "Foo", 42),
                      new Person(2, "Bar", 69),
                      new Person(2, "Baz", 17),
                  };
                  
                  var filtered = people.AsQueryable().Where(
                      PersonFilterBuilder.And(
                          PersonFilterBuilder.FilterByName("ba"),
                          PersonFilterBuilder.FilterByAge(60, 70)));
                  
                  foreach (var p in filtered)
                  {
                      Console.WriteLine(p);
                  }
                  
                  return;
                  
                  public record Person(int Id, string Name, int Age);
                  
                  public static class PersonFilterBuilder
                  {
                      public static Expression<Func<Person, bool>> FilterByName(string name) =>
                         p => p.Name.StartsWith(name, StringComparison.CurrentCultureIgnoreCase);
                  
                      public static Expression<Func<Person, bool>> FilterByAge(int from, int to) =>
                         p => from <= p.Age && p.Age <= to;
                  
                      public static Expression<Func<Person, bool>> And(
                          Expression<Func<Person, bool>> left,
                          Expression<Func<Person, bool>> right)
                      {
                          var p = Expression.Parameter(typeof(Person));
                  
                          return Expression.Lambda<Func<Person, bool>>(
                              Expression.And(
                                  new Replace(left.Parameters[0], p).Visit(left.Body),
                                  new Replace(right.Parameters[0], p).Visit(right.Body)),
                              p);
                      }
                  }
                  
                  public class Replace(Expression from, Expression to) : ExpressionVisitor
                  {
                      [return: NotNullIfNotNull("node")]
                      public override Expression? Visit(Expression? node) =>
                          node == from ? to : base.Visit(node);
                  }
                  

                  Как мне, вот, написать действительно "хороший" тест на метод FilterBuilder.And(...)? Хочу именно тест, который подтвердит мне, что этот метод действительно комбинирует два произвольных выражения через оператор and.

                  В случае предикатов это очень легко
                  public record Person(int Id, string Name, int Age);
                  
                  public static class PersonFilterBuilder
                  {
                      public static Func<Person, bool> FilterByName(string name) =>
                         p => p.Name.StartsWith(name, StringComparison.CurrentCultureIgnoreCase);
                  
                      public static Func<Person, bool> FilterByAge(int from, int to) =>
                         p => from <= p.Age && p.Age <= to;
                  
                      public static Func<Person, bool> And(
                          Func<Person, bool> left,
                          Func<Person, bool> right) =>
                          p => left(p) && right(p);
                  }
                  
                  public class PersonFilterBuilderTests
                  {
                      [Theory]
                      [InlineData(false, false, false)]
                      [InlineData(true, false, false)]
                      [InlineData(false, true, false)]
                      [InlineData(true, true, true)]
                      public void And_combines_left_and_right(bool lval, bool rval, bool expected)
                      {
                          var left = A.Fake<Func<Person, bool>>();
                          var right = A.Fake<Func<Person, bool>>();
                  
                          Person person = new(1, "blablabla", 666);
                  
                          A.CallTo(() => left(person)).Returns(lval);
                          A.CallTo(() => right(person)).Returns(rval);
                  
                          PersonFilterBuilder.And(left, right)(person).Should().Be(expected);
                      }
                  }
                  

                  но в случае лямбд все очень сильно усложняется.


                  1. mayorovp
                    29.12.2023 06:56

                    Да точно так же для деревьев выражений тест пишется если нужно. Хотя я бы нормализовал выражение и проверил результат ToString()

                    Кстати, зачем вы вообще мокаете делегаты здесь? Оно же только увеличивает число строк в тесте и запутывает его.

                    Func<Person, bool> left = _ => lval;
                    Func<Person, bool> right = _ => rval;


                    1. nronnie
                      29.12.2023 06:56

                      Хотя я бы нормализовал выражение и проверил результат ToString()

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

                      Кстати, зачем вы вообще мокаете делегаты здесь?

                      Ну, можно, наверное, и как у вас, но, mock он как бы еще и проверит что делегат left вернет lval именно на вызов с аргументом person (и, соответственно для right) - как-то при этом меньше представляется шансов получить "false positive" (хотя, разумеется, написать тест действительно защищенный от FP на 100% - по-моему это просто в принципе невозможно).

                      False positive
                          // в коде откровенная ересь и тест с mocks на таком коде не пройдет 
                          // (последний test case даст false вместо true), а в тесте без mocks все будет ок
                          public static Func<Person, bool> And(
                              Func<Person, bool> left,
                              Func<Person, bool> right) =>
                              p => left(null!) && right(null!);
                      }
                      


                    1. nronnie
                      29.12.2023 06:56

                      Кстати, вот, еще - LINQ-выражения это удобная штука, но для тестирования тоже подарок еще тот. потому что если оно не совсем уж тривиальное, то это по сути конструкция с приличным "cyclomatic complexity"- а такие штуки всегда покрываются тестами очень тяжко и с какой-то матерью.


              1. vvdev
                29.12.2023 06:56

                Поддерживаю вопрос товарища @dopusteam

                У меня есть несколько догадок касательно того, что имелось ввиду, но ни в одной не уверен.


  1. anonymous
    29.12.2023 06:56

    НЛО прилетело и опубликовало эту надпись здесь