Java API设计方法
Java API设计方法
Java API设计指南(一)
接口的功能被夸大了:
在Java的世界,有一些API的设计原则是很通用的,如尽量使用接口的方式来表达所有的API(不要使用类来描述API)。接口自有它的价 值,但是将所有的API都通过接口来表示并不见得总是一个好的设计方案。在使用一个接口来描述API时,必须有一个足够的理由。下面给出了一些理由:
1、接口可以被任何人所实现。假设String是一个接口而非类,永远都无法确认用户提供的String实现能够遵循你希望的规则:字符串类是 一个不变量;它的hashCode是按照一定的算法规则来返回数字;而length永远都不会是一个负数等。如果真的由用户来提供一个String的实 现,可以想象代码中要加入多少异常处理代码和相关的判断语句才能保证程序的健壮性。
实践告诉我们,如果API完全是由接口来定义,用户在使用这些API时会发现不得不进行大量的强制转型(译注:个人认为,强制转型并不是因为 API是通过接口来定义引起的,而是不好的API定义引起的,而且强制转型从程序的设计角度几乎是无法避免的,除非所有的子类都不添加任何新的功能,而这 一点与前面的抽象类演化又是矛盾的)。
2、接口不可能拥有构造函数或者是static方法。如果需要接口的实例,不可能直接实例化接口,只能通过某种方式,可能是new也可能是通过 参数传递的方式来获得一个接口的具体实现对象。当然,这个对象可能是由你来实现的,也可能是由第三方供应商开发的。如果Integer是一个接口而非一个 类,则无法通过new Integer(int)来构造一个Integer对象,可能会通过一个IntegerFactory.newInteger(int)来获得一个 Integer对象实例,天啊,API变得更加复杂和难以理解了。
(译注:个人认为原文作者有些过于极端化了,因为Java不是一个纯粹的API,它同时是一种语言,一个平台,所以提供的String和Integer,都是作为基础类型来提供。虽然同意作者的观点,但是作者使用的上述例子,个人认为不是很有说服力。)
3、接口无法进行演化。假设在2.0版本的API中为一个接口添加一个方法,多米诺骨牌倒了,大量直接实现了这个接口的类,根本就无法通过编 译,直到实现了这个方法为止。 当然可以在调用这个新方法的时候,通过捕捉AbstractMethodError这个异常来保证二进制的兼容性,但是如此笨重的方法,实在不是智者所 为。除非告诉用户说千万不要直接实现这个接口,请先继承所提供的那个抽象类,这样做就不会有问题了,不过用户会尖锐的责问:那为什么要提供这样一个接口, 不如直接提供一个抽象类算了。
4、接口是不可以被序列化的,虽然Java的序列化存在许多问题,但是仍然不可避免的要用到它。象JMX的API就严重依赖于序列化接口。序列 化是针对序列化接口的子类来处理的,当一个可序列化的对象被反序列化时,就有会一个相同的新对象被重新创建出来。如果这个子类没有提供一个public构 造函数,那么可能很难在程序中使用这个功能,因为只能反序列化而不能进行序列化。而且在反序列化时,只能使用接口进行强制转型。如果要序列化的内容是一个 类,那就不需要提供这样的序列化接口。
当然,对于下列情况,接口还是非常有用的:
1、回调:如果功能完全由用户来实现,在这种情况下,接口显然比抽象类更加合适。例如Runnable接口。特别是那些通常只含有一个方法的, 往往是接口,而非类(译注:最常用的就是各种Listener)。如果一个接口中包含有大量的抽象方法,用户在实现这个接口的时候,就不得不必须实现一些 空方法。所以对于有多个方法的接口,建议提供一个抽象类,这样在接口中添加新的方法,而不需要强迫用户实现新的方法。(译注:看来作者很推荐使用接口+基 类的方式来编写API,不过Java SE本身就是这样做的,例如MouseAdapter,KeyAdapter等。个人认为,如果是规范,当然最好是接口,象J2EE规范;如果是框架,或 者是功能包,还是建议使用接口+基类的方式。所谓的回调其实是SPI(Service Provider Interface)的一种)。
2、多重继承:在一个继承体系比较深的结构里,可以通过接口来实现多重继承。Comparable是一个最好的例子,比如Integer实现了 Comparable接口,因为Integer的父类是Number,所以通过接口的方式实现了多重继承。但是在Java的核心类库中,这样的经典例子并 不多。通常一个类实现了多个接口并不一定是一个好的设计,因为这往往将许多责任强加在一个类上,有违基本的设计原则,而且很容易产生重复代码。如果真的需 要这样一个功能,使用一个匿名类或者是一个内部类来实现这些接口,或者使用一个抽象类作为基类也是不错的方案。
3、动态代理:价值不可估量的动态代理类java.lang.reflect.Proxy class 可以在运行的时候根据接口生成实现的内容。它将对一个接口的调用转换成对某一个对象具体方法的调用,非常的灵活,可以有效的减少代码重复。但是对于一个抽 象类,就不可能动态生成一个代理对象了。如果喜欢使用动态代理技术,那么使用接口对软件开发是非常有效的。(CGLIB有时可以有效地对抽象类实现动态代 理,但是有许多限制,而且其文档也较少。)
谨慎的分包:
Java在控制类和方法的可见性上,所支持的方式实在乏善可陈,除了public,proteced,private以外,就只能通过 pakcage来控制。如果一个类或者方法想让外部的包可见,则所有的类和方法都可以访问它了,不能指定外部哪些类可以访问自身。这就意味着如果将API 分成若干个包进行发布,则必须对这些包详细设计,避免减少API的公开性。 最简单的方法当然是把所有的API放在一个包中,这样很容易通过package来降低访问性。如果API不超过30个类,这个方案简直是完美。
但事事往往不尽如人事,经常API非常大,不适合放在一个包中。这时候可能要不得不进行私有分包了(这里的私有与private不一样的,只是 一种伪私有),私有只是不在JavaDoc中输出这些类的文档信息。如果查看JDK,会发现许多以sun.*或者com.sun.*打头的包相关的文档信 息并没有包含在JDK的JavaDoc中。如果开发人员主要通过JavaDoc来使用API,那可能根本不会注意到这些包的存在,只有查看源代码或者分析 API的人才能看到这些API内容。即使发现了这些没有通过文档公开的类,也不建议使用它们,因为不通过文档公开的API,往往也意味着它可能会随着时间 的改变进行演化,也有可能在演化的过程中不能保持兼容性。(译注:象C#支持assembly的访问机制,个人就感觉很好,象Osgi支持Bundle, 允许定制输出类也是不错误的解决方案,不过前者是语言级,而后者是框架级。)
还有将包隐藏起来的一个方式就是在包的名称中包含internal。所以Banana的API可能会有公开的包 com.example.banana, com.example.banana.peel,也可能还有s com.example.banana.internal 和com.example.banana.internal.peel。
别忘记所谓的私包同样是可以访问的,更多时候这样的私包只是出于安全的考虑,建议用户不要随便访问,并没有任何语言级的约束。还有一些技术可以 解决这个问题,例如NetBeans的API教程中就给出了一种解决方案。在JMX的API中,则使用了另外一种方式。象 javax.management.JMX这个类,就只提供了static方法而没有提供public构造函数。这也就意味着你不能实例化这样一个类。 (译注:不明白这个例子的意义。)
下面在设计JMX时的一些技巧
不变类是一个很好的设计,如果一个类可以设计成不变类,就不要用可变类!如果详细了解这样设计的原因,请参见《Effective Java》的第十三条。如果没有读过这本书,很难设计出好的API。
另外字段信息应该是private的,只有static和final修饰的字段信息才能变成public,允许外部访问。这一条是一个非常基础的原则,这里提到这个原则,只是因为在早期的API设计时,有些API违反了这个原则,这里不再给出一个例子了。
避免奇怪的设计。对于Java代码,已经有了许多约定俗成的方法了,如get/set方法,标准的异常类。即使觉得有了更好的方法,也尽量避免 使用这些方法。如果使用了一些奇怪的方法名称,这样使用API的用户必须学习新的内容,不能按照原有的习惯来理解代码,会增加学习成本,也会增加误用的可 能。
再举个例子,象java.nio以及java.lang.ProcessBuilder就是一个不好的设计,它不使用getThing()和 setThing()方法这种方式,而使用了thing()和thing(T)这两个方法。许多人认为这是一个不错的设计方法,但是这样违反了常用的方法 设计原则,强迫用户来学习这种API。(译注:java.nio和java.lang.ProcessBuilder是指JDK6中的包,害得我在 JDK1.4中找了半天,参见http: //download.java.net/jdk6/doc/api/java/lang/ProcessBuilder.html,这里所谓的 thing和Thing也不是真有这个方法和类,而是ProcessBuilder中的command和command(List)等多个方法。)
不要实现Cloneable, 即使想某一个类支持对象的复制,这个接口也没有太多的价值,如果真想支持复制功能,提供一个复制构造函数或者是一个static方法来复制对象,又或者提 供一个static的工厂方法来创建对象,也会更加有效。例如想让Banana这个类拥有clone的能力,可以使用代码如下:
publicBanana(Bananab){//copyconstructor this(b.colour,b.length); } //...or... publicstaticBanananewInstance(Bananab){ returnnewBanana(b.colour,b.length); } |
构造函数的优点就在于子类可以调用父类的构造函数。static函数则是可以返回具体类的子类实现。
《Effective Java》书中第十条则给出了clone()带来的痛苦。
(译注:个人不同意这个观点,我觉得clone非常有用,特别是在多线程的处理中,我会再撰写关于clone方面的文章,而且前面提到的缺点也都是可以通过一些设计上的技巧来改正。)
异常应该尽可能的是unchecked类型的,《Effective Java》书中第41条则给出了详细的说明。如果当前API只能抛出异常,而且开发人员可以对异常进行处理,如释放资源,就可以使用Checked异常。 因此所谓的Checked异常就是API内部与外部开发人员进行问题交互的一种方式。如网络异常,文件异常或者是UI异常等信息。如果是输入参数不合法, 或者是一个对象的状态不正确,就应该使用Unchecked异常。
一个类如果不是抽象类,就应当是final类不可被继承。《Effective Java》第15章给出了足够的理由,同时也建议每个方法在默认情况下都应该是final(目前Java正好相反)(译注:这点我也赞成,觉得方法默认为 final更好,但是目前Java发展到当前情况下,已经不可能大规模的更改了,不能不说是Java语言的一个遗憾之处,C#这一点处理的更好,默认为 final,后面的留言也提到这个了)。如果一个方法可以被覆盖,一定要在文档中清楚的描述这个方法被覆盖后带来的后果,最好还能提供一些例子程序进行演 示以避免开发人员误用。
总结
- 设计需要演化,否则会降低它的价值。
- 先保证API的正确性,在此基础上再追求简单和高效
- 接口并不如想像中的那么有用。
- 谨慎分包可以带来更多的价值。
- 不要忘记阅读《Effective Java》(译注:难道作者和Josh Bloch's有分赃协议不成。)
以下是当前文章一些讨论的意见(因为比较多,所以我没有全部翻译,但是国外技术论坛上的一些讨论,其价值往往比文章的价值更大,建议可以自行阅读一下。):
Gregor Zeitlinger写道:
如果使用作者给出的API标准,C#很多方面是不是做的更好呢。
C#的方法在默认情况是final的,不可被重载。(译注:个人意见,在这一点上C#比Java更好,而且,参数默认就应该是final,因为java参数是传值的,所以也不应该改变)。
C#只有unchecked exception(新的规范中又重新提出了checked exception)。(译注:个人认为checked exception的价值还是很大的,不过在Java中,有些被误用了)。
Eamonn McManus 写道:
我个人不认为让大部分方法都成为final是一个好的设计方案。根据我个人的经验,这种处理方式将会严重的降低代码的复用度。
如果更极端的说一下:也许不应该有private方法,所以的方法都应该是protected甚至是public,这样可以有益于复用度。当然这样处理带来的一个明显缺点就是没有人知道哪个方法可以被安全的复写(overrid)。
(译注:这也太极端了,如果这样,一个API的规模恐怕会是原来的10倍以上,不要说复用,恐怕怎么用我都不知道了,想想一下一个1000个类,10000个public方法的API包吧)。
Gregor Zeitlinger写道:
在什么情况下我们要同时提供接口和抽象类呢。举个例子,Java的集合框架中提供了List这个接口和AbstractList这个抽象类。
接口的好处在于
- 多继承
- 其实现子类可以有最大的灵活性
- 能够将API的描述信息与其实现彻底分离
类的好处在于:
- 提供通用的方法,避免重复代码
- 能够支持接口的演化
Eamonn McManus 写道:
Bloch在《Effective Java》中强烈建议在提供一个接口的同时,尽量提供一个实现了该接口的抽象基类。这话在Java集合框架的设计体现的淋漓尽至,他老兄就是 Collection框架的主设计师。这个设计的模式在许多场景中都是非常有用的,不过也不要把它当作金科玉律,一言而敝之:不要为模式而模式。
即使你使用了接口+基类的方式,也不能保证你API的演化,除非你只在基类中添加方法,而不在接口中添加方法,这种情况带来的坏处就是混乱,如 果一个类想调用这个新添加的方法,因为接口中没有添加这个方法,所以通过接口是无法调用的,那么只能将它强行转型,然后再调用,但有时候又很难确认你的强 行转型是正确,糟糕的ClassCastException又出现了。除非你能保证所有的子类都继承这个基类,不过这种情况和中彩票的机会相差不多吧。
现在来谈一下unchecked exceptions和C#的问题,许多人都觉得在Java中Checked exceptions并不是一个缺陷,或者说它不是一个严重的问题。但我不这样认为:象IOException这种异常,应该是Checked Exception,以便由编译器来提醒程序员时要正确处理资源问题,这是一件好事,但是在Java中,有大量不必要的异常成为Checked Exception,这些Checked Exception却给程序员带来了许多麻烦。
Robert Cooper 写道:
即使你使用了接口+基类的方式,也不能保证你API的演化。
我认为,如果可能的话,在为基类添加新方法的同时,也应该在接口中添加新的方法。我向来如此,也没有出现过什么问题。
很清楚的一点就是,如果这样做了,接口的定义改了,如果一个类是直接实现这个接口,就需要实现所有的方法。
Michael Feathers 写道:
在什么环境下我们要同时提供接口和抽象类呢。
接口+基类并不是可以用于所有的场景!
大部分情况下,我都会使用接口+基类这种方式,不过这种方式也会带有几个缺点。
如果你的API比较复杂,很难找到一个准确的入口点来使用你的API。比如说我需要一个IX,但是我要一步步的向下查找AbstractX,以及相关的实现,这种接口+基类的方式加深了继承的层次,增加了API的复杂度。
Java API设计指南(一)