ClojureScript

检查数组访问

2017 年 7 月 14 日
Mike Fikes

这是 抢先预览 系列的第三篇帖子。

ClojureScript 编译器的大部分历史都可以用务实权宜之计,然后是连续改进的主题来描述。这很好地说明了 `aget` 的历史。

第一个可行的 `aget` 实现是一个简单的函数。六年前它看起来像这样

(defn aget [array i]
   (js* "return ~{array}[~{i}]"))

这利用了内部的 `js*` 特殊形式来直接生成 JavaScript,该 JavaScript 使用下标符号来访问元素。它看起来大致像这样

function aget(array, i) {
  return array[i];
}

请注意,`js*` 不打算在应用程序级 ClojureScript 代码中使用。

在编译器的早期历史中,`js*` 在随 ClojureScript 标准库一起提供的运行时函数中被相当广泛地使用。随着时间的推移,标准库中完全去除了对 `js*` 的原始使用,并将其隐藏在可重用的宏后面。

随着时间的推移,我们的朋友 `aget`(以及 `aset`)经过改进,更接近于 Clojure,允许它通过使用可变参数来访问嵌套的数组结构。

熟悉 JavaScript 的读者会注意到,上面的 `aget` 实现对于 JavaScript 对象来说效果很好。这个事实,像 `js*` 一样,在标准库的早期也被滥用。不幸的是,由于关于替代方案的文档不足,用户开始模仿这个内部细节。

但是 `aget` 从未设计为支持这种特定用途。“`a-`” 函数族(包括 `aclone`、`amap`、`areduce`)都是为 _数组_ 而设计的,而不是为 _对象_ 设计的。`aget` 的附加参数是数字数组索引,而不是字符串属性名称。尽管如此,也许出于对易用性的诱惑,类似于

(aget #js {:foo 1} "foo")

的形式在野外被大量使用,以避免点式属性访问和 `:advanced` 编译带来的名称混淆。这是一个实现上的意外,它竟然能正常工作,但它还是变得非常流行。

由此产生的一个问题是,`aget` 进一步演化以匹配其预期用途的挑战。以下是一些浮现在脑海中的例子:

  • 在 Clojure 中,如果你将一个非整数数组索引传递给 `aget`,它将向下舍入到最接近的整数。最好让 ClojureScript 的 `aget` 匹配这种行为。这可以通过在实现中使用 `int` 来轻松实现,从而使生成的 JavaScript 看起来像 `array[ndx|0]`。但这会破坏使用 `aget` 来访问对象属性的现有代码。

  • 在 Clojure 中,如果你传递一个负的数组索引,或者是一个超出范围的索引,你会得到一个异常。最好考虑为 ClojureScript 的 `aget` 添加这样的安全机制。但同样,任何尝试盲目地将索引视为数字的行为都会与传递字符串索引的 `aget` 发生冲突。

  • 在将来,核心库函数可能会有为它们编写的规范。同样的问题也存在:传递给 `aget` 的索引应该满足 `number?` 谓词,但如果这样做,野外的大量代码将被认为不符合规范。

当然,这并不是第一次,也可能不是最后一次,某些语言机制的内部被发现适合某些用途,而不是其预期的用途。Guy L. Steele Jr. 和 Richard P. Gabriel 在 _Lisp 的演变_ 中有一段有趣的讨论,他们发现 MacLisp 的 `ERRSET` 和 `ERR` 原语可以用作流控制机制,但不幸的是也会捕获意外错误。这促使在 1972 年向 MacLisp 引入 `THROW` 和 `CATCH` 原语。作者继续说:“设计(谨慎或其他)、意外使用和后来的重新设计模式很常见。”

如果 `aget` 和 `aset` 为数组保留,那么你应该使用什么来访问对象属性呢?ClojureScript 使 Google Closure 库很容易获得,`goog.object` 命名空间中有一些值得检查的不错功能。特别是,`goog.object/get``goog.object/set` 是适合此目的的适当 API。例如,这会做你想做的事

(goog.object/get #js {:foo 1} "foo")

事实上,`goog.object/get` 更安全,因为它检查传入的对象不是 `nil`,并且要访问的字段确实存在于对象上,允许你提供一个备用的“未找到”值以供未找到时返回。如果你需要进行嵌套属性访问,有一个 `goog.object/getValueByKeys` 也可以被视为可变参数 `aget` 调用的直接替代品。

ClojureScript 标准库本身也存在一些地方,`aget` 和 `aset` 被误用于对象访问,这些地方已经清理完毕。事实证明,`goog.object/get` 的性能足以替换几乎所有用于对象访问的 `aget`。在相对少数的非使用 `goog.object/get` 的地方(在标准库实现中高度性能敏感的区域),编译器使用新的内部 `unchecked-get` 宏来完成这项工作。

我们鼓励你修改你控制的代码,以确保 `aget` 和 `aset` 仅用于数组访问,并考虑使用 `goog.object` 中的功能(无论是直接使用,还是通过 `cljs-oops` 之类的库间接使用)来访问对象属性。

新的编译器增强功能

为此目的,即将发布的 ClojureScript 版本包含一个新的 `:checked-arrays` 编译器选项,你可以将其设置为 `:warn` 或 `:error`。对于任一设置,编译器都会发出一个新的 `:invalid-array-access` 警告,该警告指示(当通过类型推断已知时)`aget` 或 `aset` 正在对非数组进行操作,或者正在提供非数字索引。此外,传递给 `aget` 和 `aset` 的运行时值也会被检查。如果 `:checked-arrays` 设置为 `:warn`,则在传递类型不正确的値或越界数组索引时,会生成警告日志。如果设置为 `:error`,则会改为抛出异常。

为了获得最佳性能,对于 `:advanced` 构建,所有此类检查都会被消除,数组访问会编译成有效的 JavaScript 数组下标符号。

这个新的编译器选项可以用来突出显示这些 API 被用于对象属性访问的实例。例如,`(aget #js {:foo 1} "foo")` 将导致此警告被发出

WARNING: cljs.core/aget, arguments must be an array followed by numeric indices, got [object string] instead (consider goog.object/get for object access) at line 1

要启用此新功能,只需添加

:checked-arrays :warn

到你的 ClojureScript 编译器选项 中。

通过仔细按照预期使用 ClojureScript 标准库中的 API,这有助于库的演化,无论是在正确性还是性能方面。这是我们都能从中受益的!