4

前言

任何一个写JAVA代码的程序员都是一名API设计师!无论是否与他人分享代码,代码都将被自己或是他人使用。因此,所有的JAVA开发者都应该了解一个好的API设计的基本内容。

一个好的API设计需要严谨的思考和大量的经验。幸运的是,我们可以从其它聪明的人如Ference Mihaly那里学习到这些。Ference Mihaly的博客启发了我写出这篇JAVA8 API附录。当我们设计Speedment API时,非常依赖他的清单(我建议所有人都阅读以下他的指南)。

从一开始就步入正轨是很重要的,因为一旦API发布以后,就等于对使用它的客户发出坚定的承诺。就像Joshua Bloch所说的那样:“公开的API是永恒的,就像钻石一样。你只有一次机会使其成为正确的设计,所以尽己所能”。一个精心设计的API使坚定而准确的承诺以及实现的高度灵活性结合在一起,并最终惠及API的的设计者和使用者。

为什么要使用清单?设计正确的API(比如,定义一组JAVA类的可见部分)比编写API背后进行真实操作的实现类要困难的多。这是极少数人才能掌握的艺术。使用清单可以使开发人员避免最明显的错误,从而成为一个更好的开发人员,并节约了大量的时间。

强烈建议API设计师从使用者的视角来优化代码的简洁性,易用性和一致性 - 而不是去考虑API的具体实现。同时,他们应该尽可能隐藏实现的细节。

不要返回Null说明值的缺失

可以说,不一致的空处理(导致无处不在的NullPointerException)是JAVA历史上最大的错误来源。一些设计师认为引入null是计算机领域最大的错误。幸运的是,随着Optional类的出现,在JAVA8中引入了缓解空处理问题的第一步。确保一个可能返回空值的方法用返回Optional代替。

这明确的向API用户说明这个方法可能返回值,也可能不返回值。不要试图出于提高性能的原因使用null而不是optional。JAVA8的分析系统会优化大多数的Optional对象。避免在参数和变量中使用Optional。

正确的做法

public Optional<String> getComment() {
    return Optional.ofNullable(comment);
}

错误的做法

public String getComment() {
    return comment; // comment is nullable
}

不要使用数组获取或是发送数据

在JAVA5中引入枚举时,出现了一个重大的API问题。我们都知道枚举类有一个方法values()返回该枚举类所有值的数组。现在,因为JAVA框架必须确保客户代码不会修改枚举类的值(比如,直接写入数组),那么每个value()方法的调用都会产生内部数组的一个复制数组。

这样影响了性能,并且降低了客户代码的可用性。如果枚举类返回了一个不可修改的List,这个List可以在每一次调用中被复用,那么客户端就可以获得一个更好更可用的枚举值模型。在通常情况下,如果API要返回一组对象,可以考虑提供一个Stream。这可以说明该值只可读(而不是具有set()方法的List)。

它还允许客户代码轻松的收集另一个数据结构中的元素,并且进行及时的处理。不仅如此,API能够惰性初始化元素(比如,从文件,端口或是数据库中获取内容)。JAVA8的分析系统会确保在JAVA堆上尽可能少的创建对象。

同样,不要使用数组作为传入方法的参数,因为,除非防御性的复制了数组,否则另一个线程可能在方法执行期间修改了数组的内容。

正确的做法

public Stream<String> comments() {
    return Stream.of(comments);
}

错误的做法

public String[] comments() {
    return comments; // Exposes the backing array!
}

可以添加静态接口方法作为对象创建的单一入口

避免客户代码直接选择一个接口的实现类。允许客户代码直接创建实现类造成了API和客户端代码之间更直接的耦合。它还使API的涉及范围更广,因为我们现在需要维护所有的实现类,使它们和外部观察到的实现完全一致,而不是面向接口。

可以添加一个静态的接口方法,允许客户代码通过该方法创建实现该接口的实现。比如,如果我们有个Point接口,其中有两个方法int x()int y(),然后我们暴露一个静态方法Point.of(int x, int y)提供该接口的一个实现。

所以,如果x和y都是0,我们可以返回一个特殊的实现类PointOrigoImpl(该类不包含x或是y域),否则我们可以返回另一个类PointImpl,该类包含x和y域并且值被设置为传入值。确保实现类在另一个包中,并且不是API的一部分(比如将Point放入com.company. product.shape,将实现类放入com.company.product.internal.shape)。

正确的做法

Point point = Point.of(1,2);

错误的做法

Point point = new PointImpl(1,2);

使用Lambda表达式加上功能性接口的组合取代继承

出于某种原因,对于任何JAVA类,都只能有一个父类。不仅如此,在API中暴露抽象类或是基类供客户代码进行继承是一个很麻烦的API承诺。应当彻底避免API继承,并且替换为静态的接口,该静态接口可以接收一个或多个lambda参数,并且将这些lambda作用于默认的内部API实现类。

这样的话能够使关注点更好的分离。比如,不再从一个公开的API类AbstractReader继承并且重写抽象的方法abstract void handleError(IOException ioe),而是在Reader接口中暴露一个静态的方法或是构造器来接收Consumer<IOException>并且运用于内部的ReaderImpl

