Java编程细节之十个最佳实践

jopen 10年前

本文讲述的十个最佳实践,这十个最佳实践要比通常Josh Bloch Effective Java规范更加细致。Josh Bloch的清单很容易学习,考虑的多是日常的情形,而本文则包括了不常见的情形例如API或SPI设计,尽管不常见,他们却可能有着大的影响。

译注:Java SPI (Service Provider Interface)是针对厂商或者插件提供的接口,提供类似“Callback”的功能,实现对API的定制。关于SPI的详细信息可以参见java.util.ServiceLoader文档

我在开发和维护JOOQ的过程中遇见过这类事情,JOOQ是一个用Java模拟SQL的 internal DSL(Domain Specific Language). 作为一个internal DSL, JOOQ挑战了Java编译器和泛型的极限,它结合泛型,可变参数和重载的方式恐怕是Josh Bloch在“average API”中不做推荐的。

让我来告诉你这10个Java编程细节的最佳实践。

1、记住C++的析构函数

要记住C++的析构函数(C++ destructors)?不想这样做?那你得很幸运以至于从来不需要调试那些由于分配内存而没有在对象移除后释放内存而导致内存泄露的代码。感谢Sun/Oracle实现了垃圾回收机制。

不过尽管如此,析构函数有一个有趣的特征。通常按照分配的逆序来释放内存是有道理的。在Java中也记着这一点,在你操作类似于析构函数的语义时

  • JUnit 注释中使用@Before和@After时
  • 在分配和释放JDBC资源时
  • 在调用父类方法时

有多种实例。下面是一个具体的例子,它展示了如何实现事件监听器SPI:

