省略 return 的语法糖

最近为了实现 Jekyll (3.8.2) 的 TextBundle 支持读了一点 Ruby 代码。期间发现了有一个方法:

# Read all the files in <source>/<dir>/_posts and create a new Document
# object with each one.
#
# dir - The String relative path of the directory to read.
#
# Returns nothing.
def read_posts(dir)
  read_publishable(dir, "_posts", Document::DATE_FILENAME_MATCHER)
end

在调用方却是这样使用的

# Retrieves all the posts(posts/drafts) from the given directory
# and add them to the site and sort them.
#
# dir - The String representing the directory to retrieve the posts from.
#
# Returns nothing.
def retrieve_posts(dir)
  return if outside_configured_directory?(dir)
  site.posts.docs.concat(post_reader.read_posts(dir))
  site.posts.docs.concat(post_reader.read_drafts(dir)) if site.show_drafts
end

这里 read_posts 方法并没有像文档中写的那样 returns nothing。由于 Ruby 语言允许省略 return 关键词,自动使用最后一个表达式的返回值作为返回值,这个问题变得更加隐蔽:要让 read_posts 的文档过时,甚至不需要修改 read_posts 方法本身,只要让这个方法中最后一个方法调用的最后一个方法调用的最后一个方法调用(此处省略复读 1 万次)返回一个值就好了。

这意味着对任意方法的返回值类型的修改,很可能会导致灾难性的连锁反应。因为在修改某个方法的时候,我们很难找到某个方法所有的调用者,以及每个调用者的调用者们,并确保他们的行为正确性不会受我们这次修改的影响。因为没有编译器和函数签名帮我们把关,我们无法确保我们的修改不会造成没有预期到的后果。

缺乏强制性的文档:注释

动态类型语言是很强调注释的:由于缺乏类型标记,我们无法让代码的阅读者或方法的调用者仅通过阅读代码了解方法的参数应该满足什么条件,这个缺陷必须通过注释来弥补。而注释的最大问题就没有强制性,这使得注释很容易和实际代码实现脱钩,不再如实反映代码的实际行为。

def read_posts(dir)
  read_publishable(dir, "_posts", Document::DATE_FILENAME_MATCHER)
end

def retrieve_posts(dir)
  site.posts.docs.concat(read_posts(dir))
end

以 Jekyll 那个例子来说,read_posts 调用了 read_publishable 并将其返回值作为自己的返回值,而 retrieve_posts 中使用了 read_posts 的返回值,那么一旦 read_publishable 的返回值发生改变,即使是运行时错误也只会发生在 retrieve_posts中。我们假设负责这份代码的程序员尽职尽责,会修复所有自己发现的问题,并且不会忘记更新相关方法的文档。这位尽职尽责的程序员也有很大几率会忘记更新整个过程中既没被他修改过,也没在运行时抛出异常的 read_posts 方法的注释。这里提到 Jekyll 举例子并不是要批评其代码质量,只是这个例子很好地说明了注释作为缺乏强制性的文档,是多么容易变得过时。

单元测试即文档

如果可以选择的话,单元测试是更好的文档。有的方法注释里面还会包含 example 项,用来举例方法如何使用,我觉得比起因为缺乏强制力而很容易变得过时的 example 注释,真正可以执行的单元测试,更加能够反映方法的使用方法。单元测试包含了方法的可能的输入和预期的输出,因此阅读单元测试本身也是理解方法行为的一个途径。单元测试是一种「有强制性的文档」,是因为如果方法的逻辑被修改而单元测试没有更新,那么单元测试很大可能性不会通过,这会迫使修改代码的开发者更新单元测试,使其与最新的代码行为同步。

自注释代码

即使不使用单元测试,也有办法让编写注释变得不必要。Objective-C 语言有一个特点,方法名往往很长并具有叙述性。我们偏向写很长的方法名来尽可能准确详细地描述一个方法的行为,而不是起一个很简洁的名字然后依靠文档了解释这个方法都做了什么事。Objective-C 语言中的这个传统带来的一个好处:我们很少需要编写注释,读代码本身就足以理解代码的行为。我们称这种代码为自注释代码(self-documenting code)。仅当代码中存在违反直觉的内容的场景下,比如你要 workaround 一个第三方库的 bug,或者为了获得性能而写了一个可读性不是很好的实现时,才需要依靠额外的注释来说明。

