Why Java Sucks and C# Rocks(5):匿名方法

原文 - Why Java Sucks and C# Rocks(5):匿名方法

确切地说,这里的标题应该是“C#中的匿名方法”,因为这是C#中特有的功能。在之前的文章里,虽然我都用长篇文字加代码示例来说明问题,但总有朋友认为我谈的只是C#和Java的“区别”,算不上优势。不过从这篇文章开始,我们将正式进入C# 2.0的时代,这也是C#大步甩开Java语言的开端——可以看出,Anders Hejlsberg从此开始实现他对于编程语言的各种理想,而并非纠缠于与Java所谓的“竞争”中。例如这篇文章要讨论的“匿名方法”特性,以及随之而来的“函数式编程”痕迹,便开始引领C#在开发理念上的进步。

委托#

委托(Delegate),事实上这是在.NET 1.0(请注意不是C#,而是.NET平台的概念)时代便有的东西。不过,因为在C# 1.0中并没有提供一个“改变编程思维”的特性来体现这一概念,便没有多提。不过到了C# 2.0,既然我们要开始谈匿名方法了,便不得不提“委托”这个非常关键的概念。如果您没有接触过这个概念,不妨可以简单地将“委托”理解为一种“类型安全”的函数指针:

C#
public delegate void Action<T>(T arg);
public delegate T Func<T>();
public delegate TResult Func<T, TResult>(T arg);
public delegate void MouseEventHandler(object sender, MouseEventArgs e);

在C#中定义委托对象时需要用到delegate关键字,然后便像声明一个方法那样指定委托名称,参数名和返回值得名称等等。委托可以带有泛型参数,这样便可以定义十分通用的委托类型,如上面的Action委托及两个Func委托。提供这种通用的委托类型对于某些编程实践有着十分重要的意义,这点在以后的文章中也会提到。不过,在还没有提供泛型支持的.NET 1.0,或者说是在C# 1.0时代,所有的委托都是如上面MouseEventHandler那样拥有的具体类型委托。

在.NET中,委托作用是引用一个“方法”,以及其调用时所需要的完整上下文,换句话说,有了一个委托对象之后,我们便可以直接“调用”这个方法了。自然,委托所引用的方法必须与委托的签名完全相同,这也是上文中“类型安全函数指针”所表示的含义。委托在调用时的开销和一个虚方法差不多,可以说它的性能非常高,因此它也是在很多情况下优化“反射调用”性能的常用手段。

在.NET中,“事件”是委托的一个重要使用场景,最近有人质疑.NET的事件是个设计上的错误,它完全应该像Java那样基于普通的接口来实现“事件”概念。对此我有不同的看法,不过这是一个较大的话题,因此我将其从现在这篇文章中剥离开来,独立成篇。而现在我先讨论其他一些委托的典型使用场景。

匿名方法及其典型使用场景#

在.NET中,我们可以将委托对象作为方法的参数或是返回值来使用,例如:

C#
static Func<T1, Func<T2, TResult>> Curry<T1, T2, TResult>(this Func<T1, T2, TResult> f) { ... }

您可以已经意识到了,这便是所谓的“高阶函数”。高阶函数的优势有许多,简单概括一下便是“更好的抽象和组合能力”。只是在C# 1.0中,我们必须独立定义一个方法之后,才能将其构造为一个委托对象,不过从C# 2.0开始,我们可以使用“匿名方法”来构造一个委托对象,例如上面的Curry方法可以实现为:

C#
static Func<T1, Func<T2, TResult>> Curry<T1, T2, TResult>(this Func<T1, T2, TResult> f)
{
// in C# 3.0: x => y => f(x, y)
return delegate(T1 x)
{
return delegate(T2 y) { return f(x, y); };
};
}

在代码中使用delegate关键字可在代码中内联地创建一个委托对象,并会在需要时形成一个闭包,您可以简单理解为调用这个匿名方法所需要的完整上下文。例如在上面这段代码中,内层的匿名函数可以访问到外层匿名函数的参数x,以及Curry方法的参数f。在C#中使用匿名函数时,可以访问字面范围内(lexical scope)的所有成员,这也逐渐让C#有了函数式编程的意味,当然这一切都还得等到C# 3.0阶段才会真正发扬光大,目前还只是C# 2.0。

匿名方法是语言的特性,和运行时没有任何关系,完全是编译器施展的魔法,于是有些人便认为这就是个无足轻重的语法糖。语法糖没错,但是“无足轻重”的评价我无法赞同。匿名函数带来了许多编程模式上的改变。由于语法特性的缺失,这些编程模式在C# 1.0或是Java语言中是麻烦到几乎无法使用的,更别提“推广”开来。关于这方面的文章我写过不少,它们都是真正用于产品开发的案例:

  • 简化回调:在异步编程中回调函数是十分常见的。有了匿名方法之后,创建一个回调函数十分容易,并且可以利用闭包直接使用回调函数中所需要的成员,在简化开发的同时,依旧保证了强类型的静态检查能力。
  • 延迟初始化器:我们可以使用匿名函数提供一个对象的初始化逻辑,并交由一个线程安全的初始化器使用。这里利用了高阶函数来封装逻辑,在传统的面向对象语言中实现这点,则往往需要利用工厂方法模式,这需要创建各种抽象类及具体类。事实上,利用匿名方法及高阶函数之后,GoF23中的许多模式,如“工厂方法”、“策略”及“模板方法”等等,都有了更加简单的实现方式,甚至完全成为自然而然的编程方法。
  • 缓存容器辅助方法:使用缓存容器时往往有着固定的模式,如“检查缓存,如果没有则访问数据库,将结果放入缓存后并返回”。有了匿名方法之后,我们可以将“访问数据库”这个操作通过参数交由缓存容器的辅助方法,辅助方法仅仅在缓存失效的情况下采取执行这个操作,这样既封装了重复的逻辑,又保证了代码的流畅性。
  • AsyncTaskDispatcher:这是一个用于简化多个异步操作之间协作关系的组件,我们只要将异步操作之间的依赖关系提供给Dispatcher,则Dispatcher便会自动调配异步操作的执行顺序。这里使用利用到匿名函数来表示各个异步操作,并利用闭包在多个异步操作之间共享状态。

自然,利用高阶函数或是匿名方法也会带来一些额外的问题,例如延迟带来的陷阱,但瑕不掩瑜,匿名方法依旧是C#中最重要的语言特性之一,也是如Scala,Python,Ruby等高级语言中的标准配置。

C#的匿名方法与Java的匿名类型#

说起来,Java语言从1.4版本开始也加入了匿名类型的特性。简单地说,匿名类型是指以“内联”的方式在代码中定义一个抽象类型(即接口、抽象类甚至任何非final类)的具体实例。例如之前某篇文章中用Java实现了生成一个minInclusive到maxExclusive之间数列的迭代器:

Java
public class Range implements Iterable<Integer> {
private int m_maxExclusive;
private int m_current;
public Range(int minInclusive, int maxExclusive) {
this.m_maxExclusive = maxExclusive;
this.m_current = minInclusive;
}
@Override
public Iterator<Integer> iterator() {
return new Iterator<Integer>() {
public boolean hasNext() {
return m_current < m_maxExclusive;
}
public Integer next() {
int current = m_current;
m_current = m_current + 1;
return current;
}
public void remove() {
throw new UnsupportedOperationException();
}
};
}
}

在Range类的iterator方法中,我们直接返回了一个Iterator<Integer>接口的实例,这个实例直接内联地提供了接口中hasNext、next和remove三个方法的实现,并且使用了外部的m_maxInclusive及m_current字段。那么这不也是个闭包吗?没错,Java中的匿名类型的确也有一定这方面的特性,虽然使用起来比较麻烦,也不利于单元测试等等,因此一些开发实践中都不太提倡使用匿名类型(某些标准场景除外)。平心而论,我并不觉得这是个没有意义的特性,毕竟它提供了另一种选择,而且在C# 2.0之前我有时也会怀念Java语言的这个特性。

既然C#中的匿名方法和Java的匿名类型有一定的共性,那么我们便可以寻找两者之间的差异。除了语法之外,我认为两者最大的区别在于对匿名方法(类型)外的“局部变量”的操作能力上。闭包的典型使用场景之一是支持简单的并行计算。例如.NET 4.0提供了一个并行库,其中包含类似于如下接口的Parallel.For方法:

C#
static void ParallelFor(int minInclusive, int maxExclusive, Action<int> body) { ... }

显然在.NET 2.0中我们便可以自行编写这样的方法,并配合匿名方法可以很轻松的开展简单的并行计算。例如一个并行的n * n的矩阵加法,我们便可以写作:

C#
static int ParallelSum(int[,] array, int n)
{
var processorCount = Environment.ProcessorCount;
var sum = 0;
ParallelFor(0, processorCount, delegate(int part)
{
var minInclusive = part * n / processorCount;
var maxExclusive = minInclusive + n / processorCount;
var partSum = 0;
for (int x = minInclusive; x < maxExclusive; x++)
{
for (int y = 0; y < n; y++)
{
partSum += array[x, y];
}
}
Interlocked.Add(ref sum, partSum);
});
return sum;
}

从代码上看,sum是ParallelSum方法的“局部变量”,不过在匿名方法内部也可以对它进行修改,例如上面的代码中就对其进行了CAS加法,因此我们可以认为在C#中的闭包在使用上是完全透明的。在Java中,如果要在匿名类型里访问外部的局部变量,则必须在局部变量声明时增加final关键字,这意味着这个局部变量是无法修改的。这么做可以避免错误共享之类的问题,但也限制我们在需要的时候必须用一点特殊的方式回避这种限制。例如在编写之前的并行矩阵相加以及AsyncTaskDispatcher代码时,则可能需要借助于这样一个包装类:

Java
public class Wrapper<T> {
public T value;
}

这样即便是引用Wrapper对象的局部变量不能修改,我们也能修改Wrapper对象的value字段的值。我不喜欢这样的设计,我认为这部分灵活性交由程序员来控制。C#虽然理论上有着误用的可能,但这也只是十分少见的情况,而且有了检查工具之后,误用几乎可以完全避免了。

本文为 赵劼 发表在 个人博客 的系列文章之一。