當前位置: 妍妍網 > 碼農

C#中如何在匿名函式輸出變量,你了解多少?

2024-03-18碼農


概述: 早在 2005 年,隨著 C# 2.0 標準的釋出,我們可以透過從當前上下文中捕獲變量來將變量傳遞給匿名委托的正文。2008 年,C# 3.0 為我們帶來了 lambda、使用者匿名類、LINQ 請求等等。現在是 2017 年 1 月,大多數 C# 開發人員都期待著 C# 7.0 標準的釋出,它應該為我們提供一系列新的有用功能。但是,仍有一些舊功能需要修復。這就是為什麽有很多方法可以搬起石頭砸自己的腳。今天我們將討論其中之一,它與 C# 中匿名函式主體中一個非常不明顯的變量捕獲機制有關。介紹正如我上面所說,我們將討論 C# 中匿名函式主體中變量捕獲機制的特殊性。我應該提前警告一下,這篇文章將包含

早在 2005 年,隨著 C# 2.0 標準的釋出,我們可以透過從當前上下文中捕獲變量來將變量傳遞給匿名委托的正文。2008 年,C# 3.0 為我們帶來了 lambda、使用者匿名類、LINQ 請求等等。現在是 2017 年 1 月,大多數 C# 開發人員都期待著 C# 7.0 標準的釋出,它應該為我們提供一系列新的有用功能。但是,仍有一些舊功能需要修復。這就是為什麽有很多方法可以搬起石頭砸自己的腳。今天我們將討論其中之一,它與 C# 中匿名函式主體中一個非常不明顯的變量捕獲機制有關。

介紹

正如我上面所說,我們將討論 C# 中匿名函式主體中變量捕獲機制的特殊性。我應該提前警告一下,這篇文章將包含大量的技術細節,但我希望有經驗的程式設計師和初學者都會發現我的文章有趣且易於理解。

但說得夠多了。我將給你一個簡單的程式碼範例,你應該告訴,控制台中將打印什麽。

所以,我們開始吧。

