匿名方法
匿名方法是.NET 2.0中引入的高級特性,“匿名”二字說明它可以把實現內聯地寫在一個方法中,從 而形成一個委托對象,而不用有明確地方法名,例如:
static void Test()
{
Action<string> action = delegate(string value)
{
Console.WriteLine(value);
};
action("Hello World");
}
但是匿名方法的關鍵並不僅於“匿名”二字。其最強大的特性就在於匿名方法形成了一個閉包,它可 以作為參數傳遞到另一個方法中去,但同時也能訪問方法的局部變量和當前類中的其它成員。例如:
class TestClass
{
private void Print(string message)
{
Console.WriteLine(message);
}
public void Test()
{
string[] messages = new string[] { "Hello", "World" };
int index = 0;
Action<string> action = (m) =>
{
this.Print((index++) + ". " + m);
};
Array.ForEach(messages, action);
Console.WriteLine("index = " + index);
}
}
如上所示,在TestClass的Test方法中,action委托調用了同在TestClass類中的私有方法Print,並對 Test方法中的局部變量index進行了讀寫。在加上C# 3.0中Lambda表達式的新特性,匿名方法的使用得到 了極大的推廣。不過,如果使用不當,匿名方法也容易造成難以發現的問題。
問題案例
某位兄弟最近在一個簡單的數據導入程序,主要工作是從文本文件中讀取數據,進行分析和重組,然 後寫入數據庫。其邏輯大致如下:
static void Process()
{
List<Item> batchItems = new List<Item>();
foreach (var item in ...)
{
batchItems.Add (item);
if (batchItems.Count > 1000)
{
DataContext db = new DataContext();
db.Items.InsertAllOnSubmit (batchItems);
db.SubmitChanges();
batchItems = new List<Item>();
}
}
}
每次從數據源中讀取數據後,添加到batchItems列表中,當batchItems滿1000條時便進行一次提交。 這段代碼功能運行正常,可惜時間卡在了數據庫提交上。數據的獲取和處理很快,但是提交一次就要花較 長時間。於是想想,數據提交和數據處理不會有資源上的沖突,那麼就把數據提交放在另外一個線程上進 行處理吧!於是,使用ThreadPool來改寫代碼:
static void Process()
{
List<Item> batchItems = new List<Item>();
foreach (var item in ...)
{
batchItems.Add (item);
if (batchItems.Count > 1000)
{
ThreadPool.QueueUserWorkItem((o) =>
{
DataContext db = new DataContext();
db.Items.InsertAllOnSubmit(batchItems);
db.SubmitChanges();
});
batchItems = new List<Item>();
}
}
}
現在,我們將數據提交操作交給ThreadPoll執行,當線程池中有額外線程時,就會發起數據提交操作 。而數據提交操作不會阻塞數據處理,因此按照那位兄弟的意圖,數據會不斷進行處理,最後只要等待所 有數據庫提交完成就可以了。思路很好,可惜運行時發現,原本(不利用多線程時)運行正常的代碼,如 今會“莫名其妙”地拋出異常。更為奇怪的是,數據庫中的數據出現了丟失的情況:處理了並“提交”了 一百萬條數據,但是數據庫裡卻少了一部分。於是對著代碼左看右看,百思不得其解。
您看出問題原因來了嗎?
分析原因
要發現問題所在,我們必須了解匿名方法在.NET環境中的實現方式。
.NET中本沒有什麼“匿名方法”,也沒有類似的新特性。“匿名方法”完全是由編譯器施展的魔法, 它會將匿名方法中需要訪問的所有成員一起包含在閉包中,確保所有的成員調用都符合.NET標准。例如在 文章第一節中的第2個示例,實際上由編譯器處理之後就變成了如下的樣子(自然字段名經過“友好化” 處理):
class TestClass
{
...
private sealed class AutoGeneratedHelperClass
{
public TestClass m_testClassInstance;
public int m_index;
public void Action(string m)
{
this.m_index++;
this.m_testClassInstance.Print(m);
}
}
public void TestAfterCompiled()
{
AutoGeneratedHelperClass helper = new AutoGeneratedHelperClass();
helper.m_testClassInstance = this;
helper.m_index = 0;
string [] messages = new string[] { "Hello", "World" };
Action<string> action = new Action<string>(helper.Action);
Array.ForEach(messages, action);
Console.WriteLine(helper.m_index);
}
}
由此就可以看出編譯器是如何實現一個閉包的:
編譯器自動生成一個私有的內部輔助類,並將其設為sealed,這個類的實例將成為一個閉包對象。
如果匿名方法需要訪問方法的參數或局部變量,那麼該參數或局部變量將“升級”成為輔助類中的公 有Field字段。
如果匿名方法需要訪問類中的其它方法,那麼輔助類中將保存類的當前實例。
值得一提的是,在實際情況下以上三點理論都皆可能不滿足。在某些特別簡單的情況下(例如匿名方 法中完全不涉及局部變量和其他方法),編譯器只會簡單生成一個靜態的方法來構造一個委托實例,因為 這樣可以獲得更好的性能。
對於之前的案例,我們現在也將它進行一番改寫,這樣便可“避免”使用匿名對象,也可以清楚地展 現出問題原因:
private class AutoGeneratedClass
{
public List<Item> m_batchItems;
public void WaitCallback(object o)
{
DataContext db = new DataContext();
db.Items.InsertAllOnSubmit (this.m_batchItems);
db.SubmitChanges();
}
}
static void Process()
{
var helper = new AutoGeneratedClass();
helper.m_batchItems = new List<Item>();
foreach (var item in ...)
{
helper.m_batchItems.Add(item);
if (helper.m_batchItems.Count > 1000)
{
ThreadPool.QueueUserWorkItem(helper.WaitCallback);
helper.m_batchItems = new List<Item>();
}
}
}
編譯器會自動生成一個AutoGeneratedClass類,並且在Process方法中使用這個類的實例來代替原來的 batchItems局部變量。同樣,交給ThreadPool的委托對象也從匿名方法變成了AutoGeneratedClass實例的 公有方法。因此線程池每次調用的便是該實例的WaitCallback方法。
現在問題應該一目了然了吧?每次把委托交給線程池之後,線程池並不會立即執行,而會保留到合適 的時間再進行。而WaitCallback方法在執行時,它會讀取m_batchItems這個Field字段“當前”所引用的 對象。而與此同時,Process方法已經“拋棄”了原本我們要提交的數據,因此會引起提交到數據庫中數 據的丟失。同時,在准備每批次數據的過程中,很有可能會發起兩次數據提交,兩個線程提交同樣一批 Item時,就拋出了所謂“莫名其妙”的異常。
解決問題
找到了問題所在,解決起來自然輕而易舉:
private class WrapperClass
{
private List<Item> m_items;
public WrapperClass(List<Item> items)
{
this.m_items = items;
}
public void WaitCallback(object o)
{
DataContext db = new DataContext();
db.Items.InsertAllOnSubmit (this.m_items);
db.SubmitChanges();
}
}
static void Process()
{
List<Item> batchItems = new List<Item>();
foreach (var item in ...)
{
batchItems.Add(item);
if (batchItems.Count > 1000)
{
ThreadPool.QueueUserWorkItem(
new WrapperClass(batchItems).WaitCallback);
batchItems = new List<Item>();
}
}
}
這裡我們明確地准備一個封裝類,用它來保留我們需要提交的數據。而每次提交時則使用保留好的數 據,自然不會發生不該有的“數據共享”,從而避免了錯誤的發生1。
總結
匿名方法是強大的,但是也會造成一些令人難以察覺的陷阱。對於使用匿名方法創建的委托,如果不 會立即同步執行,並且其中使用了方法的局部變量,那麼您就需要對其留個心眼了。因為此時“局部變量 ”事實上已經由編譯器轉變成一個自動類的實例上的Field字段,而這個字段將被當前方法和委托對象共 享。如果您在創建了委托對象之後還會修改共享的“局部變量”,那麼請再三確認這樣做符合您的意圖, 而不會造成問題。
此類問題也不光會出現在匿名方法中。如果您使用Lambda表達式創建了一個表達式樹,其中也用到了 一個“局部變量”,那麼表達式樹在解析或執行時同樣也會獲取“當前”的值,而不是創建表達式樹時的 值。
這也是為什麼Java中的內聯寫法——匿名類——如果要共享方法內的“局部變量”,則必須將變量使 用final關鍵字來修飾:這樣這個變量只能在聲明時賦值,避免了後續的“修改”可能會造成的“古怪問 題”。
注1:一個更簡潔的解決方案可以參考29樓overred兄弟的回復。