Java 8 API 设计经验浅析

本文由码农网 –
小峰原创翻译,转载请看清文末的转载要求,欢迎参与我们的付费投稿计划!

前言

10个微妙的Java编码最佳实践,java编码最佳实践

这是一个比Josh Bloch的Effective
Java规则更精妙的10条Java编码实践的列表。和Josh
Bloch的列表容易学习并且关注日常情况相比,这个列表将包含涉及API/SPI设计中不常见的情况,可能有很大影响。

我在编写和维护jOOQ(Java中内部DSL建模的SQL)时遇到过这些。作为一个内部DSL,jOOQ最大限度的挑战了Java的编译器和泛型,把泛型,可变参数和重载结合在一起,Josh
Bloch可能不会推荐的这种太宽泛的API。

让我与你分享10个微妙的Java编码最佳实践:

1. 牢记C++的析构函数

记得C++的析构函数?不记得了?那么你真的很幸运,因为你不必去调试那些由于对象删除后分配的内存没有被释放而导致内存泄露的代码。感谢Sun/Oracle实现的垃圾回收机制吧!

尽管如此,析构函数仍提供了一个有趣的特征。它理解逆分配顺序释放内存。记住在Java中也是这样的,当你操作类析构函数语法:

  • 使用JUnit的@Before和@After注释
  • 分配,释放JDBC资源
  • 调用super方法

还有其他各种用例。这里有一个具体的例子,说明如何实现一些事件侦听器的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);
}

臭名昭著的哲学家就餐问题是另一个说明它为什么重要的好例子。
关于哲学家用餐的问题,请查看链接:

*规则:无论何时使用before/after, allocate/free,
take/return语义实现逻辑时,考虑是否逆序执行after/free/return操作。*

任何写Java代码的人都是API设计师!无论编码者是否与他人共享代码,代码仍然被使用:要么其他人或他们自己使用,要么两者皆有。因此,对于所有的Java开发人员来说,了解良好API设计的基础很重要。

我们之前已经介绍了关于java8中lambda和函数式编程的相关内容,虽然我们开始了Java8的旅程,但是很多人直接从java6上手了java8,
也许有一些JDK7的特性你还不知道,在本章节中带你回顾一下我们忘记了的那些特性。
尽管我们不能讲所有特性都讲一遍,挑出常用的核心特性拎出来一起学习。

2. 不要相信你早期的SPI演进判断

向客户提供SPI可以使他们轻松的向你的库/代码中注入自定义行为的方法。当心你的SPI演进判断可能会迷惑你,使你认为你
(不)打算需要附加参数。
当然,不应当过早增加功能。但一旦你发布了你的SPI,一旦你决定遵循语义版本控制,当你意识到在某种情况下你可能需要另外一个参数时,你会真的后悔在SPI中增加一个愚蠢的单参数的方法:

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

如果你也需要消息ID和消息源,怎么办?API演进将会阻止你向上面的类型添加参数。当然,有了Java8,你可以添加一个defender方法,“防御”你早期糟糕的设计决策:

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

注意,不幸的是,defender方法不能使用final修饰符。
但是比起使用许多方法污染你的SPI,使用上下文对象(或者参数对象)会好很多。

interface MessageContext {
 String message();
 Integer id();
 MessageSource source();
}

interface EventListener {
 // Awesome!
 void message(MessageContext context);
}

比起EventListner SPI你可以更容易演进MessageContext
API,因为很少用户会实现它。
规则:
无论何时指定SPI时,考虑使用上下文/参数对象,而不是写带有固定参数的方法。
备注:
通过专用的MessageResult类型交换结果也是一个好主意,该类型可以使用建设者API构造它。这样将大大增加SPI进化的灵活性。

一个好的API设计需要仔细思考和大量的经验。幸运的是,我们可以从其他更聪明的人,如Ference
Mihaly——正是他的博客启发我写了这篇Java
8 API附录——那里得到学习。在设计Speedment
API时,我们非常依赖于他列出的接口清单。(我建议大家不妨读一读他的指南。)

图片 1

3. 避免返回匿名,本地或者内部类

Swing程序员通常只要按几下快捷键即可生成成百上千的匿名类。在多数情况下,只要遵循接口、不违反SPI子类型的生命周期(SPI
subtype lifecycle),这样做也无妨。
但是不要因为一个简单的原因——它们会保存对外部类的引用,就频繁的使用匿名、局部或者内部类。因为无论它们走到哪,外部类就得跟到哪。例如,在局部类的域外操作不当的话,那么整个对象图就会发生微妙的变化从而可能引起内存泄露。

规则:在编写匿名、局部或内部类前请三思能否将它转化为静态的或普通的顶级类,从而避免方法将它们的对象返回到更外层的域中。

注意:使用双层花括号来初始化简单对象:

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

这个方法利用了 JLS
§8.6规范里描述的实例初始化方法(initializer)。表面上看起来不错,但实际上不提倡这种做法。因为要是使用完全独立的HashMap对象,那么实例就不会一直保存着外部对象的引用。此外,这也会让类加载器管理更多的类。

从一开始就做到这一点很重要,因为一旦API发布,就会成为使用API的人坚实的基石。正如Joshua
Bloch曾经说过的:“公共API,就像钻石一样永恒久远。你有机会把它做正确的话,就应该竭尽全力去做。”

异常改进

4. 现在就开始编写SAM!

Java8的脚步近了。伴随着Java8带来了lambda表达式,无论你是否喜欢。尽管你的API用户可能会喜欢,但是你最好确保他们可以尽可能经常的使用。因此除非你的API接收简单的“标量”类型,比如int、long、String
、Date,否则让你的API尽可能经常的接收SAM。

什么是SAM?SAM是单一抽象方法[类型]。也称为函数接口,不久会被注释为@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。JOOX就包含很多的SAM:

$(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)))

规则:对你的API用户好一点儿,从现在开始编写SAM/函数接口。

备注:有许多关于Java8 lambda表达式和改善的Collections
API的有趣的博客:

一个精心设计的API结合了两个世界的精华,既是坚实而精确的基石,又具有高度的实施灵活性,最终让API设计师和API使用者受益。

try-with-resources

5.避免让方法返回null

我曾写过1、2篇关于java
NULLs的文章,也讲解过Java8中引入新的Optional类。从学术或实用的角度来看,这些话题还是比较有趣的。

尽管现阶段Null和NullPointerException依然是Java的硬伤,但是你仍可以设计出不会出现任何问题的API。在设计API时,应当尽可能的避免让方法返回null,因为你的用户可能会链式调用方法:

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

从上面代码中可看出,任何一个方法都不应返回null。实际上,在通常情况下使用null会被认为相当的异类。像
jQuery或 jOOX这样的库在可迭代的对象上已完全的摒弃了null。

Null通常用在延迟初始化中。在许多情况下,在不严重影响性能的条件下,延迟初始化也应该被避免。实际上,如果涉及的数据结构过于庞大,那么就要慎用延迟初始化。

规则:无论何时方法都应避免返回null。null仅用来表示“未初始化”或“不存在”的语义。

至于为什么要使用接口清单?正确地获取API(即定义Java类集合的可见部分)比编写构成API背后实际工作的实现类要困难得多。它是一个真的很少有人掌握的艺术。使用接口清单允许读者避免最明显的错误,成为更好的程序员和节省大量的时间。

这个特性是在JDK7种出现的,我们在之前操作一个流对象的时候大概是这样的:

6.设计API时永远不要返回空(null)数组或List

尽管在一些情况下方法返回值为null是可以的,但是绝不要返回空数组或空集合!请看
java.io.File.list()方法,它是这样设计的:

此方法会返回一个指定目录下所有文件或目录的字符串数组。如果目录为空(empty)那么返回的数组也为空(empty)。如果指定的路径不存在或发生I/O错误,则返回null。

因此,这个方法通常要这样使用:

File directory = // ...

if (directory.isDirectory()) {
 String[] list = directory.list();

 if (list != null) {
 for (String file : list) {
  // ...
 }
 }
}

大家觉得null检查有必要吗?大多数I/O操作会产生IOExceptions,但这个方法却只返回了null。Null是无法存放I/O错误信息的。因此这样的设计,有以下3方面的不足:
  • Null无助于发现错误
  • Null无法表明I/O错误是由File实例所对应的路径不正确引起的
  • 每个人都可能会忘记判断null情况

以集合的思维来看待问题的话,那么空的(empty)的数组或集合就是对“不存在”的最佳实现。返回空(null)数组或集合几乎是无任何实际意义的,除非用于延迟初始化。

规则:返回的数组或集合不应为null。

强烈建议API设计者将自己置于客户端代码的角度,并从简单性,易用性和一致性方面优化这个视图——而不是考虑实际的API实现。同时,他们应该尽量隐藏尽可能多的实现细节。

try {
 // 使用流对象
 stream.read();
 stream.write();
} catch(Exception e){
 // 处理异常
} finally {
 // 关闭流资源
 if(stream != null){
 stream.close();
 }
}

7. 避免状态,使用函数

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
  • 很难让有状态的资源全局可用,因为状态没有被描述

规则:更多的以函数风格实现。通过方法参数转移状态。极少操作对象状态。

不要用返回Null来表示一个空值

可以证明,不一致的null处理(导致无处不在的NullPointerException)是历史上Java应用程序错误最大的唯一来源。一些开发人员将引入null概念当作是计算机科学领域犯的最糟糕的错误之一。幸运的是,减轻Java
null处理问题的第一步是在Java
8中引入了Optional类。确保将返回值为空的方法返回Optional,而不是null。

这向API使用者清楚地表明了该方法可能返回值,也可能不返回值。不要因为性能原因的诱惑使用null而不使用Optional。反正Java
8的转义分析将优化掉大多数Optional对象。避免在参数和字段中使用Optional。

你可以这样写

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

而不要这样写

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

这样无疑有些繁琐,而且finally块还有可能抛出异常。在JDK7种提出了try-with-resources机制,
它规定你操作的类只要是实现了AutoCloseable接口就可以在try语句块退出的时候自动调用close
方法关闭流资源。

8. 短路式 equals()

这是一个比较容易操作的方法。在比较复杂的对象系统中,你可以获得显著的性能提升,只要你在所有对象的equals()方法中首先进行相等判断:

@Override
public boolean equals(Object other) {
 if (this == other) return true;
 // 其它相等判断逻辑...
}

注意,其它短路式检查可能涉及到null值检查,所以也应当加进去:

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

规则: 在你所有的equals()方法中使用短路来提升性能。

不要将数组作为API的传入参数和返回值

当Java
5中引入Enum概念时,出现了一个重大的API错误。我们都知道Enum类有一个名为values()的方法,用来返回所有Enum不同值的数组。现在,因为Java框架必须确保客户端代码不能更改Enum的值(例如,通过直接写入数组),因此必须得为每次调用value()方法生成内部数组的副本。

这导致了较差的性能和较差的客户端代码可用性。如果Enum返回一个不可修改的List,该List可以重用于每个调用,那么客户端代码可以访问更好和更有用的Enum值的模型。在一般情况下,如果API要返回一组元素,考虑公开Stream。这清楚地说明了结果是只读的(与具有set()方法的List相反)。

它还允许客户端代码容易地收集另一个数据结构中的元素或在运行中对它们进行操作。此外,API可以在元素变得可用时(例如,从文件,套接字或从数据库中拉入),延迟生成元素。同样,Java
8改进的转义分析将确保在Java堆上创建实际最少的对象。

也不要使用数组作为方法的输入参数,因为——除非创建数组的保护性副本——使得有可能另一个线程在方法执行期间修改数组的内容。

你可以这样写

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

而不要这样写

public String[] comments() {
    return comments; // Exposes the backing array!
}
public static void tryWithResources() throws IOException {
 try( InputStream ins = new FileInputStream("/home/biezhi/a.txt") ){
 char charStr = (char) ins.read();
 System.out.print(charStr);
 }
}

9. 尽量使方法默认为final

有些人可能不同意这一条,因为使方法默认为final与Java开发者的习惯相违背。但是如果你对代码有完全的掌控,那么使方法默认为final是肯定没错的:

  • 如果你确实需要覆盖(override)一个方法(你真的需要?),你仍然可以移除final关键字
  • 你将永远不会意外地覆盖(override)任何方法

这特别适用于静态方法,在这种情况下“覆盖”(实际上是遮蔽)几乎不起作用。我最近在Apache
Tika中遇到了一个很糟糕的遮蔽静态方法的例子。看一下:

  • TaggedInputStream.get(InputStream)
  • TikaInputStream.get(InputStream)

