Закрытие переменной цикла в Scala

Как обсуждалось в блоге Eric Lippert Закрытие переменной цикла, считающейся вредной, закрытие переменной цикла в С# может иметь неожиданные последствия. Я пытался понять, применил ли тот же "gotcha" к Scala.

Прежде всего, поскольку это вопрос Scala, я попытаюсь объяснить пример Eric Lippert С#, добавив несколько комментариев к его коду

// Create a list of integers
var values = new List<int>() { 100, 110, 120 };

// Create a mutable, empty list of functions that take no input and return an int
var funcs = new List<Func<int>>();

// For each integer in the list of integers we're trying
// to add a function to the list of functions
// that takes no input and returns that integer
// (actually that not what we're doing and there the gotcha).
foreach(var v in values)
  funcs.Add( ()=>v );

// Apply the functions in the list and print the returned integers.
foreach(var f in funcs)
  Console.WriteLine(f());

Большинство людей ожидают, что эта программа будет печатать 100, 110, 120. На самом деле она печатает 120, 120, 120. Проблема в том, что функция () => v, которую мы добавляем в список funcs, закрывается по переменной v, а не по значению v. Поскольку v изменяет значение, в первом цикле все три закрытия, которые мы добавляем в список funcs, "видим" ту же переменную v, которая (к моменту применения их во втором цикле) имеет значение 120 для всех из них.

Я попытался перевести код примера на Scala:

import collection.mutable.Buffer
val values = List(100, 110, 120)
val funcs = Buffer[() => Int]()

for(v <- values) funcs += (() => v)
funcs foreach ( f => println(f()) )
// prints 100 110 120
// so Scala can close on the loop variable with no issue, or can it?

Действительно ли Scala не страдает от одной и той же проблемы или я просто перевел код Эрика Липперта и не смог воспроизвести его?

Такое поведение сработало у многих доблестных разработчиков С#, поэтому я хотел убедиться, что нет никаких странных аналогичных ошибок с Scala. Но также, как только вы понимаете, почему С# ведет себя так, как это делает вывод, пример кода примера Eric Lippert имеет смысл (он работает так, как работают закрытия): так что же делает Scala по-другому?

Ответы

Ответ 1

Scala не имеет такой же проблемы, потому что v не является var, это val. Поэтому, когда вы пишете

() => v

компилятор понимает, что предполагается создать функцию, которая возвращает это статическое значение.

Если вместо этого вы используете var, у вас может быть такая же проблема. Но гораздо яснее, что это заданное поведение, поскольку вы явно создаете var, а затем возвращаете функцию:

val values = Array(100, 110, 120)
val funcs = collection.mutable.Buffer[() => Int]()
var value = 0
var i = 0
while (i < values.length) {
  value = values(i)
  funcs += (() => value)
  i += 1
}
funcs foreach (f => println(f()))

(Обратите внимание: если вы попробуете funcs += (() => values(i)), вы получите исключение из-за пределов, потому что вы закрыли переменную i, которая при вызове теперь находится 3!)

Ответ 2

Близким эквивалентом примера С# будет цикл while и var. Он будет вести себя так же, как на С#.

С другой стороны, for(v <- values) funcs += (() => v) переводится на values.foreach(v => funcs += () => v)

просто чтобы указать имена, которые могли бы быть

def iteration(v: Int) = {funcs += () => v)
values.foreach(iteration)

Закрытие () => v появляется в теле итерации, и то, что он захватывает, не является некоторым var, разделяемым всеми итерациями, но аргумент вызова на итерацию, который не является общим, и, кроме того, является постоянным значением, а не Переменная. Это предотвращает неинтуитивное поведение.

В реализации foreach может быть переменная, но это не то, что видит закрытие.

Если в С# вы перемещаете тело цикла в отдельный метод, вы получаете тот же эффект.

Ответ 3

Обратите внимание, что Scala for-comprehension работает совсем по-другому. Это:

for(v <- values) funcs += (() => v)

переводится во время компиляции:

values.foreach(v => funcs += (() => v))

So v - новая переменная для каждого значения.

Ответ 4

Если вы разбираете пример С#, вы увидите, что класс для хранения закрытых переменных генерируется компилятором. Reflector отображает этот класс как:

[CompilerGenerated]
private sealed class <>c__DisplayClass2
{
    // Fields
    public int v;

    // Methods
    public int <Main>b__1()
    {
        return this.v;
    }
}

Отражатель отображает такой симпатичный С#, вы не можете увидеть, как этот класс используется. Чтобы увидеть, что вам нужно посмотреть на raw IL.

