Skip to content

Instantly share code, notes, and snippets.

@lagenorhynque
Last active November 11, 2022 07:00
Show Gist options
  • Save lagenorhynque/ac6b87833a9c657f035dd39abfe393f5 to your computer and use it in GitHub Desktop.
Save lagenorhynque/ac6b87833a9c657f035dd39abfe393f5 to your computer and use it in GitHub Desktop.
Everyday Life with clojure.spec

Everyday Life

with clojure.spec


(defprofile lagénorhynque
  :id           @lagenorhynque
  :reading      "/laʒenɔʁɛ̃k/"
  :aliases      ["カマイルカ🐬"]

  :languages    [Clojure Haskell English français]

  :interests    [programming language-learning law mathematics]

  :commits      ["github.com/lagenorhynque/duct.module.pedestal"
                 "github.com/lagenorhynque/duct.module.cambium"]
  :contributes  ["github.com/japan-clojurians/clojure-site-ja"])

twitter icon


  1. Clojure

  2. Clojure開発で困ること

  3. clojure.spec


Clojure


Clojureとは

  • 関数型言語

  • JVM言語

  • Lisp

  • 動的型付き言語

Rich Hickeyが作った"simple"な言語


簡単なプログラムの例

dev> (defn hello [name]
       (println (str "Hello, " name "!")))
#'dev/hello

dev> (hello "World")
Hello, World!
nil

dev> (hello "Clojure")
Hello, Clojure!
nil

Clojureでの開発のしかた

  • コンパイルが通るようにひとまとまりのコードを書き、コンパイルできたらたいてい期待通りに動作する

    • 優れた型システムを備えた静的型付き言語のイメージ(?)
  • 動くと思われるひとまとまりのコードを書き、動かしてみて期待通りでなければ適宜デバッグする

    • 典型的な動的型付き言語のイメージ(?)

  • REPLと繋がったエディタで小さな単位で動かしながらコードを書き、書き上がったひとまとまりのコードは期待通りに動作する
    • ClojureなどLisp系言語での開発スタイル

    • いわゆる「REPL駆動開発」

      • 多くのLispではREPL周りのツールが高度に発達している

      • REPLと連携しながらの開発を前提に言語が設計されているとさえ考えられる


Clojure開発で困ること


例えば、こんな関数を定義する