TikaInputStream扩展了TaggedInputStream,以一种相对不同的实现遮蔽了它的静态get()方法。

与常规方法不同,静态方法不能互相覆盖,因为调用的地方在编译时就绑定了静态方法调用。如果你不走运,你可能会意外获得错误的方法。

规则:如果你完全掌控你的API,那么使尽可能多的方法默认为final。

考虑添加静态接口方法以提供用于对象创建的单个入口点

避免允许客户端代码直接选择接口的实现类。允许客户端代码创建实现类直接创建了一个更直接的API和客户端代码的耦合。它还使得API的基本功能更强,因为现在我们必须保持所有的实现类,就像它们可以从外部观察到,而不仅仅只是提交到接口。

考虑添加静态接口方法,以允许客户端代码来创建(可能为专用的)实现接口的对象。例如,如果我们有一个接口Point有两个方法int
x() 和int y() ,那么我们可以显示一个静态方法Point.of( int x,int y)
,产出接口的(隐藏)实现。

所以,如果x和y都为零,那么我们可以返回一个特殊的实现类PointOrigoImpl(没有x或y字段),否则我们返回另一个保存给定x和y值的类PointImpl。确保实现类位于另一个明显不是API一部分的另一个包中(例如,将Point接口放在com.company。product.shape中,将实现放在com.company.product.internal.shape中)。

你可以这样写

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

而不要这样写

Point point = new PointImpl(1,2);

使用多个资源

10. 避免方法(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消息到方法。但是这个调用会发生什么呢?

acceptAll("Message", 123, "abc");

编译器将T推断为<? extends Serializable &
Comparable<?>>,这将会使调用不明确!

所以无论何时你有一个“accept-all”签名(即使是泛型),你将永远不能类型安全地重载它。API使用者可能仅仅在走运的时候才会让编译器“偶然地”选择“正确的”方法。但是也可能使用accept-all方法或者无法调用任何方法。

规则: 如果可能,避免“accept-all”签名。如果不能,不要重载这样的方法。

青睐功能性接口和Lambdas的组合优于继承

出于好的原因,对于任何给定的Java类,只能有一个超类。此外,在API中展示抽象或基类应该由客户端代码继承,这是一个非常大和有问题的API
功能。避免API继承,而考虑提供静态接口方法,采用一个或多个lambda参数,并将那些给定的lambdas应用到默认的内部API实现类。

这也创造了一个更清晰的关注点分离。例如,并非继承公共API类AbstractReader和覆盖抽象的空的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();
    }
};
try ( InputStream is = new FileInputStream("/home/biezhi/a.txt");
 OutputStream os = new FileOutputStream("/home/biezhi/b.txt")
) {
 char charStr = (char) is.read();
 os.write(charStr);
}

结论

Java是一个野兽。不像其它更理想主义的语言,它慢慢地演进为今天的样子。这可能是一件好事,因为以Java的开发速度就已经有成百上千个警告,而且这些警告只能通过多年的经验去把握。

敬请期待更多关于这个主题的前十名列表!

这是一个比Josh Bloch的Effective
Java规则更精妙的10条Java编码实践的列表。和Josh Bloch的列表容易…

确保你将@FunctionalInterface注解添加到功能性接口

使用@FunctionalInterface注解标记的接口,表示API用户可以使用lambda实现接口,并且还可以通过防止抽象方法随后被意外添加到API中来确保接口对于lambdas保持长期使用。

你可以这样写

@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
}

当然如果你使用的是非标准库的类也可以自定义AutoCloseable,只要实现其close方法即可。

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

如果有两个或更多的具有相同名称的函数将功能性接口作为参数,那么这可能会在客户端侧导致lambda模糊。例如,如果有两个Point方法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);
}

捕获多个Exception

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

默认方法可以很容易地添加到接口,有时这是有意义的。例如,想要一个对于任何实现类都期望是相同的并且在功能上要又短又“基本”的方法,那么一个可行的候选项就是默认实现。此外,当扩展API时,出于向后兼容性的原因,提供默认接口方法有时是有意义的。

众所周知,功能性接口只包含一个抽象方法,因此当必须添加其他方法时,默认方法提供了一个安全舱口。然而,通过用不必要的实现问题来污染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接受null但是却做了,用户会感到困惑。

你可以这样写

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);
}
try {
 Thread.sleep(20000);
 FileInputStream fis = new FileInputStream("/a/b.txt");
} catch (InterruptedException e) {
 e.printStackTrace();
} catch (IOException e) {
 e.printStackTrace();
}

不要简单地调用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();

这样代码写起来要捕获很多异常,不是很优雅,JDK7种允许你捕获多个异常:

考虑在不同的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(" "));
try {
 Thread.sleep(20000);
 FileInputStream fis = new FileInputStream("/a/b.txt");
} catch (InterruptedException | IOException e) {
 e.printStackTrace();
}

并且catch语句后面的异常参数是final的,不可以再修改/复制。

处理反射异常

使用过反射的同学可能知道我们有时候操作反射方法的时候会抛出很多不相关的检查异常,例如:

try {
 Class<?> clazz = Class.forName("com.biezhi.apple.User");
 clazz.getMethods()[0].invoke(object);
} catch (IllegalAccessException e) {
 e.printStackTrace();
} catch (InvocationTargetException e) {
 e.printStackTrace();
} catch (ClassNotFoundException e) {
 e.printStackTrace();
}

尽管你可以使用catch多个异常的方法将上述异常都捕获,但这也让人感到痛苦。
JDK7修复了这个缺陷,引入了一个新类ReflectiveOperationException可以帮你捕获这些反射异常:

try {
 Class<?> clazz = Class.forName("com.biezhi.apple.User");
 clazz.getMethods()[0].invoke(object);
} catch (ReflectiveOperationException e){
 e.printStackTrace();
}

文件操作

我们知道在JDK6甚至之前的时候,我们想要读取一个文本文件也是非常麻烦的一件事,而现在他们都变得简单了,
这要归功于NIO2,我们先看看之前的做法:

读取一个文本文件

BufferedReader br = null;
try {
 new BufferedReader(new FileReader("file.txt"));
 StringBuilder sb = new StringBuilder();
 String line = br.readLine();
 while (line != null) {
 sb.append(line);
 sb.append(System.lineSeparator());
 line = br.readLine();
 }
 String everything = sb.toString();
} catch (Exception e){
 e.printStackTrace();
} finally {
 try {
 br.close();
 } catch (IOException e) {
 e.printStackTrace();
 }
}

大家对这样的一段代码一定不陌生,但这样太繁琐了,我只想读取一个文本文件,要写这么多代码还要
处理让人头大的一堆异常,怪不得别人吐槽Java臃肿,是在下输了。。。

下面我要介绍在JDK7中是如何改善这些问题的。

Path

Path用于来表示文件路径和文件,和File对象类似,Path对象并不一定要对应一个实际存在的文件,
它只是一个路径的抽象序列。

要创建一个Path对象有多种方法,首先是final类Paths的两个static方法,如何从一个路径字符串来构造Path对象:

Path path1 = Paths.get("/home/biezhi", "a.txt");
Path path2 = Paths.get("/home/biezhi/a.txt");
URI u = URI.create("file:////home/biezhi/a.txt");
Path pathURI = Paths.get(u);

通过FileSystems构造

Path filePath = FileSystems.getDefault().getPath("/home/biezhi", "a.txt");

Path、URI、File之间的转换

File file = new File("/home/biezhi/a.txt");
Path p1 = file.toPath();
p1.toFile();
file.toURI();

读写文件

你可以使用Files类快速实现文件操作,例如读取文件内容:

byte[] data = Files.readAllBytes(Paths.get("/home/biezhi/a.txt"));
String content = new String(data, StandardCharsets.UTF_8);

如果希望按照行读取文件,可以调用

List<String> lines = Files.readAllLines(Paths.get("/home/biezhi/a.txt"));

反之你想将字符串写入到文件可以调用

Files.write(Paths.get("/home/biezhi/b.txt"), "Hello JDK7!".getBytes());

你也可以按照行写入文件,Files.write方法的参数中支持传递一个实现Iterable接口的类实例。
将内容追加到指定文件可以使用write方法的第三个参数OpenOption:

Files.write(Paths.get("/home/biezhi/b.txt"), "Hello JDK7!".getBytes(),
 StandardOpenOption.APPEND);

默认情况Files类中的所有方法都会使用UTF-8编码进行操作,当你不愿意这么干的时候可以传递Charset参数进去变更。

当然Files还有一些其他的常用方法:

InputStream ins = Files.newInputStream(path);
OutputStream ops = Files.newOutputStream(path);
Reader reader = Files.newBufferedReader(path);
Writer writer = Files.newBufferedWriter(path);

创建、移动、删除

创建文件、目录

if (!Files.exists(path)) {
 Files.createFile(path);
 Files.createDirectory(path);
}

Files还提供了一些方法让我们创建临时文件/临时目录:

Files.createTempFile(dir, prefix, suffix);
Files.createTempFile(prefix, suffix);
Files.createTempDirectory(dir, prefix);
Files.createTempDirectory(prefix);

这里的dir是一个Path对象,并且字符串prefix和suffix都可能为null。
例如调用Files.createTempFile(null, ".txt")会返回一个类似/tmp/21238719283331124678.txt

读取一个目录下的文件请使用Files.listFiles.walk方法

复制、移动一个文件内容到某个路径

Files.copy(in, path);
Files.move(path, path);

删除一个文件

Files.delete(path);

小的改进

Java8是一个较大改变的版本,包含了API和库方面的修正,它还对我们常用的API进行很多微小的调整,
下面我会带你了解字符串、集合、注解等新方法。

字符串

使用过JavaScript语言的人可能会知道当我们将一个数组中的元素组合起来变成字符串有一个方法join,
例如我们经常用到将数组中的字符串拼接成用逗号分隔的一长串,这在Java中是要写for循环来完成的。

Java8种添加了join方法帮你搞定这一切:

String str = String.join(",", "a", "b", "c");

第一个参数是分隔符,后面接收一个CharSequence类型的可变参数数组或一个Iterable。

集合

集合改变中最大的当属前面章节中提到的Stream
API,除此之外还有一些小的改动。

类/接口 新方法
Iterable foreach
Collection removeIf
List replaceAll, sort
Map forEach, replace, replaceAll, remove(key, value),
putIfAbsent, compute, computeIf, merge
Iterator forEachRemaining
BitSet stream
  • Map中的很多方法对并发访问十分重要,我们将在后面的章节中介绍
  • Iterator提供forEachRemaining将剩余的元素传递给一个函数
  • BitSet可以产生一个Stream对象

通用目标类型判断

Java8对泛型参数的推断进行了增强。相信你对Java8之前版本中的类型推断已经比较熟悉了。
比如,Collections中的方法emptyList方法定义如下:

static <T> List<T> emptyList();

emptyList方法使用了类型参数T进行参数化。
你可以像下面这样为该类型参数提供一个显式的类型进行函数调用:

List<Person> persons = Collections.<Person>emptyList();

不过编译器也可以推断泛型参数的类型,上面的代码和下面这段代码是等价的:

List<Person> persons = Collections.emptyList();

我还是习惯于这样书写。

注解

Java 8在两个方面对注解机制进行了改进,分别为:

  • 可以定义重复注解
  • 可以为任何类型添加注解

重复注解

之前版本的Java禁止对同样的注解类型声明多次。由于这个原因,下面的第二句代码是无效的:

@interface Basic {
 String name();
}
@Basic(name="fix")
@Basic(name="todo")
class Person{ }

我们之前可能会通过数组的做法绕过这一限制:

@interface Basic {
 String name();
}
@interface Basics {
 Basic[] value();
}
@Basics( { @Basic(name="fix") , @Basic(name="todo") } )
class Person{ }

Book类的嵌套注解相当难看。这就是Java8想要从根本上移除这一限制的原因,去掉这一限制后,
代码的可读性会好很多。现在,如果你的配置允许重复注解,你可以毫无顾虑地一次声明多个同一种类型的注解。
它目前还不是默认行为,你需要显式地要求进行重复注解。

创建一个重复注解

如果一个注解在设计之初就是可重复的,你可以直接使用它。但是,如果你提供的注解是为用户提供的,
那么就需要做一些工作,说明该注解可以重复。下面是你需要执行的两个步骤:

  • 将注解标记为@Repeatable
  • 提供一个注解的容器下面的例子展示了如何将@Basic注解修改为可重复注解
@Repeatable(Basics.class)
@interface Basic {
 String name();
}
@Retention(RetentionPolicy.RUNTIME)
@interface Basics {
 Basic[] value();
}

