文档结构  
翻译进度:已翻译     翻译赏金:0 元 (?)    ¥ 我要打赏
参与翻译: 班纳睿 (20)

这篇文章是一个系列中的一部分,在这个系列里我会调查那些看起来有点可疑的代码(被称为“代码异味”),并且探索一些可行的替代方法。

在我对重构的研究过程中,我看到过大量的模式(异味)一次又一次地出现。他们没有一个是特别新的 ,而且有大量书籍、博客以及视频都会讲到代码异味和如何处理他们,但是我想演示一些特殊的而且不那么简单的例子,以及IntelliJ IDEA可以怎么帮助你完成(或者没办法帮忙)。

第 1 段(可获 1.38 积分)

我一直在尝试解决的第一个问题就是关于null值的使用,特别是它会导致在代码上不得不做空值检查。

我原本以为Java 8的Optional应该能解决不少这样的问题。我一直认为使用一个明确声明自己可能为null的类型就是正确的解决方案,它就是被设计用来让你决定在这些情况下该怎么做的。

然而,事情永远没有看起来那么简单,而且我怀疑这就是为什么在 IntelliJ IDEA里并没有什么魔法神奇般的“优化我的项目”的选项吧。而且相当令人失望的是,这些还是需要我们开发者来认真地思考该如何去做。

第 2 段(可获 1.4 积分)

我们必须明白null可能意味着很多东西,比如可以是如下一些情形:

  1. 从未被初始化的值(有意或者无意的)
  2. 值非法
  3. 不需要有值
  4. 此值不存在
  5. 有东西出错lee或者应该存在的东西却不在
  6. .....也有可能是其他很多东西

如果你是在一个有着清晰目标的比较自律的团队里,那你可以认为你们代码库不存在上面的这些问题 – 例如,在构造方法里总是将final字段进行初始化,在实例化前使用builder或者工厂类对正确组合进行校验,在你们应用的核心部分避免使用null值(比如,检查所有边界条件)等等诸如此类。

第 3 段(可获 1.5 积分)

Optional真正能解决的只有一个,即用例4。比如,你想从数据库查询指定ID的Customer记录。按以前做法的话,你可能会用null来呈现,但是这有可能让人模棱两可 – 意思是说对应的客户没有找到吗?或者说指定ID的客户是存在的,只是没有值而已?又或者是由于数据库连接失败导致的返回值为null?通过返回一个空的Optional,这些歧义就可以得到解决 – 即对应ID的客户没找到。

在一个正常、成熟的,而且有很多人都在上面工作的代码库里,是否有可能分辨我们所有的null的意思,以及我们该如何处理他们? 我们应该通过一些细小的改变来慢慢改进。

第 4 段(可获 1.56 积分)

案例研究

我将在本文中使用Morphia项目作为例子,就跟我之前关于重构的演讲和文章里所使用的一样。 这是一个很不错的样例项目,因为一是它开源,二是它小巧易于下载和研究,三是它比较成熟,可以包含很多你在自己项目里可能会遇到的实际问题的样例代码。

异味:返回null

我在所有位置使用 在路径中查找(Find in Path)的功能搜索所有显式返回null的地方。我准备将这些地方都改成返回Optional对象的方式。

Find places that return null

使用“Find in Path” 搜寻“return null”的结果

第 5 段(可获 1.36 积分)

例子1:Converter.decode()

鉴于大量的*Converter 类的decode方法好像都返回一个null值,也许我们想将Converter的超类(是一个叫做TypeConverter的抽象类)改成返回Optional,就显得是顺理成章的事情了。然而仔细阅读代码,我们才发现:其实有个模式不断在重复,即该方法会检查传给它的值是否是null,如果是则返回null:

@Override
public Object decode(final Class targetClass, final Object fromDBObject, final MappedField optionalExtraInfo) {
    if (fromDBObject == null) {
        return null;
    }

    //...now do the decoding...
}
第 6 段(可获 1.31 积分)

