Fork me on GitHub

我为何需要使用空接口?

FxCop设计规则中的第三条提供了对接口的检查.下面是它的描述:

一个接口提供了一组行为和使用契约(usage contract),任何一个类型都可以实现这个Interface, 而不需要考虑这个类型的继承层次。一个类型通过实现接口的成员而实现这个接口。一个空的接口没有定义任何成员,因此,也就没有任何契约能够被实现。

如果你的设计包含一个空的接口,并且希望一些类型实现这个接口,你很可能希望使用这个接口作为一个标记来标示一组类型。如果你只需要区分这些类型在运行时,一个更佳的解决方式是使用自定义属性(attribute)。使用有或没有一个属性或通过属性的字段(Property)去标示一组类型。如果你希望这种标示能够被使用在编译时,就只好使用空接口了。 
 
这说明在大多数情况下,空接口都说明在设计上存在错误。这里有一个例子:
interface ThingBase {};
interface Thing1 : ThingBase
{

// Operations here...

};
interface Thing2 : ThingBase {
// Operations here...
};
考察这个定义,我们可以观察到两个事实:
Thing1 Thing2 有共同的基类,因此是相关的。

不管Thing1Thing2有什么共同之处,都可以在ThingBase接口中找到。
当然,只要看一看ThingBase,我们就会发现Thing1 Thing2 根本没有共享任何操作,因为ThingBase 是空的。 假如我们是在使用面向对象模型,这种做法就显然很奇怪:在面向对象模型中,与某个对象通信的唯一途径是向它发送消息。但要发送消息,我们需要有操作。假如ThingBase没有操作,我们就无法向它发送消息,而Thing1 Thing2 也就是相关的,因为它们没有共同的操作。但看到Thing1 Thing2 有共同的基类,我们就会得出这样的结论:它们相关的,否则共同的基类就根本不会存在。到了这里,大多数程序员都会开始挠头,想知道到底在发生什么事情。
使用这样的设计的一个常见理由是,要多态地处理Thing1 Thing2
例如,我们可以继续先前的定义:
interface ThingUser
{

void putThing(ThingBase thing);

};

现在使用共同的基类的目的就清楚了:我们既想要把Thing1 代理、也想要把Thing2 代理传给putThing。这能否证明使用空的基接口是正当的?
要回答这个问题,我们需要思考一下在putThing 的实现中发生的事情。显然, putThing 不可能调用ThingBase 上的操作,因为在那里没有操作。这意味, putThing 必须要能做以下两件事情之一:

1. putThing 能够记住事物的值。
2. putThing 能够试着向下转换到Thing1 Thing2,然后调用操作。
putThing 的伪码实现看起来可能像是这样:
void putThing(ThingBase thing)
{

if (is_a(Thing1, thing))
{

// Do something with Thing1...

} else if (is_a(Thing2, thing)) {

// Do something with Thing2...

} else {

// Might be a ThingBase?

// ...

}

}

这个实现试着依次把它的参数向下转换成每种可能的值,直到它找 到参数实际的运行时类型。当然,任何一本像样的面向对象课本都会告 诉你,这是在滥用继承,并且会带来维护问题。如果你发现自己在编写像putThing 这样的操作,依赖于人为的基接口,问问你自己,你是否真的需要采用这种做法。例如,这样的设计可能更加适宜:

interface Thing1 {
// Operations here...

};

interface Thing2 {

// Operations here...

};

interface ThingUser {

void putThing1(Thing1 thing);

void putThing2(Thing2 thing);

};

在这种设计中, Thing1 Thing2 是不相关的,而ThingUser 为每种类 型的代理提供了单独的操作。这些操作的实现不需要使用任何向下转换,而且在我们的面向对象世界里,一切都安然无恙。
下面是空的基接口的另一种常见用法:
interface PersistentObject {};

interface Thing1 : PersistentObject
{

// Operations here...

};

interface Thing2 : PersistentObject
{

// Operations here...

};

显然,这种设计把持久功能放在PersistentObject 基接口中,并且要求想要拥有持久状态的对象继承PersistentObject。表面上,这是合理的:毕竟,这样使用继承是一种沿用已久的设计模式,那么,它可能有什么问题?我们发现,这种设计有这样一些问题:
上面的继承层次用来给 Thing1 Thing2 增加行为。但在严格的OO 模型中,行为只能通过发送消息来调用。这引发了这样一个问题:PersistentObject 实际上该怎样着手完成它的工作;推测起来,它对Thing1 and Thing2 的实现(也就是,内部状态)有所了解,所以它可以把该状态写入数据库。但如果是这样, PersistentObjectThing1,以及Thing2 就不能再在不同的地址空间中实现了,因为如果是那样, PersistentObject 就不再能知道Thing1 Thing2 的状态。
换一种做法, Thing1 Thing2 可以使用PersistentObject 提供的某种功能, 使它们的内部状态持久。但PersistentObject 没有任何操作,那么Thing1 Thing2 实际上又该怎样去完成这件事情呢?再一次,唯一可行的做法是,在同一个地址空间中实现PersistentObjectThing1,以及Thing2,并让它们在幕后共享实现状态,也就是说,它们不能在不同的地址空间中实现。
上面的继承层次把世界分成两半,一个含有持久对象,另一个含有非持久对象。这种做法有着深远的影响:

假定你有一个应用,它已经实现了一些非持久对象。随着时间推移,需求发生变化,你发现现在你想让部分对象持久。采用上面的设计,你无法做到这一点,除非你改变你的对象的类型,因为它们现在必须继承PersistentObject。这当然是一个极其糟糕的消息:你不仅要改变你的服务器中的对象的实现,还要找到并更新所有正在使用你的对象的客户,因为它们突然有了一种全新的类型。更糟糕的是,你无法让它们向后保持兼容:或者让所有客户随着服务器发生改变,或者一个客户都不改变。要想让某些客户“不升级”,这是不可能的。

这种设计不能扩展到支持多种特性。设想一下,我们有另外一些行为,对象可以继承它们,比如序列化、容错、持久,以及用搜索引擎进行搜索的能力。我们很快就会陷入多重继承的泥淖。更糟糕的是,每种可能的特性组合都会创建一种完全独立的类型层次。这意味着,你不再能编写出一些操作,一般化地对一些对象类型进行操作。例如,你不能把持久对象传到需要非持久对象的地方, 即使对象的接收者并不在乎对象的持久方面。这很快就会造成碎片化的、难以维护的类型系统。不久,你会发现,你不是在重写应用,就是获得了某种难以使用也难以维护的东西。

但愿前面的讨论成为一个警告:空接口几乎总是表明,你的应用通过所定义的接口之外的机制共享了实现状态。如果你发现自己在编写空的接口定义,你至少应该后退一步,思考一下手上的问题;其他设计可能会更加适宜,更能清晰地表达你的意图。如果无论如何你都要使用空接口,那么要注意,你几乎肯定会失去这样的能力:改变对象模型在物理的服务器进程上的分布方式,因为你无法把共享了隐藏状态的接口分置在不同的地址空间中。

posted @ 2005-10-22 08:49  张善友  阅读(4004)  评论(11编辑  收藏  举报