我们将深入研究Java语言的三个常被忽视的方面-valueOf,instanceOf和exception。抽丝剥茧,细说架构那些事——【优锐课】
学习诸如Java之类的编程语言的基础知识,是成为一名优秀程序员的重要组成部分,但正是这些小细节使我们能够从优秀程序员发展为出色的工匠。就像木工了解凿子和router刨机的细微差别,以及专业战士了解平衡和杠杆作用的复杂性一样,我们必须了解能够提供最重要结果的小方面。
在“编写更好的Java技巧”系列的这篇文章中,我们将深入探讨Java语言的三个常被忽视的方面。首先,我们将研究由框基本类型提供的valueOf方法,以及如何尽可能避免使用这些方法。接下来,我们将遵循相同的思路,并探索instanceof关键字以及如何避免滥用此功能。
最后,我们将研究何时何地抛出异常以最大程度地发挥作用,以及如何在正确的位置抛出异常如何使精心设计的类与调试的噩梦有所不同。
1. 尽可能避免使用valueOf
像Java这样的强类型语言最显着的好处之一是,编译器可以在编译时强制执行我们的意图。通过对每种数据应用类型,我们可以对数据的性质做出明确的声明。
例如,如果我们将变量定义为具有int类型,则声明该变量不能大于231-1,也不能小于-231。随着面向对象编程(OOP)的引入,我们可以通过创建类并实例化该类的对象来定义新的性质。例如,我们可以定义一个Address类并实例化具有特定Address状态的变量:
public class Address {
2
3
private final String name;
4
private final String street;
5
6
public Address(String name, String street) {
7
this.name = name;
8
this.street = street;
9
}
10
11
public String getName() {
12
return name;
13
}
14
15
public String getStreet() {
16
return street;
17
}
18
}
19
20
Address someAddress = new Address("John Doe", "117 Spartan Way");
这种强类型化是OOP核心概念(例如多态和动态调度)的基础。 这些概念共同产生了一个应用程序,该应用程序在Java执行程序之前明确说明了我们的意图。尽管强类型在许多情况下可能很乏味,但在许多情况下,它使我们能够在部署应用程序之前知道我们的意图在逻辑上是否合理。例如,如果我们在实例化Address对象时尝试将int值作为名称或街道传递给它(即new Address(1,2)),则Java编译器会抱怨:
1
error: incompatible types: int cannot be converted to String
2
Address someAddress = new Address(1, 2);
我们打算让String代表我们的名称和街道值,但我们改为提供了一个int。由于编译器无法将int视为String(即int至少不具有String的行为),因此编译器会引发错误并拒绝编译我们的应用程序。
当创建较大的程序员时,这种类型的安全性是必不可少的工具,成千上万的类彼此交互并通过它们之间的关系完成实质上复杂的任务。
尽管Java是一种强类型语言,但仍有许多方法可以绕过这种类型检查。最常见的方法之一是使用String对象表示所有数据。例如,看到以下JavaScript对象表示法(JSON)数据作为代表性状态传输(REST)请求或响应的主体发送的情况并不少见:
1
{
2
"name": "John Doe",
3
"accountValue": "100"
4
}
乍一看,accountValue字段可能是一个int值,甚至可能是一个很长的值,但这里存在一个更加可疑的问题。虽然我们在此响应看到的值是一个整数,它可以是任何字符串值。例如,没有什么可以阻止此身体成为:
{
2
"name": "John Doe",
3
"accountValue": "unknown"
4
}
现在这成为一个棘手的解析问题。我们问题的根源是:accountValue可以采用哪些值?从类型的角度来看,它可以是Stringclass表示的任何值。实际上,我们知道该值应该是整数,但从语义上讲,不能保证一定会是整数。
当我们将有关accountValue性质的决策传递给应用程序的其余部分时,这个问题变得更加严重。例如,我们可以使用一个简单的原始Java对象(POJO)来反序列化此JSON:
1
public class Account {
2
3
private String name;
4
private String accountValue;
5
6
public String getAccountValue() {
7
return accountValue;
8
}
9
10
// ...getters & setters...
11
}
当我们的应用程序中的另一个类调用Account#getAccountValue时,将返回一个String。此字串没有说明accountValue的性质(例如,其最大值或最小值可以是多少,或者在数值上高于或低于另一个帐户值)。解决此问题的一个快速方法是立即将String转换为int或long(在这种情况下,由于精度更高,我们将使用long):
public class Account {
2
3
private String name;
4
private String accountValue;
5
6
public long getAccountValueAsLong() {
7
return Long.valueOf(accountValue);
8
}
9
10
// ...getters & setters...
11
}
使用这种方法,我们封装了accountValue字段并隐藏其确切表示。从外部,另一个类会认为accountValue是long,因为getAccountValueAsLong返回long。这是OOP的一种很好的用法,但只能解决这个问题。我们知道accountValue应该只能是一个长整数,但这并不能阻止它成为任何String值。
另外,由于JSON中的实际accountValue字段是一个String,所以我们仍然需要提供一个返回字符串的getAccountValue,并且必须为setAccountValue提供一个String以便反序列化发生(不需要其他技巧)。
例如,如果在我们的JSON中将accountValue设置为unknown,则调用Long.valueOf(“ unknown”)会导致以下错误:
1
Exception in thread "main" java.lang.NumberFormatException: For input string: "unknown"
2
at java.lang.NumberFormatException.forInputString(NumberFormatException.java:65)
3
at java.lang.Long.parseLong(Long.java:589)
4
at java.lang.Long.valueOf(Long.java:803)
5
at Main.main(Main.java:5)
请务必注意,此异常发生在运行时,而不是编译时:编译器在编译期间没有足够的信息来推断提供给Long.valueOf的值不会是可以安全转换为String的String。相反,在执行过程中,将“未知”传递给Long.valueOf时会引发异常,从而导致我们的应用程序突然退出。
由于我们决定将accountValue表示为String,因此我们现在将类型检查从编译时推迟到运行时。我们没有允许编译器对程序的源代码执行静态类型检查分析,而是将检查推迟到了运行时,其中Long.valueOf现在负责实现类型检查。
即使我们尝试在我们的getAccountValue方法中捕获此NumberFormatException异常,我们现在仍然有责任决定遇到不可转换为长整数的数字时该怎么做。我们可能会倾向于认为这是不可能的,因为每个人都必须确定帐户值是一个数字。
但是,由于考虑到JSON甚至代码中都没有记载预期会很长的时间,所以我们认为这是错误的。我们的accountValue字段的类型是String,没有什么可以阻止不熟悉我们的应用程序的用户正确地将accountValue设置为String。
相反,我们应该更改JSON以正确表示我们的意图:
1
{
2
"name": "John Doe",
3
"accountValue": 100
4
}
这使我们能够在Account类中正确表示accountValue:
1
public class Account {
2
3
private String name;
4
private long accountValue;
5
6
// ...getters & setters...
7
}
当我们的应用程序中的其他类现在访问Account对象时,他们知道accountValue将是有效的long。这使他们可以根据长期的性质做出适当的决定。例如,另一个类可以通过将值与0进行比较来轻松确定accountValue是否为负:getAccountValue()<0。
在某些情况下,需要使用valueOf方法调用(例如Long.valueOf)。如果JSON表示超出我们的控制范围,我们别无选择,只能将accountValue视为String。在这种情况下,我们应该立即将accountValue转换为long并确保所有其他类与long的值而不是String进行交互。一种方法是包装已解析的帐户:
1
public class AccountState {
2
3
private String name;
4
private String accountValue;
5
6
// ...getters & setters...
7
}
8
9
public class Account {
10
11
private String name;
12
private long accountValue;
13
14
public Account(AccountState state) {
15
this.name = state.getName();
16
this.accountValue = extractAccountValue(state.getAccountValue());
17
}
18
19
private static long extractAccountValue(String value) {
20
21
try {
22
return Long.valueOf(value);
23
}
24
catch (NumberFormatException e) {
25
return 0;
26
}
27
}
28
29
// ...getters & setters...
30
}
31
32
AccountState state = // ...parse JSON into AccountState object
33
Account account = new Account(state);
34
35
account.getAccountValue() > 0;
但是请注意,即使一开始不应该提供一个值,我们也必须对将accountValue设置为哪个值做出明智的决定。
因此,在可能的情况下,我们应避免使用valueOf方法将String转换为某些原始值,包括:
Integer.valueOf
Long.valueOf
Float.valueOf
Double.valueOf
Boolean.valueOf
将它们包含在应用程序中应视为一种代码味道,它表示我们有一个应该由另一种数据类型表示的String。
2. 尽可能避免instanceof
与valueOf相似,instanceof关键字提供了规避Java编译器类型检查系统的机会。尽管在某些情况下(尤其是在使用低级代码或使用反射时),instanceof可能是必需的,但应将其视为代码气味。可能表明我们正在跳过严格的类型检查(因此失去了Java类型检查系统的优势)。
在许多情况下,instanceof用于将已知超类型的对象安全地转换为所需子类型的对象(称为向下转换)。在自定义类中实现equals方法时,这种向下转换很常见:
public class Foo {
2
3
private int value;
4
5
@Override
6
public boolean equals(Object obj) {
7
8
if (this == obj) {
9
return true;
10
}
11
else if (!(obj instanceof Foo)) {
12
return false;
13
}
14
else {
15
Foo other = (Foo) obj;
16
return value == other.value;
17
}
18
}
19
}
首先检查obj是否是Foo类的实例(即obj instanceof Foo),我们确保将实际的Foo对象向下转换为Foo。这称为安全或已检查的下垂。如果在将对象转换为对象的实现类型之前不对其进行检查,则发生未经检查的下降:
1
public interface Vehicle {}
2
3
public class Boat implements Vehicle {}
4
5
public class Truck implements Vehicle {}
6
7
public class Foo {
8
9
public void doSomething(Vehicle vehicle) {
10
Truck truck = (Truck) vehicle;
11
System.out.println(truck);
12
}
13
}
14
15
Foo foo = new Foo();
16
Vehicle vehicle = new Truck();
17
foo.doSomething(vehicle);
我们知道此转换是安全的,因为我们知道先验地为doSomething提供了一个实现类型为Truck的对象。但是,我们可以改为提供Boat对象:
1
Foo foo = new Foo();
2
Vehicle vehicle = new Boat();
3
foo.doSomething(vehicle);
这样做会导致以下错误:
1
Exception in thread "main" java.lang.ClassCastException: class Boat cannot be cast to class Truck (Boat and Truck are in unnamed module of loader 'app')
2
at Foo.doSomething(Application.java:16)
3
at Application.main(Application.java:29)
请注意,这是运行时例外,因为编译器无法在运行时推断出车辆的类型。在我们的例子中,我们知道实现类型是因为我们已经静态设置了它,但是有时可能无法在编译时确定实现类型:
1
public class Bar {
2
3
public Vehicle createVehicle() {
4
5
int random = // randomly select 0 or 1
6
7
if (random == 0) {
8
return new Truck();
9
}
10
else {
11
return new Boat();
12
}
13
}
14
}
15
16
Foo foo = new Foo();
17
Bar bar = new Bar();
18
foo.doSomething(bar.createVehicle());
在这种情况下,根据只能在运行时才知道的一些标准来随机确定实现类型(例如,用户输入或随机数生成器)。因此,此检查必须推迟到运行时,如果执行了不安全的向下转换,则会在运行时抛出异常。这个问题非常普遍,以至于Java在使用泛型执行不安全的向下转换时甚至会发出警告(由于Java使用未定义的泛型类型而无法确定其运行时泛型类型):
1
public class Foo {
2
3
public void doSomething(List> list) {
4
Listfoos = (List ) list;
5
}
6
}
在这种情况下,(List
1
Type safety: Unchecked cast from Listto List
由于向下转换可能会导致问题,因此,尽可能地通过instanceof使用已检查的向下转换始终是一个好主意。此外,我们应该完全避免使用instanceof。在许多情况下,instanceof调用用作适当多态性的替代方法。
例如,以下是instanceof的常见用法:
1
public interface Vehicle {}
2
3
public class Boat implements Vehicle {
4
5
public void engagePropeller() {
6
// ...
7
}
8
}
9
10
public class Truck implements Vehicle {
11
12
public void engageAxel() {
13
// ...
14
}
15
}
16
17
public class VehicleDriver {
18
19
public void drive(Vehicle vehicle) {
20
21
if (vehicle instanceof Boat) {
22
Boat boat = (Boat) vehicle;
23
boat.engagePropeller();
24
}
25
else if (vehicle instanceof Truck) {
26
Truck truck = (Truck) vehicle;
27
truck.engageAxel();
28
}
29
}
30
}
本质上,我们试图以不同的方式对待每种Vehicle的实现类型(即Boat和Truck)。这正是多态性的用例。无需通过instanceof检查Vehicle的实现类型并进行向下转换,我们可以在Vehicle接口中创建一个称为drive的新方法,并使每种实现类型都执行适当的方法。
1
public interface Vehicle {
2
public void drive();
3
}
4
5
public class Boat implements Vehicle {
6
7
@Override
8
public void drive() {
9
engagePropeller();
10
}
11
12
public void engagePropeller() {
13
// ...
14
}
15
}
16
17
public class Truck implements Vehicle {
18
19
@Override
20
public void drive() {
21
engageAxel();
22
}
23
24
public void engageAxel() {
25
// ...
26
}
27
}
28
29
public class VehicleDriver {
30
31
public void drive(Vehicle vehicle) {
32
vehicle.drive();
33
}
34
}
尽管在某些特定情况下需要instanceof(例如,实现equals方法),但通常应避免instanceof检查和不安全的下调。相反,我们应该使用多态来根据对象的实现类型来改变行为。
3. 提早抛出异常
当必须在运行时进行错误检查时,最好尽早抛出异常。在大型,复杂的环境中,在一个线程中实例化对象,然后在另一个线程中调用对象,不正确的异常处理会导致调试的噩梦。在许多情况下,异常处理不当的结果可能是微妙而隐蔽的。
例如,假设我们有以下类:
1
public class SecurityManager {
2
3
private final SecurityTransactionRepository repo;
4
5
public SecurityManager(SecurityTransactionRepository repo) {
6
this.repo = repo;
7
}
8
9
public OptionalfindTransactionById(long id) {
10
return repo.findById(id);
11
}
12
}
此类看起来足够简单:它在其构造函数中接收一个SecurityTransactionRepository对象,并将SecurityTransaction对象的查询推迟到此存储库。我们可以通过仅向SecurityManager构造函数提供正确的对象并调用findTransactionById方法来执行findTransactionsById:
1
SecurityTransactionRepository repo = // create repository...
2
SecurityManager manager = new SecurityManager(repo);
3
4
Optionaltransaction = manager.findTransactionById(1);
当事情没有按计划进行时,就会开始出现问题。例如,如果我们的回购对象为空,该怎么办?在这种情况下,我们的通话顺序将如下所示:
1
SecurityManager manager = new SecurityManager(null);
2
3
Optionaltransaction = manager.findTransactionById(1);
如果尝试执行此代码,我们将看到如下所示的堆栈跟踪:
1
Exception in thread "main" java.lang.NullPointerException
2
at SecurityManager.findTransactionById(Application.java:25)
3
at Application.main(Application.java:33)
发生此NullPointerException(NPE)是因为传递给SecurityManager构造的SecurityTransactionRepository对象为null。一旦findTransactionById方法尝试遵循空的SecurityTransactionRepository对象,则将引发NullPointerException。防止发生此异常的一种简单方法是检查空的repo对象:
1
public class SecurityManager {
2
3
private final SecurityTransactionRepository repo;
4
5
public SecurityManager(SecurityTransactionRepository repo) {
6
this.repo = repo;
7
}
8
9
public OptionalfindTransactionById(long id) {
10
11
if (repo == null) {
12
return Optional.empty();
13
}
14
else {
15
return repo.findById(id);
16
}
17
}
18
}
如果我们使用传递给SecurityManager构造函数的null SecurityTransactionRepository对象重新执行应用程序,则对findTransactionById方法的调用现在将导致空的Optional对象。解决NPE问题后,我们引入了一个更为微妙的问题,可能会再次损害我们的利益。
假设我们的SecurityManager对象在一个地方创建,而findTransactionById方法在另一个地方调用。另外,假设在构造SecurityManager对象之后很晚才调用findTransactionById方法,可能是几分钟甚至几小时。
这在Spring应用程序和OSGi应用程序中很常见,在这些应用程序中,将创建bean或服务并将其连接到设备的完全不同的部分。对于OSGi,可以在一个捆绑包中创建服务,然后将其作为服务注入到完全不同的捆绑包中。
在这种情况下,如果尝试使用nullSecurityTransactionRepository对象执行findTransactionById方法,则将返回空的Optional。如果尝试调试为什么未找到预期的SecurityTransaction对象,则会发现这是因为不存在这样的SecurityTransaction(但我们希望它存在),或者是因为SecurityTransactionRepository为空。
一旦发现SecurityTransactionRepository为null,就知道它必须已经作为null传递到SecurityManager构造函数中(因为它有资格成为final)。
尽管此故障排除过程非常标准,但我们的调试目前遇到了重大障碍。我们需要找到使用nullconstructor参数在哪里构造此SecurityManager对象。如果此实例化过程发生在另一个项目或捆绑软件中,或者发生在几分钟,几小时甚至几天前,那么我们现在必须搜索整个应用程序以找到根本原因。
如果有多个实例化SecurityManager对象,则可以在调试过程中添加另一把扳手。在这种情况下,哪一个实例使用nullSecurityTransactionRepository实例化了我们的SecurityManager?所有这些问题均源于一个简单的事实:我们没有在正确的位置处理null SecurityTransactionRepository的可能性。
我们不希望使用null SecurityTransactionRepository创建SecurityManager,因此,我们应该在SecurityManager的构造函数中明确声明。为此,我们可以使用Objects#requireNonNull方法,如果传递给它的参数为null,则抛出NPE;如果参数不为null,则返回传递给它的参数。
1
public class SecurityManager {
2
3
private final SecurityTransactionRepository repo;
4
5
public SecurityManager(SecurityTransactionRepository repo) {
6
this.repo = Objects.requireNonNull(repo);
7
}
8
9
public OptionalfindTransactionById(long id) {
10
return repo.findById(id);
11
}
12
}
如果我们使用空的SecurityTransactionRepository重新运行我们的应用程序,我们将看到抛出NPE,但是这次,异常源自SecurityManager的构造函数:
1
this.repo = Objects.requireNonNull(repo);
这样可以确保,如果我们的应用程序启动并且向SecurityManager对象的构造函数提供了一个空的SecurityTransactionManager对象,则该应用程序将引发问题的源头。使用报告的堆栈跟踪,我们可以找到SecurityManager构造函数的调用方:
1
Exception in thread "main" java.lang.NullPointerException
2
at java.base/java.util.Objects.requireNonNull(Objects.java:221)
3
at SecurityManager.(Application.java:22)
4
at Application.main(Application.java:32)
对于上面的堆栈跟踪:
1
Application.main(Application.java:32)
将空检查移入构造器的概念与按合同设计(DBC)的概念紧密相关。 在此技术中,每种方法都具有以下方面:
1. 前提:调用该方法必须为真
2. 后置条件:方法完成执行后必须为真的事物
3. 不变量:在对象的整个生命周期中必须为真的事物
例如,先决条件可能是一些数据可用,而后置条件可能是方法的结果(如果自身乘以)必须等于提供给该方法的参数(即平方根函数)。虽然DBC可以过形式化,但我们可以使用不变的概念来表示我们的空检查。当我们检查提供给SecurityManager构造函数的SecurityTransactionRepository对象是否不为null时,我们知道,如果成功构造了SecurityManager对象,则SecurityTransactionRepository将永远不会为null。
由于SecurityTransactionManager被标记为final,因此构造函数是唯一可以设置其值的机制。如果提供给构造函数的值非空,那么我们知道在对象的生命周期内,repofield将为非空。因此,我们不再需要再次检查repo字段是否为空:我们假设它为非空。这等于我们的SecurityManager类中的一个不变式。因此,检查repo对象在findTransactionById定义内是否为null会浪费代码。取而代之的是,我们假定它在类中的那一点不为空。
通常,最好尽快并在发生错误的时刻抛出异常。将异常推迟到以后的时间将使调试过程更加混乱,并浪费开发人员尝试寻找问题根源的时间。就我们的SecurityManager而言,错误是将null SecurityTransactionManager传递给了它的构造函数,因此,应该在构造函数中抛出NPE。如果我们希望SecurityTransactionRepository可以为null(因此在构造函数中抛出NPE是不正确的),则应使用诸如Optional或Null Object模式之类的机制明确声明该假设。
结论
在本文中,我们研究了每个装箱的原始类中包含的valueOf方法,以及如何避免因使用它们而造成陷阱。接下来,我们研究了instanceof方法以及除非完全必要,否则如何避免使用它。
最后,我们研究了如何在代码的正确位置抛出异常如何使精心设计的类与细微的陷阱相互区别。尽管这些细节可能会埋入用Java编写代码的日常工作中,但正是这些小细节使优秀的程序员和优秀的工匠之间产生了差异。
感谢阅读!
另外近期整理了一套完整的java架构思维导图,分享给同样正在认真学习的每位朋友~
更多JVM、Mysql、Tomcat、Spring Boot、Spring Cloud、Zookeeper、Kafka、RabbitMQ、RockerMQ、Redis、ELK、Git等Java学习资料和视频课程干货欢迎私信我~