首先的问题是fromDBObject真的可以为null吗?这段代码相当复杂,所以没法分辨,但是理论上来说似乎可以,因为这个值是从数据库里取出的。

快速浏览下代码,就可以发现该方法的所有实例其实都是从一个唯一的地方被调用的,因此我们不需要在21个不同的地方进行检查,仅仅在一个地方做检查即可,然后在之后的代码里就可以认为fromDBObject永远不可能为null。

解决方案:@NotNull注解的参数

我最初认为我们可以使用Optional的想法在这里被证实是错误的。相反的,我将decode方法的签名修改让fromDBObject不能为null – 我选择了使用JetBrains的注解来完成。

第 7 段(可获 1.51 积分)
public abstract T decode(Class targetClass,@NotNull Object fromDBObject,MappedField optionalExtraInfo);


Add null check to call site

接下来我将移动对fromDBObject可能为null的地方的null检查代码(以及后续的null返回代码),也就是调用decode方法的那个地方。

将null检查移到fromDBObject可能为null的那个唯一的地方

虽然这会使得调用结构有点不够整洁,但是却可以将逻辑限定在一个单独的地方,而不是散落在所有的实现那里。 那么,我们可以将fromDBObject不可能为null的事实记录下来,或者借助注解来完成这件事,这样我们就永远不需要在Converter的实现类中做null检查了。

第 8 段(可获 1.36 积分)

接下来我们可以清理下代码,移除掉所有重复代码。IntelliJ IDEA可以帮我们完成。如果我们返回过来注意TypeConverter类里的使用@NotNullparameter注解的那个抽象方法上,我们会看到有一条警告信息:

Overridden parameters are not annotated

警告说实现并没有注解这个参数

当我们在这条警告信息上使用Alt+Enter组合快捷键时,得到的快速修复建议是让我们通过简单的一步就可以将该注解应用到该方法所有的实现上,因此我们就这么做。

Quick fix to apply the annotation to all implementations

使用快速修复来应用于所有实现上

我们的VCS日志显示所有的Converter实现类都已经被更新了。

第 9 段(可获 1.24 积分)

All Converters updated

这样我们就把@NotNull注解添加到了decode方法的参数上,但是我们还没有追踪并移除所有重复的null检查代码。好消息是,现在我们可以说fromDBObject不可能为null了,我们可以使用检查功能来找到所有的null检查并移除掉他们。

其中一种办法是导航到其中一个我们知道已经做了检查的地方(在这里我选择了StringConverter)来看看IntelliJ IDEA给了我们什么警告信息。

Null check not needed as value is never null

不需要Null检查,因为该值从未被使用到

这是由“持续条件和例外(Constantconditions & exceptions)”的检查项所触发的。我们可以仅仅使用该检查项对整个代码库进行搜索来找到轻松的找到其他的例子,即通过Ctrl+Shift+Alt+I组合快捷键,并在弹出框内输入该检查项的名字

第 10 段(可获 1.61 积分)

Run inspection by name

使用Ctrl+Shift+Alt+I来运行指定名字的检查项

这样返回的结果里除了我自己的Converters,还有更多的东西,但是通过对结果按目录进行分组,我就可以很轻易的看到所有的Converter实现类。

Inspection results

通过右边的预览窗口我可以检查哪些代码被标记了,以及IntelliJ IDEA针对每个问题推荐的做法。在浏览并确保这就是我所寻找的null检查之后,我就选定了converters目录并按下了“简化布尔表达式”按钮。

为了能完整的看到这些变更,我进入VCS本地变更窗口并使用“显示变更”(Show Diff (Ctrl+D))来检查36个文件所发生的变更。

第 11 段(可获 1.48 积分)

Using the diff view to sanity check changes

使用diff视图对改动进行完整性检查

我们甚至可以使用diff试图左一些小的编辑 – 在这里我使用Ctrl+Y快捷键移除掉了文件26和27行里多余的空行(即右边的文件)。通过使用Alt+Right快捷键检查所有修改过的文件时,我发现有一个样例中null检查没有删除(这是一个测试的converter,因此它不在converters目录里),但是我在diff视图里一样可以很轻松的进行快速修复从而删掉它。

