Clojure

clojure.spec - 基本原理和概觀

問題

文件不足

Clojure 是動態語言。這表示許多事情,包括執行程式碼不需要型別註解。儘管 Clojure 支援一些型別提示,但它們不是強制機制,也不全面,且僅限於傳達資訊給編譯器,以協助產生有效率的程式碼。Clojure 會透過 JVM 本身執行更豐富型別的執行時期檢查。

然而,將資訊單純表示為資料一直是 Clojure 的指導原則,社群廣泛重視並實踐這項原則。因此,Clojure 系統的重要屬性會透過資料的形狀和其他謂詞屬性來表示和傳達,不會在任何地方擷取或檢查,因為執行時期型別是無法區分的異質映射和向量。

文件字串可用於與人類使用者溝通,但程式或測試無法利用它們,也就是說它們的力量很小。使用者已轉向各種函式庫,例如 SchemaHerbert,以取得更強大的規格。

映射規格應僅包含鍵集

大多數用於指定結構的系統會將鍵集的規格(例如映射中的鍵、物件中的欄位)與那些鍵指定的數值的規格混為一談。也就是說,在這種方法中,映射的架構可能會說:a 鍵的型別是 x 型別,b 鍵的型別是 y 型別。這是僵化和冗餘的主要來源。

在 Clojure 中,我們透過動態組成、合併和建構映射來獲得力量。我們例行處理由不可靠外部來源產生的選用和部分資料、動態查詢等。這些映射表示相同鍵的不同集合、子集合、交集和聯集,且通常在任何使用的地方都應具有相同的語意。定義每個子集合/聯集/交集的規格,然後重複陳述每個鍵的語意,既是一種反模式,在最動態的情況下也無法執行。

手動剖析和錯誤回報不夠好

許多使用者,特別是初學者,會對手寫剖析和解構程式碼產生的錯誤訊息感到沮喪和挑戰,特別是在巨集中有兩個執行內容(巨集在編譯時期執行,其擴充在執行時期執行,任何一個都可能因使用者錯誤而失敗)。這導致有人要求「巨集語法」,但事實上巨集只是資料→資料的函式,任何資料驗證和解構的解決方案都應能像其他函式一樣適用於它們。也就是說,巨集是上述問題的一個實例。

產生式測試和健壯性

最後,在所有語言中,動態或非動態,測試對於品質至關重要。許多關鍵屬性無法透過常見的類型系統擷取。但手動測試的效能/努力比非常低。基於屬性的產生式測試,正如在 test.check 中對 Clojure 實作的那樣,已被證明遠比手動撰寫的測試更強大。

然而,基於屬性的測試需要定義屬性,這需要額外的努力和專業知識才能產生,而且在函式層級上,與函式規格有大量的重疊。函式層級中的許多有趣屬性已經可以透過結構化 + 謂詞規格擷取。理想情況下,規格應與產生式測試整合,並「免費」提供特定類別的產生式測試。

需要一種標準方法

簡而言之,Clojure 沒有標準、具表現力、強大且整合的規格和測試系統。

clojure.spec 旨在提供它。

目標

溝通

種類 - 外觀、形式、種類、類型,等同於規格 (ere) 觀察、看待
               + -iēs 抽象名詞字尾

指定 - 種類 + -ficus -fic (製作)

規格是關於某事物的「外觀」,但最重要的是,它是被觀察的事物。規格應易於閱讀,由程式設計師已在使用的「字詞」(謂詞函式) 組成,並整合在文件中。

統一規格在各種脈絡中的用法

資料結構、屬性值和函式的規格都應相同,並存在於全球命名空間目錄中。

最大化規格工作的槓桿作用

撰寫規格應能啟用自動

  • 驗證

  • 錯誤報告

  • 解構

  • 儀器化

  • 測試資料產生

  • 產生式測試產生

最小化入侵

不需要求人們例如以不同的方式定義其函式。對 docmacroexpand 進行輕微修改,將允許獨立撰寫的規格裝飾 fn/macro 行為,而不需重新定義。

分解地圖/金鑰/值

將地圖 (金鑰集) 規格與屬性 (金鑰 → 值) 規格分開。鼓勵並支援命名空間關鍵字對值規格的屬性粒度規格。將金鑰組合成集合 (以指定地圖) 變得正交,並且在完全動態的情況下可以進行檢查,亦即,即使沒有地圖規格,也可以檢查屬性 (金鑰值)。

啟用並開始對語意變更和相容性進行對話

當程式設計師在保留名稱不變的情況下重新定義事物時,他們會遭受很大的痛苦。然而,有些變更相容,有些會中斷,而且大多數工具無法區分。使用集合成員資格和正規表示式等結構,可以確定相容性,並提供相容性檢查工具 (同時將一般謂詞等式排除在外)。

準則

會犯錯

我們並不 (也無法) 生活在一個不會犯錯的世界裡。相反地,我們會定期檢查我們是否沒有犯錯。亞馬遜並不會透過 UPS<Trucks<Boxes<TV>>> 將您的電視寄給您。因此,您偶爾可能會收到微波爐,但供應鏈並不會因此而負擔正確性的證明。相反地,我們會在邊緣檢查並執行測試。

表達力 > 證明

沒有理由將我們的規格限制在我們可以證明的事物上,但這正是類型系統主要在做的事。我們希望傳達和驗證我們系統的更多內容。這超越了結構/表示類型和標記,而進入到例如縮小網域或詳細說明輸入之間或輸入與輸出之間關係的謂詞。此外,我們最關心的屬性通常是執行時期值的屬性,而不是某些靜態概念。因此,規格不是一個類型系統。

名稱很重要

所有程式都使用名稱,即使類型系統沒有使用,而且它們會擷取重要的語意。Int x Int x Int 根本不夠好 (它是長度/寬度/高度還是高度/寬度/深度?)。因此,規格不會有未標籤的序列元件或未標籤的聯集繫結。當規格需要與使用者討論規格時,例如在錯誤報告中,反之亦然,例如當使用者想要覆寫規格中的產生器時,這種效用就會變得明顯。當所有分支都已命名時,您可以使用路徑來討論規格的部分。

全域 (命名空間) 名稱更重要

Clojure 支援命名空間關鍵字和符號。請注意,我們在此僅討論命名空間限定的名稱,而非 Clojure 命名空間物件。這些功能嚴重未被善用,且傳達了重要的優點,因為它們始終可以在字典/資料庫/映射/集合中並存而不會發生衝突。spec 將允許(僅)命名空間限定的關鍵字和符號來命名規格。將命名空間鍵用於其資訊映射的人員(我們希望看到這種做法的成長)可以直接在這些名稱下註冊這些屬性的規格。這會徹底改變映射的自我描述,特別是在動態環境中,並鼓勵組合和一致性。

不要進一步新增/過載 Clojure 的(實體化)命名空間

不會附加任何內容到變數、元資料等。所有函數都有命名空間名稱,可用作其相關資料(例如規格)的鍵,而這些資料則儲存在其他地方。

程式碼是資料(反之亦然)

在 Lisp(因此 Clojure)中,程式碼是資料。但在定義其周圍的語言之前,資料並非程式碼。在此領域中的許多 DSL 會針對架構驅動資料表示。但預測規格具有開放且廣泛的詞彙,而且大多數有用的謂詞已存在,並在核心和其他命名空間中以函數的形式廣為人知,或者可以寫成簡單的表達式。必須「資料化」所有這些謂詞(可能會重新命名),這幾乎沒有增加價值,而且在理解精確語意方面必定會付出代價。spec 反而利用了原始謂詞和表達式一開始就是資料的事實,並擷取該資料以用於與使用者在文件和錯誤報告中進行通訊。是的,這表示 clojure.spec 的更多表面積將會是巨集,但規格絕大部分都是由人員編寫,而且在組合時也是手動編寫。

集合(映射)與成員資格有關,僅此而已

根據上述,定義其鍵值詳細資訊的地圖是關注事項的基本組成,將不會受到支援。地圖規格詳述必要/選用鍵(例如設定成員資格事項),而關鍵字/屬性/值語意是獨立的。地圖檢查為兩階段,首先是必要鍵的存在,然後是鍵/值的一致性。後者可以在執行階段不存在(名稱空間限定)鍵時執行。這對於組成和動態性至關重要。

資訊性與實作性

人們總是會嘗試使用規格系統來詳述實作決策,但他們這麼做對自己有害無益。最佳且最有用的規格(和介面)與純資訊面向相關。只有資訊規格才能透過網路和跨系統運作。我們將永遠優先考量資訊方法,並在發生衝突時優先採用資訊方法。

K.I.S.S.

在這個領域中幾乎沒有底層概念,我們將努力堅持這些概念。有少數不同的結構概念 - 少數原子類型、順序事物、集合和地圖。不出所料,這些是 Clojure 資料類型,而且基本運算只會提供給這些類型。同樣地,有一些數學工具可以用來討論這些概念 - 地圖的集合邏輯和順序的正規表示式 - 這些工具具有有價值的屬性。我們會優先採用這些工具,而不是臨時解決方案。

建立在 test.check 上,但不要求了解它

spec 的生成式測試基礎將利用 test.check,而不是重新發明它。但是,除非 spec 使用者想要撰寫自己的產生器,或補充 spec 的產生式測試,並加上他們自己的其他基於屬性的測試,否則 spec 使用者不應該需要了解任何關於 test.check 的資訊。test.check 不應該有任何生產執行階段相依性。

