.NET
August 2008 11

Замыкания в C#

Перед прочтением статьи, ответьте на следующий вопрос — что будет напечатано, после исполнения следующего кода?

P p = Console.WriteLine; // P объявлен как delegate void P();
foreach (var i in new [] { 1, 2, 3, 4 }) {
  p += () => Console.Write(i);
}
p();

(К сожалению, не хватает кармы для нормального оформления)

Я провел опрос среди своих коллег, и только три человека из десяти смогли ответить правильно, причем только двое точно знали, что происходит и почему. Я, к своему стыду, правильного ответа не знал.
Так вот, переведенный выше код покажет 4444. Однако, если этот код слегка изменить:

P p = Console.WriteLine;
foreach (var i in new int[] { 1, 2, 3, 4 }) {
  int j = i;
  p += () => Console.Write(j);
}
p();
… то результат будет 1234. Давайте разберемся, почему так происходит.

Наш анонимный метод (лямбда-выражение — это всего лишь «синтаксический сахар» для анонимных методов) использует в теле внешнюю переменную. Эта переменная становится захваченной (captured), и ее время жизни увеличивается до времени жизни делегата, которые ее использует. Это позволяет методу в принципе использовать значения захваченных переменных.

Теперь в дело вступают инстанциация (instantiation) переменных и их область видимости (scope). В первом случае, переменная i инстанциируется один раз перед foreach. Фактически код

foreach (var i in new[] { 1, 2, 3, 4 }) {
//…
}
эквивалентен

{
 int i;
 foreach (i in new[] { 1, 2, 3, 4 }) {
  //…
 }
}
Во втором случае переменная j создается и инстанциируется внутри цикла на каждой итерации. Переменные замыкаются в своей области видимости. Таким образом, в первом случае замкнутая переменная i будет изменятся при каждой итерации и к концу цикла будет равна четырем. Именно поэтому делегат выведет четыре четверки. Во втором случае, j будет замкнута внутри области видимости цикла и будет неизменна (фактически, будет созданно четыре экземпляра переменной j, каждая из которых получит свое значение), и делегат выведет 1234.

Все это становится вполне очевидно, если мы заглянем внутрь сгенерированного компилятором кода, например, при помощи Reflector. Скомпилированный код первого примера (после некоторого причесываения) выглядит вот так:

class DecodedFoo {
 private delegate void P();
 class Anonim {
  public int i;
  public void p()
  {
   Console.Write(i);
  }
 }
 public void Print() {
  P p = Console.WriteLine;
  var a = new Anonim();
  var array = new[] { 1, 2, 3, 4 };
  for (var i = 0; i < array.Length; i++) {
   a.i = array[i];
   p += a.p;
  }
  p();
 }
}
Весьма интересно, что компилятор развернул цикл foreach в for.

Второй пример будет выглядеть вот так:

class DecodedBar {
 private delegate void P();
 class Anonim {
  public int j;
  public void p() {
   Console.Write(j);
  }
 }
 public void Print() {
  P p = Console.WriteLine;
  foreach (var i in new [] { 1, 2, 3, 4 }) {
   var a = new Anonim();
   a.j = i;
   p += a.p;
  }
  p();
 }
}
Ссылки по теме для заинтересовавшихся:
http://blogs.msdn.com/abhinaba/archive/2005/10/18/482180.aspx

http://blogs.msdn.com/oldnewthing/archive/2006/08/02/686456.aspx
http://blogs.msdn.com/oldnewthing/archive/2006/08/03/687529.aspx
http://blogs.msdn.com/oldnewthing/archive/2006/08/04/688527.aspx

P.S. Resharper 4.0 умеет определять такие вот случаи, и для первого примера он выдает предупреждение «Access to modified closure» и предлагает переделать первый пример во второй. Но, однако, он не умеет отделять случаи, когда делегат вызывается внутри цикла, от слчуаев, когда делегат вызывается вне цикла.

+41
41.1k 36
Comments 41