所有文章 > API设计 > 使用 Java 8 进行 API 设计
使用 Java 8 进行 API 设计

使用 Java 8 进行 API 设计

任何编写 Java 代码的人都是 API 设计人员!编码员是否与他人共享他们的代码并不重要,代码仍然被使用;要么是别人,要么是他们自己,要么两者都有。因此,对于所有 Java 开发人员来说,了解良好 API 设计的基础知识变得非常重要。

从一开始就把它做好是很重要的,因为一旦发布了 API,就向应该使用它的人做出了坚定的承诺。正如 Joshua Bloch 曾经说过的:“公共 API 就像钻石一样,是永恒的。你只有一次机会把它做好,所以要全力以赴。”一个设计良好的 API 结合了两个世界的优点,坚定而精确的承诺与高度的实现灵活性相结合,最终使 API 设计者和 API 用户都受益。

为什么要使用清单?获得正确的 API(即定义 Java 类集合的可见部分)可能比编写构成 API 背后实际工作的实现类要困难得多。这确实是一门很少有人精通的艺术。使用清单可以让读者避免最明显的错误,成为更好的程序员并节省大量时间。

我们强烈鼓励 API 设计人员将自己放在客户端代码的角度,并根据简单性、易用性和一致性对该视图进行优化,而不是考虑实际的 API 实现。同时,他们应该尽量隐藏尽可能多的实现细节。

不返回 Null 来表示没有值

可以说,不一致的空处理(导致无处不在的 NullPointerException)是历史上 Java 应用程序错误的最大单一来源。一些开发人员认为 null 概念的引入是计算机科学领域中最严重的错误之一。幸运的是,缓解 Java 空处理问题的第一步是在 Java 8 中引入的 Optional 类。确保一个可以返回无值的方法返回一个 Optional 而不是 null。

这清楚地告诉 API 用户,该方法可能返回值,也可能不返回值。不要因为性能原因而使用 null 而不是 Optional。Java 8 的转义分析将优化掉大多数可选对象。避免在参数和字段中使用 Optional。

这样做:

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

不要这样做:

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

请勿使用数组将值传入 API 和从 API 传递值

在 Java 5 中引入 Enum 概念时,确实存在一个显著的 API 设计缺陷。Enum 类中的 values() 方法返回一个包含所有枚举值的数组。为了确保客户端代码不能通过直接修改数组来更改枚举的值,Java 框架每次调用 values() 方法时都必须生成一个内部数组的副本。

这种设计的缺陷在于它不仅导致性能下降,还影响了客户端代码的可用性。如果 values() 方法返回的是一个不可修改的 List,则这个 List 可以在每次调用中重用,使得客户端代码能够访问更好、更有用的枚举值模型。

在设计 API 时,如果需要返回一个元素集合,建议使用 Stream 而不是数组。Stream 的使用能够明确表示结果是只读的(不同于具有 set() 方法的 List),并且它允许客户端代码更方便地将元素收集到其他数据结构中,或即时对这些元素进行操作。

此外,API 还可以在元素可用时延迟生成它们,例如从文件、套接字或数据库中逐步拉取数据。这样不仅能提高代码的灵活性,还能减少内存消耗,尤其是通过 Java 8 中改进的逃逸分析,确保在 Java 堆上实际创建的对象最少。

同样重要的是,避免在方法的输入参数中使用数组。除非对数组进行防御性复制,否则在方法执行期间,其他线程有可能修改数组的内容,这会带来潜在的线程安全问题。

通过采用不可修改的集合或 Stream 作为返回值和输入参数,API 设计能够提升性能、安全性和可用性,使得客户端代码更为简洁、可靠。

这样做:

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),用以生成接口的实现实例。

如果参数 xy 都为零,可以返回一个特殊的实现类 PointOrigoImpl(不包含 xy 字段)。如果 xy 不为零,则返回另一个类 PointImpl,该类保存给定的 xy 值。同时,确保实现类位于一个明显不属于API 一部分的包中,例如将 Point 接口放在 com.company.product 包中,而其实现类放在 com.company.product.internal.shape 包中。

这样做:

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

不要这样做:

Point point = new PointImpl(1, 2);

使用函数接口和lambda的组合

相较于继承,更推荐使用函数接口和 Lambda 表达式的组合。Java 语言规定,任何给定的类只能有一个超类。此外,在 API 中公开需要由客户端代码继承的抽象类或基类,这将是一个重大且具有潜在问题的 API 设计承诺。因此,应完全避免在 API 中使用继承,而是考虑提供静态接口方法,这些方法接受一个或多个 Lambda 参数,并将这些给定的 Lambda 应用于默认的内部 API 实现类。

这种做法也有助于实现更清晰的关注点分离。例如,与其从公共 API 类 AbstractReader 继承并重写抽象方法 void handleError(IOException ioe),不如在 Reader 接口中公开静态方法或构建器,该方法接受 Consumer 并将其应用于内部的泛型 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 表达式保持可用性。

这样做:

@FunctionalInterface
public interface CircleSegmentConstructor {
CircleSegment apply(Point cntr, Point p, double ang);
// 不能再添加抽象方法
}

不要这样做:

public interface CircleSegmentConstructor {
CircleSegment apply(Point cntr, Point p, double ang);
// 后续可能会意外添加抽象方法
}

避免重载函数接口作为参数的方法

当有两个或更多具有相同名称的函数且它们以函数接口作为参数时,这可能会导致客户端代码中的 Lambda 表达式产生歧义。例如,如果有两个 Point 方法 add(Function renderer)add(Predicate 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);
}

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

默认方法可以轻松添加到接口中,并且在某些情况下这样做是合理的。例如,当某些方法对于所有实现类都是相同的、功能简单且基础时,默认实现是一个可行的选择。此外,在扩展 API 时,为了向后兼容,提供默认接口方法有时也是必要的。

众所周知,函数式接口只包含一个抽象方法,因此在需要添加其他方法时,默认方法提供了一个解决方案。然而,应避免让 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()

Java 8 的 API 设计者在命名 Optional.get() 方法时犯了一个错误,它实际上应该被命名为 Optional.getOrThrow() 或类似的名称。因为调用 get() 而不先检查值是否存在于 Optional.isPresent() 方法中,是一个非常常见的错误,这种错误完全否定了 Optional 最初承诺的消除 null 的功能。

在 API 的实现类中,应尽量使用 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(" "));

原文链接:API Design With Java 8

#你可能也喜欢这些API文章!