功能

概觀

預測性規格

基本概念是,規範只不過是謂詞的邏輯組合。在最底層,我們討論的是你習慣使用的簡單布林謂詞,例如 int?symbol?,或你自行建立的表達式,例如 #(< 42 % 66)spec 新增了邏輯運算,例如 spec/andspec/or,它們以邏輯方式結合規範,並提供深入的報告、產生和符合支援,以及在 spec/or 的情況下,標記回傳。

對應

對應鍵集的規範提供對應所需和選用鍵集的規範。對應的規範是透過呼叫 keys 產生,其中 :req:opt 關鍵字引數對應到鍵名稱的向量。

:req 鍵支援邏輯運算子 andor

(spec/keys :req [::x ::y (or ::secret (and ::user ::pwd))] :opt [::z])

spec 和其他系統之間最明顯的差異之一是,對應規範中沒有指定 的位置,例如 ::x 可以接受。spec 的(強制)意見是,與命名空間關鍵字相關聯的值的規範,例如 :my.ns/k,應註冊在該關鍵字本身下,並套用在該關鍵字出現的任何對應中。這有許多優點

  • 它確保在所有使用該關鍵字的應用程式中一致,因為所有使用都應共用一個語意

  • 它也確保程式庫和使用者之間的一致性

  • 它減少了重複,因為否則許多對應規範需要對 k 做出相符的宣告

  • 即使沒有對應規範宣告這些鍵,也可以檢查命名空間關鍵字規範

在動態建構、組合或產生對應時,最後一點至關重要。為每個對應子集/聯集/交集建立規範是不可行的。它也有助於快速偵測錯誤資料 - 在引入資料時與使用資料時。

當然,許多現有的基於對應的介面使用非命名空間鍵。為了支援將它們連接到適當的命名空間和可重複使用的規範,keys 支援 :req:opt-un 變體

(spec/keys :req-un [:my.ns/a :my.ns/b])

此規格說明一個需要非限定關鍵字 :a:b 的映射,但使用分別命名為 :my.ns/a:my.ns/b 的規格(如果已定義)來驗證和產生它們。請注意,這無法傳遞與命名空間關鍵字相同的權限給非限定關鍵字 - 產生的映射並非自我描述。

序列

序列/向量的規格使用一組標準正規表示式運算子,具有正規表示式的標準語義

  • cat - 謂詞/模式的串接

  • alt - 一組謂詞/模式中的一個選擇

  • * - 謂詞/模式的零次或多次出現

  • + - 一次或多次

  • ? - 一次或沒有

  • & - 採用正規表示式運算並進一步使用一個或多個謂詞來限制它

這些可以任意巢狀以形成複雜的表達式。

請注意,catalt 要求其所有組成部分都標有標籤,並且每個組成部分的回傳值都是一個映射,其鍵對應於匹配的組成部分。通過這種方式,規格正規表示式充當解構和解析工具。