这种完整性检查让我明白IntelliJ IDEA所做的自动修改有哪些,而且也使得我能够做一些额外的调整。最后要运行所有的测试,当他们通过时,那就是时候把所有的这些修改提交上去了。

第 12 段(可获 1.85 积分)

如果添加一个新的类库来标记一个参数不能为null而且当你传入null值时,从而IDE给你发出警告,这样会让你很不爽,那么你完全可以在提交代码之前就移除掉@NotNull注解 – 毕竟它已经完成工作了。这时候你至少应该在Javadoc里标注下,说该参数已经确定不能为null了。

例 2:Converter.encode()

之前我们已经知道 decode 方法只是显式返回null值的众多位置中其中之一。既然我们已经处理过它了,那我们就可以看看其他的例子。

在同一个 TypeConverter 类里的 encode 方法也可能会返回null。但是与 decode 不同,当这些converter实现类显式返回null时是正常的:

第 13 段(可获 1.54 积分)
@Override
public Object encode(Object value, MappedField optionalExtraInfo) {
    return value.equals('\0') ? null : String.valueOf(value);
}

解决方案:Optional

这里用 Optional 时机刚刚好 – 我们是在明确的声明在某些情形下encode方法不会返回值。Optional.empty()应该最能够诠释这种场景。因此我将 TypeConverter.encode() 方法修改成返回一个 Optional 。这样使得所有的实现更加凌乱了,因为这些东西都需要被包装成在一个 Optional对象里面,但是这样可以更明确的让人们知道这个方法有时不会返回值的。我非常想为大家展示一个IntelliJ IDEA魔法般的操作(有些情况下Type Migration 可以起作用,但是在我的例子里不行)但是我这样做了修改 – 即将超类的返沪类型改成了 Optional 并修复了所有出错的地方。好消息是,一旦那我修改方法签名让它返回 Optional,IntelliJ IDEA 就会为我提供一个快速修复,即将返回值使用 Optional进行包装。

第 14 段(可获 2.19 积分)

Wrap value in Optional

使用Optional对值进行包装

相似的,null值也可以替换掉。

Return Optional.empty() instead of null

用Optional.empty()来替代null返回值

既然我们已经将Optional作为返回类型,那我们就需要在调用的地方也要使用这个新类型。在我这个例子中,实际上并没有得到任何编译警告,因为我的返回值原本是一个Object对象,显然Optional也是一个Object对象(注意:这也就是为什么我们不应该使用原始的Object类型,因为这样做相当于将强类型语言的优势给丢掉了。这段代码使用Generics会更好些)。我只需要将调用 encode方法的那个地方将Optional包装解开。我要使用令人讨厌的orElse(null),不过既然这是原始方法也在做的事情,而且代码中只有这一处用到,所以我还可以接受k。 – 我可以将它标记为技术债务,然后循序渐进的去处理这个问题,而不是在代码里追着这种问题不放。

第 15 段(可获 2.06 积分)

Dealing with the optional return

这样保留了原来的行为,但是应该被当作日后需要处理的技术债务。

在那些调用encode方法的测试里,我把他们改成调用encode(o).get() – 通常这样是不安全的,但是 a) 测试不应该返回空的Optional,b) 如果它们返回了空的话,就会失败,那也没事。实际上在我的例子里并没有什么值都没返回的测试用例,因此我应该添加一些测试用来返回空的Optional。

通常来说,将API修改成使用Optional并不是一件琐碎的事情,但是像这种情况下,确实有助于澄清意图, – 当没有有效值时,它不会返回null值,而是返回一个空的Optional,这样你就可以决定该做什么 – 返回一个替代值,抛出一个异常,或者执行一些其它的操作。不过要注意,这样可能会比较危险。

第 16 段(可获 1.81 积分)

注意:这个例子没问题,但是这个代码里有其他一些方法非常适合返回Optional,却由于改变它们对于上游的影响太过巨大 – 这些方法在多个地方被调用而且返回的类型在各种复杂的方法中被使用到,这就使得几乎不可能追踪到正确的位置来测试Optional并解包,以及让它在代码里传递。最后总结下,如果你能够将影响局限到一到两个调用层次,而且能够立刻处理返回的Optional类型的话,就使用它好了。

样例3:Mapper.getId()

第 17 段(可获 1.28 积分)

这里有另外一个返回null值的例子:

public Object getId(final Object entity) {
        Object unwrapped = entity;
        if (unwrapped == null) {
            return null;
        }
        unwrapped = ProxyHelper.unwrap(unwrapped);
        try {
            return getMappedClass(unwrapped.getClass()).getIdField().get(unwrapped);
        } catch (Exception e) {
            return null;
        }
    }


这个例子很好的展示了null能够表达的两种不同的东西。第一个null是说我们有一个null的输入,因为我就返回了一个null的输出。看起来相当的合理,但是就是没帮上啥忙。第二个null则是说“有错误发生了,而我没法给你一个值,因此就给你一个null吧”。在第一个场景下,一个null的ID还算是个有效的响应吧,但是如果能告诉调用方错误是什么,从而可以做相应处理的话,那就很更有用些。即使调用方无法处理该异常,那把异常捕获后返回null也是极其不友好的,特别是当该异常都没有记录日志的时候。 这对于隐藏问题的真实原因确实是个很不错的办法,:)。异常应该在他们出现的地方被处理掉,或者以一种有益的方式传递下去,而不应该被吞掉,除非你真的能确定那根本就不是错误。
.

第 18 段(可获 2.63 积分)

因此,这里的 null意思是:“有未知情况发生,而我却不知道该如何处理,因此我给你返回一个null值,希望这样挺好的”,这对于调用者来说一点都不清晰。这些场景绝对应该避免出现,而且你没法使用 Optional来解决问题。正确的解决方案就是实现更加清晰的错误处理机制。

总结

Null是一个特别棘手的问题。其中最主要的问题就是我们不知道它是什么意思。它是由于值的缺失,但是这又可能是由多重原因造成的。使用null 并不是用来标识这些原因的有用的手段,因为从它的定义来看,它没有值,毫无意义。

第 19 段(可获 1.55 积分)

症状:

  • 在应用代码中广泛使用Null检查,null到底意味着什么或者其值是否真的可以为null,对于开发者来说,完全是不清楚的。.
  • 显式的返回了null

推荐的解决方案:

  • 使用Optional。 并不是所有返回null的地方都可以使用此种方案。但是如果一个方法故意返回了一个null值,而且其意为“没有找到”或者 “没有其中的一个”,那么这种情况下就可以返回Optional值作为候选方案。
  • 使用@NotNull或者@Nullable注解。对于复杂的代码库来说,其中一个问题就是要理解实际的有效值有哪些。如果你在检查寻找null参数,那么就可以尝试在其签名上使用@NotNull注解,这样就可以知道是否有null值传入。如果从未传入null值,那你就可以将其移除。如果你觉得单纯为了使用@NotNull注解而引入了一个依赖感觉很不爽,那你大可以只是临时引用,让IntelliJ IDEA告诉你是否会有问题,如果有就修复发现的问题然后再运行所有的测试来确保一切都正常工作。如果都没问题了,那你就可以移除掉该注解,并添加一个Javadoc注释(当然没有注解那么安全,不过对于使用或者维护该代码的开发者来说仍然可以起到了很大的提醒作用)并且将更新的代码提交到代码库里。
  • 异常处理。 在出现异常的情况下返回null值是不够友好的,而且对调用者来说也是有点意外的。这样可能会导致之后的代码会抛出NullPointerException,或者至少会有更多的null检查(却不知道为什么其值为null)。如果能抛出一个能描述一些东西的异常,那就会相当有用。
  • 有的时候,这没问题。字段级别的null值(这些地方大家都能轻易明白什么会为null而且为什么为null)就是完全可以被接受的例子。
第 20 段(可获 3.54 积分)

文章评论