Как замкнуть переменную в C# и не выстрелить себе в ногу

в 8:01, , рубрики: .net, C#, cil, il, pvs-studio, static code analysis, Блог компании PVS-Studio, замыкание переменных, захват переменных, Компиляторы, особенности C#, статический анализ кода

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

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

Эта статья для тех, кто посчитал иначе. Давайте разберёмся в причинах такого поведения.

Почему так происходит?

При объявлении анонимной функции (это может быть анонимный делегат или лямбда) внутри вашего класса, на этапе компиляции будет объявлен еще один класс-контейнер, содержащий в себе поля для всех захваченных переменных и метод, содержащий тело анонимной функции. Для приведенного выше участка кода дизассемблированная структура программы после компиляции будет выглядеть так:

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 — реализовали очередную диагностику, направленную на поиск ошибок неправильного захвата переменных в анонимные функции внутри циклов. В свою же очередь спешу предложить вам проверить ваш код на наличие ошибок и опечаток нашим статическим анализатором.

На этой ноте я заканчиваю данную статью, а вам желаю чистого кода и безбажных программ.

Автор: PVS-Studio

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js