C#和Java的闭包-Jon谈《The Beauty of Closures》(转)

我的梦境 提交于 2020-03-02 02:48:38

原文: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.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的内容)

现在我们已经定义好“过滤”的方法,接下来就是要调用它。为了演示闭包的重要作用,我会先使用一个简单的不需要使用到闭包都能解决的案例,然后再进一步到比较难的案例。

过滤案例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闭包的倡议都是想解决这个问题。稍后我会发一篇文章讲一下我对这些不同倡议的看法。

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!