C#和Java的閉包-Jon談《The Beauty of Closures》(轉)

原文:http://csharpindepth.com/Articles/Chapter5/Closures.aspxjava

第一段略。。。程序員

大多數講閉包的文章都是說函數式語言,由於它們每每對閉包的支持最完善。當你在使用函數式語言時,極可能已經清楚瞭解了什麼是閉包,因此我想寫一篇在經典OO語言出現的閉包有什麼用處應該也是很合適的事情。這篇文章我準備講一下C#(一、二、3)和JAVA(7之前版本)的閉包。數組

什麼是閉包?

簡單來說,閉包容許你將一些行爲封裝,將它像一個對象同樣傳來遞去,並且它依然可以訪問到原來第一次聲明時的上下文。這樣可使控制結構、邏輯操做等從調用細節中分離出來。訪問原來上下文的能力是閉包區別通常對象的重要特徵,儘管在實現上只是多了一些編譯器技巧。安全

利用例子來觀察閉包的好處(和實現)會比較容易, 下面大部分內容我會使用一個單一的例子來進行講解。例子會有JAVA和C#(不一樣版本)來講明不一樣的實現。全部的代碼能夠點這裏下載閉包

需求場景:過濾列表

按必定條件過濾某個列表是很常見的需求。雖然寫幾行代碼遍歷一下列表,把知足條件的元素挑出來放到新列表的「內聯」方式很容易知足需求,但把判斷邏輯提取出來仍是比較優雅的作法。惟一的難點就是如何封裝「斷定一個元素是否符合條件」邏輯,閉包正好能夠解決這個問題。函數

雖然我上面說了「過濾」這個詞,但它可能會有兩個大相徑庭的意思「把元素濾出來放到列表裏」或者把「把元素濾出來扔掉」。好比說「偶數過濾」是把「偶數」保留下來仍是過濾掉?因此咱們使用另外一個術語「斷言」。斷言就是簡單地指某樣東西是否是知足某種條件。在咱們的例子中便是生成一個包含了原列表知足斷言條件的新列表。ui

在C#中,比較天然地表現一個斷言就是經過delegate,事實上C# 2.0有一個Predicate<T>類型。(順帶一提,由於某些緣由,LINQ更偏向於Func<T, bool>;我不知道這是爲何,相關的解釋也不多。然而這兩個泛型類的做用實際上是同樣的。)在Java中沒有delegate,所以咱們會使用只有一個方法的interface。固然C#中咱們也可使用interface,但會使得代碼看起來很混亂,並且不能使用匿名函數和拉姆達表達式-C#中符合閉包特徵的實現。下面的interface/delegate供你們參考:this

// Declaration for System.Predicate<T> public delegate bool Predicate<T>(T obj)
// Predicate.java public interface Predicate<T>
{
    boolean match(T item);
}

在兩種語言中過濾用的代碼都比較簡單,得先說明在這裏我會避免使用C#的Extension Method來讓代碼看起來更加簡單明瞭。-可是使用過LINQ的人要注意where這個Extension Method。(它們的延遲執行有些區別,但這裏我會避免觸及)編碼

// In ListUtil.cs static class ListUtil
{
    public static IList<T> Filter<T>(IList<T> source, Predicate<T> predicate)
    {
        List<T> ret = new List<T>();
        foreach (T item in source)
        {
            if (predicate(item))
            {
                ret.Add(item);
            }
        }
        return ret;
    }
}
// In ListUtil.java public class ListUtil
{
    public static <T> List<T> filter(List<T> source, Predicate<T> predicate)
    {
        ArrayList<T> ret = new ArrayList<T>();
        for (T item : source)
        {
            if (predicate.match(item))
            {
                ret.add(item);
            }
        }
        return ret;
    }
}

(兩種語言中我都寫了一個Dump方法用來輸出指定list的內容)spa

如今咱們已經定義好「過濾」的方法,接下來就是要調用它。爲了演示閉包的重要做用,我會先使用一個簡單的不須要使用到閉包都能解決的案例,而後再進一步到比較難的案例。

過濾案例1:找出長度較短的字符串(固定長度)

咱們的需求場景都會比較簡單基礎,但但願你們能從中看出它們的不一樣之處。咱們將會有一個字符串list,而後根據這個list生成另外一個只包含長度較「短」的字符串list。創建list很簡單-創建斷言纔是難點。

在C# 1.0中只能經過單獨的方法來表現一個斷言邏輯,而後再建立一個delegate指向該方法。(固然因爲代碼使用了泛並不能真地在C# 1.0下面經過編譯,但要注意delegate實例是如何被創建的-這是重點)

// In Example1a.cs static void Main()
{
    Predicate<string> predicate = new Predicate<string>(MatchFourLettersOrFewer);
    IList<string> shortWords = ListUtil.Filter(SampleData.Words, predicate);
    ListUtil.Dump(shortWords);
}static bool MatchFourLettersOrFewer(string item)
{
    return item.Length <= 4;
}

在C# 2.0中有三種方式實現,第一,使用上面同樣的代碼;第二,利用方法組轉換(Method Group Conversion)對代碼進行簡化;第三,利用匿名函數將斷言直接寫在調用上下文中。使用方法組轉換比較浪費時間-它只是把new Predicate<string>(MatchFourLettersOrFewer) 變成了 MatchFourLettersOrFewer。在示例代碼中有它的實現(在Example1b.cs中)。相對而言,匿名函數要有趣得多:

static void Main()
{
    Predicate<string> predicate = delegate(string item)
        {
            return item.Length <= 4;
        };
    IList<string> shortWords = ListUtil.Filter(SampleData.Words, predicate);
    ListUtil.Dump(shortWords);
}

這樣一來,就再也不須要一個外部獨立的方法用來封裝斷言邏輯,而且,斷言放在了被使用的點上。很好很強大。它背後是怎麼工做的呢?若是你用ildasm或者reflector去看一下生成的代碼,你會發現其實它了第一個版本產生的代碼很大程度是同樣的,編譯器只是幫咱們完成了某些工做。稍後咱們會看到它更強悍的能力。

在C# 3.0中除了有上面三種方式,還有拉姆達表達式。對於本文來說,拉姆達表達式只是匿名函數的一個簡化形式。(這兩種東東最大的區別在於LINQ中的拉姆達表達式能被轉換成表達式樹,但這與本文無關)使用拉姆達表達式:

static void Main()
{
    Predicate<string> predicate = item => item.Length <= 4;
    IList<string> shortWords = ListUtil.Filter(SampleData.Words, predicate);
    ListUtil.Dump(shortWords);
}

因爲在右邊使用了<=,看起來像是有個大箭頭指着item.Length,但爲了保持先後一致,只好請你們將就着看了。這裏其實能夠寫成等價的Predicate<string> predicate = item => item.Length < 5

在Java中沒有delegate-只能實現上面定義的interface。最簡單的方法就是定義一個類並實現該interface,如:

// In FourLetterPredicate.java public class FourLetterPredicate implements Predicate<String>
{
    public boolean match(String item)
    {
        return item.length() <= 4;
    }
}// In Example1a.java public static void main(String[] args)
{
    Predicate<String> predicate = new FourLetterPredicate();
    List<String> shortWords = ListUtil.filter(SampleData.WORDS, predicate);
    ListUtil.dump(shortWords);
}

這裏沒有使用任何華麗的語言特性,爲了實現一點小小的邏輯,它使用了一整個獨立的類。根據Java的慣例,類應該放在單獨的文件裏,這使得程序的可讀性變差。固然可使用嵌套類的方式來避免這種問題,但邏輯仍是離開了使用它的地方-至關於囉嗦版的C# 1.0解決方案。(這裏不打算給出嵌套版的實現代碼,有須要的朋友能夠看看打包代碼裏面Example1b.java。)Java能夠經過匿名類把代碼書寫成內聯的方式,在匿名類的光芒照射下,代碼進化了:

// In Example 1c.java public static void main(String[] args)
{
    Predicate<String> predicate = new Predicate<String>()
    {
        public boolean match(String item)
        {
            return item.length() <= 4;
        }
    };
    
    List<String> shortWords = ListUtil.filter(SampleData.WORDS, predicate);
    ListUtil.dump(shortWords);
}

