Вебинар: C# разработка и статический анализ: в чем практическая польза? - 18.11
Еще в далеком 2005 с выходом стандарта C# 2.0 появилась возможность передачи переменной в тело анонимного делегата посредством ее захвата (или замыкания, кому как угодно) из текущего контекста. В 2008 вышел в свет новый стандарт C# 3.0, принеся нам лямбды, пользовательские анонимные классы, LINQ запросы и многое другое. Сейчас на дворе январь 2017 и большинство C# разработчиков с нетерпением ждут релиз стандарта C# 7.0, который должен привнести много новых полезных "фич". А вот фиксить старые "фичи", никто особо не торопится. Поэтому способов случайно выстрелить себе в ногу по-прежнему хватает. Сегодня мы поговорим об одном из их, и связан он с не совсем очевидным механизмом захвата переменных в тело анонимных функций в языке C#.
Как я уже и писал выше, в данной статье мы обсудим особенности работы механизма захвата переменных в тело анонимных методов в языке C#. Сразу хочу оговориться, что данная статья будет содержать много технических подробностей, но, я надеюсь, что мне удастся доступно и интересно рассказать об этом как опытным, так и начинающим разработчикам.
А теперь ближе к делу. Я напишу простой пример кода, а вам необходимо будет сказать, что конкретно в данном случае будет выведено в консоль.
И так, приступим:
void Foo()
{
var actions = new List<Action>();
for (int i = 0; i < 10; i++)
{
actions.Add(() => Console.WriteLine(i));
}
foreach(var a in actions)
{
a();
}
}
А теперь внимание, ответ. В консоль будет выведено десять раз число десять:
10
10
10
10
10
10
10
10
10
10
Эта статья для тех, кто посчитал иначе. Давайте разберёмся в причинах такого поведения.
При объявлении анонимной функции (это может быть анонимный делегат или лямбда) внутри вашего класса, на этапе компиляции будет объявлен еще один класс-контейнер, содержащий в себе поля для всех захваченных переменных и метод, содержащий тело анонимной функции. Для приведенного выше участка кода дизассемблированная структура программы после компиляции будет выглядеть так:
В данном случае метод Foo из приведенного в начале участка кода объявлен внутри класса Program. Для лямбды () => Console.WriteLine(i) компилятором был сгенерирован класс-контейнер c__DisplayClass1_0, а внутри него - поле i содержащее одноименную захваченную переменную и метод b__0 содержащий тело лямбды.
Давайте рассмотрим дизассемблированный IL код метода b__0 (тело лямбды) с моими комментариями:
.method assembly hidebysig instance void '<Foo>b__0'() cil managed
{
.maxstack 8
// Помещает на верх стека текущий экземпляр класса (аналог 'this').
// Это необходимо для доступа к полям текущего класса.
IL_0000: ldarg.0
// Помещает на верх стека значение поля 'i'
// экземпляра текущего класса.
IL_0001: ldfld int32
TestSolution.Program/'<>c__DisplayClass1_0'::i
// Вызывает метод вывода строки в консоль.
// В качестве аргументов передаются значения со стека.
IL_0006: call void [mscorlib]System.Console::WriteLine(int32)
// Выходит из метода.
IL_000b: ret
}
Все верно, это именно то, что мы делаем внутри лямбды, никакой магии. Идем дальше.
Как известно, тип int (полное название - Int32) является структурой, а значит при передаче куда-либо передается не ссылка на него в памяти, а копируется непосредственно его значение.
Копироваться значение переменной i должно (по логике вещей) во время создания экземпляра класса-контейнера. И если вы ответили неверно на мой вопрос в начале статьи, то вероятнее всего вы ожидали, что контейнер будет создан непосредственно перед объявлением лямбды в коде.
На самом деле переменная i после компиляции вообще не будет создана внутри метода Foo. Вместо этого будет создан экземпляр класса-контейнера c__DisplayClass1_0, а его поле i будет проинициализировано вместо локальной переменной i значением 0. Более того, везде, где до этого мы использовали локальную переменную i, теперь используется поле класса-контейнера.
Важный момент заключается также в том, что экземпляр класса-контейнера будет создан перед циклом, так как его поле i будет использоваться в цикле как итератор.
В итоге мы получаем один экземпляр класса-контейнера на все итерации цикла for. А добавляя при каждой итерации в список actions новую лямбду, мы, по факту, добавляем в него одну и ту же ссылку на ранее созданный экземпляр класса-контейнера. В результате чего, когда мы обходим циклом foreach все элементы списка actions, то все они содержат один и тот же экземпляр класса-контейнера. А если учесть, что цикл for выполняет инкремент к значению итератора после каждой итерации (даже после последней), то значение поля i внутри класса контейнера после выхода из цикла становится равным десяти после выполнения цикла for.
Убедиться во всем мной вышесказанном можно, взглянув на дизассемблированный IL код метода Foo (естественно с моими комментариями):
.method private hidebysig instance void Foo() cil managed
{
.maxstack 3
// -========== ОБЪЯВЛЕНИЕ ЛОКАЛЬНЫХ ПЕРЕМЕННЫХ ==========-
.locals init(
// Список 'actions'.
[0] class [mscorlib]System.Collections.Generic.List'1
<class [mscorlib]System.Action> actions,
// Класс-контейнер для лямбды.
[1] class TestSolution.Program/
'<>c__DisplayClass1_0' 'CS$<>8__locals0',
// Техническая переменная V_2 необходимая для временного
// хранения результата операции суммирования.
[2] int32 V_2,
// Техническая переменная V_3 необходимая для хранения
// енумератора списка 'actions' во время обхода циклом 'foreach'.
[3] valuetype
[mscorlib]System.Collections.Generic.List'1/Enumerator<class
[mscorlib]System.Action> V_3)
// -================= ИНИЦИАЛИЗАЦИЯ =================-
// Создается экземпляр списка Actions и присваивается
// переменной 'actions'.
IL_0000: newobj instance void class
[mscorlib]System.Collections.Generic.List'1<class
[mscorlib]System.Action>::.ctor()
IL_0005: stloc.0
// Создается экземпляр класса-контейнера и
// присваивается в соответствующую локальную переменную.
IL_0006: newobj instance void
TestSolution.Program/'<>c__DisplayClass1_0'::.ctor()
IL_000b: stloc.1
// Загружается на стек ссылка экземпляра класса-контейнера.
IL_000c: ldloc.1
// Число 0 загружается на стек.
IL_000d: ldc.i4.0
// Присваивается со стека число 0 полю 'i' предыдущего
// объекта на стеке (экземпляру класса-контейнера).
IL_000e: stfld int32
TestSolution.Program/'<>c__DisplayClass1_0'::i
// -================= ЦИКЛ FOR =================-
// Перепрыгивает к команде IL_0037.
IL_0013: br.s IL_0037
// Загружаются на стек ссылки списка 'actions' и
// экземпляра класса-контейнера.
IL_0015: ldloc.0
IL_0016: ldloc.1
// Загружается на стек ссылка на метод 'Foo'
// экземпляра класса-контейнера.
IL_0017: ldftn instance void
TestSolution.Program/'<>c__DisplayClass1_0'::'<Foo>b__0'()
// Создается экземпляр класса 'Action' и в него передается
// ссылка на метод 'Foo' экземпляра класса-контейнера.
IL_001d: newobj instance void
[mscorlib]System.Action::.ctor(object, native int)
// Вызывается метод 'Add' у списка 'actions' добавляя
// в него экземпляр класса 'Action'.
IL_0022: callvirt instance void class
[mscorlib]System.Collections.Generic.List'1<class
[mscorlib]System.Action>::Add(!0)
// Загружается на стек значение поля 'i' экземпляра
// класса-контейнера.
IL_0027: ldloc.1
IL_0028: ldfld int32
TestSolution.Program/'<>c__DisplayClass1_0'::i
// Присваивается технической переменной 'V_2' значение поля 'i'.
IL_002d: stloc.2
// Загружается на стек ссылка на экземпляр класса-контейнера
// и значение технической переменной 'V_2'.
IL_002e: ldloc.1
IL_002f: ldloc.2
// Загружается на стек число 1.
IL_0030: ldc.i4.1
// Суммирует первые два значения на стеке и присваивает их третьему.
IL_0031: add
// Присваивает со стека результат суммирования полю 'i'.
// (по факту инкремент)
IL_0032: stfld int32
TestSolution.Program/'<>c__DisplayClass1_0'::i
// Загружается значение поля 'i' экземпляра
// класса-контейнера на стек.
IL_0037: ldloc.1
IL_0038: ldfld int32
TestSolution.Program/'<>c__DisplayClass1_0'::i
// Загружается на стек число 10.
IL_003d: ldc.i4.s 10
// Если значение поля 'i' меньше числа 10,
// то перепрыгивает к команде IL_0015.
IL_003f: blt.s IL_0015
// -================= ЦИКЛ FOREACH =================-
// Загружается на стек ссылка на список 'actions'.
IL_0041: ldloc.0
// Технической переменной V_3 присваивается результат
// выполнения метода 'GetEnumerator' у списка 'actions'.
IL_0042: callvirt instance valuetype
[mscorlib]System.Collections.Generic.List'1/Enumerator<!0> class
[mscorlib]System.Collections.Generic.List'1<class
[mscorlib]System.Action>::GetEnumerator()
IL_0047: stloc.3
// Инициализация блока try (цикл foreach преобразуется
// в конструкцию try-finally).
.try
{
// Перепрыгивает к команде IL_0056.
IL_0048: br.s IL_0056
// Вызывает у переменной V_3 метод get_Current.
// Результат записывается на стек.
// (Ссылка на объект Action при текущей итерации).
IL_004a: ldloca.s V_3
IL_004c: call instance !0 valuetype
[mscorlib]System.Collections.Generic.List'1/Enumerator<class
[mscorlib]System.Action>::get_Current()
// Вызывает у объекта Action текущей итерации метод Invoke.
IL_0051: callvirt instance void
[mscorlib]System.Action::Invoke()
// Вызывает у переменной V_3 метод MoveNext.
// Результат записывается на стек.
IL_0056: ldloca.s V_3
IL_0058: call instance bool valuetype
[mscorlib]System.Collections.Generic.List'1/Enumerator<class
[mscorlib]System.Action>::MoveNext()
// Если результат выполнения метода MoveNext не null,
// то перепрыгивает к команде IL_004a.
IL_005d: brtrue.s IL_004a
// Завершает выполнение блока try и перепрыгивает в finally.
IL_005f: leave.s IL_006f
} // end .try
finally
{
// Вызывает у переменной V_3 метод Dispose.
IL_0061: ldloca.s V_3
IL_0063: constrained. Valuetype
[mscorlib]System.Collections.Generic.List'1/Enumerator<class
[mscorlib]System.Action>
IL_0069: callvirt instance void
[mscorlib]System.IDisposable::Dispose()
// Завершает выполнение блока finally.
IL_006e: endfinally
}
// Завершает выполнение текущего метода.
IL_006f: ret
}
Товарищи из Microsoft утверждают, что это не баг, а фича, и это поведение было реализовано преднамеренно, с целью увеличения производительности работы программ. Больше информации по ссылке. На деле же это выливается в баги, и непонимание со стороны начинающих разработчиков.
Интересный факт заключается в том, что аналогичное поведение было и у цикла foreach до стандарта C# 5.0. Microsoft буквально засыпали жалобами о неинтуитивном поведении в баг-трекере, после чего с выходом стандарта C# 5.0 это поведение было изменено посредством объявления переменной итератора внутри каждой итерации цикла, а не перед ним на этапе компиляции, но для всех остальных конструкций циклов подобное поведение осталось без изменений. Подробнее об этом можно прочитать по ссылке в разделе Breaking Changes.
Вы спросите, как же избежать данной ошибки? На самом деле ответ очень простой. Необходимо следить за тем, где и какие переменные вы захватываете. Помните, класс-контейнер будет создан там, где вы объявили свою переменную, которую в дальнейшем будете захватывать. Если захват происходит в теле цикла, а переменная объявлена за его пределами, то необходимо переприсвоить ее внутри тела цикла в новую локальную переменную. Корректный вариант приведенного в начале примера мог бы выглядеть так:
void Foo()
{
var actions = new List<Action>();
for (int i = 0; i < 10; i++)
{
var index = i; // <=
actions.Add(() => Console.WriteLine(index));
}
foreach(var a in actions)
{
a();
}
}
Если выполнить данный код, то в консоль будут выведены числа от 0 до 9 как и ожидалось:
0
1
2
3
4
5
6
7
8
9
Посмотрев на IL код цикла for из данного примера, мы увидим, что экземпляр класса-контейнера будет создаваться каждую итерацию цикла. Таким образом, список actions будет содержать ссылки на разные экземпляры с корректными значениями итераторов.
// -================= ЦИКЛ FOR =================-
// Перепрыгивает к команде IL_002d.
IL_0008: br.s IL_002d
// Создает экземпляр класса-контейнера и загружает ссылку на стек
IL_000a: newobj instance void
TestSolution.Program/'<>c__DisplayClass1_0'::.ctor()
IL_000f: stloc.2
IL_0010: ldloc.2
// Присваивает полю 'index' в классе-контейнере
// значение переменной 'i'.
IL_0011: ldloc.1
IL_0012: stfld int32
TestSolution.Program/'<>c__DisplayClass1_0'::index
// Создает экземпляр класса 'Action' с ссылкой на метод
// класса-контейнера и добавляет его в список 'actions'.
IL_0017: ldloc.0
IL_0018: ldloc.2
IL_0019: ldftn instance void
TestSolution.Program/'<>c__DisplayClass1_0'::'<Foo>b__0'()
IL_001f: newobj instance void
[mscorlib]System.Action::.ctor(object, native int)
IL_0024: callvirt instance void class
[mscorlib]System.Collections.Generic.List'1<class
[mscorlib]System.Action>::Add(!0)
// Выполняет инкремент к переменной 'i'
IL_0029: ldloc.1
IL_002a: ldc.i4.1
IL_002b: add
IL_002c: stloc.1
// Загружает на стек значение переменной 'i'.
// В этот раз она уже не в классе-контейнере.
IL_002d: ldloc.1
// Сравнивает значение переменной 'i' c числом 10.
// Если 'i < 10', то перепрыгивает к команде IL_000a.
IL_002e: ldc.i4.s 10
IL_0030: blt.s IL_000a
Напоследок напомню, что все мы люди, и все мы допускаем ошибки, и надеяться всегда только на человеческий фактор при поиске ошибок и опечаток в коде не только не логично, но и как правило долго и ресурсоемко. Поэтому всегда есть смысл использовать технические решения для поиска и выявления ошибок в вашем коде. Машина не только не знает усталости, но и чаще всего выполняет работу быстрее.
Совсем недавно мы - разработчики статического анализатора PVS-Studio - реализовали очередную диагностику, направленную на поиск ошибок неправильного захвата переменных в анонимные функции внутри циклов. В свою же очередь спешу предложить вам проверить ваш код на наличие ошибок и опечаток нашим статическим анализатором.
На этой ноте я заканчиваю данную статью, а вам желаю чистого кода и безбажных программ.
0