0%

整洁代码推荐实践

前言

这里的建议大多数应用于 Java 语言,其他编程语言也可以适当参考。
有一些更通用的、关于如何编写整洁代码的建议,可以参考《整洁代码之道》

减少无意义的判空;学会使用 @Nullable @NonNull 注解

如果已知引用不可能为空,就无需判空。例如,在以下情况下,Java 语言会保证变量不为空:

  • 前面的代码已经做了判空处理;
  • 待检查的对象是由一个保证非空返回值的方法返回的,如构造工厂方法或 Objects.requireNonNull 方法;
  • 待检查的对象是由基本类型自动装箱得到的。
  • 在 switch-case 语句中,switch 的变量不用再判空,因为如果为空会抛出 NullPointerException(建议在 switch-case 之前加上判空);
  • 变量 instanceof 操作符返回 true 时,不用再判空。因为如果为空,instanceof 操作符会返回 false
  • 通过 try-catch 捕获的异常对象不用判空;
  • new 方法创建的对象一定不为空;

可空性和非空性是可以传递的,但 Java 语言没有提供这样的语法检查机制,这被称为“十亿美元的错误”。一些新兴的语言通过将可空性纳入类型系统来解决这个问题。如果你仍然需要维护 Java 代码或者不能使用 Kotlin,你可以考虑使用 @Nullable@NonNull 注解来标注可空性和非空性。

尽量避免使用 checked exceptions

Java 语言中,异常分为两种:checked exceptions 和 unchecked exceptions。Checked exceptions 是指那些必须在编译时被捕获或者抛出的异常,而 unchecked exceptions 是指那些可以在运行时被忽略的异常。虽然 checked exceptions 的设计初衷是为了提高程序的健壮性和可靠性,但是在实际开发中,它们也带来了一些问题和挑战:

  1. 增加代码复杂性: 强制性的异常处理导致代码复杂度提升。当方法声明了 checked exceptions,调用者被迫处理这些异常,即便在某些情况下这些异常的发生概率极低,这可能导致代码冗余。更进一步,强制性的异常处理使得处理异常的代码散布在各个调用处,这对代码的内聚性和可复用性产生了负面影响。
  2. 异常处理流于形式: 由于对 checked exceptions 的处理是强制性的,开发者往往会选择使用空的 catch 块或者仅仅重新抛出异常,这并没有真正地处理异常,而只是在形式上遵守了规则。
  3. 限制运行时灵活性:Checked exceptions 需要在编译时进行处理,这可能会对程序的运行时灵活性造成限制。例如,当使用反射或者其他动态技术时,编译时的异常声明可能变得不再适用。
  4. 不兼容 Lambda 表达式:Lambda 表达式的方法签名是由调用处接口的方法签名推断得出的,checked exceptions 作为方法签名的一部分,通常不会在调用处接口中声明,因此该异常不能通过函数式接口界面传递给上层调用者。因此,使用 checked exceptions 会阻碍方法在 Lambda 表达式中的使用。

总的来说,开发者需要全面理解和应对 checked exceptions 的各种挑战,在代码中审慎使用该特性,以提高代码的质量和可维护性。

不仅是语法糖:尽量使用 Lambda 表达式或方法引用替代匿名内部类

相比匿名内部类,Lambda 表达式有以下好处:

  1. 更加简短,便于阅读,特别是在方法体本身只有一两行的情况下,大大减少冗余信息展示,而聚焦业务逻辑本身。
  2. 不会隐式捕获外部类的 this 指针,能在很多情形下避免内存泄露。
  3. 不能获取指向自身的this 指针:在 Lambda 方法体内调用 this 指向的实际上是其外部类对象。这在某些特殊情况下会有些小小的不便,但大多数情况下会避免潜在的错误:比如在使用入参为 “Lambda 类型与外部类类型的公共类”(最常见的如Object)的方法时,不会在应该传入外部类对象时,错误传入匿名内部类对象。
  4. 鼓励使用函数式的表达方式,简化业务代码。

如果你一时想不起方法签名,而使用了匿名内部类的表示,Intellij 或者 Android Studio 这样的 IDE 也会在可转化为 Lambda 表达式的情况下提醒你。不用担心,大胆应用建议的操作吧!

自顶向下、由抽象到具体地组织代码

好的代码就像一篇良好组织的文章或报告,首先是概述或总结,然后是具体的章节和详细信息。即使只是更换方法在文件中的先后顺序,也能有效提高编写和阅读代码的效率。下面是编写和组织代码的一些建议:

  1. 首先定义接口和抽象类:在编码开始时,先确定并定义系统的高层次结构,如接口和抽象类,以概述主要功能和行为。
  2. 模块化高层逻辑:创建清晰的高层模块来表示系统的核心逻辑,隔离复杂性并提供一个简单的外部视图。
  3. 方法实现紧跟声明:代码中的某个方法 A 调用私有方法 B 时,应将方法 B 的代码放在方法 A 后面。这样,读者可以在了解方法 A 的功能后,马上看到方法 B 的实现,避免分散注意力。
  4. 清晰注释关键接口:对于关键的接口和抽象类,提供清晰的注释来说明其用途和作用,便于理解和后续维护。
  5. 避免混淆抽象和具体实现:在代码中明确区分抽象概念和具体实现,避免在一个区域混合使用,以免造成混乱。

不要惧怕异常,更不要隐藏异常:快速失败好过处处兜底

