首页 > 技术文章 > 技术文章精译 >

Buggy Java Code:Java程序员最容易犯的10个错(2)

更新时间:2018-10-18 | 阅读量(756)

Java语言最开始是为了交互电视机而开发的,随着时间的推移,他已经广泛应用各种软件开发领域。基于面向对象的设计,屏蔽了诸如C,C++等语言的一些复杂性,提供了垃圾回收机制,平台无关的虚拟机技术,Java创造了一种前所未有的开发方式。另一方面,得益于Java提出的“一次编码,到处运行”的口号,让Java更加出名。但是Java中的异常也是处处发生,下面我就列出了我认为的Java开发最容易出现的10个错误。 这是第二篇,剩下的5个常见错误。 ### #6、NPE 避免出现空指针引用的对象是一个很好的习惯。比如,一个方法最好返回一个空数组或者空集合,而不是返回一个null,这些都可以避免出现NPE。 下面是一段代码演示在一个方法中遍历另一个方法返回的集合: ``` List accountIds = person.getAccountIds(); for (String accountId : accountIds) { processAccount(accountId); } ``` 如果用户没有账户信息,则getAccountIds()方法返回一个null,随后而来的就是NPE的出现。为了解决这个问题,我们需要添加一个null-check。如果返回值用一个空的集合来代替,那么我们就可以直接避免出现多余的判断代码。 为了避免出现NPE,还有一些不同的方法。其中一个就是使用Optional类型来包装可能为空的对象值: ``` Optional optionalString = Optional.ofNullable(nullableString); if(optionalString.isPresent()) { System.out.println(optionalString.get()); } ``` JAVA8在Optional上提供了更优雅的做法: ``` Optional 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 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 hatsToRemove = new LinkedList<>(); for (IHat hat : hats) { if (hat.hasEarFlaps()) { hatsToRemove.add(hat); } } for (IHat hat : hatsToRemove) { hats.remove(hat); } ``` ##### 使用iterator.remove方法 这应该是更好的解决方案,不需要创建额外的集合: ``` Iterator 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 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 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 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 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 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/
叩丁狼学员采访 叩丁狼学员采访
叩丁狼头条 叩丁狼头条
叩丁狼在线课程 叩丁狼在线课程