Введение
Как я уже и писал выше, в данной статье мы обсудим особенности работы механизма захвата переменных в тело анонимных методов в языке 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 — реализовали очередную диагностику, направленную на поиск ошибок неправильного захвата переменных в анонимные функции внутри циклов. В свою же очередь спешу предложить вам проверить ваш код на наличие ошибок и опечаток нашим статическим анализатором.
На этой ноте я заканчиваю данную статью, а вам желаю чистого кода и безбажных программ.
Если хотите поделиться этой статьей с англоязычной аудиторией, то прошу использовать ссылку на перевод: Ivan Kishchenko. How to capture a variable in C# and not to shoot yourself in the foot
Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Комментарии (41)
aikixd
27.01.2017 11:32+3На Джавескрипте такая же фигня есть. Как-то весьма давно наткнулся на эту фичу так глубоко, что думал уже весь модуль переписать. Если результаты работы замкнутой переменной не видно в одном месте, а тем более если обрабатываются они в разное время то пара веселых деньков вам обеспечено.
Ответил бы неправильно, если бы не знал что вопрос с подвохом. Не ожидал этого в шарпе.
P.S.
Енумератор режет глаз. Впрочем как и энумератор. Используйте русский перечеслитель.mayorovp
27.01.2017 11:39+2На джаваскрипте до появления let все было еще хуже. А на C# время жизни переменной всегда определялось блоком, а не методом. Вот и ноют иногда :)
ggrnd0
27.01.2017 11:39+1Лучше тогда итератор.
aikixd
27.01.2017 12:02Это разные вещи. Перечеслитель привязан к коллекциям, итератор к численному значению.
ggrnd0
27.01.2017 12:14+5Вероятно вы спутали IEnumerable и IEnumerator — 1й коллекция.
Разница между Iterator и Enumerator в том что слово Iterator вошло в русский язык, а Enumerator нет.
Значения же у них одинаковые.darkdaskin
30.01.2017 16:11-1В C# итератором обычно называют реализацию IEnumerator с использованием yield return. Так что в контексте статьи не всякий перечислитель является итератором.
mayorovp
31.01.2017 06:42Реализация IEnumerator с использованием yield return всегда называлась "генератор", а не "итератор".
ggrnd0
31.01.2017 11:50К сожалению на русском MSDN итератор встречяается только в контексте IEnumerable+yield
Но в js и java(там нет yield пока) это и правда называется генератор.
Думаю в питоне и прочих тоже. Но MS — это MS=)
mayorovp
27.01.2017 11:46+7Это и правда фича. Потому что переменная цикла for — она принципиально общая для разных итераций и изменяемая!
Рассмотрим такой код:
for (int i=0; i<10; i++) { actions.Add(() => Console.WriteLine(i)); if (i % 2 == 0) i++; actions.Add(() => Console.WriteLine(i)); }
Какое еще может быть тут поведение? Автоматически создавать новую переменную внутри цикла с тем же именем — нельзя, оператор
i++
будет работать не так как ожидается.
Захватывать переменную по значению? Тоже нельзя, они же всегда захватываются по ссылке, за что такое исключение переменным цикла?
Даже создавать копию переменной только для захвата — тоже нельзя! Все из-за оператора i++ в теле цикла.
А уж если вспомнить, что внутри замыкания переменную цикла тоже можно менять — становится ясным, что цикл for может работать только так как он работает сейчас.
А вот с циклом foreach — ситуация другая. Там переменная цикла — неизменяемая, и никаких проблем в перетаскивании ее из внешнего блока во внутренний — нет.
alexeykuzmin0
27.01.2017 12:21+4А почему бы не дать возможность разработчику указать, как он хочет захватить — по ссылке или по значению? Так это сделано, например, в C++.
areht
28.01.2017 11:59Так в статье 2 примера, в одном захват по ссылке, во втором — нет. Для этого не нужны специальные операторы.
sophist
28.01.2017 13:26Почему бы не дать возможность разработчику явно указать, как он хочет захватить? Явно и без привязки к циклам.
Например, так же, как это сделано для параметров функций — по умолчанию по значению, с ключевым словом ref — по ссылке.areht
28.01.2017 14:25Ну… " var index = " недвусмысленно указывает, что я хочу значение.
Вы хотите вместо этого явно где-то писать " ref int index "? Ну и выделите явно функцию, зачем вам замыкание?
kishchenko
30.01.2017 12:37+2В C# захват всегда происходит по ссылке, но во втором примере мы захватываем ссылку на копию объекта, сделанную внутри цикла, а в первом — на один объект для всех итераций цикла.
areht
30.01.2017 13:10-2> ссылку на копию объекта,
А вы можете привести пример, где бы это имело значение (т.е. было бы важно)?kishchenko
30.01.2017 13:29+3В статье есть 2 примера, которые наглядно демонстрируют отличия в поведении.
Не вижу смысла пересказывать то, что уже написано.
Dreamer_other
30.01.2017 12:34Очевидно потому, что в C# объекты передаются ТОЛЬКО по ссылке. Городить этот огород только для value типов смысла мало.
mayorovp
30.01.2017 13:07-1Нет, вы не поняли. Речь идет не о передаче объектов, а о захвате переменных. И в данном случае, захватываются по ссылке как объекты, так и value-типы! Посмотрите пример с циклом
for
: там индексi
вполне себе value-тип, но захвачен по ссылке.
sand14
27.01.2017 12:00+3Когда вышел C# 5.0, здесь уже была статья, что для цикла foreach поведением изменено таким образом, что на каждой итерации переменная для хранение текущего значения пересоздается (с возможностью через ключ компилятора вернуть старое поведение),
и что для цикла for остается старое поведение — как раз по причине того, что счетчик for принципиально един для всех итераций (по сути — сахар для while с объявленной вне тела цикла переменной-счетчиком).
Но за новую статью — спасибо. Более понятно и наглядно написано, как показалось.
DrBAXA
27.01.2017 12:10+2Тоже ответил неверно. Java просто не позволяет изменяемые переменные захватывать и думаю что правильно делает. Хотя и не понимаю почему нельзя просто копировать значение (ссылку если объект).
DjoNIK
27.01.2017 12:16+1Если копировать ссылку (можно переписать пример с reference type), будет такая же песня )) В контейнере на момент выполнения actions будет последний экземпляр. Вся суть в том, что используя замыкание, по факту, вы уже работаете не с оригинальной переменной, а с публичным полем класса-контейнера. Ну и как следствие — хранимое там значение регламентируется временем жизни экземпляра этого класса-контейнера.
DieselMachine
27.01.2017 12:54+4Просто копировать значение нельзя, потому что пишут например такой код, который в этом случае сломается
var runInserts = true; Task.Run(() => { while (runInserts) { } }); ... runInserts = false;
Sirikid
27.01.2017 13:15Java позволяет захватывать все что угодно, вот только это "все" захватывается по значению, по другому Java просто не умеет, поэтому, что бы избежать контринтуитивного поведения, запрещено изменять захваченные переменные.
Если разрешить такие изменения получится вот так:
int x = 0; Runnable y = () -> { ++x; } y.run(); // x по прежнему 0, ведь y изменил скопированную переменную
DjoNIK
27.01.2017 12:12Статья однозначно полезная и интересная с технической точки зрения. Единственный спорный момент — утверждение, что реализации замыкания для for ошибочна и что это бага.
Выше уже неоднократно говорили о том, что различия в поведении замкнутых переменных для for и foreach вызваны различием в семантике обоих циклов.
AxisPod
27.01.2017 12:43+1Почему бага, статья при этом утверждает, что это фича. Да и собственно логичное поведение. Разве что управлять этим нельзя, ничего менять не надо. В крайнем случае сделать как в C++, только с текущим дефолтным поведением.
vladimirkolyada
27.01.2017 13:03Всего лет на 10 задержались:) https://blogs.msdn.microsoft.com/ruericlippert/2009/11/12/1094/
AlexS
27.01.2017 18:02Имхо, точно написано — проблемы начинающих разработчиков. Каждый так или иначе стреляет себе в ногу, вот и вы попали :) Welcome to the club!
creker
28.01.2017 01:09Только недавно наткнулся на что-то подобное. Захватил в лямбде поле класса и удивлялся, чего у меня неправильно работает. Поле в процессе работы переписывалось, а в вместе с ним менялось и то, что попало в замыкание.
Помог опыт Go, где есть точно такая же фича и цикл из примера будет работать точно так же.mayorovp
28.01.2017 19:59-1Вообще-то, поле класса будет "замыкаться" одинаково вне зависимости от языка программирования! Разве что в Rust компилятор на что-нибудь ругнется...
VAAKAraceGUM
30.01.2017 12:34+1Планируете ли Вы в будущем выпустить плагины для таких IDE, как CLion и Rider для более удобной и продуктивной работы с PVS-Studio в Linux системах? Спасибо.
kishchenko
30.01.2017 12:56+1Здравствуйте!
Интеграция с CLion поддерживается:
http://www.viva64.com/ru/m/0036/#ID0ECCBI
Quilin
Как бывший джаваскриптер я сразу же правильно ответил на вопрос, даже не зная внутренней механики этого процесса в шарпе. Но за разъяснения и статью спасибо!