美文网首页
Buggy Java Code:Java程序员最容易犯的10个错

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

作者: 叩丁狼教育 | 来源:发表于2018-10-18 13:17 被阅读73次

    翻译:叩丁狼教育吴嘉俊

    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

    相关文章

      网友评论

          本文标题:Buggy Java Code:Java程序员最容易犯的10个错

          本文链接:https://www.haomeiwen.com/subject/eqgjzftx.html