完成了这样的定义之后,Person类可以通过多个@Basic注解进行注释,如下所示:

@Basic(name="fix")
@Basic(name="todo")
class Person{ }

编译时, Person 会被认为使用了
@Basics( { @Basic(name="fix") , @Basic(name="todo")} )
这样的形式进行了注解,所以,你可以把这种新的机制看成是一种语法糖,
它提供了程序员之前利用的惯用法类似的功能。为了确保与反射方法在行为上的一致性,
注解会被封装到一个容器中。 Java
API中的getAnnotation(Class<T> annotationClass)方法会为注解元素返回类型为T的注解。
如果实际情况有多个类型为T的注解,该方法的返回到底是哪一个呢?

我们不希望一下子就陷入细节的魔咒,类Class提供了一个新的getAnnotationsByType方法,
它可以帮助我们更好地使用重复注解。比如,你可以像下面这样打印输出Person类的所有Basic注解:

返回一个由重复注解Basic组成的数组

public static void main(String[] args) {
 Basic[] basics = Person.class.getAnnotationsByType(Basic.class);
 Arrays.asList(basics).forEach(a -> {
 System.out.println(a.name());
 });
}

Null检查

Objects类添加了两个静态方法isNull和nonNull,在使用流的时候非常有用。

例如获取一个流的所有不为null的对象:

Stream.of("a", "c", null, "d")
 .filter(Objects::nonNull)
 .forEach(System.out::println);

Optional

空指针异常一直是困扰Java程序员的问题,也是我们必须要考虑的。当业务代码中充满了if
else判断null
的时候程序变得不再优雅,在Java8中提供了Optional类为我们解决NullPointerException。

我们先来看看这段代码有什么问题?

class User {
 String name;
 public String getName() {
 return name;
 }
}
public static String getUserName(User user){
 return user.getName();
}

这段代码看起来很正常,每个User都会有一个名字。所以调用getUserName方法会发生什么呢?
实际这是不健壮的程序代码,当User对象为null的时候会抛出一个空指针异常。

我们普遍的做法是通过判断user != null然后获取名称

public static String getUserName(User user){
 if(user != null){
 return user.getName();
 }
 return null;
}

但是如果对象嵌套的层次比较深的时候这样的判断我们需要编写多少次呢?难以想象

处理空指针

使用Optional优化代码

public static String getUserNameByOptional(User user) {
 Optional<String> userName = Optional.ofNullable(user).map(User::getName);
 return userName.orElse(null);
}

当user为null的时候我们设置UserName的值为null,否则返回getName的返回值,但此时不会抛出空指针。

在之前的代码片段中是我们最熟悉的命令式编程思维,写下的代码可以描述程序的执行逻辑,得到什么样的结果。
后面的这种方式是函数式思维方式,在函数式的思维方式里,结果比过程更重要,不需要关注执行的细节。程序的具体执行由编译器来决定。
这种情况下提高程序的性能是一个不容易的事情。

我们再次了解下Optional中的一些使用方法

Optional方法

创建 Optional 对象

你可以通过静态工厂方法Optional.empty,创建一个空的Optional对象:

Optional<User> emptyUser = Optional.empty();

创建一个非空值的Optional

Optional<User> userOptional = Optional.of(user);

如果user是一个null,这段代码会立即抛出一个NullPointerException,而不是等到你试图访问user的属性值时才返回一个错误。

可接受null的Optional

Optional<User> ofNullOptional = Optional.ofNullable(user);

使用静态工厂方法Optional.ofNullable,你可以创建一个允许null值的Optional对象。

如果user是null,那么得到的Optional对象就是个空对象,但不会让你导致空指针。

使用map从Optional对象中提取和转换值

Optional<User> ofNullOptional = Optional.ofNullable(user);
Optional<String> userName = ofNullOptional.map(User::getName);

这种操作就像我们之前在操作Stream是一样的,获取的只是User中的一个属性。

默认行为及解引用Optional对象