对于第一次阅读一段代码的开发者来说,将方法的描述融入方法名,也意味着在阅读调用方的代码的时候就读到了方法的文档,不需要一次一次通过「跳转到定义」来单独阅读每个感兴趣的方法的注释了。从这个角度来看长方法名 == 短方法名 + 注释

长方法名与代码补全

对简短的方法名的追求自古就存在:从 ls cd pwd 等 unix 工具的命名,到 usr tmp opt 等目录名都有体现。然而从编程语言的角度来看,动态类型语言往往更加偏向使用缩写和短方法名。我认为人们对短方法名的追求其根本原因是一致的:在缺乏代码补全协助的情况下,短方法名会带来更快的代码编写速度以及更少的输入错误率。可以打 cd 我想没人会想手动输入 changeDirectory 吧。

现如今随着 IDE 功能的完善,坚持短方法名的意义已经不像以前那么大了,自动补全工具非常智能,绝大多数时候,只需要输入方法中的部分关键字即可准确定位到完整的方法。对于很多静态类型语言来说,我依然可以只输入 cd 然后按 tab 键让 IDE 帮我补全剩下的内容。

然而对于 Ruby 这样的动态类型语言,之所以仍然倾向使用短方法名,除了历史原因外,很大原因在于 IDE 能做的事情受到了限制:由于动态类型语言的特点,变量没有类型标记,方法不需要提前声明,这使得自动补全工具往往也不知道某个参数是什么类型,某个类型有哪些方法可以调用。在这种自动补全帮不上忙的环境下,语言的使用者自然会倾向于使用短方法名。

类型系统即文档

类型系统1将一部分运行时错误(抛出异常)转换为语义错误(编译器错误),使得开发者可以尽早发现问题。而我们都知道,越早发现问题,趁人类头脑中的缓存还没被覆盖上其他内容,问题就越容易解决。如果程序部署一年后才发现问题,要想修复相关的bug,无论记忆多么好的人都得回头再读一遍自己以前写的代码。

如果将文档理解为初期需要花时间编写,但可以未来增加代码的可读性的东西。有理由认为类型系统(和单元测试一样)正是另一种文档的形态。戴着类型的枷锁实现功能花费的时间,将会在未来阅读代码时得到补偿。更不用说编译器帮忙找到的语义错误,使得修改函数返回值这件事变得非常安全。

动态类型与静态类型之争

从业务的角度看,无论是动态类型语言还是静态类型语言都能很好地完成任务。如果类型系统可以当作一种文档的话,那么类型系统的缺失一定程度上也可以通过注释、断言、测试的完善来弥补。这意味着从业务功能实现的角度来看,类型系统不是必要的。

同时,没有类型系统、注释、断言、测试这些文档不代表代码就必定变得难以阅读。代码的可读性还取决于代码对应的业务逻辑本身是否复杂,以及编写代码的人是否成功地把业务逻辑的复杂性拆分到不同的过程中去。这意味着无论是动态类型语言还是静态类型语言,都可以写出逻辑清晰可读性好的代码。

动态类型与静态类型之争,在于程序员是否倾向于使用类型系统这种文档形式。无论是注释、断言还是测试,程序员可以开发过程中自由地选择是否使用这些文档形式。唯有类型系统是与语言绑定的,没得选。要么一定得用,要么想用也用不了。

未来

尽管听起来像是个人偏好的问题,但有两个理由让我相信静态类型语言拥有更好的未来:

  1. 类型系统可以协助编译器提供更进一步的性能优化。
  2. IDE 可以借助类型系统确保程序员提供的信息,来辅助程序的构建。

这两个理由中,后者在我看来意义更加重大。今天的编译器已经可以通过类型推导消灭了静态类型语言中大量重复的类型标记。我相信未来可以通过工具的改进,让 IDE 替程序员消灭更多重复劳动,同时提供更加友好的编程界面,以及新的根据代码生成的文档类型,以辅助程序员理解代码背后的业务逻辑模型。

脚注

  1. 这里特指强静态类型的类型系统