Блог переехал. Актуальная версия поста находится по адресу: http://aakinshin.net/ru/blog/dotnet/closures/.
Язык C# даёт нам возможность пользоваться замыканиями — мощным механизмом, который позволяет анонимным методам и лямбдам захватывать свободные переменные в своём лексическом контексте. И в .NET-мире многие программисты очень любят использовать замыкания, но немногие понимают, как они действительно работают. Начнём с простого примера:
public void Run() { int e = 1; Foo(x => x + e); }
Ничего сложного тут не происходит: мы просто «захватили» локальную переменную e
в лямбду, которая передаётся в некоторый метод Foo
. Посмотрим, во что компилятор развернёт такую конструкцию*:
public void Run() { DisplayClass c = new DisplayClass(); c.e = 1; Foo(c.Action); } private sealed class DisplayClass { public int e; public int Action(int x) { return x + e; } }
Как видно из примера, для нашего замыкания создаётся дополнительный класс, который содержит захватываемую переменную и целевой метод. Это знание поможет нам осознать поведение замыканий в различных ситуациях.
Цикл for
Наверное, это самый классический пример, который приводят все:
public void Run() { var actions = new List<Action>(); for (int i = 0; i < 3; i++) actions.Add(() => Console.WriteLine(i)); foreach (var action in actions) action(); }
В этом примере сделана типичная ошибка. Начинающие программисты думаю, что этот код выведет "0 1 2"
, но на самом деле он выведет "3 3 3"
. Такое странное поведение легко понять, если взглянуть на развёрнутую версию этого метода:
public void Run() { var actions = new List<Action>(); DisplayClass c = new DisplayClass(); for (c.i = 0; c.i < 3; c.i++) list.Add(c.Action); foreach (Action action in list) action(); } private sealed class DisplayClass { public int i; public void Action() { Console.WriteLine(i); } }
В таком случае часто говорят, что переменная замыкается по ссылке, а не по значению. Эту особенность замыканий многие осуждают, как непонятную, хотя она является достаточно логичной для тех, кто хорошо представляет, что скрыто под капотом замыканий. Эту тему очень подробно обсуждает Эрик Липперт в постах О вреде замыканий на переменных цикла и Замыкания на переменных цикла. Часть 2.
Цикл foreach
Посмотрим более интересный пример:
public void Run() { var actions = new List<Action>(); foreach (var i in Enumerable.Range(0, 3)) actions.Add(() => Console.WriteLine(i)); foreach (var action in actions) action(); }
Что выведет это код? Увы, однозначного ответа на этот вопрос нету. Дело в том, что в ранних версиях C# поведение foreach было подобно поведению for: переменная цикла создавалась один раз и захватывалась во всех лямбдах. А в C# 5.0 это поведение поменяли (тут Эрик Липперт признаётся, что Microsoft всё-таки сделали breaking change). Теперь этот код выводит "0 1 2"
. Заметьте, что это особенность именно языка, а не платформы. Если вы работаете из VisualStuido 2012 и меняете TargetFramework на 3.5, то ничего не поменяется, а вот из VisualStudio 2010 вы сможете пронаблюдать старое поведение. На Stackoverflow Джон Скит объясняет почему было решено сделать различное поведение для for
и foreach
. Взглянем на новый вариант развёрнутой версии кода:
public void Run() { var actions = new List<Action>(); foreach (int i in Enumerable.Range(0, 3)) { DisplayClass c = new DisplayClass(); с.i = i; list.Add(c1.Action); } foreach (Action action in list) action(); } private sealed class DisplayClass { public int i; public void Action() { Console.WriteLine(i); } }
Легко можно заметить разницу: в C# 5.0 на каждую итерацию цикла foreach
мы имеем новый экземпляр сгенерированного класса, обеспечивающего логику замыкания. На Хабре можно почитать поподробнее про замыкания в новой версии C#.
Замыкание нескольких переменных
Рассмотрим ситуацию в которой у нас есть несколько переменных, которые замыкаются в различных переменных:
public void Run() { int x = 1, y = 2; Foo(u => u + x, u => u + y); }
Можно подумать, что в этом случае у нас сгенерируется два дополнительных класса, каждый из которых будет отвечать за единственную переменную. Но на самом деле будет только один сгенерированный класс:
public void Run() { DisplayClass с = new DisplayClass(); с.x = 1; с.y = 2; Foo(с.ActionX, c.ActionY); } private sealed class DisplayClass { public int x; public int y; public int ActionX(int u) { return u + x; } public int ActionY(int u) { return u + y; } }
Таким образом, лямбды оказываются «связаны»: сборщик мусора доберётся до них только после того, как не останется ссылок ни на одну из них. Представьте ситуацию, в которой первая лямбда используется при инициализации долгоживущего объекта, а вторая — по окончанию работы с ним. И пусть таких объектов будет много. В этом случае инициализирующие лямбды будут болтаться в памяти очень долго, хотя никто их больше никогда не будет вызывать.
Scope
Есть ещё одна особенность работы замыканий, о которой полезно знать. Рассмотрим пример:
public void Run(List<int> list) { foreach (var element in list) { var e = element; if (Condition(e)) Foo(x => x + e); } }
А теперь вопрос: в каком месте будет создан объект замыкания? Не смотря на то, что лямбда создаётся внутри if
-а, объект будет создаваться в том же scope
-е, что и захватываемая переменная:
public void Run(List<int> list) { foreach (int element in list) { DisplayClass c = new DisplayClass(); c.e = element; if (Condition(c.e)) Foo(c.Action); } } private sealed class DisplayClass { public int e; public int Action(int x) { return x + e; } }
Такая особенность может иметь значение в случае, если list
очень большой, а условие Condition(e)
выполняется весьма редко. Ведь будет происходить бесполезное создание экземпляров класса DisplayClass
, что негативно скажется на памяти и производительности. Мы можем исправить эту ситуацию:
public void Run(List<int> list) { foreach (var element in list) if (Condition(element)) { var e = element; Foo(x => x + e); } }
Данный метод будет разворачиваться более оптимально, ведь теперь конструктор DisplayClass
будет вызываться только тогда, когда он действительно нужен:
public void Run(List<int> list) { foreach (int element in list) if (Condition(element)) { DisplayClass c = new DisplayClass(); c.e = element; Foo(c.Action); } } private sealed class DisplayClass { public int e; public int Action(int x) { return x + e; } }
Задачки
На приведённую тему есть три задачки в ProblemBook.NET: ClosureAndForeach, ClosureAndFor, ClosureAndVariable.
* Для дизассемблирования удобно пользоваться утилитой dotPeek от JetBrains с включённой опцией
Show compiler-generated code
. Приведённый в статье код немного причёсан по сравнению с дизассемблированной версией для повышения читаемости.
Комментариев нет:
Отправить комментарий