voidFoo()
{
var actions = newList<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
10

這篇文章是為那些不這麽認為的人準備的。讓我們試著梳理一下,這種行為的原因是什麽。

為什麽會這樣?

在類中聲明匿名函式(可以是匿名委托或 lambda)後,將在編譯期間聲明另一個容器類,該類包含所有捕獲變量的欄位和一個包含匿名函式主體的方法。上面給出的程式碼片段的程式的反組譯結構如下:

在本例中,此片段中的 Foo 方法在 Program 類中聲明。編譯器為 lambda ( ) => Console.WriteLine( i) 生成了一個容器類_c__Display class1_0 ,並在類容器內部生成了一個欄位 i ,該欄位具有一個具有相同名稱和方法 b__0 的捕獲變量 ,_其中包含 lambda 的主體。

讓我們考慮一下 b__0 方法(lambda 正文)的反組譯 IL 程式碼以及我的評論:

.method assembly hidebysig instance void '<Foo>b__0'() cil managed
{
.maxstack 8
// Puts the current class item (equivalent to 'this')
// to the top of the stack.
// It is necessary for the access to
// the fields of the current class.
IL_0000: ldarg.0
// Puts the value of the 'i' field to the top of the stack
// of the current class instance
IL_0001: ldfld int32
TestSolution.Program/'<>c__Display class1_0'::i
// Calls a method to output the string to the console.
// Passes values from the stack as arguments.
IL_0006: call void [mscorlib]System.Console::WriteLine(int32)
// Exits the method.
IL_000b: ret
}

沒錯,這正是我們在 lambda 中所做的,沒有魔法。讓我們繼續。

眾所周知,int 型別(全稱 Int32)是一個結構體,這意味著它透過值傳遞,而不是透過參照傳遞。

在建立容器類例項期間,應復制 i 變量的值(根據邏輯)。如果您錯誤地回答了我在文章開頭的問題,那麽您很可能期望容器將在程式碼中聲明 lambda 之前建立。

實際上,在 Foo 方法中編譯後根本不會建立 i 變量。取而代之的是,將建立容器類 c__Display class1_0 的例項,並且其欄位將使用 0 而不是 i 變量進行初始化。此外,在我們使用局部變量 i 的所有片段中,都會有一個使用容器類的欄位。

重要的一點是,容器類的例項是在迴圈之前建立的,因為它的欄位 i 將在迴圈中用作叠代器。

因此,我們為 for 迴圈的所有叠代獲得一個容器類的例項。在每次叠代時向_操作_列表添加一個新的 lambda,我們實際上添加了對之前建立的容器類例項的相同參照。因此,當我們使用 foreach 迴圈遍歷_操作_列表的所有項時,它們都具有相同的容器類例項。我們考慮到 for 迴圈在每次叠代後(甚至在最後一次叠代之後)遞增叠代器的值,那麽在執行 for 迴圈後,結束迴圈後容器類內 i 欄位的值等於 10。

您可以透過檢視 Foo 方法的反組譯 IL 程式碼來確保它(帶有我的評論):

.method private hidebysig instance voidFoo() cil managed
{
.maxstack 3
// -========== DECLARATION OF LOCAL VARIABLES ==========-
.localsinit(
// A list of 'actions'.
[0] class [mscorlib]System.Collections.Generic.List'1
< class [mscorlib]System.Action> actions,
// A container class for the lambda.
[1] classTestSolution.Program/
'<>c__Display class1_0' 'CS$<>8__locals0',
// A technical variable V_2 is necessary for temporary
// storing the results of the addition operation.
[2] int32 V_2,
// Technical variable V_3 is necessary for storing
// the enumerator of the 'actions' list during
// the iteration of the 'foreach' loop.
[3] valuetype
[mscorlib]System.Collections.Generic.List'1/Enumerator< class
[mscorlib]System.Action>
V_3)
// -================= INITIALIZATION =================-
// An instance of the Actions list is created and assigned to the
// 'actions' variable.
IL_0000: newobj instance void class
[mscorlib]System.Collections.Generic.List'1< class
[mscorlib]System.Action>::.ctor()
IL_0005: stloc.0
// An instance of the container class is created
// and assigned to a corresponding local variable
IL_0006: newobj instance void
TestSolution.Program/'<>c__Display class1_0'::.ctor()
IL_000b: stloc.1
// A reference of the container class is loaded to the stack.
IL_000c: ldloc.1
// Number 0 is loaded to the stack.
IL_000d: ldc.i4.0
// 0 is assigned to the 'i' field of the previous
// object on the stack (an instance of a container class).
IL_000e: stfld int32
TestSolution.Program/'<>c__Display class1_0'::i
// -================= THE FOR LOOP =================-
// Jumps to the command IL_0037.
IL_0013: br.s IL_0037
// The references of the 'actions'
// list and an instance of the container class
// are loaded to the stack.
IL_0015: ldloc.0
IL_0016: ldloc.1
// The reference to the 'Foo' method of the container class
// is loaded to the stack.
IL_0017: ldftn instance void
TestSolution.Program/'<>c__Display class1_0'::'<Foo>b__0'()
// An instance of the 'Action' class is created and the reference
// to the 'Foo' method of the container class is passed into it.
IL_001d: newobj instance void
[mscorlib]System.Action::.ctor(object, native int)
// The method 'Add' is called for the 'actions' list
// by adding an instance of the 'Action' class.
IL_0022: callvirt instance void class
[mscorlib]System.Collections.Generic.List'1< class
[mscorlib]System.Action>::Add(!0)
// The value of the 'i' field of the instance of a container class
// is loaded to the stack.
IL_0027: ldloc.1
IL_0028: ldfld int32
TestSolution.Program/'<>c__Display class1_0'::i
// The value of the 'i' field is assigned
// to the technical variable 'V_2'.
IL_002d: stloc.2
// The reference to the instance of a container class and the value
// of a technical variable 'V_2' is loaded to the stack.
IL_002e: ldloc.1
IL_002f: ldloc.2
// 1 is loaded to the stack.
IL_0030: ldc.i4.1
// It adds two first values on the stack
// and assigns them to the third.
IL_0031: add
// The result of the addition is assigned to the 'i' field
// (in fact, it is an increment)
IL_0032: stfld int32
TestSolution.Program/'<>c__Display class1_0'::i
// The value of the 'i' field of the container class instance
// is loaded to the stack.
IL_0037: ldloc.1
IL_0038: ldfld int32
TestSolution.Program/'<>c__Display class1_0'::i
// 10 is loaded to the stack.
IL_003d: ldc.i4.s 10
// If the value of the 'i' field is less than 10,
// it jumps to the command IL_0015.
IL_003f: blt.s IL_0015

// -================= THE FOREACH LOOP =================-
//// The reference to the 'actions' list is loaded to the stack.
IL_0041: ldloc.0
// The technical variable V_3 is assigned with the result
// of the 'GetEnumerator' method of the 'actions' list.
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
// The initialization of the try block
// (the foreach loop is converted to
// the try-finally construct)
.try
{
// Jumps to the command IL_0056.
IL_0048: br.s IL_0056
// Calls get_Current method of the V_3 variable.
// The result is written to the stack.
// (A reference to the Action object in the current iteration).
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()
// Calls the Invoke method of the Action
// object in the current iteration
IL_0051: callvirt instance void
[mscorlib]System.Action::Invoke()
// Calls MoveNext method of the V_3 variable.
// The result is written to the stack.
IL_0056: ldloca.s V_3
IL_0058: call instance bool valuetype
[mscorlib]System.Collections.Generic.List'1/Enumerator< class
[mscorlib]System.Action>::MoveNext()
// If the result of the MoveNext method is not null,
// then it jumps to the IL_004a command.
IL_005d: brtrue.s IL_004a
// Finishes the try block execution and jumps to finally.
IL_005f: leave.s IL_006f
} // end .try
finally
{
// Calls the Dispose method of the V_3 variable.
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()
// Finishes the execution of the finally block.
IL_006e: endfinally
}
// Finishes the execution of the current method.
IL_006f: ret
}































結論

Microsoft 的人說這是一個功能,而不是一個錯誤,這種行為是故意的,旨在提高程式的效能。您將透過此連結找到更多資訊。實際上,它會導致新手開發人員的錯誤和困惑。

一個有趣的事實是, foreach 迴圈在 C# 5.0 標準之前具有相同的行為。Microsoft 被關於錯誤跟蹤器中非直觀行為的抱怨轟炸,但隨著 C# 5.0 標準的釋出,透過在每個迴圈叠代中聲明叠代器變量來改變這種行為,而不是在編譯階段之前,但對於所有其他結構,類似的行為保持不變。有關詳細資訊,請參閱_「中斷性變更_」部份中的連結。

你可能會問如何避免這樣的錯誤?其實答案很簡單。您需要跟蹤捕獲的位置和變量。請記住,容器類將在您聲明要捕獲的變量的位置建立。如果捕獲發生在迴圈的主體中,並且變量是在迴圈外部聲明的,則有必要在迴圈主體內將其重新分配給新的局部變量。開頭給出的範例的正確版本可以如下所示:

voidFoo()
{
var actions = newList<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

從此範例中檢視 for 迴圈的 IL 程式碼,我們將看到在迴圈的每次叠代中都會建立一個容器類的例項。因此,操作列表將包含對具有正確叠代器值的各種例項的參照。

// -================= THE FOR LOOP =================-
// Jumps to the command IL_002d.
IL_0008: br.s IL_002d
// Creates an instance of a container class
// and loads the reference to the stack.
IL_000a: newobj instance void
TestSolution.Program/'<>c__Display class1_0'::.ctor()
IL_000f: stloc.2
IL_0010: ldloc.2
// Assigns the 'index' field in the container class
// with a value 'i'.
IL_0011: ldloc.1
IL_0012: stfld int32
TestSolution.Program/'<>c__Display class1_0'::index
// Creates an instance of the 'Action' class with a reference to
// the method of a container class and add it to the 'actions' list.
IL_0017: ldloc.0
IL_0018: ldloc.2
IL_0019: ldftn instance void
TestSolution.Program/'<>c__Display class1_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)
// Performs the increment to the 'i' variable
IL_0029: ldloc.1
IL_002a: ldc.i4.1
IL_002b: add
IL_002c: stloc.1
// Loads the value of the 'i' variable to the stack
// This time it is not in the container class
IL_002d: ldloc.1
// Compares the value of the variable 'i' with 10.
// If 'i < 10', then jumps to the command IL_000a.
IL_002e: ldc.i4.s 10
IL_0030: blt.s IL_000a

最後,讓我提醒您,我們都是人類,我們都會犯錯誤,這就是為什麽在尋找錯誤和錯別字時只希望人為因素是不合邏輯的,並且通常是漫長且資源密集型的。因此,使用技術解決方案來檢測程式碼中的錯誤始終是一個好主意。機器不會感到疲倦,並且完成工作的速度要快得多。

如果你喜歡我的文章,請給我一個贊!謝謝