C#——委托、Lambda表達式、閉包和內存泄漏


使用委托的典型情況

首先看看委托的常見的使用情景:定義一個委托、使用剛定義的委托聲明一個委托變量、根據需要將方法和該變量綁定,最后在合適的地方使用它。代碼形式如下:

//定義委托
public delegate void SomeDelegate();
class SomeClass
{
    public void InstanceFunction()
    {
        //Do something
    }
    public static void StaticFunction()
    {
        //Do something
    }
}

public class SomeUserClass
{
    public void SomeAction()
    {
        //聲明委托變量
        SomeDelegate del;
        SomeClass someClass = new SomeClass();
        //綁定到實例方法
        del = someClass.InstanceFunction;
        //使用它
        del();
        //綁定到靜態方法
        del = SomeClass.StaticFunction;
        //再次使用它
        del();
    }
}

先不談委托的其他用途,通過上面的例子,可以將委托簡單理解為一個“方法類型”。可將委托聲明的變量和與委托簽名相符的方法綁定,之后就可以像使用方法一樣使用這個變量。

委托是安全封裝方法的類型,類似於 C 和 C++ 中的函數指針。 與 C 函數指針不同的是,委托是面向對象的、類型安全的和可靠的。 委托的類型由委托的名稱確定。——來自MSDN

上面的做法是將委托變量del分別與一個實例方法和一個靜態方法綁定。這兩種方式都被稱作使用命名方法。

在 C# 1.0 中,通過使用在代碼中其他位置定義的方法顯式初始化委托來創建委托的實例。 C# 2.0 引入了匿名方法的概念,作為一種編寫可在委托調用中執行的未命名內聯語句塊的方式。 C# 3.0 引入了 lambda 表達式,這種表達式與匿名方法的概念類似,但更具表現力並且更簡練。 這兩個功能統稱為匿名函數。 通常,面向 .NET Framework 3.5 及更高版本的應用程序應使用 lambda 表達式。——來自MSDN

我個人是更加偏好於使用Lambda表達式,至於匿名方法,用法幾乎與Lambda表達式一樣。下文的示例代碼中我都將用更加簡潔的Lambda表達式來書寫。Lambda表達式可以參考MSDN——Lambda表達式

使用Lambda表達式初始化委托
在這一節,先看看Func<TResult>,可以參考MSDN——Func 委托得到更多信息。

Func<TResult>實際上是.net封裝好的一個委托,它不接受參數、返回一個TResult類型的值。

比如我們可以通過如下代碼來聲明一個Func<int>的變量、並為其綁定一個方法、然后使用它:

public class AnotherClass{
    //聲明委托變量
    private Func<int> funcInt;

    private int info;

    //聲明符合Func<int>簽名的函數
    private int FunctionReturnsInt()
    {
        return info;
    }

    private void SomeUserFunction()
    {
        //將方法綁定至委托變量
        funcInt = FunctionReturnsInt;
        //通過變量調用方法
        int result = funcInt();
        //Do something
    }
}

對於上面的代碼,如果改用Lambda表達式,就會簡潔很多,如下:

public class AnotherClass{
    //聲明委托變量
    private Func<int> funcInt;

    private int info;

    private void SomeUserFunction()
    {
        //將Lambda表達式綁定至委托變量
        funcInt = () => { return info; };
        //通過變量調用方法
        int result = funcInt();
        //Do something
    }
}

使用Lambda表達式省掉了書寫命名方法的過程,代碼看起來更加清新。然而,稍不注意,Lambda表達式就會“毀滅”你的代碼。

閉包

在Lambda表達式“毀滅”你的代碼前,先看看下面的代碼會輸出什么:

List<Func<int>> funcs = new List<Func<int>>();
for (int i = 0; i < 3; i++)
{
    funcs.Add(() => { return i; });
}
foreach(var item in funcs)
{
    Console.WriteLine(item().ToString());
}

對於不理解閉包的人,第一反應自然是輸出0、1、2。但事實上,它輸出的是3、3、3。造成這種“出人意料”的結果的原因,就是閉包。

關於閉包,這里不作過多、過復雜的介紹,想要深入了解,可以查閱相關資料。