@Override  public void beforeEvent(EventContext e) {    super.beforeEvent(e);    // Super code before my code  }    @Override  public void afterEvent(EventContext e) {    // Super code after my code    super.afterEvent(e);  }

另一个好的例子展示了为什么它对于臭名昭著的哲学家就餐问题十分重要。更多关于哲学家就餐问题请参看这篇好文:http://adit.io/posts/2013-05-11-The-Dining-Philosophers-Problem-With-Ron-Swanson.html

规则:在任何你要使用before/after, allocate/free, take/return这样的语义来实现逻辑时,想一想after/free/return操作是否应该以逆序的方式执行里面的内容。

2、不要信任你早期SPI演化的结论

给客户提供SPI是一个简单的方式让他们在库或者代码中注入自定义的行为。但是请注意,你的SPI演化结论可能会欺骗你,让你觉得你可能(不再)需要那个参数。是的,任何功能都不应过早的加入。但是一旦你发布了你的SPI并且你决定遵循语义的版本控制,你就会后悔自己在SPI中加了一个愚蠢的,只有一个参数的方法,你意识到在某些情况下你还需要另一个参数:

interface EventListener {    // Bad    void message(String message);  }

这里如果你还需要一个message ID和一个message source怎么办?API演化可以阻止你轻易的给上面这个类添加参数。诚然,在java8中,你可以添加一个defender方法,来’defend’你早期的坏的设计决定。

interface EventListener {    // Bad    default void message(String message) {      message(message, null, null);    }    // Better?    void message(      String message,      Integer id,      MessageSource source    );  }

请注意,不幸的是defender方法不能定义为final

不过这跟用一堆方法污染你的api相比,已经很不错了,在这里可以使用一个上下文对象或者参数对象

interface MessageContext {    String message();    Integer id();    MessageSource source();  }    interface EventListener {    // Awesome!    void message(MessageContext context);  }

相对于EventListener SPI而言,你可以更容易地扩展MessageContext API,很少有人会实现它(指EventListener)。

规则:无论何时当你要指定一个SPI,考虑使用上下文对象或者参数对象,而不要写有固定数量参数的方法。

备注:同时,通过特定的MessageRsult类来传递结果通常是一个好主意,这种类可以使用builder API来创建。这会给你的SPI增添更多的可扩展性。

3、避免返回匿名类,局部类或者内部类

Swing开发者大概有很多快捷键来为他们成百上千个匿名类创建代码。在很多情况下,创建这些代码并不难因为你可以将他们依附于接口,而不用自找麻烦来思考整个SPI子类的生命周期。

但是你不应该太频繁的使用匿名类,局部类或内部类,原因很简单,他们给每一个外部实例保留一个引用。他们无论走到哪里都会带着这个外部实例,例如如果你不注意,他们会带到局部类以外的范围。这可能会成为一个主要的内存泄露点,因为你的整个对象图谱将会突然以一种不被发觉的方式变得混乱起来。

规则:当你需要写匿名类,局部类,或者内部类时,看看能不能把他设为静态甚至是常规的顶层类。避免从方法中向外层返回匿名类,局部类和内部类。

备注:有很多明智的关于双花括号给简单对象实例化的实践:

new HashMap<String, String>() {{    put("1", "a");    put("2", "b");  }}

这里利用了JLS8.6中提到的Java实例初始化器。看起来不错(或许有点怪),但实际上是非常差的主意。不然怎么会有这个完全独立的HashMap实例,这个为外层实例保存一个引用的实例,只是碰巧也无所谓。而且,你还得创建额外的类给类的装载器来管理。

4、开始写SAMs吧(single abstract method)单个抽象方法

Java8已经在敲门了。无论你喜欢与否,Java8会带来lambdas表达式。但你的API使用者可能会喜欢他们,你最好是确认他们可以尽量多的使用这些。因此,如果你的API不接收简单的“标量”类型类如int, long, String, Date, 那么就让你的API尽量多地接收SAMs吧。

什么是SAM? SAM是Single Abstract Method[Type], 即单一抽象方法,也称作函数式接口(Functional Interface)。它即将使用@FunctionalInterface注解。这与规则2并无出入,因为EventListener实际上就是一个SAM. 最好的SAM应该只有一个参数,进而可以简化为一个lambda表达式,比如我们这样写:

listeners.add(c -> System.out.println(c.message()));

而不是这样写

listeners.add(new EventListener() {    @Override    public void message(MessageContext c) {      System.out.println(c.message()));    }  });

想象通过JOOX处理XML,这里会有一些SAMs特色:

$(document)    // Find elements with an ID    .find(c -> $(c).id() != null)    // Find their child elements    .children(c -> $(c).tag().equals("order"))    // Print all matches    .each(c -> System.out.println($(c)))

规则:好好对待你的用户,现在就开始写SAMs/Functional 接口吧。

备注:这里有一些关于Java8 Lambda和改善的Collection API的文章:

5、避免在API方法中返回null

我已经写过几次关于Java NULL的博客了。我也写过关于Java8中引入Optional的文章。这些有趣的话题都是从学术和实践两方面而来的。

虽然Nulls和NullPointerException在一段时期内还会是Java的一大头痛难题,但你仍可以设计一种API使得你的用户不会遇到任何问题。尽量试着避免在API方法中返回null。你的API用户应该无论合适都可以串联方法:

initialise(someArgument).calculate(data).dispatch();

在上面这段代码中,任何方法都不应该返回null。实际上,总的来讲,使用null这种语法应当是例外情况。在像JQuery(或者JOOX,Java中的一个端口),null是被完全避免的。因为你总是在操作可迭代的对象。无论你有匹配的内容与否,都与下一个方法的调用无关。

Null通常在延迟初始化时出现。很多情况下,延迟初始化是可以避免的,并且对性能的影响不大。事实上,延迟初始化只应该被很小心的使用,特别是在有大型数据结构参与的时候。

规则:尽可能地避免方法返回nulls。只在非初始化或者缺省的情况下使用null。

6、API方法永远不要返回null数组或null链表

其实在一些情形下方法返回null值是可以的,但是任何情况想都绝不要返回null数组或者null collection!我们来看一个可怕的java.io.File.list()方法。它返回:

字符串数组,这些字符串制定此抽象路径名表示的目录中的文件和目录。如果目录为空,则数组为空。如果这个抽象路径并不表示一个目录,或者发生I/O错误,则返回null。

因此,对待这个方法正确方式是:

File directory = // ...    if (directory.isDirectory()) {    String[] list = directory.list();      if (list != null) {      for (String file : list) {        // ...      }    }  }
第六行检查null值有必要吗?大多数I/O操作产生的是IOException,但是这个返回了null,Null不能保存任何错误信息来说明为什么会发生这个I/O错误。所以这里错在三方面:
  • Null无助于找到错误。
  • Null并不能区分是I/O错误还是文件的实例不能表示一个目录。
  • 在这里,任何人都记不住这个null。
在collection contexts(集合上下文)中,“absence”(缺席)的概念最好使用空的数组或集合来实现。除非是延迟初始化,否则“缺席”数组或集合几乎从不使用。

规则:数组或集合永远不应该是null。

7、避免使用状态(state),使用函数式编程(functional)

HTTP的优点就是它是非状态性的。所有有关的状态都被转化到每一个请求和回复当中。这对于REST的命名很重要:Representational State Transfer(表征状态转移)。这个在Java中的实现是一件非常棒的事。想一想规则2,在方法接收到有状态的参数对象。如果状态都在这些对象中传递,而不是总在外面操作,事情会变得简单很多。比如JDBC, 下面的例子是从一个存储的过程中取出游标。
CallableStatement s =    connection.prepareCall("{ ? = ... }");    // Verbose manipulation of statement state:  s.registerOutParameter(1, cursor);  s.setString(2, "abc");  s.execute();  ResultSet rs = s.getObject(1);    // Verbose manipulation of result set state:  rs.next();  rs.next();

这些东西让JDBC变成很棘手的API。每一个API都难以置信地具有状态性,难以操作。具体来说,主要是两个问题:

  • 在多线程环境下很难正确处理有状态的API。
  • 很难使得有状态的资源全局可用,因为状态没有被备份。
 Java编程细节之十个最佳实践

人们相信上面的使用满足公平使用原则。

规则:实现更多功能风格的东西。通过方法参数传递状态。少操作对象的状态。

8、equals()的捷径

这是一个简单易用的东西。在大型对象图表中,如果你所有的对象的equals()方法都先使用”dirt cheaply” 比较一下他们的身份类型,那么你可以获得很大的性能提升。

@Override  public boolean equals(Object other) {    if (this == other) return true;    // Rest of equality logic...  }

注意,其它捷径检查包括检查null值,他也应该在那里:

@Override  public boolean equals(Object other) {    if (this == other) return true;    if (other == null) return false;    // Rest of equality logic...  }

规则:为所有的equals()寻找捷径,从而提高性能。

9、默认把方法设为final

有些人并不同意这一点,因为默认把东西设为final跟java程序员们通常所作的格格不入。但是如果你对于源代码有完全的控制,默认把方法设为final绝对不会有任何错,因为:

  • 如果你需要重写一个方法(真的需要么?),你仍可以删掉final关键词。
  • 你永远不会意外地重写某个方法。
这个特别适用于静态方法,在那里重写没有任何意义。我最近在Apache Tika看过一个非常差的关于遮盖静态方法的例子, 考虑一下:

TikaInputStream 继承TaggedInputStream并且以一种不同的实现方法遮盖了他的静态get()方法。

与正常的方法不同,静态方法不能相互重写,因为调用方在编译时绑定了一个静态方法的调用。如果你不幸运,你可能会偶尔得到错误的方法调用。

规则:如果你对你的API有完全的控制,试着将尽可能多的方法默认设为final。

10、避免 method(T…)

偶尔使用”accept-all”可变参数方法并没什么错,这个方法的参数是Object:

void acceptAll(Object... all);

写这样的方法给Java生态系统带来了一丝JavaScript的感觉。当然,在实际应用中,你可能会想把实际的类型加以限制,比如String… 又因为你不像限制太多,你就会认为用泛型T代替Object是一个好主意:

void acceptAll(T... all);

但这并不是一个好主意。T可能总会被认为是一个Object。事实上,在上面的方法中你可以不用泛型。更重要的是,你认为你能重载上面的方法,但实际上你并不能:

void acceptAll(T... all);  void acceptAll(String message, T... all);

虽然这个看起来像是你也可以传递一个String给这个方法。但是如果执行这行会发生什么?

编译器会把<?extends Serializable & Comparable<?>>当做T,这样以来这个调用就有了歧义。

因此,无论何时你使用“accept-all”(即使它是泛型),你永远不能在保证类安全的前提下重载它。API用户可能会很幸运的让编译器恰恰偶然选择了那个正确的方法。但是他们也可能被骗而去使用”accept-all”方法,或者他们压根不能调用任何方法。

规则:如果可以,避免“accept-all”特性。如果不可以,永远不要重载这样的方法。

总结

Java是一个野兽,与其他花哨的语言不同,它是慢慢演化成今天的样子的。而那其实是一件好事,因为以Java开发的速度,有很多的警告,只有在多年的经验基础上才能被搞定。

原文链接: dzone 翻译: ImportNew.com - 汤米猫
译文链接: http://www.importnew.com/8815.html