Java代码审查的准则

100 阅读8分钟

让另一双眼睛扫描你的代码总是有用的,可以帮助你在破坏生产之前发现错误。你不需要是一个专家来审查别人的代码。一些编程语言的经验和一个审查清单应该可以帮助你开始工作。我们整理了一份在审查Java代码时应该注意的事项清单。请继续阅读!

1.遵循Java代码惯例

遵循语言惯例有助于快速浏览代码并理解它,从而提高可读性。例如,Java中所有的包名都用小写,常量用大写,变量名用CamelCase,等等。在这里可以找到完整的约定清单。

有些团队制定了他们自己的约定,所以在这种情况下要灵活处理!

2.用lambdas和流代替命令式代码

如果你使用的是Java 8+,用流和lambdas代替循环和极其冗长的方法,可以使代码看起来更干净。lambdas和流允许你在Java中编写功能代码。下面的片段以传统的命令式方法过滤奇数:

List oddNumbers = new ArrayList<>();
for (Integer number : Arrays.asList(1, 2, 3, 4, 5, 6)) {
	if (number % 2 != 0) {
	  oddNumbers.add(number);
  }
}

这就是过滤奇数的函数式方法:

List oddNumbers = Stream.of(1, 2, 3, 4, 5, 6)
  .filter(number -> number % 2 != 0)
  .collect(Collectors.toList());

3.谨防NullPointerException

在编写新方法时,如果可能的话,尽量避免返回空值。这可能会导致空指针异常。在下面的片段中,如果列表中没有整数,最高的方法返回空值:

class Items {
	private final List items;
	public Items(List items) {
	        this.items = items;
	}
	public Integer highest() {
	  if (items.isEmpty()) return null;
	  Integer highest = null;
	  for (Integer item : items) {
	      if (items.indexOf(item) == 0) highest = item;
	      else highest = highest > item ? highest : item;
	  }
	  return highest;
	}
}

在直接调用一个对象上的方法之前,我建议检查空值,如下图所示:

Items items = new Items(Collections.emptyList());
Integer item = items.highest();
boolean isEven = item % 2 == 0; // throws NullPointerException ❌
boolean isEven = item != null && item % 2 == 0  // ✅

虽然在你的代码中到处都有空值检查,但这可能是非常麻烦的。如果你使用的是Java 8+,可以考虑使用Optional 类来表示那些可能没有有效状态的值。它允许你轻松地定义替代行为,并且对链式方法很有用。

在下面的片段中,我们使用Java Stream API来寻找最高的数字,该方法返回一个Optional 。注意,我们正在使用Stream.reduce ,该方法返回一个Optional

public Optional highest() {
    return items
            .stream()
            .reduce((integer, integer2) -> 
							integer > integer2 ? integer : integer2);
}
Items items = new Items(Collections.emptyList());
items.highest().ifPresent(integer -> {             // ✅
    boolean isEven = integer % 2 == 0;
});

另外,你也可以使用注释,如@Nullable@NonNull ,如果在构建代码时出现空冲突,就会产生警告。例如,将一个@Nullable 参数传递给一个接受@NonNull 参数的方法。

4.直接将客户端代码中的引用分配给一个字段

暴露在客户端代码中的引用可以被操作,即使该字段是最终的。让我们通过一个例子来更好地理解这一点:

private final List items;
public Items(List items) {
        this.items = items;
}

在上面的片段中,我们直接将客户端代码中的一个引用分配给一个字段。客户端可以很容易地突变列表的内容并操纵我们的代码,如下图所示:

List numbers = new ArrayList<>();
Items items = new Items(numbers);
numbers.add(1); // This will change how items behaves as well

相反,可以考虑克隆该引用或创建一个新的引用,然后将其分配给字段,如下图所示:

private final List items;
public Items(List items) {
    this.items = new ArrayList<>(items);
}

在返回引用时,同样的规则也适用。你需要谨慎行事,以免暴露内部的可改变状态。

5.小心处理异常

在捕捉异常时,如果你有多个catch块,请确保catch块的顺序从最具体到最少。在下面的代码段中,由于Exception 类是所有异常之母,所以异常永远不会在第二个块中被捕获:

try {
	stack.pop();
} catch (Exception exception) {
	// handle exception
} catch (StackEmptyException exception) {
	// handle exception
}

如果这种情况是可以恢复的,并且可以由客户(你的库或代码的消费者)处理,那么使用检查过的异常是很好的。例如,IOException 是一个检查过的异常,它迫使客户处理这种情况,如果客户选择重新抛出异常,那么应该是有意识的调用无视该异常。

6.思考数据结构的选择

Java集合提供了ArrayListLinkedListVectorStackHashSetHashMapHashtable 。了解每种结构的优点和缺点,以便在正确的情况下使用它们,这一点很重要。一些提示可以帮助你做出正确的选择:

  • Map:如果你有键、值对形式的无序项目,并且需要有效的检索、插入和删除操作,那么就很有用。HashMap,Hashtable,LinkedHashMap 都是Map 接口的实现。

  • List:非常常用于创建一个有序的项目列表。这个列表可能包含重复的内容。ArrayListList 接口的一个实现。使用Collections.synchronizedList 可以使一个列表成为线程安全的,因此不需要使用Vector这里有一些关于为什么Vector 基本上已经过时的信息。

  • Set:与 list 类似,但不允许重复。HashSet 实现了Set 接口。

7.暴露前请三思

在Java中有相当多的访问修饰符可供选择--public,protected,private 。除非你想把一个方法暴露给客户代码,否则你可能想默认保持一切private 。一旦你暴露了一个API,就没有回头路了。

例如,你有一个类Library ,它有以下的方法来按名字检出一本书:

public checkout(String bookName) {
	Book book = searchByTitle(availableBooks, bookName);
  availableBooks.remove(book);
  checkedOutBooks.add(book);
}

private searchByTitle(List availableBooks, String bookName) {
...
}

如果你没有默认保持searchByTitle 方法的私有性,并且它最终被公开,其他类可能会开始使用它并在它上面建立逻辑,而你可能希望它是Library 类的一部分。这可能会破坏Library 类的封装,或者以后不可能在不破坏别人的代码的情况下进行恢复/修改。有意识地进行暴露!

8.对接口的代码

如果你有某些接口的具体实现(例如:ArrayListLinkedList ),如果你在代码中直接使用它们,那么就会导致高耦合性。坚持使用List 接口可以让你在未来任何时候切换实现而不破坏任何代码:

public Bill(Printer printer) {
	this.printer = printer;
}

new Bill(new ConsolePrinter());
new Bill(new HTMLPrinter());

在上面的片段中,使用Printer 接口允许开发者转移到另一个具体的类HTMLPrinter

9.不要强迫适应接口

看一下下面这个接口:

interface BookService {
		List fetchBooks();
    void saveBooks(List books);
    void order(OrderDetails orderDetails) throws BookNotFoundException, BookUnavailableException;	
}

class BookServiceImpl implements BookService {
...

创建这样一个接口有什么好处吗?这个接口是否有被其他类实现的范围?这个接口是否足够通用,可以由另一个类来实现?如果这些问题的答案都是否定的,那么我肯定会建议你避免这个不必要的接口,因为你将来要维护它。Martin Fowler在他的博客中对此解释得非常好。

那么,什么是一个好的接口的用例呢?比方说,我们有一个class Rectangle 和一个class Circle ,它有计算周长的行为。如果有一个要求,总结所有形状的周长--多态性的用例,那么拥有接口会更有意义,如下所示:

interface Shape {
		Double perimeter();
}

class Rectangle implements Shape {
//data members and constructors
    @Override
    public Double perimeter() {
        return 2 * (this.length + this.breadth);
    }
}

class Circle implements Shape {
//data members and constructors
    @Override
    public Double perimeter() {
        return 2 * Math.PI * (this.radius);
    }
}

public double totalPerimeter(List shapes) {
	return shapes.stream()
               .map(Shape::perimeter)
               .reduce((a, b) -> Double.sum(a, b))
               .orElseGet(() -> (double) 0);
} 

10.当覆盖equals时,重写hashCode

因其价值而相等的对象被称为价值对象。例如,金钱、时间。这样的类必须覆盖equals 方法,以便在值相同的情况下返回真。equals 方法通常被其他库用于比较和平等检查;因此覆盖equals 是必要的。每个Java对象也有一个哈希代码值,用来区分它和另一个对象:

class Coin {
    private final int value;

    Coin(int value) {
        this.value = value;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Coin coin = (Coin) o;
        return value == coin.value;
    }
}

在上面的例子中,我们只重写了Objectequals 方法:

HashMap coinCount = new HashMap() {{
  put(new Coin(1), 5);
  put(new Coin(5), 2);
}};

//update count for 1 rupee coin
coinCount.put(new Coin(1), 7);

coinCount.size(); // 3 🤯 why? 

我们会期望coinCount 将1卢比硬币的数量更新为7,因为我们覆盖了equals。但是HashMap 内部检查2个对象的哈希码是否相等,然后才通过equals 方法来测试相等。两个不同的对象可能有相同的哈希代码,也可能没有,但是两个相等的对象必须总是有相同的哈希代码,这是由hashCode 方法的契约定义的。所以先检查哈希码是一个早期退出条件。这意味着equalshashCode 方法都必须被重载以表达平等。


如果你编写或审查Java代码,DeepSource可以帮助你实现代码审查的自动化,为你节省大量的时间。只要在版本库的根部添加一个.deepsource.toml 文件,DeepSource就会立即将其拾起进行扫描。扫描将在你的代码中找到改进的范围,并通过有用的描述帮助你修复它们。