簡單地講,閉包是一個代碼塊(在C#中,指的是匿名方法或者Lambda表達式,也就是匿名函數),並且這個代碼塊使用到了代碼塊以外的變量,於是這個代碼塊和用到的代碼塊以外的變量(上下文)被“封閉地包在一起”。當使用此代碼塊時,該代碼塊里使用的外部變量的值,是使用該代碼塊時的值,並不一定是創建該代碼塊時的值

一句話概括,閉包是一個包含了上下文環境的匿名函數。

有點拗口,不過暫且先根據這個解釋,我們回去看看上面的代碼。

代碼中的Lambda表達式(代碼塊)() => { return i; },使用了for循環中的循環變量i。

在for循環中,我們通過Lambda表達式(代碼塊)創建了三個匿名函數、並添加進委托列表中;當for循環結束后,我們逐個調用與委托列表綁定的三個匿名函數。

在調用這三個匿名函數時,雖然for循環已經結束,其控制變量i也“看起來不存在了”,但事實是,變量i已經被加入到上面每一個匿名函數各自的上下文中,也就是說,上面的三個匿名函數,都“閉包”着變量i。

此時i的值已經等於3,於是這三個匿名函數都將返回3並交給Console去輸出。

為了看清楚后台究竟發生了什么,用Visual Studio自帶的IL Disassembler打開編譯出的exe文件,查看結果。

 

 

 

對於閉包,編譯的結果是:編譯器為閉包生成了一個類,i作為一個公共的字段存在於其中。

也就是說,雖然for循環已經結束,但是i仍然以一種“看不見”的方式活躍在內存中。所以當我們調用這三個匿名函數時,使用的都將是同一個i(指的是變量,而不是它具體的值)。

接下來修改代碼如下:

List<Func<int>> funcs = new List<Func<int>>();
for (int i = 0; i < 3; i++)
{
    int j = i;
    funcs.Add(() => { return j; });
}
foreach(var item in funcs)
{
    Console.WriteLine(item().ToString());
}

再次運行,輸出結果為0、1、2。分析下原因。

在每一次循環時,我們都創建了一個新的變量j。為了區分每一次循環中的j,第一次循環時,我稱它為j0,此時它從i中獲得的值為0,並且本次循環中,創建了一個匿名函數並使用了j0,形成了一個閉包。在第二次循環時,將創建另一個變量j1,此時它從i中獲得的值為1,此循環中的匿名函數將使用變量j1,形成另一個閉包;第三次循環類似。

一下子豁然開朗了。在這次的代碼中,三個匿名函數使用的j並不是同一個變量,所以會有后面的結果。

關於foreach語句的閉包

還是先看一段代碼:

List<int> values = new List<int>() {0,1,2 };
List<Func<int>> funcs = new List<Func<int>>();

foreach (var item in values)
{
    funcs.Add(() => { return item; });
}
foreach(var item in funcs)
{
    Console.WriteLine(item().ToString());
}

這段代碼的輸出是0、1、2。看起來似乎與前面所講的有矛盾。

在C# 5.0之前的版本,在foreach的循環中,將會共用一個item,這段代碼的輸出就是2、2、2;C# 5.0之后,foreach的實現方式作了修改,在每一次循環時,都會產生一個新的item用來存放枚舉器當前值,所以此時的情形類似於上面for循環的第二種情形。

閉包?內存泄漏?

再看一段代碼:

class Program
{
    static void Main(string[] args)
    {
        Console.WriteLine("Program start");
        ShowMemory();
        Console.WriteLine("Create object");
        SomeClass someClass = new SomeClass();
        ShowMemory();
        Console.WriteLine("Call function");
        someClass.SomeFunction();
        ShowMemory();
        Console.WriteLine("Release delegate");
        someClass.func = null;
        ShowMemory();
    }

    private static void ShowMemory()
    {
        GC.Collect();
        Console.WriteLine("Memory used : " + GC.GetTotalMemory(true));
        Console.WriteLine("--------------------------------------------------");
        Console.ReadKey();
    }
    public class MemoryHolder
    {
        public byte[] data;
        public int info;
        public MemoryHolder()
        {
            data = new byte[10 * 1024 * 1024];
            info = 100;
            Console.WriteLine("MemoryHolder created");
        }
        ~MemoryHolder()
        {
            Console.WriteLine("MemoryHolder released");
        }
    }
    public class SomeClass
    {
        public Func<int> func;
        public void SomeFunction()
        {
            MemoryHolder holder = new MemoryHolder();
            func = () => { return holder.info; };
            Console.WriteLine("Function exited");
        }
    }
}

看看運行結果:

 

 

可以看出,原本在SomeFunction調用結束時就應該被釋放的MemoryHolder對象,並沒有被釋放,而是在使用它的閉包被釋放時,才真正被釋放掉。也就是說,閉包會延長它使用的外部變量的生命周期,直到閉包本身被釋放。

那么閉包會不會造成內存泄漏?

我認為只有不嚴謹的代碼才會造成內存泄漏。正如上述代碼中的someClass.func或者someClass對象、在不需要它(們)的時候沒有被正確釋放它(們),就會造成了本該被銷毀的holder對象不會被正確地被銷毀、自然也就造成了內存泄漏。但是不應該讓閉包背這個鍋。

 

總結

1、匿名函數是個語法糖,很方便,但是也容易帶來問題。
2、如果一定要使用閉包,那么切記做好內存的回收。
3、養成良好的代碼習慣。


————————————————
版權聲明:本文為CSDN博主「SerenaHaven」的原創文章,遵循 CC 4.0 BY-SA 版權協議,轉載請附上原文出處鏈接及本聲明。
原文鏈接:https://blog.csdn.net/SerenaHaven/article/details/80047622


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM