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

來源:互聯網
上載者:User

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

第一段略。。。

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

什麼是閉包?

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

利用例子來觀察閉包的好處(和實現)會比較容易, 下面大部份內容我會使用一個單一的例子來進行講解。例子會有JAVA和C#(不同版本)來說明不同的實現。所有的代碼可以點這裡下載。

需求情境:過濾列表

按一定條件過濾某個列表是很常見的需求。雖然寫幾行代碼遍曆一下列表,把滿足條件的元素挑出來放到新列表的“內聯”方式很容易滿足需求,但把判斷邏輯提取出來還是比較優雅的做法。唯一的痛點就是如何封裝“判定一個元素是否符合條件”邏輯,閉包正好可以解決這個問題。

雖然我上面說了“過濾”這個詞,但它可能會有兩個截然不同的意思“把元素濾出來放到列表裡”或者把“把元素濾出來扔掉”。比如說“偶數過濾”是把“偶數”保留下來還是過濾掉?所以我們使用另一個術語“斷言”。斷言就是簡單地指某樣東西是不是滿足某種條件。在我們的例子中即是產生一個包含了原列表滿足斷言條件的新列表。

在C#中,比較自然地表現一個斷言就是通過delegate,事實上C# 2.0有一個Predicate<T>類型。(順帶一提,因為某些原因,LINQ更偏向於Func<T, bool>;我不知道這是為什麼,相關的解釋也很少。然而這兩個泛型類的作用其實是一樣的。)在Java中沒有delegate,因此我們會使用只有一個方法的interface。當然C#中我們也可以使用interface,但會使得代碼看起來很混亂,而且不能使用匿名函數和拉姆達運算式-C#中符合閉包特徵的實現。下面的interface/delegate供大家參考:

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

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

// In ListUtil.csstatic 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.javapublic 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的內容)

現在我們已經定義好“過濾”的方法,接下來就是要調用它。為了示範閉包的重要作用,我會先使用一個簡單的不需要使用到閉包都能解決的案例,然後再進一步到比較難的案例。

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

我們的需求情境都會比較簡單基礎,但希望大家能從中看出它們的不同之處。我們將會有一個字串list,然後根據這個list產生另一個只包含長度較“短”的字串list。建立list很簡單-建立斷言才是痛點。

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

// In Example1a.csstatic 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.javapublic class FourLetterPredicate implements Predicate<String>{    public boolean match(String item)    {        return item.length() <= 4;    }}// In Example1a.javapublic 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.javapublic 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.cspublic 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.csstatic 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.javapublic 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.csstatic 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.csstatic 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.csstatic 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.csstatic 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閉包的倡議都是想解決這個問題。稍後我會發一篇文章講一下我對這些不同倡議的看法。

相關文章

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.