正确的做法

Reader reader = Reader.builder()
    .withErrorHandler(IOException::printStackTrace)
    .build();

错误的做法

Reader reader = new AbstractReader() {
    @Override
    public void handleError(IOException ioe) {
        ioe. printStackTrace();
    }
};

确保在功能接口上添加了@FunctionalInterface注解

在接口上添加@FunctionalInterface注解说明API用户可以使用lambda表达式实现该接口。它也确保了随着时间推移,该接口仍然可以用于lambda表达式中,防止抽象方法在以后被意外的添加到API中。

正确做法

@FunctionalInterface
public interface CircleSegmentConstructor {
    CircleSegment apply(Point cntr, Point p, double ang);
    // abstract methods cannot be added
}

错误做法

public interface CircleSegmentConstructor {
    CircleSegment apply(Point cntr, Point p, double ang);
    // abstract methods may be accidently added later
}

避免使用功能接口作为参数重载方法

如果有两个或多个相同名称的方法都把功能接口作为参数,这有可能对客户端造成lambda歧义。比如,如果有两个方法add(Function<Point, String> renderer)add(Predicate<Point> logCondition),然后我们在客户代码中试图调用point.add(p -> p + "lambda"),编译器将无法决定使用哪个方法并报错。因此我们应当根据特定的用途来对方法命名。

正确做法

public interface Point {
    addRenderer(Function<Point, String> renderer);
    addLogCondition(Predicate<Point> logCondition);
}

错误做法

public interface Point {
    add(Function<Point, String> renderer);
    add(Predicate<Point> logCondition);
}

避免在接口中过度使用default方法

可以很方便的在接口中添加default方法,在某些时候这样做是有意义。比如,一个方法应当对所有类都相同,并且有一个短小且基础的实现,那么它就可以作为接口中的一个默认方法。而且,当接口扩展的时候,有时候为了向后兼容性可以提供默认接口方法。

如我们所知,功能接口只包含一个抽象方法,所以当必须添加其它方法时,默认方法提供了一个解决方法。但是,应当避免API接口因为不必要的实现问题而演化为一个实现类。所以如果不知道是否要添加默认实现,可以考虑将方法逻辑移动到一个单独的工具类或是将其放在实现类中。

正确的做法

public interface Line {
    Point start();
    Point end();
    int length();
}

错误的做法

public interface Line {
    Point start();
    Point end();
    default int length() {
        int deltaX = start().x() - end().x();
        int deltaY = start().y() - end().y();
    return (int) Math.sqrt(
        deltaX * deltaX + deltaY * deltaY
        );
    }
}

确保API方法在使用参数前检查参数的合法性

从历史上看,人们在确保验证方法输入参数方面一直徘徊不前。所以,当之后出现了错误时,出错的真实原因变得模糊不清,隐藏在一层层的栈踪迹中。确保参数在实现类中被使用之前进行空检查,或是符合范围约束,或是任何前序条件。不要试图因为性能原因跳过参数检查。

JVM会优化并删除冗余的检查,生成高效的代码。使用Objects.requireNonNull()方法。参数检查也是一种遵循API规定的重要方法。如果API不应当接收空值但是却不知为何接收了,用户会感到困惑。

正确的做法

public void addToSegment(Segment segment, Point point) {
    Objects.requireNonNull(segment);
    Objects.requireNonNull(point);
    segment.add(point);
}

错误的做法

public void addToSegment(Segment segment, Point point) {
    segment.add(point);
}

不要直接使用Optional.get()

JAVA8的设计者在为Optional.get()方法命名时犯了个错误,它应当称作Optional.getOrThrow()或是类似的名字。调用get()方法却不用Optional.isPresent()方法检查值是否存在是一个很常见的错误,它严重违背了Optional类的初衷。可以使用Optional的其它方法比如map()flatMap()或是ifPresent()方法来确保在调用get()之前调用isPresent()方法。

正确做法


Optional<String> comment = // some Optional value 
String guiText = comment
  .map(c -> "Comment: " + c)
  .orElse("");

错误做法

Optional<String> comment = // some Optional value 
String guiText = "Comment: " + comment.get();

在API中将流分离到不同的行上

无论如何,所有的API都会有错。当从API用户那里获得栈跟踪时,将流方法分布在不同的行往往使问题追踪更加容易。而且会增加代码的可读性:

正确的做法

Stream.of("this", "is", "secret") 
  .map(toGreek()) 
  .map(encrypt()) 
  .collect(joining(" "));

错误的做法

Stream.of("this", "is", "secret").map(toGreek()).map(encrypt()).collect(joining(" "));

参考内容

使用 Optional 处理 null
Java8 如何正确使用 Optional

clipboard.png
想要了解更多开发技术,面试教程以及互联网公司内推,欢迎关注我的微信公众号!将会不定期的发放福利哦~


raledong
2.7k 声望2k 粉丝

心怀远方,负重前行