如你所見,比起C# 2.0和C# 3.0的代碼,這個顯得仍是比較囉嗦了點,但至少代碼被放在了它應該在的地方。這就是Java目前支持的閉包……接下來本文進入第二個例子。

過濾案例2:找出長度較短的字符串(可變長度)

目前爲止咱們的斷言並不須要訪問到原來的「上下文」-長度是硬編碼的,而後字符串是以參數的形式傳進去的。如今,需求變更一下,容許用戶指定多長的字符串纔算是合適的。

首先,咱們回到C# 1.0。它其實不支持真正的閉包-找不到一塊簡單的地方來存儲咱們須要的變量。固然,咱們能夠在當前方法的上下文中聲明一個變量來解決這個問題(好比利用靜態成員變量),但這明顯不是一個好的解決方法-理由只有一個,類立刻變成了線程不安全的。解決的方法就是不要把狀態存儲在當前上下文中,轉而存儲在新建的類中。這麼一來,代碼看起來跟原來的Java代碼很是類似,區別只是這裏使用delegate,而Java使用interface。

// In VariableLengthMatcher.cs public class VariableLengthMatcher
{
    int maxLength;

    public VariableLengthMatcher(int maxLength)
    {
        this.maxLength = maxLength;
    }

    /// <summary>     /// Method used as the action of the delegate     /// </summary>     public bool Match(string item)
    {
        return item.Length <= maxLength;
    }
}// In Example2a.cs static void Main()
{
    Console.Write("Maximum length of string to include? ");
    int maxLength = int.Parse(Console.ReadLine());

    VariableLengthMatcher matcher = new VariableLengthMatcher(maxLength);
    Predicate<string> predicate = matcher.Match;
    IList<string> shortWords = ListUtil.Filter(SampleData.Words, predicate);
    ListUtil.Dump(shortWords);
}

相對來講,C# 2.0和C# 3.0的改動要小得多:只需將硬編碼的常量改爲變量便可。先無論這背後的原理-一會看完Java版的代碼後再來研究這個問題。

// In Example2b.cs (C# 2) static void Main()
{
    Console.Write("Maximum length of string to include? ");
    int maxLength = int.Parse(Console.ReadLine());

    Predicate<string> predicate = delegate(string item)
    {
        return item.Length <= maxLength;
    };
    IList<string> shortWords = ListUtil.Filter(SampleData.Words, predicate);
    ListUtil.Dump(shortWords);
}
// In Example2c.cs (C# 3) static void Main()
{
    Console.Write("Maximum length of string to include? ");
    int maxLength = int.Parse(Console.ReadLine());

    Predicate<string> predicate = item => item.Length <= maxLength;
    IList<string> shortWords = ListUtil.Filter(SampleData.Words, predicate);
    ListUtil.Dump(shortWords);
}

Java版的代碼(使用了匿名類的那個版本)改動也比較簡單,但有一點不爽的是-必須把參數聲明爲final。瞭解其原理前先來看一下代碼:

// In Example2a.java public static void main(String[] args) throws IOException
{
    System.out.print("Maximum length of string to include? ");
    BufferedReader console = new BufferedReader(new InputStreamReader(System.in));
    final int maxLength = Integer.parseInt(console.readLine());
    
    Predicate<String> predicate = new Predicate<String>()
    {
        public boolean match(String item)
        {
            return item.length() <= maxLength;
        }
    };
    
    List<String> shortWords = ListUtil.filter(SampleData.WORDS, predicate);
    ListUtil.dump(shortWords);
}

那麼,C#和Java的代碼到底有什麼不一樣呢?在Java中,變量的被匿名類捕獲。在C#中,變量自己被delegate捕獲。爲了證實C#捕獲了變量自己,咱們來改一下C# 3.0的代碼,使變量的值在變量在過濾後發生改變,看看改變是否反映到下一次過濾:

