翻译:叩丁狼教育吴嘉俊
Java语言最开始是为了交互电视机而开发的,随着时间的推移,他已经广泛应用各种软件开发领域。基于面向对象的设计,屏蔽了诸如C,C++等语言的一些复杂性,提供了垃圾回收机制,平台无关的虚拟机技术,Java创造了一种前所未有的开发方式。另一方面,得益于Java提出的“一次编码,到处运行”的口号,让Java更加出名。但是Java中的异常也是处处发生,下面我就列出了我认为的Java开发最容易出现的10个错误。
这是第二篇,剩下的5个常见错误。
#6、NPE
避免出现空指针引用的对象是一个很好的习惯。比如,一个方法最好返回一个空数组或者空集合,而不是返回一个null,这些都可以避免出现NPE。
下面是一段代码演示在一个方法中遍历另一个方法返回的集合:
List<String> accountIds = person.getAccountIds();
for (String accountId : accountIds) {
processAccount(accountId);
}
如果用户没有账户信息,则getAccountIds()方法返回一个null,随后而来的就是NPE的出现。为了解决这个问题,我们需要添加一个null-check。如果返回值用一个空的集合来代替,那么我们就可以直接避免出现多余的判断代码。
为了避免出现NPE,还有一些不同的方法。其中一个就是使用Optional类型来包装可能为空的对象值:
Optional<String> optionalString = Optional.ofNullable(nullableString);
if(optionalString.isPresent()) {
System.out.println(optionalString.get());
}
JAVA8在Optional上提供了更优雅的做法:
Optional<String> optionalString = Optional.ofNullable(nullableString);
optionalString.ifPresent(System.out::println);
从Java8开始,Optional就是Java中很有用的一个功能,但是就我的了解来看,在日常开发中使用Optional的程序员并不多。如果使用的是Java8之前的版本,Google的guava是一个不错的选择。
#7、忽略异常
很多开发一般会留着异常不处理。最好的做法还是建议开发人员及时的处理异常。异常的抛出,往往都有特定的含义,作为开发,我们需要定位这些异常,并关注异常出现的原因。如果需要,我们应该重新抛出异常,给用户以提示,或者记录到日志中。再不济,也应该解释为什么我们不去处理这个异常,而不仅仅只是忽略它。
selfie = person.shootASelfie();
try {
selfie.show();
} catch (NullPointerException e) {
// Maybe, invisible man. Who cares, anyway?
}
一个更好的做法,通过给异常一个合适的名称来告知其他开发,为什么我们忽略该异常:
try { selfie.delete(); } catch (NullPointerException unimportant) { }
#8、同步修改异常
这个异常出现的原因在于我们使用iterator对象遍历一个集合的同时,尝试用集合的修改方法去修改集合本身。比如,我们想在一个帽子集合中删除所有带有耳套的帽子:
List<IHat> hats = new ArrayList<>();
hats.add(new Ushanka()); // that one has ear flaps
hats.add(new Fedora());
hats.add(new Sombrero());
for (IHat hat : hats) {
if (hat.hasEarFlaps()) {
hats.remove(hat);
}
}
如果我们执行代码,会抛出一个ConcurrentModificationException异常。如果有两个线程同时访问一个集合,一个线程在遍历集合,另一个线程尝试修改集合,也会抛出这个异常。在开发中,多线程并发修改一个集合是非常常见的事情,要正确完成这个工作,需要使用并发编程相关的工具,比如同步锁,支持并发修改的集合等。在单线程和多线程下解决这个问题,也有一些区别。下面是简单的验证在单线程情况下怎么解决这个问题:
搜集到一个集合并在另一个循环中删除
我们可以把带耳廓的帽子在第一遍循环的时候查询到另一个集合中,然后再遍历这个集合,再从原始的集合中删除。
List<IHat> hatsToRemove = new LinkedList<>();
for (IHat hat : hats) {
if (hat.hasEarFlaps()) {
hatsToRemove.add(hat);
}
}
for (IHat hat : hatsToRemove) {
hats.remove(hat);
}
使用iterator.remove方法
这应该是更好的解决方案,不需要创建额外的集合:
Iterator<IHat> hatIterator = hats.iterator();
while (hatIterator.hasNext()) {
IHat hat = hatIterator.next();
if (hat.hasEarFlaps()) {
hatIterator.remove();
}
}
使用ListIterator方法
如果我们要修改的集合实现了List接口,使用ListIterator是一个恰当的方法。实现了ListIterator接口的遍历器,不仅允许删除元素,还提供了add操作和set操作。ListIterator继承了Iterator接口,所以下面这个例子和遍历器删除的例子几乎一样,唯一的区别就是获得的遍历器的类型,我们使用的是listIterator()方法获取遍历器。下面的方法我们除了展示remove方法,我们还会展示ListIterator.add方法:
IHat sombrero = new Sombrero();
ListIterator<IHat> hatIterator = hats.listIterator();
while (hatIterator.hasNext()) {
IHat hat = hatIterator.next();
if (hat.hasEarFlaps()) {
hatIterator.remove();
hatIterator.add(sombrero);
}
}
使用ListIterator,删除和添加操作可以合并成set方法一次性调用:
IHat sombrero = new Sombrero();
ListIterator<IHat> hatIterator = hats.listIterator();
while (hatIterator.hasNext()) {
IHat hat = hatIterator.next();
if (hat.hasEarFlaps()) {
hatIterator.set(sombrero); // set instead of remove and add
}
}
使用Stream API
使用Java8提供的stream方法,允许开发者将集合转化成stream,然后通过filter进行过滤。下面是一个使用streamAPI来过滤帽子的方法,也可以避免出现ConcurrentModificationException异常。
hats = hats.stream().filter((hat -> !hat.hasEarFlaps()))
.collect(Collectors.toCollection(ArrayList::new));
Collectors.toCollection方法会使用过滤出来的对象创建一个新的ArrayList。这可能会出现一些问题,比如假如过滤出来的元素非常多,那么创建出来的ArrayList会非常大,所以使用的时候需要注意一下。使用Java8提供的List.removeIf方法也是另一种解决方案,并且更加清晰:
hats.removeIf(IHat::hasEarFlaps);
在底层,其实也是使用iterator.remove方法完成的。
使用特殊的集合
如果在最开始,我们使用CopyOnWriteArrayList代替ArrayList,那么最初的操作根本就不会出错,因为CopyOnWriteArrayList提供了修改的方法(比如set,add,remove)而不会导致集合背后的数组发生变化,但是会创建一个新的修改版本。所以遍历方法一直遍历的是原始版本的集合数据,修改是发生在新版本集合之上的,这样就避免出现了ConcurrentModificationException异常。所以,背后的原理其实就是每次修改的时候,都创建一个新的集合。
当然,还有类似的其他集合类型,比如CopyOnWriteSet和ConcurrentHashMap。
另一个在集合并发修的时候,可能产生的问题就是,当为集合创建一个stream,在遍历这个stream的时候,在后台修改原始集合。stream有一个基本的使用原则,就是在使用stream查询的时候,不要修改原始的集合。下面展示了一个错误的stream使用案例:
List<IHat> filteredHats = hats.stream().peek(hat -> {
if (hat.hasEarFlaps()) {
hats.remove(hat);
}
}).collect(Collectors.toCollection(ArrayList::new));
peek方法针对所有的元素,施加了对应的操作,但是这个操作是把匹配的元素从原始的集合中删除,这会导致异常的发生。(注:peek针对每一个元素实施操作,但是peek是惰性操作,返回的仍然是stream,在遇到toCollection方法的时候,才会真正执行,但这个时候,把元素删除了)
#9、破坏契约
通常情况下,标准库或者第三方提供的代码,都需要准守一些既定的规则。比如,必须要遵循正确的hashCode和equals逻辑,才能匹配Java集合框架提供的功能,或者其他使用hashCode和equals方法的场景。如果违反这些约定,不会导致直接的编译异常或者运行异常,而是在貌似正常的执行过程中,隐藏着巨大的危险。类似这样的错误代码,常常会绕过测试,进入生产环境,并且产生一系列意料之外的影响,比如错误的UI行为,错误的数据报告,极低的应用性能,数据丢失等等。
幸运的是,这种约定的情况很少。我上面已经提到了hashCode和equals约定,这个约定在具有hash和比较对象的集合中会用到,比如HashMap和HashSet。这个约定包含两条规则:
- 如果两个对象相等,则他们的hash值必须相等。
- 如果两个对象的hash值相等,这两个对象可能相等,也可能不等。
如果违反了第一条规则,会导致hashmap中存取数据出现错误。下面是一个违反了第一条规则的示例代码:
public static class Boat {
private String name;
Boat(String name) {
this.name = name;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Boat boat = (Boat) o;
return !(name != null ? !name.equals(boat.name) : boat.name != null);
}
@Override
public int hashCode() {
return (int) (Math.random() * 5000);
}
}
可以看到,Boat类覆写了equals和hashCode方法。但是,他违反了约定,因为hashCode方法返回了一个随机值。
下面的代码展示了问题,我们先想hashset中添加了一个名为Enterprise的boat,但是我们想获取的时候,却有可能找不到:
public static void main(String[] args) {
Set<Boat> boats = new HashSet<>();
boats.add(new Boat("Enterprise"));
System.out.printf("We have a boat named 'Enterprise' : %bn", boats.contains(new Boat("Enterprise")));
}
另一个约定的例子是finalize方法。
我们可以选择在finalize方法中释放类似打开文件的资源,但是这是一个错误的想法。因为约定中说明,finalize方法只能在GC的时候执行,但你怎么知道什么时候执行GC呢?
#10、使用泛型但并不指定泛型类型
我们来看看下面这段代码:
List listOfNumbers = new ArrayList();
listOfNumbers.add(10);
listOfNumbers.add("Twenty");
listOfNumbers.forEach(n -> System.out.println((int) n * 2));
我们定义了一个未指定泛型类型的ArrayList(raw ArrayList),因为没有具体参数化泛型的类型,所以我们能往这个list中添加任何对象。但是在最后一行代码中,我们强行把元素转化成int,乘以2并打印。这段代码不会出现编译错误,但是在运行的时候会抛出异常,因为我们尝试把一个字符串转型成整型。显然,因为我们隐藏了类型,所以类型系统也无法正确帮我们编写安全的代码。
要避免这个错误,只需要在实例化集合的时候指明具体的泛型类型即可:
List<Integer> listOfNumbers = new ArrayList<>();
listOfNumbers.add(10);
listOfNumbers.add("Twenty");
listOfNumbers.forEach(n -> System.out.println((int) n * 2));
唯一的区别在第一句代码。
我们修改之后的代码在编译的时候就会报错,因为我们尝试把一个字符串放进只能存放整形的集合中。记住,在使用泛型类型的时候,一定要指定泛型的类型,是一个非常重要的编码习惯。
小结
Java平台依赖JVM和语言本身的特性,为我们简化了开发中的很多复杂性。但是,他提供的这些功能,比如内存管理,OOP工具等,并不能让开发者一劳永逸。所以,熟悉Java库,阅读Java源码,阅读JVM相关文档是非常有必要的。最后,在开发中配合使用几个错误分析工具,能降低我们的错误发生概率。
原文:https://www.voxxed.com/2017/03/buggy-java-code-part-ii/
想获取更多技术视频,请前往叩丁狼官网:http://www.wolfcode.cn/openClassWeb_listDetail.html
网友评论