.method private hidebysig static void Main(string[] args) cil managed
{
    .entrypoint
    .maxstack 4
    .locals init (
        [0] class [mscorlib]System.Collections.Generic.List`1<int32> values,
        [1] class [mscorlib]System.Collections.Generic.List`1<class [mscorlib]System.Func`1<int32>> funcs,
        [2] class ConsoleApplication1.Program/<>c__DisplayClass2 CS$<>8__locals3,
        [3] class [mscorlib]System.Func`1<int32> f,
        [4] class [mscorlib]System.Collections.Generic.List`1<int32> <>g__initLocal0,
        [5] valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator`0<int32> CS$5$0000,
        [6] bool CS$4$0001,
        [7] valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator`0<class [mscorlib]System.Func`1<int32>> CS$5$0002)
    L_0000: nop 
    L_0001: newobj instance void [mscorlib]System.Collections.Generic.List`1<int32>::.ctor()
    L_0006: stloc.s <>g__initLocal0
    L_0008: ldloc.s <>g__initLocal0
    L_000a: ldc.i4.s 100
    L_000c: callvirt instance void [mscorlib]System.Collections.Generic.List`1<int32>::Add(!0)
    L_0011: nop 
    L_0012: ldloc.s <>g__initLocal0
    L_0014: ldc.i4.s 110
    L_0016: callvirt instance void [mscorlib]System.Collections.Generic.List`1<int32>::Add(!0)
    L_001b: nop 
    L_001c: ldloc.s <>g__initLocal0
    L_001e: ldc.i4.s 120
    L_0020: callvirt instance void [mscorlib]System.Collections.Generic.List`1<int32>::Add(!0)
    L_0025: nop 
    L_0026: ldloc.s <>g__initLocal0
    L_0028: stloc.0 
    L_0029: newobj instance void [mscorlib]System.Collections.Generic.List`1<class [mscorlib]System.Func`1<int32>>::.ctor()
    L_002e: stloc.1 
    L_002f: nop 
    L_0030: ldloc.0 
    L_0031: callvirt instance valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator`0<!0> [mscorlib]System.Collections.Generic.List`1<int32>::GetEnumerator()
    L_0036: stloc.s CS$5$0000
    L_0038: newobj instance void ConsoleApplication1.Program/<>c__DisplayClass2::.ctor()
    L_003d: stloc.2 
    L_003e: br.s L_0060
    L_0040: ldloc.2 
    L_0041: ldloca.s CS$5$0000
    L_0043: call instance !0 [mscorlib]System.Collections.Generic.List`1/Enumerator`0<int32>::get_Current()
    L_0048: stfld int32 ConsoleApplication1.Program/<>c__DisplayClass2::v
    L_004d: ldloc.1 
    L_004e: ldloc.2 
    L_004f: ldftn instance int32 ConsoleApplication1.Program/<>c__DisplayClass2::<Main>b__1()
    L_0055: newobj instance void [mscorlib]System.Func`1<int32>::.ctor(object, native int)
    L_005a: callvirt instance void [mscorlib]System.Collections.Generic.List`1<class [mscorlib]System.Func`1<int32>>::Add(!0)
    L_005f: nop 
    L_0060: ldloca.s CS$5$0000
    L_0062: call instance bool [mscorlib]System.Collections.Generic.List`1/Enumerator`0<int32>::MoveNext()
    L_0067: stloc.s CS$4$0001
    L_0069: ldloc.s CS$4$0001
    L_006b: brtrue.s L_0040
    L_006d: leave.s L_007e
    L_006f: ldloca.s CS$5$0000
    L_0071: constrained. [mscorlib]System.Collections.Generic.List`1/Enumerator`0<int32>
    L_0077: callvirt instance void [mscorlib]System.IDisposable::Dispose()
    L_007c: nop 
    L_007d: endfinally 
    L_007e: nop 
    L_007f: nop 
    L_0080: ldloc.1 
    L_0081: callvirt instance valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator`0<!0> [mscorlib]System.Collections.Generic.List`1<class [mscorlib]System.Func`1<int32>>::GetEnumerator()
    L_0086: stloc.s CS$5$0002
    L_0088: br.s L_009e
    L_008a: ldloca.s CS$5$0002
    L_008c: call instance !0 [mscorlib]System.Collections.Generic.List`1/Enumerator`0<class [mscorlib]System.Func`1<int32>>::get_Current()
    L_0091: stloc.3 
    L_0092: ldloc.3 
    L_0093: callvirt instance !0 [mscorlib]System.Func`1<int32>::Invoke()
    L_0098: call void [mscorlib]System.Console::WriteLine(int32)
    L_009d: nop 
    L_009e: ldloca.s CS$5$0002
    L_00a0: call instance bool [mscorlib]System.Collections.Generic.List`1/Enumerator`0<class [mscorlib]System.Func`1<int32>>::MoveNext()
    L_00a5: stloc.s CS$4$0001
    L_00a7: ldloc.s CS$4$0001
    L_00a9: brtrue.s L_008a
    L_00ab: leave.s L_00bc
    L_00ad: ldloca.s CS$5$0002
    L_00af: constrained. [mscorlib]System.Collections.Generic.List`1/Enumerator`0<class [mscorlib]System.Func`1<int32>>
    L_00b5: callvirt instance void [mscorlib]System.IDisposable::Dispose()
    L_00ba: nop 
    L_00bb: endfinally 
    L_00bc: nop 
    L_00bd: ret 
    .try L_0038 to L_006f finally handler L_006f to L_007e
    .try L_0088 to L_00ad finally handler L_00ad to L_00bc
}

Внутри первого foreach вы можете увидеть, что создается только один экземпляр этого класса. Значения итератора назначаются в это общедоступное поле v. Список funcs заполняется делегатами этого объекта b__1.

Итак, что происходит в С#, это

  • Создать объект области закрытия
  • Итерирование значений...
    • Нажмите ссылку на функцию доступа к закрытию в funcs
    • Обновить объект области ограничения v с текущим значением.
  • Итератор над funcs. Каждый вызов вернет текущее значение v.