我们决定采用orElse方法读取这个变量的值,使用这种方式你还可以定义一个默认值,
遭遇空的Optional变量时,默认值会作为该方法的调用返回值。
Optional类提供了多种方法读取 Optional实例中的变量值。

  • get()是这些方法中最简单但又最不安全的方法。如果变量存在,它直接返回封装的变量
    值,否则就抛出一个NoSuchElementException异常。所以,除非你非常确定Optional
    变量一定包含值,否则使用这个方法是个相当糟糕的主意。此外,这种方式即便相对于
    嵌套式的null检查,也并未体现出多大的改进。
  • orElse(T other)是我们在代码清单10-5中使用的方法,正如之前提到的,它允许你在
    Optional对象不包含值时提供一个默认值。
  • orElseGet(Supplier<? extends T> other)是orElse方法的延迟调用版,Supplier
    方法只有在Optional对象不含值时才执行调用。如果创建默认值是件耗时费力的工作,
    你应该考虑采用这种方式(借此提升程序的性能),或者你需要非常确定某个方法仅在
    Optional为空时才进行调用,也可以考虑该方式(这种情况有严格的限制条件)。
  • orElseThrow(Supplier<? extends X> exceptionSupplier)和get方法非常类似,
    它们遭遇Optional对象为空时都会抛出一个异常,但是使用orElseThrow你可以定制希
    望抛出的异常类型。
  • ifPresent(Consumer<? super T>)让你能在变量值存在时执行一个作为参数传入的
    方法,否则就不进行任何操作。

当前除了这些Optional类也具备一些和Stream类似的API,我们先看看Optional类方法:

方法 描述
empty 返回一个空的 Optional 实例
get 如果该值存在,将该值用Optional包装返回,否则抛出一个NoSuchElementException异常
ifPresent 如果值存在,就执行使用该值的方法调用,否则什么也不做
isPresent 如果值存在就返回true,否则返回false
filter 如果值存在并且满足提供的谓词,就返回包含该值的 Optional 对象;
否则返回一个空的Optional对象
map 如果值存在,就对该值执行提供的 mapping 函数调用
flatMap 如果值存在,就对该值执行提供的 mapping 函数调用,
返回一个 Optional 类型的值,否则就返 回一个空的Optional对象
of 将指定值用 Optional 封装之后返回,如果该值为null,则抛出一个NullPointerException异常
ofNullable 将指定值用 Optional 封装之后返回,如果该值为 null,则返回一个空的Optional对象
orElse 如果有值则将其返回,否则返回一个默认值
orElseGet 如果有值则将其返回,否则返回一个由指定的 Supplier 接口生成的值
orElseThrow 如果有值则将其返回,否则抛出一个由指定的 Supplier 接口生成的异常

用Optional封装可能为null的值

目前我们写的大部分Java代码都会使用返回NULL的方式来表示不存在值,比如Map中通过Key获取值,
当不存在该值会返回一个null。
但是,正如我们之前介绍的,大多数情况下,你可能希望这些方法能返回一个Optional对象。
你无法修改这些方法的签名,但是你很容易用Optional对这些方法的返回值进行封装。

我们接着用Map做例子,假设你有一个Map<String, Object>类型的map,访问由key的值时,
如果map中没有与key关联的值,该次调用就会返回一个null。

Object value = map.get("key");

使用Optional封装map的返回值,你可以对这段代码进行优化。要达到这个目的有两种方式:
你可以使用笨拙的if-then-else判断语句,毫无疑问这种方式会增加代码的复杂度;
或者你可以采用Optional.ofNullable方法

Optional<Object> value = Optional.ofNullable(map.get("key"));

每次你希望安全地对潜在为null的对象进行转换,将其替换为Optional对象时,都可以考虑使用这种方法。

总结

以上就是这篇文章的全部内容了,希望本文的内容对大家的学习或者使用java8能带来一定的帮助,如果有疑问大家可以留言交流,谢谢大家对脚本之家的支持。

参考资料:Java文件IO操作应该抛弃File拥抱Paths和Files

您可能感兴趣的文章:

  • JAVA8 十大新特性详解
  • Java8新特性lambda表达式有什么用(用法实例)
  • Java8新特性之字符串去重介绍
  • Java8新特性之重复注解(repeating
    annotations)浅析
  • Java8新特性之Lambda表达式浅析
  • Java8新特性之默认方法(default)浅析
  • 详谈Java8新特性泛型的类型推导
  • Java8
    新特性Lambda表达式实例详解
  • Java8新特性之lambda的作用_动力节点Java学院整理
  • Java8新特性之再见Permgen_动力节点Java学院整理

发表评论

电子邮件地址不会被公开。 必填项已用*标注