ClojureScript

檢查過的陣列存取

2017 年 7 月 14 日
Mike Fikes

這是搶先預覽系列中的第三篇文章。

ClojureScript 編譯器的歷史大部分可以概括為實用權宜之計的主題,隨後是連續的精煉。aget 的歷史很好地說明了這一點。

aget 的第一個最低可行實作是一個簡單的函數。六年前它看起來像這樣

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

這裡使用了內部 js* 特殊形式來直接發出 JavaScript,它使用下標符號進行元素存取。它大致看起來像這樣

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

請注意,js* 不應該在應用程式級別的 ClojureScript 程式碼中使用。

在編譯器的早期歷史中,js* 在與 ClojureScript 標準函式庫一起提供的執行期函數中被大量使用。隨著時間的推移,標準函式庫中完全移除了 js* 的原始用法,並隱藏在可重複使用的巨集之後。

隨著時間的推移,我們的朋友 aget(以及 aset)被精煉為更接近 Clojure 的方式,允許它使用可變參數來存取巢狀陣列結構。

熟悉 JavaScript 的讀者會注意到,上面的 aget 實作對於 JavaScript 物件運作良好。這個事實,就像 js* 一樣,也在標準函式庫的早期被濫用。不幸的是,由於關於替代方案的文件不足,使用者開始模仿這個內部細節。

aget 從未設計來支援這種特定用途。「a-」系列函數(包括 acloneamapareduce)都是為陣列而非物件而設計的。aget 的其他參數是數值陣列索引,而不是字串屬性名稱。然而,也許是為了方便起見,像

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

這樣的形式在野外被大量使用,以避免帶有點式屬性存取和 :advanced 編譯的名稱混淆。這純粹是實作上的意外,但它仍然變得非常受歡迎。

這產生的一個問題是在進一步發展 aget 以符合其預期目的時的挑戰。腦海中浮現的幾個例子包括

  • 在 Clojure 中,如果將非整數陣列索引傳遞給 aget,它會向下捨入到最接近的整數。讓 ClojureScript 的 aget 符合此行為會很好。這可以透過在實作中使用 int 來輕鬆實現,導致發出的 JavaScript 看起來像 array[ndx|0]。但這會破壞使用 aget 進行物件屬性存取的現有程式碼。

  • 在 Clojure 中,如果傳遞負陣列索引或超出範圍的索引,則會收到例外。考慮在 ClojureScript 的 aget 中新增此類安全機制會很好。但是,同樣地,任何盲目地將索引視為數字的嘗試都會與傳遞字串索引的 aget 相衝突。

  • 在未來,也許核心函式庫函數會有為它們編寫的 specs。也會出現同樣的問題:傳遞給 aget 的索引應滿足 number? 謂詞,但如果這樣做,則會判定許多野外程式碼為不符合規範。

這當然不是第一次,也不太可能是最後一次發現某些語言機制的內部結構適用於與預期目的不同的用途。《Lisp 的演變》一書,作者是 Guy L. Steele Jr. 和 Richard P. Gabriel,其中有趣地討論了發現 MacLisp 的 ERRSETERR 原語可以用作流程控制機制,但同時也會不幸地捕獲意外錯誤。這促使在 1972 年在 MacLisp 中引入 THROWCATCH 原語。作者接著說「設計(無論是仔細還是其他方式)、非預期用途,以及後來的重新設計的模式很常見。」

如果 agetaset 保留給陣列,那麼應該使用什麼來存取物件屬性?ClojureScript 可以輕鬆存取 Google Closure 函式庫,而且 goog.object 命名空間中有一些不錯的設施值得查看。尤其是,goog.object/getgoog.object/set 是適用於此目的的合適 API。例如,這會執行您想要的操作

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

事實上,goog.object/get 更安全,因為它會檢查傳遞的物件是否為 nil 以及存取的欄位是否實際存在於該物件上,允許您提供替代的「找不到」值,如果找不到則返回。如果您需要執行巢狀屬性存取,則可以使用 goog.object/getValueByKeys,它也可以被視為可變 aget 呼叫的直接替代品。

ClojureScript 標準函式庫本身有一些地方將 agetaset 誤用於物件存取,這些地方已清理乾淨。事實證明,goog.object/get 的效能足以取代幾乎所有使用 aget 進行物件存取的地方。在少數幾個不適用的地方(在標準函式庫實作中高度注重效能的區域),編譯器會使用新的內部 unchecked-get 巨集來完成這項工作。

我們鼓勵您修改您控制的程式碼,以確保 agetaset 僅用於陣列存取,並考慮使用 goog.object 中的設施(直接或透過 cljs-oops 等函式庫間接使用)來存取物件屬性。

新的編譯器增強功能

為此,即將發布的 ClojureScript 版本包含新的 :checked-arrays 編譯器選項,您可以將其設定為 :warn:error。使用任一設定,編譯器都會發出新的 :invalid-array-access 警告,指示(在透過類型推斷得知時)agetaset 正在對非陣列進行操作或提供非數值索引。此外,還會檢查傳遞給 agetaset 的執行期值。如果將 :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,這有助於函式庫的發展,無論是在正確性還是效能方面。這是我們所有人都可以從中受益的事情!