Особенности компиляции анонимных делегатов/лямбда-выражений в C#
Я думаю, что большинство из .NET-программистов интересовалось как именно устроен синтаксический сахар анонимных делегатов в C# 2.0, а так же лямбда-выражений, появившихся немного позже. В простых случаях анонимные делегаты превращаются в статические методы с приватным уровнем доступа и непроизносимым именем (специально чтобы вы не могли набрать такое же имя в C#), например:
static void HookCancelPress() {
Console.CancelKeyPress += delegate { Console.WriteLine("Пока!"); };
}
Компилируется как обычный приватный статический метод (обратите внимание на возможность опустить список формальных параметров в анонимных делегатах C# 2.0):
static void HookCancelPress() {
Console.CancelKeyPress += new ConsoleCancelEventHandler(Program.<Main>b__0);
}
[CompilerGenerated]
static void <Main>b__0(object param0, ConsoleCancelEventArgs param1) {
Console.WriteLine("пока!");
}
Интересное начинается тогда, когда анонимный делегат/лямбда-выражение начинается замыкаться на внешние переменные (включая параметры методов), тем самым продляя их время жизни. В этих случаях компилятор C# генерирует closure-класс (я предпочитаю его так называть) и переменная, на которую происходит замыкание, становится полем этого класса (далее в примерах кода я заменял названия closure-классов на более читаемые):
static IEnumerable<int> MultipleBy(this IEnumerable<int> source, int multiplier) {
return source.Select(x => checked(x * multiplier));
}
Компилируется в (обратите внимание на публичность полей closure-класса):
[CompilerGenerated]
sealed class DisplayClass1 {
public int multiplier;
public int <MultipleBy>b__0(int x) {
return checked(x * this.multiplier);
}
}
static IEnumerable<int> MultipleBy(this IEnumerable<int> source, int multiplier) {
DisplayClass1 closure = new DisplayClass1();
closure.multiplier = multiplier;
return source.Select(new Func<int, int>(closure.<MultipleBy>b__0));
}
Так как время жизни делегата вовсе неизвестно, то переменные, захваченные в замыкание, приходится переносить в кучу (в поле closure-класса, память под который выделяется в куче, а время жизни контроллируется сборщиком мусора). Обратите внимание, что на доступ к полю closure-класса, заменяются не только обращения к переменным внутри анонимного делегата, но и в самом методе. Это необходимо из-за того, что C# у нас язык императивный с изменяемыми переменнами, а значит должна быть возможность изменять переменные внутри делегатов и внешний метод должен “видеть” эти изменения:
static void MutableClosure() {
int value = 0;
Action f = delegate { value++; };
f();
Console.WriteLine("value = {0}", value); // value = 1
}
Превращается в:
[CompilerGenerated]
sealed class DisplayClass1 {
public int value;
public void <Foo>b__0() { this.value++; }
}
static void MutableClosure() {
DisplayClass1 closure = new DisplayClass1();
closure.value = 0;
Action f = new Action(closure.<Foo>b__0);
f();
Console.WriteLine("value = {0}", closure.value /* <--- */);
}
Таким образом, переменная, взятая в замыкание, никогда не выделяется на стеке и обладает небольшим оверхедом при доступе, так как является полем closure-класса. Существуют вырожденные случаи, когда в замыкание попадают только поля класса:
class FooValue {
readonly int value;
public FooValue(int value) {
this.value = value;
}
public Func<int, int> GetBar() {
return x => x * value;
}
}
В этих случаях делегат очень удобно компилируется в метод уровня экземпляра:
class FooValue {
readonly int value;
public FooValue(int value) {
this.value = value;
}
public Func<int, int> GetBar() {
return new Func<int, int>(this.<GetBar>b__0);
}
[CompilerGenerated]
private int <GetBar>b__0(int x) {
return x * this.value;
}
}
За счёт этого же эффекта, несколько вложенных определений анонимных методов:
static void Bar() {
var value = 1;
Action f = delegate {
Action g = delegate {
Action h = delegate { value++; };
};
};
}
Могут эффективно компилироваться всего лишь в один closure-класс:
[CompilerGenerated]
sealed class DisplayClass3 {
public int value;
public void <Bar>b__0() { new Action(this.<Bar>b__1); }
public void <Bar>b__1() { new Action(this.<Bar>b__2); }
public void <Bar>b__2() { this.value++; }
}
static void Bar() {
DisplayClass3 closure = new DisplayClass3();
closure.value = 1;
new Action(closure.<Bar>b__0);
}
Но стоит взять в замыкание ещё одну переменную, как трансформация лямбда-выражения усложняется:
class FooValue {
readonly int value;
public FooValue(int value) {
this.value = value;
}
public Func<int, int> GetBar(int delta) {
return x => x * value + delta;
}
}
Что вызывает генерацию closure-класса:
class FooValue {
[CompilerGenerated]
sealed class DisplayClass1 {
public FooValue __this;
public int delta;
public int <GetBar>b__0(int x) {
return x * this.__this.value + this.delta;
}
}
readonly int value;
public FooValue(int value) {
this.value = value;
}
public Func<int, int> GetBar(int delta) {
DisplayClass1 closure = new DisplayClass1();
closure.delta = delta;
closure.__this = this;
return new Func<int, int>(closure.<GetBar>b__0);
}
}
То есть в замыкание берутся две переменные - delta
и this
. Обратите внимание, что поле value
объявлено как readonly
, а значит его значение теоретически можно было бы взять в замыкание вместо ссылки на весь объект FooValue
(и объект мог бы быть успешно собран сборщиком мусора вне зависимости от существования замыкания). Однако C# так не делает, так как анонимный метод может быть создан в конструкторе ещё до инициализации поля value
.
Неприятные эффекты начинаются тогда, когда несколько анонимных делегатов в одном методе захватывают одну и ту же переменную:
static Func<int> SharedClosure() {
var xs = new int[10000000];
var index = 0;
Action notEvenUsed = () => Console.WriteLine(xs[index]);
return () => index++;
}
Компилятор C# разделяет closure-класс между двумя анонимными делегатами:
[CompilerGenerated]
sealed class DisplayClass2 {
public int[] xs; // <-- !!!!!
public int index;
public void <SharedClosure>b__0() {
Console.WriteLine(this.xs[this.index]);
}
public int <SharedClosure>b__1() {
return this.index++;
}
}
static Func<int> SharedClosure() {
DisplayClass2 closure = new DisplayClass2();
closure.xs = new int[10000000];
closure.index = 0;
new Action(closure.<SharedClosure>b__0);
return new Func<int>(closure.<SharedClosure>b__1);
}
Видите проблему? Не смотря на то, что один делегат тут даже вовсе не используется, второй делегат продляет жизнь не только переменной value
, на которую он замыкается, но ещё и хранит в себе переменную xs
! Таким образом могут появляться трудноотлавливаемые утечки памяти, ведь пользователь никак не ожидает, что делегат сохраняет в себе ссылку на переменную, которую он вовсе не брал в замыкание. Правильной трансформацией было бы использование двух closure-классов: первый хранил бы в себе переменную index
, а второй - ссылку на первый closure-класс и переменную xs
.
Продолжение следует…