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

本文翻译:吴嘉俊,叩丁狼高级讲师。 

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/

 

阅读更多
换一批

没有更多推荐了,返回首页