malliとclj-kondoで変わる(?)Clojure開発体験

9 min読了の目安(約8300字TECH技術記事

メリークリスマス!!🎄🎄
この記事は Clojure Advent Calendar 2020 の25日目に向けたものです。

TL;DR

まだ malli で定義した仕様をすべて clj-kondo 側で扱えるわけではないです。
そのため現時点で実用に耐えうるかというとそうではありません。
ただ無いよりは有った方が開発体験は改善するいうレベルです。

malli とは

metosin/malli は Clojure/ClojureScript 向けにデータに対する仕様を定義し、データがその仕様に沿っているかをバリデートするための機能を提供するライブラリです。

近いライブラリとして clojure.specplumatic/schema がありますが、malli が特徴的なのはすべての仕様を「データ」として定義できる点です。

(require '[malli.core :as m])

(def IntMap
  ;; key がキーワード、 value が整数であるマップの定義
  ;; キーワードとシンボルからなるただのデータ
  [:map-of :keyword 'int?])

(m/validate IntMap {:foo 1}) ; => true
(m/validate IntMap {:foo "bar"}) ; => false

データでの定義だと、ものによっては表現力に難があったりしますが、再帰的な仕様定義にも対応していたりと malli は高度な仕様定義も可能となっています。

(def IntCons
  [:schema {:registry {::int-cons [:maybe [:tuple pos-int? [:ref ::int-cons]]]}}
   ::int-cons])

(m/validate IntCons [10 [20 [30 nil]]]) ; => true
(m/validate IntCons [10 [-20 [30 nil]]]) ; => false

基本的にはデータのバリデーションが主な機能なのですが、前々から plumatic/schema の s/defn や clojure.spec の fdef のような契約プログラミングに用いれる関数向けの仕様定義が熱望されていました。

clj-kondo 連携

そんな関数向けの仕様定義ですが #306 にて clj-kondo との連携という(良い意味で)予想外の形でマージされました。
(現状の最新リリースである 0.2.1 には含まれていないので master ブランチで試す必要があります)

clj-kondo については Clojure Advent Calendar 2020 の9日目の記事を参照してください。

plumatic/schemaclojure.spec で個人的に不満だった点の1つに「評価時でないとエラーが検出されない」ということがありました。
動的型付言語としては至極当然ではあるのですが、やはり評価/実行する前にわかるに越したことはありませんし、仮に仕様定義をしていても該当関数を実行するテストが存在しなければチェックは動かず意味がありません。

しかし clj-kondo は静的にコードを解析して警告などを出してくれるので評価の有無は関係ありません。
これは Clojure における開発体験を一変させる可能性があるのでは!?と思っています。

仕組み

ではどのような仕組みで clj-kondo 連携が成されるのかを実際に試しながら見ていこうと思います。
malli は前述の通り master を使いたいので Leiningen ではなく Clojure CLI を使います。

まずは deps.edn の用意をします。
metosin/malli はこの記事を書いた時点で最新のコミットの SHA を指定しています。

deps.edn
{:paths ["src"]
 :deps {org.clojure/clojure {:mvn/version "1.10.1"}
        metosin/malli {:git/url "https://github.com/metosin/malli"
                       :sha "4c854a283697fbc22856145e514be7c2b1853edf"}}}

次に src/foo/core.clj です。
まずは簡単に plus という整数を加算するだけの関数を例に入出力を定義します。

src/foo/core.clj
(ns foo.core
  (:require
   [malli.core :as m]))

(defn plus
  [a b]
  (+ a b))

;; ここで plus は引数として整数を2つ受け取り、整数を返す旨を定義
(m/=> plus [:=> [:tuple int? int?] int?])

(comment
  (plus 1 2) ; => 3
  (plus 1 "2") ; java.lang.String cannot be cast to java.lang.Number
  )

この時点ではまだ malli は clj-kondo と連携していません。

$ clj-kondo --lint src
linting took 35ms, errors: 0, warnings: 0

では deps.edn があるディレクトにて clj コマンドから以下のコードを評価してみてください。

$ clj
Clojure 1.10.1
user=> (require 'malli.clj-kondo)
nil
user=> (require 'foo.core)
nil
user=> (malli.clj-kondo/emit!)
nil

すると .clj-kondo/configs/malli/config.edn というファイルが作成されることが確認できるかと思います。これは malli で定義した foo.core/plus 向けの型定義を clj-kondo が解釈できる型定義に変換されたものです。

そう。つまり malli での clj-kondo 連携とは (malli.clj-kondo/emit!) を通して clj-kondo 向けの設定を動的に生成するというものになります。

.clj-kondo/configs/malli 配下の設定は自動的には読み込まれないので、読み込まれるように設定 .clj-kondo/config.edn を追加します。

.clj-kondo/config.edn
;; configs/malli 配下の設定も利用するための設定
{:config-paths ["configs/malli"]}

その上で clj-kondo を改めて実行してみると。。期待しない引数での関数呼び出しでエラーが検出されていることがわかるかと思います。

$ clj-kondo --lint src
src/foo/core.clj:14:11: error: Expected: integer, received: string.
linting took 16ms, errors: 1, warnings: 0

この時点では定義を追加・変更する度に malli.clj-kondo/emit! を手動で実行しないと設定が更新されないので面倒なイメージを持たれてしまうかもしれませんが、エディタ側の設定でどうとでもなると思うので一旦目をつむってください。

どうしても気になる場合は記事の末尾に Vim と vim-iced の例だけ載せているのでご参照ください。

もう少し嬉しい例

foo.core/plus の例は単純すぎて嬉しさが伝わりづらいと思うので、もう少し嬉しい例を考えてみます。

私がよく困るのは関数の引数としてマップを受け取るもので、どんな key/value を指定すれば良いのかわからなくなることです。

(defn plus-pos
  "引数の p1, p2 はそれぞれ :x, :y キーに数値が紐付くマップ
  のようなことを書いておかないと忘れてしまう関数"
  [p1 p2]
  {:x (+ (:x p1) (:x p2))
   :y (+ (:y p1) (:y p2))})

こういったケースは plumatic/schemaclojure.spec でも解消できはしますが、前述の通り評価しない限りわからないので、コードを書いている途中に素早く気付ければ私としてはかなり嬉しいです。
では malli で仕様を定義してみましょう。

;; 引数として受け取るマップの定義
(def PosMap
  [:map
   [:x int?]
   [:y int?]])

;; 関数の定義
(m/=> plus-pos [:=> [:tuple PosMap PosMap] PosMap])

この定義を emit! して clj-kondo を実行すると以下のようなエラーを検知してくれます。

(plus-pos {:z 1 :y 2} {:x 3 :y 4})
;; error: Missing required key: :x

(plus-pos {:x "1" :y 2} {:x 3 :y 4})
;; error: Expected: integer, received: string.

記事の冒頭に貼ってあった動画はこのエラー検知をエディタ上で連携して表示させた例です。

このように実際に評価したり、テストコードを書かずとも引数のエラーを検知してくれて、
それをエディタ上で表示できるのでは Clojure における開発体験としてはかなり新しいかと思います!

わかっている問題点

ここまで良い点のみを紹介してきましたがもちろん問題点もまだあります。

戻り値のエラーは検知されない

以下のように関数の実際の戻り値と定義上の期待する戻り値の違いは現状検知されません。

(defn plus
  [a b]
  ;; 実際の戻り値は文字列
  (str a b))

;; 定義上の戻り値は int?
(m/=> plus [:=> [:tuple int? int?] int?])

(plus 1 2) ;; エラーは検知されない

ただ生成された clj-kondo の定義を見ると以下のように結果として何が期待されるのかは定義されています。

{plus {:arities {2 {:args [:int :int], :ret :int}}}}

これは他の関数呼び出し時の検知に利用されます。つまり関数の実際の戻り値と定義は一致している前提に現状ではなってしまっています。

(seq (plus 1 2))
;; error: Expected: seqable collection, received: integer.

malli のすべての仕様に沿った検知ができるわけではない

malli はあくまで定義された仕様を clj-kondo 向けの設定に変換しているのになので、malli で定義できても clj-kondo がサポートしていない設定には当然変換できません。
なので以下のように :enum だったり :fn といった仕様は malli 上でバリデーションできるのみで clj-kondo での検知はできません。

;; Enum
;; ----------
(def Animal
  [:enum "Cat" "Dog" "Wani"])

(defn greet
  [x]
  (str "Hello " x))

;; 生成される clj-kondo 向け設定: {:arities {1 {:args [nil], :ret :string}}}
(m/=> greet [:=> [:tuple Animal] string?])

(m/validate Animal "Snake") ; => false
(greet "Snake") ;; エラーは検知できない

;; Fn
;; ----------
(def PosFnMap
  [:and
   [:map
    [:x int?]
    [:y int?]]
   [:fn (fn [{:keys [x y]}] (> x y))]])

;; 生成される clj-kondo 向け設定: {:arities {1 {:args [:any], :ret :string}}}
(m/=> format-pos-map [:=> [:tuple PosFnMap] string?])

(m/validate PosFnMap {:x 2 :y 1}) ; => true
(m/validate PosFnMap {:x 1 :y 2}) ; => false

(format-pos-map {:x 1 :y 2}) ;; エラーは検知できない

データの変換を介すると検知できない

一度 plus-pos の定義に戻ってみましょう。元の例ではマップを直接関数に渡していましたが、基本的には let などで束縛したものを渡すケースがほとんどかと思います。
その際に以下のようにデータをいじってから関数に渡すことは一般的ではありますが、それが正しく検知できないケースが多いです。

(defn plus-pos
  [p1 p2]
  {:x (+ (:x p1) (:x p2))
   :y (+ (:y p1) (:y p2))})

(def PosMap
  [:map [:x int?] [:y int?]])
(m/=> plus-pos [:=> [:tuple PosMap PosMap] PosMap])

;; これは正しくチェックされる
(let [p1 {:x 1 :y 2}
      p2 {:x 3 :y 4}]
  (plus-pos p1 p2))

;; p2 に :y が無いことは検知されない
(let [p1 {:x 1 :y 2}
      p2 (dissoc p1 :y)]
  (plus-pos p1 p2))

;; p2 に :y が無い扱いでエラーになってしまう
(let [p1 {:x 1 :y 2}
      p2 {:x 3}
      p2 (assoc p2 :y 4)]
  (plus-pos p1 p2))

最後に

malli での clj-kondo 連携は「型」に限らない「仕様」の定義を clj-kondo で静的に解析できる設定を生成することで、今まで Clojure の弱点の1つだったコードの評価を介さない型エラーやデータ構造のエラー検知を可能としました。

これにより今まで評価するまで気付き辛かったミス/エラーに早期に気付くことができ、開発効率の向上やより安全なコーディングが可能になりそうです。

ただ前述の通り問題点はまだまだ多く、検知されないエラーやエラーの誤検知があるので今すぐ採用できるレベルではないことも事実です。(そのためにリリースが切られていないのかも?)
しかし Clojure における開発体験を一変させる可能性は秘めていると思っているので注目しておいて損はないのかなと思っています。

参考: 自動 emit! の例

前述の通り、malli の clj-kondo 向け定義は動的に生成されるため、どこかで生成するコードを評価する必要があります。
しかしそれを手作業で行うのは面倒以外のなにものでもなく、折角得られるであろう良い開発体験を霞ませてしまいます。

なので基本的に (malli.clj-kondo/emit!) での動的生成はエディタ側の設定かなにかで自動的に行わせるのが良いと思います。

Vim (vim-iced) での設定例

Vim で設定する場合は BufWritePre などのファイル保存時の autocmd をトリガーとして emit! をすれば良いです。
拙作のプラグイン vim-iced を使う場合、以下のように設定することでファイル保存時に自動的に emit! できます。

function! s:emit() abort
  if !iced#nrepl#is_connected()
    return
  endif

  call iced#nrepl#eval('(do (require ''malli.clj-kondo) (malli.clj-kondo/emit!))',
        \ {_ -> iced#message#info_str('emitted')})
endfunction

aug MyMalliSetting
  au!
  au BufWritePre *.clj,*.cljs,*.cljc,*.edn  call s:emit()
aug END

なお malli を使っていないプロジェクトでこの設定が邪魔になってしまう場合は thinca/vim-localrc などを利用して、プロジェクトローカルな設定として定義すると良いです。