【译者注:Code Smell 中文译名一般为“代码异味”,或“代码味道”,它是提示代码中某个地方存在错误的一个暗示,开发人员可以通过这种 smell(异味)在代码中追捕到问题。】
在我对重构的研究中,我看到一些模式(异味)一再出现。 这些都不是特别新鲜的事情,并且有很多书籍,博客和视频也对“代码异味”进行了介绍,也说明了如何处理它们,但我想展示一些具体的,特别的示例,当然,IntelliJ IDEA 也许能够(也可能不会)帮助你。
我试图解决的第一个问题是使用 null,特别是当它在代码周围散布 null 检查时。
我觉得 Java 8 的 Optional 应该会解决很多这样的问题。 我认为特别指出也许为 null 的类型,这样设计是为了让你明确在这样的情况下怎么做才是正确的。
然而,事情是从不会像他们看见的那样简单,我怀疑为什么在 IntelliJ IDEA 中没有“让项目可选化”的灵丹妙药。而这正是我,一位开发者,需要认真考虑应该怎么做的领域。
我们不得不接受这个事实,即 null 意味着很多事情。 这可能意味着:
-
数值没有被初始化 (不管是偶然还是故意的)
-
数值无效
-
无用数值
-
该数值不存在
-
发生一些严重的错误,应该存在的东西却没有在哪里
-
…可能还有很多其他的事情
如果你是一个具有明确设计目标而且编码规范严格的团队,你可以认为其中一部分不会在你的代码库中呈现 - 例如,在构造函数中始终初始化不变的字段,在实例化之前使用生成器或工厂来验证正确的组合 ,而不是在应用程序的核心使用 null(即将所有检查都放到一边)等等。
Optional 仅解决了其中一种情况,案例 4. 例如,你向数据库查询一个指定 ID 的客户。之前,你可能已经使用 null 来表示这一点,但这可能是模棱两可的 - 这是否意味着没有找到客户? 还是有一个客户的 ID 但是这个 ID 并没有值? 或者是由于与数据库的连接失败而返回 null? 通过返回一个空的Optional,你已经消除了歧义 - 没有使用这个 ID 的客户。
在一个正常的,成熟的代码库中,会有许多人参与其中,这是否有可能告诉我们所有的null是什么意思,以及如何处理它们呢? 我们应该能通过一些小小的尝试来取得一些进展。
案例研究
我正在使用 Morphia 项目作为文章中的一个例子,就像我在之前的谈话和关于重构的文章一样。 这是一个很好的示例项目,因为它是一个开源的且足够小,可以轻松下载和浏览,并且足够成熟,下面示例可能包含您在自己的应用程序中的实现代码。
异味:返回 null
我在路径中做了一个找到所有显式返回 null 的地方。我有一个理论,也许我们可以将这些方法改成返回一个 Optional。
“返回 null”的“查找路径”结果
示例 1: Converter.decode()
鉴于许多这些 *Converter 类似乎在 decode 方法中返回一个空值,所以我们可能希望这里更改 Converter 超类(一个名为 TypeConverter 的抽象类)以返回 Optional,这似乎是合理的。但是仔细看看代码实际所发生的情况:我们看到完全相同的模式一再发生 - 该方法检查传入的值是否为空,如果有,返回null:
第一个问题是 fromDBObject 实际上可以为 null 吗?代码是非常复杂的,所以很难说,但从理论上看,似乎是合理的,因为这是数据库中的一个值。
一个快速的搜索显示,这个方法的所有实例实际上只是从一个中心位置调用,所以不用在 21 个不同的地方进行这个检查,所以我们可以在一个地方做,而从那里假定 fromDBObject 不能再为null 。
解决方案: @NotNull 参数
我原来的假设,我们这里可以使用 Optional 证明是错误的。相反,我要更改 decode 的方法签名来声明 fromDBObject 不能为 null - 我选择使用 JetBrains 注释来执行此操作。
然后我移动 null 检查(和后续的空返回),其中 fromDBObject 实际上可以为 null,调用一个叫 decode 的方法。
将 null 检查移动到单个调用方法点,其中 fromDBObject 可为 null。
它使得调用方法更加不整洁,但将该逻辑限制在单个位置,而不是分散在所有实现中。现在,我们可以记录一个事实,即 fromDBObject 不会为 null,或者依靠注释来为我们做这个事情,所以我们不必在 Converter 实现中执行这个 null 检查。
接下来我们整理并删除所有重复的代码,这一点 IntelliJ IDEA 会帮我们完成。如果我们回到 TypeConverter 中含有注解为 @NotNull 参数的抽象方法,会得到一个警告:
警告实现中没有注解这个参数
我们在这个警告上使用 Alt+Enter,快速修复建议可以让我们对这个方法的所有实现应用注解,做起来很容易,只管去做。
使用快速修复应用到所有实现
我们的 VCS 日志显示所有 Converter 实现都有变化
这里为 decode 方法的参数添加了 @NotNull 注解,但是我们仍然没找到并删除所有重复的空检查代码。好消息是我们说过 fromDBObject 不会是 null,我们可以使用 inspection(检查) 来查找所有多余的空检查代码并删除它们。
其中一个办法是进入一个我们知道已经存在检查的实现(这里我选择了 StringConverter) 来查看 IntelliJ IDEA 给出来的警告。
值不可能为 null 时不需要空检查
这是由“恒定条件&异常”检查触发的。我们可以在这段代码上运行此检查,以便轻松找到其它示例,因此使用 Ctrl+Shift+Alt+I 并输入检查名称。
使用 Ctrl+Shift+Alt+I 按名称运行检查
这会返回更多的结果,通过按目录对结果进行分组,我可以轻松看到所有 Converter 实现。
我可以使用右边的预览窗口来查看哪些代码被标记出来,并看到 IntelliJ IDEA 建议怎么处理每个问题。通看一遍并确认这是我在查找的空检查之后,我选择 converters 目录并按下“Simplify boolean expression(简化布尔表达式)”按钮。
我会进入 VCS 本地变动窗口检查变化,并使用 Show Diff (比较查看,Ctrl+D) 来检查应用于 36 个文件的变更。
使用比较视图来检查变更
只要我愿意,我甚至可以在比较视图进行一些简单的编辑 —— 我会使用 Ctrl+Y 删除不需要的空行,即文件(右侧的文件)中的 26 和 27 行。使用 Alt+Right 可以检查所有存在变化的文件,我找到一个空检查未被删除的示例(这是一个测试用的 Converter,没有放在 converters 目录下),不过我甚至可以在比较视图使用快速修复来删除它。
这个智能检查让我放心使用 IntelliJ IDEA 的自动更改功能,也让我进行了一些其它调整。最后要做的就是运行所有测试,它们都通过的时候(哇哦!)就是提交这些改变最好的时机。
如果在文档中添加关于参数不能为 null,并且 IDE 会在你传入 null 值的时候产生警告,这样做会让你觉得不爽,你可以在提交代码之前删除 @NotNull —— 毕竟它的使命已经完成。这样的话,你最后应该更新 Javadoc(文档) 说明假设该参数不为 null。
示例 2: Converter.encode()
之前我们看到了 decode 方法只是那几个显示返回 null 值的方法中的一个。我们已经解决了这个问题,现在我们来看另一个示例。
同一个 TypeConverter 类中的 encode 方法也可以返回 null。但它不像 decode,当转换器实现中显示返回 null 时,它是有效的。
**解决方案:Optional **
这是使用 Optional 的好地方 —— 我们明确说明在某些情况下 encode 方法不会返回值。Optional.empty() 看起来可以很确切的代表这种情况。因此我修改 TypeConverter.encode() 方法,使之返回 Optional。这让所有实现稍稍失去整洁,因为所有东西都需要包装成 Optional,但是这会让事实变得更清楚,即这个方法有时候不会返回值。我非常愿意向你展示 IntelliJ IDEA 的魔法(在某些情况下,类型迁移会有用,但并不是在我这个示例里) 但是我做了更困难的改变 —— 我修改了超类的返回类型为 Optional,并修正由此引起错误的地方。幸好在我把方法签名的返回值改为 Optional 的时候,IntelliJ IDEA 提供了一个快速修复来将返回值封装成 Optional。
将值封装为 Optional
类似的,空值也可以被替换。
返回 Optional.empty() 来代替 null
现在我们返回的是 Optional,我们必须保证调用点能基于这个新的类型工作。在本示例中,实际上我的返回值是原来的 Object,但没有收到任何编译警告,显然 Optional 也是 Object (注意:这就是为什么我们不应该使用原始的 Object 类型,因为它基本上消除强类型语言的优点。这个代码使用泛型会更好)。我只需要改变一个地方,就是调用 encode 方法来拆开 Optional。我准备做点不太好的事情,使用 orElse(null),不过既然原来的方法就是这么做的,而且它就只出现在代码中这个地方,我会很高兴这么做 —— 我可以将其标记为技术债务,逐步解决问题而不会追求完善周围所有代码。
这样做保留了原来的行为,但应该被视为以后要解决的技术债务。
在调用 encode 方法的测试中,我只是将其更新为调用 encode(o).get() —— 通常这不安全,但是 a) 测试不会返回空的 Optional,而且 b) 如果确实返回了,测试会失败,这是正确的。实际上在这里强调了没有针对无返回值的测试,所以我会为返回空 Optional 的情况添加一个测试。
修改 API 以使用 Optional 常常并不简单,像本例的这种情况,它其实可以帮助我们搞清楚意图 —— 当没有有效的返回值时,它不应该返回 null,而应该返回空 Optional 并由你声明做什么 —— 返回另一个值、抛出异常还是执行其它操作。不过小心,这样做危险重重。
注意:这个示例没有问题,但是代码中还有其它一些方法最好也返回 Optional,然而进行这一改变之后,上游会受到巨大的影响 —— 这些方法在很多地方被返回各种复杂类型的方法调用,这使其几乎不可能找到合适的地方来测试并拆开 Optional,或者在代码中让它传播。我所学到的经验是,如果你能将影响限制在一到两个调用点上,那就立即使用 Optional 来处理返回类型。
示例 3:Mapper.getId()
这里还有另一个关于返回 null 的示例:
这是个很好的例子,它的 null 表示了两个不同的意义。第一个 null 表示我们给了一个 null 作为输入,因此会输出 null。虽然有点无用,但相当有效。第二个 null 表示“发生了错误,所以我不能给出一个值,只能给个 null”。对于第一种情况,大概空 ID 是个有效的反馈,但是对于调用者来说,知道错误是什么并进行相应的处理可能会更好。即使出现了调用者无法处理的异常,捕捉该异常并返回 null 也非常不好,尤其是在还没有记录异常的情况下。这真是个隐藏真实问题的好方法。异常应该在被发现时进行处理,或者以某种有用的方式传递出去。除非你知道那确实不是错误,否则不应该把异常吞噬掉。
那么这里的 null 表示“意想不到的事情发生了,我不知道该怎么办,所以我返回一个 null 并希望事情得到解决”,这对于调用者来说并不清楚。这种情况一定要避免,而且不能用 Optional 来解决。其解决办法是实现更清楚的错误处理。
结语
null 是一个特别难处理的问题。null 的主要问题是我们不知道它究竟是什么意思。null 表示没有值,但这可能是出于多种原因。null 并不是一个万能的方法,可以标识所有这些原因,因为它最初的定义就是没有值,没有任何意义。
症状:
-
在整个应用程序代码中广泛使用 null 检查,但你或者开发人员可能不知道它的意思,null 值真正表示什么,或者该值是否可以为 null。
-
显式返回 null
可行的解决办法:
-
Optional。它并不适用于你发现 null 的每一个地方。但是如果某个方法故意返回 null 来表示“没找到”或“没有”,Optional 就是个很好的选择。
-
@NotNull/@Nullable。复杂代码最基本的问题之一就是理解哪些值确实有效。如果你需要检查空参数,尝试将 @NotNull 加入方法签名并观察是否有 null 值曾经传入。如果没有传入 null,你就可以去掉空检查。如果你不适应添加一个依赖项却只是为了 @NotNull 注解,你可以临时应用这些注解,让 IntelliJ IDEA 告诉你是否存在问题,如果有必要就进行修复并运行测试以确保所有事情都是正确的。如果都没问题了,你可以删除这些注解,添加 Javasoc 注解(不如注解安全,但是对调用和维护方法的开发者来说非常有用),并提交更新后的代码。
-
异常处理。异常发生的时候返回 null 是非常不好的,从调用者的角度来看,这不可预料。它可能导致 NullPointerException,或者至少会导致更普遍的空检查(对于为什么值为 null 没有清晰的概念)。向下游抛出一个具有描述意义的异常是很有价值的。
-
有时候,它很好。字段级的 null (应该清楚知道什么可以是 null 以及为什么是 null) 就是一个例子,这里完全可接受 null。
https://mp.weixin.qq.com/s/QWQ7UZghj2KdPLxPhMnuaA