Why Java Sucks and C# Rocks(补2):标准事件模型

原文 - Why Java Sucks and C# Rocks(补2):标准事件模型

这又是一篇“补”,本来并不想写这方面的内容,因为这并非完全是“语言”相关。打个比方,如果您觉得.NET中的事件模型不友好,那么就按Java的做法去做咯(反之就做不到了)。不过既然正好看到有些涉及到这方面的讨论,那么我也趁此机会发表一下自己的看法吧。这次谈的是两种语言(其实在这个话题上也是平台)下“标准”的事件模型。“标准”二字意味着是被双方社区各自接受的模型,而不仅仅是为了实现“事件”这一理念而使用的任意做法。

.NET中的事件#

还是从两种事件模型开始介绍。首先是.NET中的事件模型。.NET里的“事件”是一等公民,换句话说,这是平台中所直接定义和描述的概念,我们利用反射相关的API(如GetEvent方法)可以直接获取到某个“事件”对象,然后对其进行各类操作(例如添加或删除处理器)。.NET中的事件基于“委托”,这也是.NET有别于Java平台的概念之一,在上一篇文章中也有过简单介绍,事实上委托在.NET 1.0中似乎完全是为事件量身定做的,例如在System.Windows.Forms.Form类中便定义了:

C#
public class Form : Component
{
public event MouseEventHandler MouseMove;
public event MouseEventHandler MouseDown;
public event MouseEventHandler MouseUp;
public event MouseEventHandler MouseWheel;
...
}

当然“事件”这东西不光是UI组件独有的,事实上在.NET中有一种异步模式便是基于事件的——例如WebClient类,在使用时我们可以为一个WebClient对象的DownloadProgressChanged事件注册事件处理方法:

C#
void Download()
{
WebClient client = new WebClient();
client.DownloadProgressChanged +=
new DownloadProgressChangedEventHandler(OnDownloadProgressChanged);
...
}
void OnDownloadProgressChanged(object sender, DownloadProgressChangedEventArgs e)
{
...
}

在.NET中,事件的处理器是一个符合委托签名方法,单个事件之间完全分离,它们各自的事件处理器也完全独立。

Java中的事件#

Java中并没有特定的“事件”对象,这和它的“属性”一样,都是属于纯粹“概念”上的内容,事实上它们完全是由普通的方法,接口等基本事物形成的。例如,同样作为UI组件中的窗口,javax.swing.JFrame有一套这样的API(继承自java.awt.Component):

Java
public class Component {
public void addMouseListener(MouseListener l);
public void removeMouseListener(MouseListener l);
public MouseListener[] getMouseListeners();
...
}
public interface MouseListener {
void mouseClicked(MouseEvent e);
void mouseEntered(MouseEvent e);
void mouseExited(MouseEvent e);
void mousePressed(MouseEvent e);
void mouseReleased(MouseEvent e);
}

Java中的事件不是“对象”,如它的属性一样,都是“一组API”,不过事件比属性更复杂一些。Java中的事件模型由几个部分组成:一个addXxxListener方法,用于添加事件处理器;一个removeXxxListener方法用于删除一个事件处理器;还有一个getXxxListners方法用于获得当前已经添加的所有的事件处理器。此外,在Java中的事件处理器是由接口XxxListener表示的,一个接口中包含了多个事件,换句话说,Java是对事件进行了“分组”,例如在Component对象中还有另外一组与鼠标有关的事件:

Java
public class Component {
public void addMouseMotionListener(MouseMotionListener l);
public void removeMouseMotionListener(MouseMotionListener l);
public MouseMotionListener[] getMouseMotionListeners();
...
}
public interface MouseMotionListener {
void mouseDragged(MouseEvent e);
void mouseMoved(MouseEvent e);
}

在使用事件时,往往是利用匿名类型添加事件处理器,例如:

Java
component.addMouseMotionListener(
new MouseMotionListener() {
public void mouseMoved(MouseEvent e) {
// do this, do that...
}
public void mouseDragged(MouseEvent e) { /* empty */ }
});

使用匿名类型,我们可以内联地创建一个实现了XxxListener接口的对象,这样就避免了创建一个新的类型(在Java语言中这意味着还要创建新的源文件),也方便形成一个能够访问上下文成员的闭包。不过您可以发现,如果我们只是要监听mouseMoved事件,也需要实现整个分组,即MouseMontionListener接口,只不过要将无需实现的方法留空罢了。这么做产生的问题就是,例如像MouseListener这样的接口,其中包含5个成员,那么如果我只想实现mouseClicked单个事件的话,留空其他4个方法还是太过麻烦了。因此Java也提供了对应的XxxAdaptor类,让我们可以写出这样的代码:

Java
component.addMouseListener(
new MouseAdaptor() {
public void mouseClicked(MouseEvent e) {
// do this, do that...
}
});

XxxAdaptor类会实现XxxListener接口,并且实现其中的所有方法,并全部留空。于是在使用时,我们便可以基于Adaptor类创建一个匿名类型,并选择我们需要的方法来覆盖(override)。例如上面这段代码,我们既然只要关注mouseClicked事件,那么也只要覆盖这一个方法就行了。

Java事件模型的缺点#

一句话:我不喜欢Java的事件模型。

首先,Java的事件模型比较零散。一个事件要包含三个方法,这三个方法组成一个完整的事件,缺一不可。那么,这三个方法为什么就不能“一体化”,统一成单个对象呢?.NET在这方面做的比较好,事件本身是一个独立的对象,无论是添加、删除,还是获得当前所有的事件处理器,都是从“事件对象”本身出发的功能。这与“属性”一样,我认为.NET的设计更为紧凑,优雅。当然,要实现这一点,并非一定要.NET中“委托”这样有些特殊的类型,一个普通的接口或是抽象类也可以满足“单一对象”的要求。只是,我不是十分接受Java这种“松散”的事件模型。

其次,Java的事件之间不是独立,而是经过“分组”的——当然,也有像MouseWheelListener那样的只包含mouseWheel单个事件的“分组”,但毕竟大部分分组中还是包含多个事件。这就出现一个问题,我们难以单独处理单个事件,在添加单个事件的处理器时必然要涉及到其他事件。我们来设想这样一种情况:

Java
component.addMouseListener(
new MouseAdaptor() {
public void mouseClicked(MouseEvent e) {
// do this, do that...
}
});
component.addMouseListener(
new MouseAdaptor() {
public void mousePressed(MouseEvent e) {
// do this, do that...
}
});

以上两段代码分别为mouseClicked和mousePressed事件各自添加了一个事件处理器。那么请问,当mouseClicked事件触发时,将会执行几个事件处理器?答案是2个,一个是我们添加的逻辑,还有一个是随mousePressed事件一起携带而来的“空白逻辑”。而且事实上,即便是理应“置身事外”的mouseEntered或mouseExited事件,它们也被各自添加了两个空白的处理器。对于一个对性能极度苛刻的程序员来说,这样的“浪费”可能是无法忍受的(虽然我觉得这里并不会有什么性能问题)。

此外您是否想过,为什么MouseListener和MouseMotionListener会是两个“事件组”而不合并为同一个呢?据说也是性能方面的缘故,因为MouseMotionListener中的事件都是“连续触发”的,换句话说,它们执行事件处理器的密度很高,如果将它和MouseListener合并,那么一个如mouseClicked这样的“普通型”事件处理器,也会让mouseMoved这样的“密集型”事件执行无谓的方法。由于执行密度很高,可能对于性能的影响就比较可观了。

可能您会说,把所有的事件处理逻辑实现在一个XxxAdaptor或是XxxListener中不就可以了吗?不过这就要求多个不同事件的处理器必须在同一段代码中添加,实在不够自由——这点在使用了Reactive Framework的时候体会尤甚。

不过,我认为Java事件模型最大的缺陷还是“扩展性”。Java中的事件大量依赖了接口,而在一个成熟的类库中,接口的使用应该是非常谨慎的,因为一旦发布了某个公开接口,它就不能进行任何修改,因为任何修改都会导致兼容性上的破坏,这方面在《Framework Design Guildlines》一书中进行了详细论述。试想,现在MouseListener中有5个方法,表示5个事件,那么如果我在新版本的类库中希望增加另外一个事件(如mouseDoubleClicked),那么该怎么办?似乎也只有创建新的接口。但是如果每次需要添加新的事件时都要增加新的接口,而其中仅仅是包含一个接口的话,类库中的补丁痕迹就会很重。更何况,如mouseDoubleClicked这样的事件明显也应该属于MouseListener的一部分。

.NET事件模型的遗憾#

.NET事件模型没有Java中的许多缺点,事实上如果有人说.NET的设计参考了Java的缺点,那么我认为“事件模型”可能的确是其中一个。在.NET的事件模型中,事件是一等公民,每个事件都是类的独立成员;它们的事件处理器完全独立,不会相互干涉;在类库升级时如果要增加新的事件,使用最普通最自然的方式增加便是,仅此而已。

当然,.NET中的事件模型也不够完美。在我看来它的缺点在于,它虽然是对象,但还是有限制的对象。在C#中,我们无法将一个事件作为对象传递,无法使用一个抽象类对其进行统一处理(object类型自然除外),也难针对其利用“扩展方法”等常用特性。这个问题在某些情况下会限制某些开发模型,于是我们会为其增加一些“事件即对象”的机制。

微软自己其实也意识到这个问题,因此在F#中进行了一些特别的处理。F#编译器会自动将.NET中的事件视为一个IEvent<THandler, TEventArgs>,定义如下:

F#
type IDelegateEvent<'Delegate> =
interface
abstract this.AddHandler : 'Delegate -> unit
abstract this.RemoveHandler : 'Delegate -> unit
end
type IEvent<'Delegate,'Args
when 'Delegate : delegate<'Args,unit> and 'Delegate :> System.Delegate> =
interface
inherit IDelegateEvent<'Delegate>
end

在F#中,一个.NET中的事件便是一个标准的对象,它弥补了C#里的缺点,于是许多做法在F#中便显得自然或直接了一些。例如,在F#中内置了响应式编程模型,可以直接使用。而对于C#来说,使用Reactive Framework相关功能时,则需要手动地将一个事件转化为IObservable对象——当然,有了一些辅助方法,这也就是一行代码的工作罢了。

总结#

这篇文章中我简单介绍了.NET与Java中事件模型,并谈了谈自己的看法。总而言之,.NET的事件模型虽有遗憾,但较之Java的事件模型还是有很大优势的。即便是.NET中的事件模型,在某些人看来会成为“心智负担”,但比较之下我也不愿意让.NET或C#退回到Java的设计方式上——更何况,就这样一个简单的机制就能成为值得一提的心智负担吗?我对此持保留意见。

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