Як замкнути змінну в C# і не вистрілити собі в ногу

Ще в далекому 2005 з виходом стандарту C# 2.0 з'явилася можливість передачі змінної в тіло анонімного представника допомогою її захоплення (або замикання, кому як завгодно) з поточного контексту. В 2008 вийшов у світ новий стандарт C# 3.0, принісши нам лямбды, власні анонімні класи, LINQ запити і багато іншого. Зараз на дворі січень 2017 і більшість C# розробників з нетерпінням чекають реліз стандарту C# 7.0, який повинен привнести багато нових корисних «фіч». А ось фиксить старі «фічі», ніхто особливо не поспішає. Тому способів випадково вистрілити собі в ногу, як і раніше, вистачає. Сьогодні ми поговоримо про один з їх, і пов'язаний він з не зовсім очевидним механізмом захоплення змінних в тіло анонімних функцій у мові C#.

<img src=«habrastorage.org/getpro/habr/post_images/433/af9/b4a/433af9b4a44c643067f181b878f3631c.png» alt=«Picture » 1" />


Введення
Як я вже і писав вище, у даній статті ми обговоримо особливості роботи механізму захоплення змінних в тіло анонімних методів в мові 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

Ця стаття для тих, хто вважав інакше. Давайте розберемося в причинах такої поведінки.

Чому так відбувається?
При оголошенні анонімної функції (це може бути анонімний делегат або лямбда) всередині вашого класу, на етапі компіляції буде оголошений ще один клас-контейнер, що містить у собі поля для всіх захоплених змінних та метод, що містить тіло анонімної функції. Для наведеного вище ділянки коду дизассемблированная структура програми після компіляції буде виглядати так:

<img src=«habrastorage.org/getpro/habr/post_images/7a8/602/144/7a86021449da435ccac863c7133c0ce3.png» alt=«Picture » 3" />

В даному випадку метод 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. Будь ласка, ознайомтеся зі списком.

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.