// In Example2d.cs static void Main()
{
    Console.Write("Maximum length of string to include? ");
    int maxLength = int.Parse(Console.ReadLine());

    Predicate<string> predicate = item => item.Length <= maxLength;
    IList<string> shortWords = ListUtil.Filter(SampleData.Words, predicate);
    ListUtil.Dump(shortWords);

    Console.WriteLine("Now for words with <= 5 letters:");
    maxLength = 5;
    shortWords = ListUtil.Filter(SampleData.Words, predicate);
    ListUtil.Dump(shortWords);
}

注意,咱們只是改變局部變量的值,而並無從新建立delegate的實例,或者其它等價的操做。因爲delegate實際上是直接訪問這個局部變量,因此其實它是可以知道變量發生的變化。再進一步,接下來在斷言邏輯中直接對變量進行修改:

// In Example2e.cs static void Main()
{
    int maxLength = 0;

    Predicate<string> predicate = item => { maxLength++; return item.Length <= maxLength; };
    IList<string> shortWords = ListUtil.Filter(SampleData.Words, predicate);
    ListUtil.Dump(shortWords);
}

我不打算再深刻地講這些是怎麼實現的-《C# in Depth》第5章講的都是這些細節。只是但願大家一些對「局部變量」的觀念認識被徹底顛倒。

咱們已經看到了C#是如何對捕獲的變量進行修改的,那Java呢?答案只有一個:你不能對捕獲的變量進行修改。它已經被聲明爲final,因此這個問題實際上是很無厘頭的。並且就算你人品值爆糟,忽然間能對該變量進行更改,也會發現斷言邏輯根本對修改毫無反應。變量的值在斷言聲明的時候被拷貝並存儲到匿名類內。不過,對於引用變量,它的成員發生改變仍是可以被知道的。好比說,若是你引用了一個StringBuilder,而後對它進行Append操做,那在匿名類中是能夠看到StringBuilder的改變。

對比捕獲策略:複雜性VS功能

明顯Java的設計侷限性比較大,但也同時也比較容易理解,不容易發生概念混淆的狀況,局部變量的行爲和通常狀況下沒什麼不一樣,大多數狀況下,代碼看起來也更簡單易懂。好比下面的代碼,利用Java runable interface和.NET Action delegate-兩個都是會執行一些操做,不須要參數,也不返回任何值。首先看看C#的代碼:

// In Example3a.cs static void Main()
{
    // First build a list of actions     List<Action> actions = new List<Action>();
    for (int counter = 0; counter < 10; counter++)
    {
        actions.Add(() => Console.WriteLine(counter));
    }

    // Then execute them     foreach (Action action in actions)
    {
        action();
    }
}

會輸出些什麼?其實咱們只聲明瞭一個counter變量-因此其實全部的Action捕獲的都是同一個counter變量。結果就是每一行都輸出數字10。爲了把代碼「修正」到咱們預期的效果(如輸出0到9),則須要在循環體中使用另外一個局部變量:

// In Example3b.cs static void Main()
{
    // First build a list of actions     List<Action> actions = new List<Action>();
    for (int counter = 0; counter < 10; counter++)
    {
        int copy = counter;
        actions.Add(() => Console.WriteLine(copy));
    }

    // Then execute them     foreach (Action action in actions)
    {
        action();
    }
}

這樣,每次循環體在執行的時候,都會取得一份counter的拷貝,而不是它自己-因此每一個Action取得了不一樣的變量值。若是看一下編譯器生成的代碼,你就會徹底明白這種結果是合情合理的,但這對於大多數第一次看到代碼的程序員來講,其直以爲出的結果每每是相反的。(包括我)

在Java中則徹底不存在第一個例子的情形-你根本不可能捕獲到counter變量,由於它並無被聲明爲final。使用final變量,最終獲得下面相似C#的代碼:

public static void main(String[] args)
{
    // First build a list of actions     List<Runnable> actions = new ArrayList<Runnable>();        
    for (int counter=0; counter < 10; counter++)
    {
        final int copy = counter;
        actions.add(new Runnable()
        {
            public void run()
            {
                System.out.println(copy);
            }
        });
    }
    
    // Then execute them     for (Runnable action : actions)
    {
        action.run();
    }
}
有了「捕獲變量的值」語義存在,代碼顯得清晰明瞭,更符合直覺。儘管代碼看起來比較囉嗦沒有C#那麼爽,但Java強制只能使用惟一正確的方式去書寫代碼。但同時當你須要像原來C#代碼的那種行爲時(有時候確實有這種需求),用Java實現起來是會比較麻煩。(能夠用一個只有一個元素的數組,而後引用這個數組,再對數組元素進行操做,代碼看起來會比較雜亂)。

我到底想講些什麼?

在例子中,咱們能夠看到了閉包好處其實很少。固然,咱們把控制結構和斷言邏輯成功分拆開來,但這並無使代碼比原來的更加簡潔。這種事常常發生,新特性在簡單的情形每每是看起來沒想像中那麼好,有那麼大的做用。閉包一般帶來的好處,是可組合性,若是你以爲這麼說有些扯淡,沒錯-這也是問題的一部份。當你對閉包運用很熟練甚至有些迷戀的時候,二者之間的聯繫就會變得愈來愈明顯,不然是不容易看出其玄妙所在。

閉包不是被設計來提供可組合性。它作的不過是讓delegate實現起來更加簡單(或者只有一個方法的interface,下面統一用delegate簡稱)。若是沒有閉包,直接寫一個循環結構實際上是比把封裝了一些相關邏輯的delegate傳給另外一個方法去執行循環要來得簡單。即便能夠經過delegate調用「在已有類中添加的方法」,最終你仍是沒辦法把邏輯代碼放在最合適的地方,並且沒了閉包提供的信息存儲便利,則必須依靠方法外部的上下文來存儲某些信息。

可見,閉包使delegate更加易用。這就意味着值得將API設計成爲使用delegate的形式。(我認爲這種狀況並不適用於.NET 1.1下面基本上只能用來處理線程和訂閱事件的delegate)當你開始用delegate的方式去解決問題時,如何去作變得顯而易見。好比,最多見的就是建立一個用AND或者OR(也包括其它邏輯操做符)將兩個斷言串連起來的Predicate<T>。

當把某個delegate產生的結果裝填進另外一個列表,或者對delegate進行加工產生新的,就會有徹底不一樣的組合方式,若是將邏輯看成能夠被傳遞的某種數據來考慮時,全部不一樣類型的選擇都是可行的。

這種編碼方式的好處遠不止上面說的那麼多-整個LINQ都是基於這種方式。咱們建立的過濾器只是一個能夠將有序數據轉換成另外一組數據的例子。另外還有排序,分組,聯接另外一組數據和Projecting等操做。使用傳統的編碼方式去寫這些操做雖不是很是痛苦的事情,可是若是「數據管道」中轉換操做愈來愈多時,複雜性隨之提升,另外,LINQ賦於對象延遲執行和數據流的能力,這種一次循環執行屢次操做方式明顯比屢次循環執行一次操做要節約不少內存。即便每個單獨的轉換操做被設計得很聰明高效,複雜性上仍是依舊沒法取得平衡-經過閉包封裝簡明扼要的代碼片段以及良好設計的API帶來的組合能力能夠很好去除複雜性。

結論

剛開始接觸閉包,可能不會對它有深入印象。固然,它使得你的interface或者delegate實現起來更簡單(取決於語言)。其威力只有在相關類庫利用了它的特性以後才能體現出來,容許你將自定義行爲放在合適的地方。當同一個類庫同時容許你將幾個簡單的步驟以比較天然的方式組合起來實現一些重要行爲時,其複雜性也只是幾個步驟的總和-而不是大於這個總和。我不是贊同某些人鼓吹的可組合性是解決複雜性的銀彈,但它確定是頗有用的技巧,並且因爲閉包使得它在不少地方能夠得以實施。

拉姆達表達式最重要特色就是簡潔。看一下以前的Java和C#的代碼,Java的代碼顯然比較笨拙冗長。不少Java閉包的倡議都是想解決這個問題。稍後我會發一篇文章講一下我對這些不一樣倡議的見解。

相關文章
相關標籤/搜索