user=> (require '[clojure.spec.alpha :as s])
(s/def ::even? (s/and integer? even?))
(s/def ::odd? (s/and integer? odd?))
(s/def ::a integer?)
(s/def ::b integer?)
(s/def ::c integer?)
(def s (s/cat :forty-two #{42}
              :odds (s/+ ::odd?)
              :m (s/keys :req-un [::a ::b ::c])
              :oes (s/* (s/cat :o ::odd? :e ::even?))
              :ex (s/alt :odd ::odd? :even ::even?)))
user=> (s/conform s [42 11 13 15 {:a 1 :b 2 :c 3} 1 2 3 42 43 44 11])
{:forty-two 42,
 :odds [11 13 15],
 :m {:a 1, :b 2, :c 3},
 :oes [{:o 1, :e 2} {:o 3, :e 42} {:o 43, :e 44}],
 :ex {:odd 11}}

conform/explain

如您在上面看到的,使用規格的基本操作是 conform,它採用一個規格和一個值,並回傳符合規格的值或 :clojure.spec.alpha/invalid(如果該值不符合規格)。當該值不符合規格時,您可以呼叫 explainexplain-data 以找出原因。

定義規格

定義規格的主要操作是 s/def、s/and、s/or、s/keys 和正規表示式運算。有一個 spec 函數,它可以採用一個謂詞函數或表達式、一個集合或一個正規表示式運算,還可以採用一個可覆寫由謂詞暗示的產生器的選用產生器。

但是,請注意,def, and, or, keys 規格函數和正規表示式運算都可以採用和直接使用謂詞函數和集合 - 而不必讓它們由 spec 包裝。只有在您想要覆寫一個產生器或指定一個巢狀正規表示式重新開始(與包含在同一個模式中相對)時,才需要 spec

資料規格註冊

為了讓一個規格可以透過名稱重複使用,必須透過 def 註冊。def 採用一個命名空間限定的關鍵字/符號和一個規格/謂詞表達式。根據慣例,資料規格應註冊在關鍵字下,而屬性值應註冊在其屬性名稱關鍵字下。註冊後,可以在任何 規格操作中需要規格/謂詞的地方使用該名稱。

函數規格註冊

函數可透過三個規格完全指定 - 一個用於參數、一個用於回傳值,以及一個用於函數運算,將參數與回傳值關聯起來。

函數的參數規格永遠會是一個正規表示法,將參數指定為一個清單,也就是說,清單會傳遞給函數的 apply。透過這種方式,單一規格可以處理具有多個元數的函數。

回傳值規格是單一值的任意規格。

(選擇性的) 函數規格是參數與回傳值之間關係的進一步說明,也就是說,函數的函數。它會傳遞 (例如在測試期間) 一個包含 {:args conformed-args :ret conformed-ret} 的映射,並且通常會包含將這些值關聯起來的謂詞 - 例如,它可以確保輸入映射的所有鍵都存在於回傳的映射中。

您可以在單一呼叫 fdef 中完全指定函數的所有三個規格,並透過 fn-specs 呼叫規格。

使用規格

文件

透過 fdef 定義的函數規格會在您呼叫函數名稱上的 doc 時出現。您可以在規格上呼叫 describe 以取得說明作為表單。

剖析/解構

您可以在實作中直接使用 conform 以取得其解構/剖析/錯誤檢查。conform 可以用於巨集實作和 I/O 邊界中。

在開發期間

您可以選擇性地使用 instrument 儀器化函數和命名空間,它會將函數變數替換為函數的包裝版本,測試 :args 規格。unstrument 會將函數回傳到其原始版本。您可以使用 gen/sample 產生資料以進行互動式測試。

用於測試

您可以在整個命名空間上執行一組規格產生式測試,方法是使用 check。您可以透過呼叫 gen 為規格取得與 test.check 相容的產生器。clojure.core 資料謂詞與對應產生器之間有內建的關聯,而 spec 的複合運算知道如何在這些產生器之上建立產生器。如果您在規格上呼叫 gen,而它無法為某些子樹建構產生器,它會擲回一個例外,說明在哪裡。您可以將產生器回傳函數傳遞給 spec,以提供 spec 不了解事物的產生器,而且您可以將覆寫映射傳遞給 gen,以提供規格中一個或多個子路徑的替代產生器。

在執行階段

除了上述解構使用案例之外,您可以在任何想要執行階段檢查的地方呼叫 conformvalid?,而且可以為您打算在生產環境中執行的測試建立更輕量的內部規格。

請參閱 spec 指南API 文件 以取得更多範例和使用資訊。

詞彙表

謂詞

規範 API 的許多部分呼叫「謂詞」或「preds」。這些引數可以透過以下方式滿足

  • 謂詞 (布林) fns

  • 集合

  • 已註冊的規範名稱

  • 規範 (specandorkeys 的回傳值)

  • 正規運算式運算 (catalt*+?& 的回傳值)

請注意,如果你想在正規運算式中巢狀獨立的正規運算式謂詞,你必須將其包裝在對 spec 的呼叫中,否則它將被視為巢狀模式。

規範

specandorkeys 的回傳值。

正規運算式運算

catalt*+?& 的回傳值。巢狀時,這些會形成單一表達式。

符合

conform 是使用規範的基本運算,並執行驗證和符合/解構。請注意,符合是「深入」的,並流經所有規範和正規運算式運算、對應規範等。由於 nilfalse 是合法的符合值,因此當無法使值符合時,conform 會回傳特別的 :clojure.spec.alpha/invalidvalid? 可以用作完全布林的謂詞。

說明

當值無法符合規範時,你可以使用相同的規範 + 值呼叫 explainexplain-data 來找出原因。這些說明不會在 conform 期間產生,因為它們可能會執行其他工作,而且沒有理由讓非失敗輸入或不需要報告時承擔這些成本。說明的重要組成部分是路徑explain 在瀏覽巢狀對應或正規運算式模式等時會延伸路徑,因此你會獲得比整個或葉值更好的資訊。explain-data 會回傳路徑到問題的對應。

路徑

由於規範中所有分支點都標有標籤,即對應 keysoralt 中的選項,以及 cat 的(可能省略的)元素,因此規範中的每個子表達式都可以透過路徑(命名部分的鍵向量)來參照。這些路徑用於 explaingen 覆寫和各種錯誤報告。

先前技術

規範說明幾乎沒有新奇之處。請參閱上述所有函式庫,RDF,以及各種合約系統所完成的所有工作,例如 Racket 的合約

我希望您會覺得規範實用且強大。

Rich Hickey