(defn find-artists [ds {:keys [name ids sort-order]}]
  (jdbc/execute!
   ds
   (cond-> (sql/build
            :select :*
            :from :artist)
     name (merge-where [:like :name (str \% name \%)])
     (seq ids) (merge-where [:in :id ids])
     (seq sort-order) (#(apply merge-order-by % sort-order))
     (empty? sort-order) (merge-order-by [:id :asc])
     true sql/format)))

様々な暗黙の前提がある(使う側は知る由もない)


使ってみると

example> (find-artists (ds) {})
[#:artist{:id 1, :type 1, :name "Aqours"}
 #:artist{:id 2, :type 1, :name "CYaRon!"}
 #:artist{:id 3, :type 1, :name "AZALEA"}
 #:artist{:id 4, :type 1, :name "Guilty Kiss"}
 #:artist{:id 5, :type 1, :name "Saint Snow"}
 #:artist{:id 6, :type 1, :name "Saint Aqours Snow"}]

example> (find-artists (ds) {:name "Aq"})
[#:artist{:id 1, :type 1, :name "Aqours"}
 #:artist{:id 6, :type 1, :name "Saint Aqours Snow"}]

example> (find-artists (ds) {:ids [2]})
[#:artist{:id 2, :type 1, :name "CYaRon!"}]
  • 正しい使い方を知っていれば期待通りに動作する

しかし……


example> (find-artists (ds) {:ids 2})
Execution error (IllegalArgumentException) at everyday-life-with
-clojure-spec.example/find-artists (example.clj:40).
Don't know how to create ISeq from: java.lang.Long
  • 唐突に、LongからISeqを作る方法が分からないと言われたり
    • Clojurianにはお馴染み😅

example> (find-artists (ds) {:ids ["2"]})
Execution error (PSQLException) at org.postgresql.core.v3.QueryE
xecutorImpl/receiveErrorResponse (QueryExecutorImpl.java:2533).
ERROR: operator does not exist: bigint = character varying
  Hint: No operator matches the given name and argument types. Y
ou might need to add explicit type casts.
  Position: 32
  • PostgreSQLにアクセスするので入力の型が想定と違うとPSQLExceptionが発生したり

example> (find-artists "foo" {})
Execution error (SQLException) at java.sql.DriverManager/getConn
ection (DriverManager.java:702).
No suitable driver found for foo
  • 明らかにDB接続情報でないものを与えるとSQLExceptionが発生したり

  • エラーメッセージが分かりづらい

    • エラーメッセージの不親切さに定評がある😇
  • fail-fastでない

    • "garbage in, garbage out" 🗑
  • 入出力として想定しているものが分からない

    • ドキュメントで冗長かつ不明確に説明したいわけでもない
    • 関数型言語なので不可解な副作用に悩まされることは少ないとはいえ……

従来のアプローチ

  • schema

    • スキーマ記述とバリデーションのためのサードパーティライブラリ
  • core.typed (→ Typed Clojure)

    • gradual/optional typingのための準標準ライブラリ

静的型付けのClojureがほしい?


clojure.spec


コントラクト(契約)システム

e.g. Racketのcontract system

> (define/contract (maybe-invert i b)
    (-> integer? boolean? integer?)
    (if b (- i) i))
> (maybe-invert 1 #t)
-1
> (maybe-invert #f 1)
maybe-invert: contract violation
  expected: integer?
  given: #f
  in: the 1st argument of
      (-> integer? boolean? integer?)
  contract from: (function maybe-invert)
  blaming: top-level
   (assuming the contract is correct)
  at: eval:2.0

The Racket Reference > 8.2 Function Contracts


標準ライブラリclojure.spec

述語(predicate)による仕様記述システム

NOT 型システム


clojure.specを導入する

example> (require '[clojure.spec.alpha :as s])
nil

この関数に"spec"を付けたい

(defn find-artists [ds {:keys [name ids sort-order]}]
  (jdbc/execute!
   ds
   (cond-> (sql/build
            :select :*
            :from :artist)
     name (merge-where [:like :name (str \% name \%)])
     (seq ids) (merge-where [:in :id ids])
     (seq sort-order) (#(apply merge-order-by % sort-order))
     (empty? sort-order) (merge-order-by [:id :asc])
     true sql/format)))

仕様を自然言語で表現してみると

  • 引数
    • ds: javax.sql.DataSource オブジェクト
    • {:keys [name ids sort-order]}: 以下のキーを含むかもしれない検索条件マップ
      • :name: 文字列
      • :ids: 自然数の空でないシーケンス
      • :sort-order: ソートキーのキーワードと昇順/降順の :asc または :desc のペアの空でなく第1要素についてユニークなシーケンス

  • 戻り値
    • アーティストマップのシーケンス
      • アーティストマップ: 以下のキーを必ず含むマップ
        • :id: 自然数
        • :type: 1 (グループ) または 2 (ソロ)
        • :name: 文字列

s/fdef マクロで記述すると

;;; 関数 find-artists に対するspec定義のイメージ
;;; ,,, 部分を埋めたい

(s/fdef find-artists
  :args (s/cat :ds ,,,          ; 第1引数
               :condition ,,,)  ; 第2引数
  :ret ,,,)                     ; 戻り値
  • s/fdef は関数に対するspecを定義する

アーティストマップをspecとして記述してみる

example> (s/def :artist/id nat-int?)
:artist/id
example> (s/def :artist/type #{1 2})
:artist/type
example> (s/def :artist/name string?)
:artist/name
example> (s/def ::artist (s/keys :req [:artist/id
                                       :artist/type
                                       :artist/name]))
:everyday-life-with-clojure-spec.example/artist
example> (s/valid? ::artist #:artist{:id 1
                                     :type 2
                                     :name "You Watanabe"})
true
  • s/def はspec(= 述語)に名前を付ける
  • s/valid? はspecを満たすかどうか判定する

戻り値のspec定義が定まる

;;; 関数 find-artists に対するspec定義のイメージ
;;; ,,, 部分を埋めたい

(s/fdef find-artists
  :args (s/cat :ds ,,,          ; 第1引数
               :condition ,,,)  ; 第2引数
  :ret (s/coll-of ::artist))    ; 戻り値

DataSource であることをspecとして記述してみる

example> (import '(javax.sql DataSource))
javax.sql.DataSource
example> (s/valid? #(instance? DataSource %) (ds))
true
example> (s/valid? #(instance? DataSource %) "foo")
false

第1引数のspecが定まる

;;; 関数 find-artists に対するspec定義のイメージ
;;; ,,, 部分を埋めたい

(s/fdef find-artists
  :args (s/cat :ds #(instance? DataSource %)  ; 第1引数
               :condition ,,,)                ; 第2引数
  :ret (s/coll-of ::artist))                  ; 戻り値

:ids キーの値をspecとして記述してみる

example> (s/def ::ids (s/coll-of :artist/id
                                 :min-count 1))
:everyday-life-with-clojure-spec.example/ids
example> (s/valid? ::ids [])
false
example> (s/valid? ::ids [2])
true
example> (s/valid? ::ids [2 4])
true
example> (s/valid? ::ids [2 2])
true

:sort-order キーの値をspecとして記述してみる

example> (s/def ::sort-order
           (s/and (s/coll-of (s/tuple #{:id :type :name}
                                      #{:asc :desc})
                             :min-count 1)
                  #(apply distinct? (map first %))))
:everyday-life-with-clojure-spec.example/sort-order
example> (s/valid? ::sort-order [])
false
example> (s/valid? ::sort-order [[:name :asc] [:id :desc]])
true
example> (s/valid? ::sort-order [[:name :misc] [:id :desc]])
false
example> (s/valid? ::sort-order [[:name :asc] [:name :desc]])
false

第2引数のspecが定まり、関数のspecが仕上がる

ex> (s/fdef find-artists
      :args (s/cat :ds #(instance? DataSource %)
                   :condition (s/keys :opt-un [:artist/name
                                               ::ids
                                               ::sort-order]))
      :ret (s/coll-of ::artist))
everyday-life-with-clojure-spec.example/find-artists

関数のspecを実装に組み込む(instrumentation)

example> (require '[clojure.spec.test.alpha :as stest])
nil
example> (stest/instrument `find-artists)
[everyday-life-with-clojure-spec.example/find-artists]
  • stest/instrument は関数のspecの引数に対するチェックを関数の実装に組み込む
    • 実際の開発環境では開発/テスト時に自動的に組み込まれるように設定することが多い

改めて使ってみると

example> (find-artists (ds) {})
[#:artist{:id 1, :type 1, :name "Aqours"}
 #:artist{:id 2, :type 1, :name "CYaRon!"}
 #:artist{:id 3, :type 1, :name "AZALEA"}
 #:artist{:id 4, :type 1, :name "Guilty Kiss"}
 #:artist{:id 5, :type 1, :name "Saint Snow"}
 #:artist{:id 6, :type 1, :name "Saint Aqours Snow"}]

example> (find-artists (ds) {:name "Aq"})
[#:artist{:id 1, :type 1, :name "Aqours"}
 #:artist{:id 6, :type 1, :name "Saint Aqours Snow"}]

example> (find-artists (ds) {:ids [2]})
[#:artist{:id 2, :type 1, :name "CYaRon!"}]
  • 想定通りの入力に対して変わらず動作する

そして……


example> (find-artists (ds) {:ids 2})
Execution error - invalid arguments to everyday-life-with-clojur
e-spec.example/find-artists at (form-init8369102478102661347.clj
:747).
2 - failed: coll? at: [:condition :ids] spec: :everyday-life-wit
h-clojure-spec.example/ids
  • specに違反すると直ちにエラーになってくれる
  • 入力のどの値がどのspecに違反しているか教えてくれる

example> (find-artists (ds) {:ids ["2"]})
Execution error - invalid arguments to everyday-life-with-clojur
e-spec.example/find-artists at (form-init8369102478102661347.clj
:753).
"2" - failed: nat-int? at: [:condition :ids] spec: :artist/id

example> (find-artists "foo" {})
Execution error - invalid arguments to everyday-life-with-clojur
e-spec.example/find-artists at (form-init8369102478102661347.clj
:750).
"foo" - failed: (instance? javax.sql.DataSource %) at: [:ds]

その他の主な活用方法

  • specによるドキュメンテーション

    • clojure.repl/doc の出力にも反映される
  • specによるバリデーション

  • specからサンプルデータの自動生成

  • specによるproperty-based testing


関連サードパーティライブラリ

  • Orchestra: specの instrument 時のチェックを強化する

  • Expound: specのエラーメッセージを見やすく表示する

  • speculative: 標準ライブラリ関数/マクロに対するspecを独自に提供する

  • spectrum: specを静的解析に利用する試み


clojure.specの登場で

Clojurianの日常は一変している

イマドキのClojure開発をぜひ体験しよう!

Lisp Alien


Further Reading

Clojure


clojure.spec


clojure.spec関連ライブラリ


コントラクトシステム(Racket)


サンプルコード

#!/usr/bin/env bash
# npm install -g reveal-md
reveal-md everyday-life-with-clojure-spec.md --theme night --highlight-theme monokai-sublime -w $@
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment