之所以写这篇文章,源自于组内的一些技术讨论。实际上,Effective Java的Item 37已经详细地讨论了Marker Interface。但是从整个Item的角度来看,其对于Marker Interface所提供的一系列优点及特殊特性实际上是持肯定态度的。因此很多人,包括我的同事,都将该条目中的一些结论当作是准则来去执行,却忽略了得到这些结论时的前提,进而导致了一定程度的误用。
当然,我并不是在反对Effective Java的Item 37。说实话,我也没有这个资本。只是我个人在技术上略显保守,因此希望通过这篇文章阐述一下Marker Interface可能带来的一系列问题,进而使大家更为谨慎而且准确地使用Marker Interface。
Marker Interface 简介
或许有些读者并不了解什么是Marker Interface。那么首先让我们来看看JDK中Set接口的实现:
1 public interface Set<E> extends Collection<E> { 2 }
细心的读者会发现,实际上Set较Collection没有添加任何接口函数。那为什么JDK还要为其定义一个额外的接口呢?
相信您很快就能答出来:“这是因为Set中所包含的数据中不会有重复的元素,而Collection接口作为集合类型接口的根接口,其没有添加这种限制。”
是的。JDK提供一个额外的Set接口的确就是出于这个目的。而且这种不添加任何新成员的接口实际上就是Marker Interface。而且在JDK中,Marker Interface还不少。另一个非常著名的Marker Interface就是Clonable接口:
1 public interface Cloneable { 2 }
只是这一次,Marker Interface所受到的礼遇并不相同:无论是在对Prototype模式的讲解中还是在其它日常讨论中,其都是作为反面教材来诠释什么是一个不良的设计。
那Marker Interface到底是好还是不好呢?如果没有分析,我们就不会知道为什么Marker Interface在不同的情况下得到如此不同的评价,也更不会知道如何正确地使用Marker Interface。因此我们先不说结论,而是从接口Set及Clonable两个截然不同的情况来分析Marker Interface表现出如此差异的原因。
正能量先行。我们先来分析Set这个Marker Interface表现良好的原因。当用户看到Set这个接口的时候,他首先想到的就是它是一个集合,而且该集合具有不会存在重复元素这样一个性质。在对该接口实例进行操作的时候,软件开发人员可以直接通过调用Set接口所继承过来的各个成员函数来操作它。这些接口所定义的操作需要由Set接口的实现类来定义。因此Set的这种不存在重复元素的性质实际上是由接口的实现类所保证的。如在添加一个元素的时候,我们不必担心当前是否该元素是否已经在集合中存在了:
1 Set<Item> itemSet = … 2 itemSet.add(item);
而对于其它类型的集合,如List,我们就需要检查元素是否已经在集合中存在,否则其内部将存在着对该元素的重复引用:
1 List<Item> itemList = … 2 if (!itemList.contains(item)) { 3 itemList.add(item); 4 }
反过来,另一个Marker Interface Clonable则是臭名昭著的。具体原因已经在Effective Java中的Item 17中已经讲得很清楚了。实际上,创建该接口的思路和创建Set接口的思路原本是一致的:该接口用来标示实现了该接口的类型是可以被拷贝的。其中的一个问题在于,Object类型的clone()函数是受保护的。从而使得用户代码不能调用Clonable接口的clone()函数。这样就要求用户通过其它方法来实现Clonable接口所表示的语义。进而在代码中产生了大量的如下代码:
1 if (obj instanceof Clonable) { 2 …… 3 } else { 4 …… 5 }
这样,如果一个实例实现了特定的接口,如Clonable,我们就对它进行特殊的处理。这正是Marker Interface被大量误用的一种情况:通过判断一个实例是否实现了特定Marker Interface来决定对其进行处理的逻辑。这种对Marker Interface进行使用的代码实际上破坏了封装性:Marker Interface实例无法通过成员函数等方法控制外部系统对实例的使用方式。反过来,实现了Marker Interface的类型到底是被如何处理的则是由用户代码决定的。而Marker Interface仅仅是建议用户代码对其进行操作。也就是说,Marker Interface拥有了它的使用者相关的信息,因此其与当前系统中的使用者在逻辑上是相互耦合的,从而使得实现了Marker Interface的类型无法在其它系统中重用。
而这也就是Effective Java的Item 37所强调的:通过Marker Interface来定义一个类型。我们知道,在定义一个类型的时候,我们不仅仅需要指定表示该类型所需要的数据,更为重要的则是为该类型抽象出用于操作该类型的接口。这些接口规定了该类型的操作方式,从而隔离了该类型的内部实现和用户代码。如果我们需要在这些接口之外通过判断是否是特定类型来执行特殊的处理,那么也就表示该Marker Interface所定义的类型从语义上来讲是并不合适的。
而且从上面对Set接口以及Clonable接口的比较中可以看出,如果就像Effective Java的Item 37一样通过Marker Interface来定义类型,那么对类型进行定义的方式主要分为两种:从一个接口派生以使得Marker Interface拥有较父接口多出的特殊性质。而如果Marker Interface没有一个父接口,那么其应该是Object类所具有的一种特殊性质,并可以通过Object类所提供的各个组成来按该性质进行操作,就像Serializable接口那样。
从一个接口派生来定义Marker Interface是比较常见的情况,但是也较容易出错。一个比较经典的示例仍然是基于长方形为正方形定义一个接口。假设一个系统中已经拥有了一个用来表示长方形的接口:
1 public interface Rectangle { 2 void setWidth(double width); 3 void setHeight(double height); 4 double getArea(); 5 }
由于正方形是长方形的长和宽都相等的一种特殊情况,因此我们常常认为正方形是一种特殊的长方形。对于这种情况,软件开发人员就可能决定通过从长方形接口派生来定义一个正方形:
1 public interface Square extends Rectangle { 2 }
但是在使用过程中,他会别扭得要死。原因就是因为实际上对长方形所定义的接口,如setWidth(),setHeight()等对于正方形而言完全没有意义。正方形所需要的是能够设置它的边长。因此一个正确定义Marker Interface的前提就是原有接口中的各个成员对于Marker Interface所定义的概念仍然具有明确的意义。
OK,相信您在看到长方形和正方形这个示例的时候首先想到的就是里氏替换原则(Liskov Substitution Principle)。但请不要使用里氏替换原则来判断一个Marker Interface的定义是否合适。这是因为里氏替换原则实际上是使用在对象之间的:如果S是T的子类型,那么S对象就应该能在不改变任何抽象属性的情况下替换所有的T对象。毕竟,无论如何我们创建的都应该是一个类型的实例,而不能直接创建接口的实例(基于匿名类的除外)。
例如对于Set接口,如果我们将所有对Collection接口的使用都替换为对Set接口的使用,那么至少对下面的语句进行替换时会导致编译器报出编译错误:
1 Collection<Item> itemCollection = new ArrayList<Item>();
因此,使用里氏替换原则来判断一个Marker Interface是否合适实际上真没有太多意义,这在stackoverflow上也有颇多讨论。
在前面的章节中已经提到过,Marker Interface表示实现该接口的类型具有特殊的性质。也就是说,Marker Interface是该类型的一个特性,也即是该类型的一个元数据。而在Java中,另一个可以用来表示类型元数据的Java组成是标记。在处理相似问题的情况下,不同的类库选择了不同的解决方案。例如Java中的序列化支持实际上是通过Serializable这个Marker Interface来完成的:
1 public class Employee implements java.io.Serializable 2 { 3 public String name; 4 public String address; 5 public transient int SSN; 6 public int number; 7 }
而在JPA中,用来对持久化到数据库这一功能的控制是通过标记来完成的:
1 @Entity 2 @Table(name = "employee") 3 public class Employee { 4 @Column(name = "name", unique = false, nullable = false, length = 40) 5 private String name; 6 7 @Column(name = "address", unique = false, nullable = false, length = 200) 8 private String address; 9 10 @Column(name = "number", unique = false, nullable = false) 11 private int number; 12 13 @Transient 14 private float percentageProcessed; 15 ...... 16 }
随之而来的一个问题就是:我们应该在什么情况下使用Marker Interface,又在什么情况下使用标记呢?了解何时使用的前提就是了解两者之间的优劣。由于两者是完全不同的两种语法结构,因此它们之间的区别就显得非常明显:
首先从Marker Interface说起。该方法较标记的好处则在于,通过instanceof就直接能探测一个实例是否是一个特定接口的实例,而标记则需要通过反射等方法来判断特定实例上是否有特定的标记。除了这个原因之外,对一个实例是否实现了某个接口可以在编译时就可以进行检查,而一个实例是否有某个标记则在运行时才能进行。在使用instanceof的时候,实际上我们是在探测某个实例是否是某个类型。因此对于Marker Interface来说,其首先需要有一定的实际意义。
标记较Marker Interface的好处则在于:其粒度更细。可以说,Marker Interface只能施行在类型上,而标记则可以施行在多种类型组成上,因此Marker Interface实际上是作为整体行为的一种考虑,而标记则更注重具体细节。一个定义良好的细粒度API可以提供更大的灵活性。而且相较于接口,标记的后续发展能力更强,毕竟在一个接口中添加一个成员函数是一个非常麻烦的事情。
其实Marker Interface以及标记之间拥有如此大的混淆的很大一部分原因则是两者在功能上有重复,而且在Java演化过程中出现的时机并不相同,导致在一些地方仍然拥有Marker Interface的不正当使用。实际上,像Clonable这种值得商榷的Marker Interface在JDK中还有很多很多。之所以在JDK里面会出现那么多的Marker Interface,其中一个原因也是因为Java对标记的支持比较晚的缘故。