让另一双眼睛扫描你的代码总是有用的,可以帮助你在破坏生产之前发现错误。你不需要是一个专家来审查别人的代码。一些编程语言的经验和一个审查清单应该可以帮助你开始工作。我们整理了一份在审查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集合提供了ArrayList 、LinkedList 、Vector 、Stack 、HashSet 、HashMap 、Hashtable 。了解每种结构的优点和缺点,以便在正确的情况下使用它们,这一点很重要。一些提示可以帮助你做出正确的选择:
-
Map:如果你有键、值对形式的无序项目,并且需要有效的检索、插入和删除操作,那么就很有用。HashMap,Hashtable,LinkedHashMap都是Map接口的实现。 -
List:非常常用于创建一个有序的项目列表。这个列表可能包含重复的内容。ArrayList是List接口的一个实现。使用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.对接口的代码
如果你有某些接口的具体实现(例如:ArrayList 或LinkedList ),如果你在代码中直接使用它们,那么就会导致高耦合性。坚持使用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;
}
}
在上面的例子中,我们只重写了Object 的equals 方法:
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 方法的契约定义的。所以先检查哈希码是一个早期退出条件。这意味着equals 和hashCode 方法都必须被重载以表达平等。
如果你编写或审查Java代码,DeepSource可以帮助你实现代码审查的自动化,为你节省大量的时间。只要在版本库的根部添加一个.deepsource.toml 文件,DeepSource就会立即将其拾起进行扫描。扫描将在你的代码中找到改进的范围,并通过有用的描述帮助你修复它们。