对线上异常的恐惧根植在许多开发者的内心,以至于恨不得每一个方法外面都包裹一层 try-catch。

但异常不是洪水猛兽,它是对你代码出现问题的强有力的提醒,也是对践行“快速失败”这一开发原则的有效工具。

“快速失败”(Fail-fast)是一种设计哲学,它鼓励软件在检测到问题的第一时间立即报告错误,而不是尝试继续运行并可能导致更多的错误。这个原则背后的理念是,“早期发现并处理错误”比“错误被隐藏,并在未来某个不确定的时间点引发更严重问题”要好。

具体来说,“快速失败”原则可以为软件开发提供以下好处:

  • 提早发现问题: 通过快速失败,开发者可以更快地识别出代码中的问题,从而在问题变得复杂和难以追踪之前就及时修复它们。
  • 简化调试过程: 当程序在出现问题时立即停止,开发者可以更容易地定位问题发生的具体位置,这比在程序的后期阶段追踪错误要简单得多。
  • 防止错误累积: 如果错误没有被及时发现和处理,它们可能会在系统中累积,导致更大的问题,甚至可能导致系统崩溃。快速失败有助于防止这种情况的发生。
  • 提高系统的健壮性: 系统在面对潜在的错误时能够迅速响应并采取措施,这表明系统具备良好的错误处理能力,从而提高了系统的整体健壮性。
  • 促进良好的编程习惯: 遵循快速失败原则的开发者往往会更加关注代码的质量和健壮性,从而促进更好的编程实践和习惯。

开发者常常对程序可能出现的失败或异常感到恐惧,主要是因为他们担心这些错误会使系统变得不稳定或难以维护。然而,快速失败原则实际上鼓励开发者将这种担忧转化为积极的行动,通过提前处理错误,从而降低长期的风险和成本。

以 GUI 应用程序为例,如果在每个地方都捕获异常,可能会导致一些比应用程序崩溃更糟糕的情况:

  1. 异常发生了,但被悄悄地捕获了,没有错误日志被记录,也没有人注意到这个问题。
  2. 由于捕获异常的地方可能在调用栈的很上层,距离异常发生的地方很远,应用程序不会自动从异常中恢复。如果你没有告诉它在异常发生时应该做什么(例如显示错误信息并提供一个重试按钮),它就什么也不会做。
  3. 应用程序就这样尴尬地处于一个错误的中间状态,向用户展示一个空白的或错乱的屏幕,用户除了完全退出并重新启动应用程序,没有其他办法回到之前的流程中。
  4. 另一种可能是,应用程序从错误的中间状态开始继续运行,它带着重伤继续艰难地运行,一个错误引发了无数个错误,直到最后另一个异常结束了它。这个最后的异常与最初的异常发生的地方相距甚远,你完全不知道应用程序经历了什么,才会发生如此离谱的错误。

这种情况比应用程序直接崩溃更可怕,原因如下:

  1. 你对应用程序出现问题一无所知,没有在第一时间修复它,直到愤怒的用户投诉过来你才知道发生了什么。而在此之前,已经有许多沉默的用户悄悄地流失了,还有一些用户面对白屏或错误的页面束手无策,不知道如何恢复正常。
  2. 你发现出了问题,但你完全不知道为什么。由于异常发生的第一现场被悄悄掩盖了,你失去了问题发生的上下文,找出问题出现的具体位置变得极其困难。
  3. 表面上看起来一切都没有问题,但代码复杂度增加、资源泄露、安全隐患等隐性风险会一直累积,直到应用程序的体验极差、项目难以维护。

当然,事情也没必要走向另一个极端。快速失败原则并不意味着开发者不应该捕获和处理任何异常。相反,它强调的是在发现错误时应该立即采取行动,这可能包括记录错误、通知开发者、终止当前操作或者采取其他适当的错误处理措施。关键在于如何响应这些异常:

  1. 输入验证: 在用户输入或者接收到外部数据时,立即进行验证。如果数据不符合预期,应立即返回错误或提示用户,而不是让错误的数据影响程序的其他部分。
  2. 错误日志记录: 当异常发生时,应该记录详细的错误信息和堆栈跟踪。不要捕获异常而不进行任何处理,即使是打印日志也好。这样可以避免程序在不透明的状态下继续运行。
  3. 使用断言: 在开发期间,使用断言检查不应该发生的条件。例如,在测试渠道包使用Objects.requireNonNull检查某个重要的变量不为空。这些断言在生产环境中通常是禁用的,但它们在开发和测试阶段可以帮助及早发现问题。
  4. 及时的异常处理: 在可能抛出异常的代码块周围使用 try-catch 语句,但只捕获那些你知道如何处理的异常。例如,当处理网络请求时,捕获IOException并提供用户重试的选项,或者试图从容器中取元素时,捕获 IndexOutOfBoundsException 并视作"没有对应的元素"来处理。
  5. 优化异常传播: 如果你不知道如何处理可能发生的异常,应当将其抛出,将异常传播到可以处理它的地方。例如,如果一个低级方法遇到无法解决的异常,它应该将异常传播到上层,让更高层的逻辑处理这个异常。捕获异常的最佳地点是在各个业务层级、各套独立 API 之间的交接点。
  6. 用户友好的错误处理: 当错误发生时,尝试提供清晰的用户反馈,而不是让应用崩溃。使用ToastSnackbar或者错误界面来通知用户发生了错误,并可能提供解决步骤。