使用具名函式還原匿名函式陷阱問題

眼前的包不是包, 你說的i是甚麼i

        上一回講到了『匿名函式陷阱』, 這幾天一直思考該如何簡單又具體地解釋這個現象, 今天發的這一篇來使用具名函式的方式告訴大家這個陷阱是怎麼出現的. 說明一下環境, 我所使用的是 Visual Studio 2013, 使用 C# 2013 版本來展示這個現象. 因為這是一個編譯器魔術, 在沒有試過所有環境的狀況下, 沒有辦法確認是不是在不同版本的 C# 編譯器會產生一樣的結果, 所以得特別強調測試時使用的版本.

 

 

        先從原來的程式碼看起:


       private void button1_Click(object sender, EventArgs e)
        {
            Thread t = new Thread(Test1);
            t.IsBackground = true;
            t.Start();
        }

        private void Test1()
        {
            for (int i = 0; i < 10; i++)
            {
                this.BeginInvoke(new Action(() => { Debug.WriteLine(i); }));
            }
        }

 

 

        首先要了解的課題, 匿名函式只是編譯器的魔術, 編譯器在編譯的過程會將匿名函式轉換為具名函式, 匿名委派也會轉換成具名委派. 由於事情會發生在匿名函式裡面, 所以接下來的拆解僅展示匿名函式變成具名函式的部分, 至於匿名委派的部分, 我就偷懶略過了.

 

 

        第二個重點是在上述的程式碼中, 由於 i 也會被匿名函式所使用, 所以它並不是個區域變數, 微軟稱它為 『匿名方法的 outer 變數』(註1). 在 MSDN 文件 匿名方法 (C# 程式設計手冊)  對這種變數有一個說明『與區域變數不同的是擷取變數的存留期會延伸直到參考匿名方法的委派可進行記憶體回收為止』.

 

 

        我們先來做把匿名函式轉為具名函式(方法名稱當然我亂取的) , 上述的程式碼就會變成:


        private void Test1()
        {
            for (int i = 0; i < 10; i++)
            {
                this.BeginInvoke(new Action(DebugWriteline ));
            }
        }


        private void DebugWriteline()
        {
            Debug.WriteLine(i);
        }

 

        然後很開心地編譯失敗, 出現下圖的錯誤訊息

2015-05-30_02-01-26

 

 

        因為在抽取出來的 DebugWriteLine 方法內並沒有定義 i 變數, 所以出錯. 我們想想要如何讓 Test1 方法和 DebugWriteLine 方法內的 i 可以同步 ? 最簡單的解法就是在類別下定義一個欄位(field) i, 這樣就可以讓兩個方法共用了(不過這解法當然是錯的), 於是我們進一步把程式碼改成以下形式:


        private int i;

        private void Test1()
        {
            for (i = 0; i < 10; i++)
            {
                this.BeginInvoke(new Action(DebugWriteline ));
            }
        }


        private void DebugWriteline()
        {
            Debug.WriteLine(i);
        }

 

 

        此時編譯會過關了, 執行後的結果似乎也是對的. 讓我們思考另一種形式的匿名函式寫法, 將 outer 變數在 for 迴圈的程式碼區塊內定義, 看看問題在哪裡:


        private void Test1()
        {
            for (int i = 0; i < 10; i++)
            {
                int x = i;
                this.BeginInvoke(new Action(() => { Debug.WriteLine(x); }));
            }
        }

 

 

        上面這個程式碼的 outer 變數是 x, 執行後的結果會是 0,1,2,3,4,5,6,7,8,9, 而非一串 10. 這個如果在轉換成具名函式要在類別內定義一個欄位來處理的話, 事情就大條了, 這表示我們需要 10 個 x 欄位. 搞不好有人會想到用集合或陣列來儲存, 然後就會越搞越亂. 而且使用欄位的做法也不符合對擷取變數存留期的定義.

 

 

        在確定大家都吸收了夠多的錯誤訊息後, 終於要講到編譯器倒底是怎麼做的. C# 編譯器在遇到有 outer 變數的狀況, 會自己生出一個內嵌類別出來, 而這個類別就會有個公開欄位用來處理 outer 變數的問題. 我們回到第一個程式碼的完整內容 :


    public partial class Form1 : Form
    {
        public Form1()
        {
            InitializeComponent();
        }

        private void button1_Click(object sender, EventArgs e)
        {
            Thread t = new Thread(Test1);
            t.IsBackground = true;
            t.Start();
        }

        private void Test1()
        {
            for (int i = 0; i < 10; i++)
            {
                this.BeginInvoke(new Action(() => { Debug.WriteLine(i); }));
            }
        }    
    }

 

 

        如果我們用具名函式的方式表示這個程式碼, 應該是以下這種樣子:


   public partial class Form1 : Form
    {
        public Form1()
        {
            InitializeComponent();
        }

        private void button1_Click(object sender, EventArgs e)
        {
            Thread t = new Thread(Test1);
            t.IsBackground = true;
            t.Start();
        }

        private void Test1()
        {
            CustomClass obj = new CustomClass();
            for (obj.i = 0; obj.i < 10; obj.i++)
            {
              this.BeginInvoke(new Action(obj.DebugWriteLine));
            }
        }
    
        // 這個類別名稱當然也是我自己亂取的, 編譯器自動產生的類別名稱會有很多奇怪的符號
        private sealed class CustomClass
        {
            public int i;
            public void DebugWriteLine()
            {
                Debug.WriteLine(i);
            }
        }
    }

 

 

        這樣的寫法, 不僅可以得到相同的結果, 而且符合對擷取變數存留期的定義. 事實上編譯器真的是這麼做的, 它會內嵌一個私有的密封類別, 然後在這個類別裡加入一個與 outer 變數相同型別的欄位, 並且將匿名函式移到此類別內部成為具名函式. 接著在外部的方法產生這個內嵌類別的執行個體, 將所有的 outer 變數以這個執行個體的欄位取代. 簡單用兩句話說明這原理的思考脈絡, 『眼前的包不是包, 你說的i是甚麼i』(註2).

 

       我們繼續看第二個例子, outer 變數定義在 for 迴圈內的程式碼區塊又是怎麼一回事, 照舊先把原來的寫法貼出來:


    public partial class Form1 : Form
    {
        public Form1()
        {
            InitializeComponent();
        }

        private void button1_Click(object sender, EventArgs e)
        {
            Thread t = new Thread(Test1);
            t.IsBackground = true;
            t.Start();
        }

        private void Test1()
        {

            for (int i = 0; i < 10; i++)
            {
                int x = i;
                this.BeginInvoke(new Action(() => Debug.WriteLine(x)));
            }
        }
    }

 

 

        變成具名函式的做法後, 則會變成以下的樣子:


    public partial class Form1 : Form
    {
        public Form1()
        {
            InitializeComponent();
        }

        private void button1_Click(object sender, EventArgs e)
        {
            Thread t = new Thread(Test1);
            t.IsBackground = true;
            t.Start();
        }

        private void Test1()
        {

            for (int i = 0; i < 10; i++)
            {
                CustomClass obj = new CustomClass();
                obj.x = i;
                this.BeginInvoke(new Action(obj.DebugWriteLine));
            }
        }

        // 這個類別名稱當然也是我自己亂取的, 編譯器自動產生的類別名稱會有很多奇怪的符號
        private sealed class CustomClass
        {
            public int x;
            public void DebugWriteLine()
            {
                Debug.WriteLine(x);
            }
        }
    }

 

        在經過兩個不同例子的展示後, 聰明的你應該注意到一件事, 內嵌類別的執行個體在哪裡產生的決定因素就是 outer 變數宣告的區塊. 而且經過這兩個例子, 應該就很容易了解這個陷阱是如何發生的, 不過呢, 以後版本的 C# 會不會改變這樣的行為是個未知數, 最後再特別提醒一下這是使用 C# 2013 時的狀況.

 

        註1: outer 不知道該怎麼翻譯才貼切, 因為微軟也沒有翻譯, 所以就原文照貼, 有時它也會稱呼在匿名方法內的為擷取變數(captured variable), 根據文件的記載看起來, 在外面的稱為 outer 變數, 而在匿名方法內就叫做擷取變數. 壓根兒其實是同一個變數.

        註2: 改編自蕭煌奇的歌--你是我的眼