Еще в далеком 2005 с выходом стандарта C# 2.0 появилась возможность передачи переменной в тело анонимного делегата посредством ее захвата (или замыкания, кому как угодно) из текущего контекста. В 2008 вышел в свет новый стандарт C# 3.0, принеся нам лямбды, пользовательские анонимные классы, LINQ запросы и многое другое. Сейчас на дворе январь 2017 и большинство C# разработчиков с нетерпением ждут релиз стандарта C# 7.0, который должен привнести много новых полезных «фич». А вот фиксить старые «фичи», никто особо не торопится. Поэтому способов случайно выстрелить себе в ногу по-прежнему хватает. Сегодня мы поговорим об одном из их, и связан он с не совсем очевидным механизмом захвата переменных в тело анонимных функций в языке C#.

Как я уже и писал выше, в данной статье мы обсудим особенности работы механизма захвата переменных в тело анонимных методов в языке C#. Сразу хочу оговориться, что данная статья будет содержать много технических подробностей, но, я надеюсь, что мне удастся доступно и интересно рассказать об этом как опытным, так и начинающим разработчикам.
А теперь ближе к делу. Я напишу простой пример кода, а вам необходимо будет сказать, что конкретно в данном случае будет выведено в консоль.
И так, приступим:
А теперь внимание, ответ:
Эта статья для тех, кто посчитал иначе. Давайте разберёмся в причинах такого поведения.
При объявлении анонимной функции (это может быть анонимный делегат или лямбда) внутри вашего класса, на этапе компиляции будет объявлен еще один класс-контейнер, содержащий в себе поля для всех захваченных переменных и метод, содержащий тело анонимной функции. Для приведенного выше участка кода дизассемблированная структура программы после компиляции будет выглядеть так:

В данном случае метод Foo из приведенного в начале участка кода объявлен внутри класса Program. Для лямбды () => Console.WriteLine(i) компилятором был сгенерирован класс-контейнер c__DisplayClass1_0, а внутри него — поле i содержащее одноименную захваченную переменную и метод b__0 содержащий тело лямбды.
Давайте рассмотрим дизассемблированный IL код метода b__0 (тело лямбды) с моими комментариями:
Все верно, это именно то, что мы делаем внутри лямбды, никакой магии. Идем дальше.
Как известно, тип int (полное название — Int32) является структурой, а значит при передаче куда-либо передается не ссылка на него в памяти, а копируется непосредственно его значение.
Копироваться значение переменной i должно (по логике вещей) во время создания экземпляра класса-контейнера. И если вы ответили неверно на мой вопрос в начале статьи, то вероятнее всего вы ожидали, что контейнер будет создан непосредственно перед объявлением лямбды в коде.
На самом деле переменная i после компиляции вообще не будет создана внутри метода Foo. Вместо этого будет создан экземпляр класса-контейнера c__DisplayClass1_0, а его поле i будет проинициализировано вместо локальной переменной i значением 0. Более того, везде, где до этого мы использовали локальную переменную i, теперь используется поле класса-контейнера.
Важный момент заключается также в том, что экземпляр класса-контейнера будет создан перед циклом, так как его поле i будет использоваться в цикле как итератор.
В итоге мы получаем один экземпляр класса-контейнера на все итерации цикла for. А добавляя при каждой итерации в список actions новую лямбду, мы, по факту, добавляем в него одну и ту же ссылку на ранее созданный экземпляр класса-контейнера. В результате чего, когда мы обходим циклом foreach все элементы списка actions, то все они содержат один и тот же экземпляр класса-контейнера. А если учесть, что цикл for выполняет инкремент к значению итератора после каждой итерации (даже после последней), то значение поля i внутри класса контейнера после выхода из цикла становится равным десяти после выполнения цикла for.
Убедиться во всем мной вышесказанном можно, взглянув на дизассемблированный IL код метода Foo (естественно с моими комментариями):
Товарищи из Microsoft утверждают, что это не баг, а фича, и это поведение было реализовано преднамеренно, с целью увеличения производительности работы программ. Больше информации по ссылке. На деле же это выливается в баги, и непонимание со стороны начинающих разработчиков.
Интересный факт заключается в том, что аналогичное поведение было и у цикла foreach до стандарта C# 5.0. Microsoft буквально засыпали жалобами о неинтуитивном поведении в баг-трекере, после чего с выходом стандарта C# 5.0 это поведение было изменено посредством объявления переменной итератора внутри каждой итерации цикла, а не перед ним на этапе компиляции, но для всех остальных конструкций циклов подобное поведение осталось без изменений. Подробнее об этом можно прочитать по ссылке в разделе Breaking Changes.
Вы спросите, как же избежать данной ошибки? На самом деле ответ очень простой. Необходимо следить за тем, где и какие переменные вы захватываете. Помните, класс-контейнер будет создан там, где вы объявили свою переменную, которую в дальнейшем будете захватывать. Если захват происходит в теле цикла, а переменная объявлена за его пределами, то необходимо переприсвоить ее внутри тела цикла в новую локальную переменную. Корректный вариант приведенного в начале примера мог бы выглядеть так:
Если выполнить данный код, то в консоль будут выведены числа от 0 до 9 как и ожидалось:
Посмотрев на IL код цикла for из данного примера, мы увидим, что экземпляр класса-контейнера будет создаваться каждую итерацию цикла. Таким образом, список actions будет содержать ссылки на разные экземпляры с корректными значениями итераторов.
Напоследок напомню, что все мы люди, и все мы допускаем ошибки, и надеяться всегда только на человеческий фактор при поиске ошибок и опечаток в коде не только не логично, но и как правило долго и ресурсоемко. Поэтому всегда есть смысл использовать технические решения для поиска и выявления ошибок в вашем коде. Машина не только не знает усталости, но и чаще всего выполняет работу быстрее.
Совсем недавно мы — разработчики статического анализатора PVS-Studio — реализовали очередную диагностику, направленную на поиск ошибок неправильного захвата переменных в анонимные функции внутри циклов. В свою же очередь спешу предложить вам проверить ваш код на наличие ошибок и опечаток нашим статическим анализатором.
На этой ноте я заканчиваю данную статью, а вам желаю чистого кода и безбажных программ.

Если хотите поделиться этой статьей с англоязычной аудиторией, то прошу использовать ссылку на перевод: Ivan Kishchenko. How to capture a variable in C# and not to shoot yourself in the foot

Введение
Как я уже и писал выше, в данной статье мы обсудим особенности работы механизма захвата переменных в тело анонимных методов в языке 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 (тело лямбды) с моими комментариями:
Немного IL кода
.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 (естественно с моими комментариями):
Осторожно, много IL кода
.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 будет содержать ссылки на разные экземпляры с корректными значениями итераторов.
Еще немного IL кода
// -================= ЦИКЛ 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 — реализовали очередную диагностику, направленную на поиск ошибок неправильного захвата переменных в анонимные функции внутри циклов. В свою же очередь спешу предложить вам проверить ваш код на наличие ошибок и опечаток нашим статическим анализатором.
На этой ноте я заканчиваю данную статью, а вам желаю чистого кода и безбажных программ.

Если хотите поделиться этой статьей с англоязычной аудиторией, то прошу использовать ссылку на перевод: Ivan Kishchenko. How to capture a variable in C# and not to shoot yourself in the foot
Прочитали статью и есть вопрос?
Часто к нашим статьям задают одни и те же вопросы. Ответы на них мы собрали здесь: Ответы на вопросы читателей статей про PVS-Studio, версия 2015. Пожалуйста, ознакомьтесь со списком.
Only registered users can participate in poll. Log in, please.
Правильно ли вы ответили на вопрос в начале статьи (Что будет выведено в консоль?)
62.69%Да284
36.2%Нет164
1.1%Другой ответ (напишу в комментариях)5
453 users voted. 47 users abstained.