本文是《深入理解C#(第2版)》网站上作者的文章

原文地址:https://csharpindepth.com/Articles/Chapter5/Closures.aspx

很多介绍闭包的文章都是基于函数式语言的,因为函数式语言对闭包的支持更好。因此,基于传统的OO语言来写一篇关于闭包的文章,其意义不言自明。如果你使用的是函数式语言,很可能已经了解了这些内容。本文将使用C#(1、2、3版本)和Java(7以前的版本)来进行描述。

什么是闭包

简而言之,闭包可以封装某些行为,可以像其他类型那样进行传递,并且仍然可以访问它们声明之处的上下文。这意味着,你可以将控制结构、逻辑操作符等,从如何使用它们的细节之中分离出来。能够访问原始上下文,是闭包和普通对象最主要的区别,尽管我们在实现闭包时往往使用的是普通对象和编译器诡计。

要了解闭包的好处(和实现),通过示例来阐述是最简单的。我将在本文中使用一个单独的示例,并将给出不同版本的Java和C#代码,来展示不同的方法。所有代码都可以。

示例:过滤列表

通过某些条件来过滤列表是很常见的操作。要“内联”地实现这一点是很简单的,我们可以创建一个新的列表,迭代原始列表,并将适当的元素添加到新列表中。实现这些只需要几行代码,并且将这些逻辑隐藏在一处也是很不错的。难点在于应该选取哪些项。这就轮到闭包登场了。

我在描述时使用了“过滤”这个词,这是有歧义的。是应该将项过滤到新列表中,还是应该过滤掉呢?比如,“偶数过滤器”是保留偶数还是丢弃偶数?为了避免歧义,我们将使用一个不同的术语——谓词。它表示是否与给定项匹配。我们的示例将创建一个新的列表,包含原始列表中与谓词相匹配的所有元素。

在C#中,委托是最自然的表示谓词的方式,并且.NET 2.0本身就包含一个Predicate类型。(不知道为什么LINQ倾向于使用Func,它的描述性显然不如Predicate。不过这两者的功能是完全相同的。)Java中没有委托,所以我们使用包含单一方法的接口。当然,也可以在C#中使用接口,但那样一来就会显得十分混乱,并且无法使用匿名方法和Lambda表达式了,这两个特性正是在C#中实现闭包的利器。以下是用来引用的接口和委托:

// Declaration for System.Predicate<T>
public delegate bool Predicate<T>(T obj);

// Predicate.java 
public interface Predicate<T> 
{ 
   boolean match(T item); 
}

对于这两种语言来说,用于过滤列表的代码都十分简单。但要指出的是,为了让示例更简单一些,我不会使用C#的扩展方法,以避免熟悉LINQ的朋友时不时地想起Where方法。(这两者还存在延迟执行方面的差异,不过现在先不谈这些。)

// 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方法,可以将给定的列表输出到控制台。)

现在我们已经定义了过滤方法,该调用了。为了演示闭包的重要性,我们先介绍一种不用闭包就能搞定的简单情况,然后再逐步深入。

过滤1:匹配短字符串(长度固定)

尽管我们的示例非常基础,但我还是希望你能发现它们的重要性。我们将获取一个字符串列表,从中找出所有“短”字符串,然后生成另一个列表。构建列表是十分简单的,难点在于如何创建谓词。

在C# 1中,我们需要用一个方法来表示谓词的逻辑,然后通过指定方法的名称来创建委托实例。(当然,这段代码在C# 1中无法运行,因为使用了泛型,但这不是重点,重点是如何创建委托实例。)

// 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中,我们有三种选择。可以使用和前面一样的代码,可以使用新的方法组转换来稍微简化一下,也可以使用匿名方法来“内敛地”指定谓词逻辑。增强的方法组转换并不值得大书特书,它只是将new Predicate(MatchFourLettersOrFewer)变成了MatchFourLetterOrFewer。不过下载代码中仍然保留了这种方法(在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中,除了上面提到的方法之外,你还可以使用Lambda表达式。就本文而言,Lambda表达式仅仅是一种简写的匿名方法。(这两者最大的区别是,在LINQ中,Lambda表达式可以转换为表达式树,但这与本文无关。)使用Lambda表达式的代码如下:

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

<=好像是指向item.Length的大箭头,其实只是为了保持一致性,我完全可以将其写成Predicate predicate = item => item.Length < 5;

Java没有委托,所以我们只能实现一个接口。最简单的方法是创建一个实现这个接口的新类,如下:

// 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方案的啰嗦版。(同样,我不会在此展示使用内嵌类的代码,它位于下载代码的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和C# 3的方案相比,这包含不少语法噪音,但至少代码出现在了正确的位置。这就是Java目前对闭包的支持。我们开始第二个例子。

过滤2:匹配短字符串(可变长度)

目前我们的谓词并不需要任何上下文,长度是硬编码的,要检查的字符串是作为参数传进来的。我们来改变一下,让用户可以指定允许的字符串长度。

我们首先回到C# 1。它没有任何真正的闭包支持,没有地方能保存我们所需要的信息。是的,我们可以在方法当前的上下文中使用变量(如在第一个示例的主类中使用静态变量),但这显然不是多好的解决方案,因为没有了线程安全性。答案是创建一个新类,将所需的状态与当前上下文分离开来。这与原始的Java代码极其相似,只是用委托代替了接口:

// 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和C# 3来说,改变就很少了:我们只需要用参数来代替硬编码的字符串限制。现在不必关心具体是怎么实现的,在介绍完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);
}

那么,Java代码和C#代码有什么区别呢?在Java中,匿名类捕获的是变量的值,而在C#中,委托捕获的是变量本身。为了证明C#捕获的是变量,我们来修改C# 3的代码,在列表过滤过一次之后改变参数的值,然后再过滤一次:

// 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);
}

注意,我们只修改了变量的值,而没有重新创建委托实例。委托实例访问局部变量,能够发现它被修改了。下面我们来更深入一步,让谓词本身来更改变量的值:

// 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#(第2版)》的第5章。你对“局部变量”的理解可能会发生翻天覆地的变化。

我们已经看到了C#对捕获变量的变化所作出的反应,那么Java呢?其实,答案非常简单:你不能修改捕获变量的值。它必须是final的,因此这个问题就没有意义了。即使你可以更改变量的值,谓词也不会对此作出响应。在谓词创建时,复制了捕获变量的值,存储在匿名类的实例中。记住,引用变量的值只是引用,而不是对象当前的状态。例如,捕获了一个StringBuilder,然后对其附加(append)内容,在匿名类中是可以看到这种改变的。

比较捕获策略:复杂性vs能力

Java的方案显然更加严格,但它也确实显著地简化了操作。局部变量行为没有什么改变,而且在大多数情况下,代码也很容易理解。例如,下面的代码分别使用了Java的Runnable接口和.NET的Action委托,它们都表示没有参数和返回值的行为。首先看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();
    }
}

在循环中,我们每次得到的是不同的copy变量的实例,也就是说,每个Action捕获的是不同的变量。对于大多数开发者来说(包括我),刚一看到结果时似乎都觉得很奇怪,而当了解了编译器在后台所做的事情之后,就会明白这一切了。

Java完全禁止了第一种情况,你根本不能捕获counter变量,因为它不是final的。使用final变量的代码如下,跟C#代码看起来很像:

//In Example3a.java
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#代码的行为时(肯定会有这样的需求),实现起来就相当麻烦了。(可以用声明一个单元素数组,捕获这个数组的引用,然后在需要的时候更改元素的值,但这太丑陋了。)

What's the big deal?

上面的示例仅展示了闭包的部分好处。当然,我们已经将控制结构和过滤本身所需的逻辑进行了分离,但这似乎并没有使代码简化多少。这很正常,因为示例越简单,新特性就越不那么让人印象深刻。实际上,闭包所带来的好处,是可组合性(composability)。我知道这听上去有点风马牛不相及。不过当你熟悉了闭包,甚至沉溺于此时,就会很自然地发现这其中的联系。在那之前,一切都会显得很混沌。

闭包天生并不具备可组合性。它们只是简化了委托(或单方法接口,简便起见用委托代之)的实现。如果不支持闭包,相比通过委托来调用其他提供了部分逻辑的方法来说,编写一个小循环反而更加简单。即使使用“在已有类中添加方法”这样的委托,也同样会缺乏逻辑的“局部性”,你需要更多的上下文信息。

所以说,闭包简化了委托的创建。这意味着创建使用了委托的API是非常有价值的。(在.NET 1.1中,委托几乎仅限于开启线程和处理事件,这并不是巧合。)一旦你开始以委托的思想来思考问题,那么如何对其进行组合就是显而易见的了。例如,创建一个包含其他两个谓词的Predicate,来表示它们的逻辑与/或(或其他操作),就显得毫无意义了。

将一个委托融入另一个,或者基于一个委托创建新的委托,是另一种形式的组合。当你将逻辑想象成数据之后,你就会思如泉涌。

组合的使用并不仅限于这些,整个LINQ都是构建于此。我们构建的列表过滤器只是一个转换数据序列的例子。其他操作还包括排序、分组、投影、与其他序列进行组合等等。以传统方式来编写这些操作都不是很困难的事,但当组成“数据管道”的转换变多时,复杂性也会陡然上升。此外,仰仗着LINQ to Objects的延迟执行和数据流,占用的内存也比直接按顺序运行各个转换要少得多。各个转换本身也相当智能,可以通过闭包内联地表示微小的逻辑片段,并通过设计良好的API将它们组合起来,这也降低了复杂性。

结论

闭包一开始似乎不那么显眼。当然,它可以相当简单地实现接口或创建委托(因语言而异)。只有当你使用了利用了闭包的库,并且可以在正确的地方来表达自定义行为时,它的强大之处才会显现出来。你仍然可以使用相同的库以自然地方式组合各个简单的步骤来实现重要的行为,最终的复杂度只是各个部分之和。我并不像某些人那样认为组合是解决复杂性的银弹,不过它确实是非常强大的技术,并且因为闭包的存在,可以应用于更多的场景。

Lambda表达式的一个主要特点是短。比较上面的Java和C#代码,Java的看上去十分笨重。这也是Java旨在解决的各种有关闭包的问题之一。