Clojure で静的に例外処理をチェックしたい話
この記事は Clojure Advent Calendar 2021 の9日目に向けたものです。
TL;DR
- clj-kondo の解析データと rewrite-clj を使ってエラーチェックを静的に行うことを試みてます
- 参考実装のみで実用には至っていません
Clojure でのエラーハンドリング
Clojure で最も一般的と思われるエラーハンドリングは try-catch でしょうか。
Java のライブラリ(もしくは Java ライブラリをラップした Clojure ライブラリ)を使うことも少なくないと思うので、try-catch を全く使わないということもないと思います。
(defn foo []
(throw (ex-info "test" {})))
(try
(foo)
(catch Exception ex
(.printStackTrace ex)))
Java であれば catch されていない throw があればコンパイル時にエラー扱いにしてくれますが、動的型付け言語である Clojure では当然ながら事前にそれを知ることはできず、不便と思っている方も多いかと思います。
個人で開発している liquidz/merr というエラーハンドリングのライブラリにおいても、エラーが漏れなく処理されているかを検知する方法がないものかと長年悩んでいました。
今回はそれをどうにか事前に、そして静的に知ることができないかという試みの記録です。
clj-kondo
言わずと知れた Clojure のリンタですが、clj-kondo の解析データを使っている方はあまり多くないのではないでしょうか?
VSCode の Calva では clj-kondo をバンドルしていてその解析データを使った機能を提供していたりしていて、拙作の vim-iced でも使っていたりと Clojure 開発環境の開発者にはお馴染みの機能です。
この解析データには以下のような情報が含まれます。(オプションによるのでこれがすべてではありません)
-
:namespace-definitions
: ネームスペースの定義情報 -
:namespace-usages
: どのネームスペースがどこから利用(require)されているか -
:var-definitions
: var の定義情報 -
:var-usages
: どのvarがどのネームスペース内のどのvarから利用されているか
今回注目するのは :var-usages
です。
この情報を利用すると任意の関数の呼び出しの階層を調べることができます。
例として liquidz/antq の :var-usages
を載せます。
$ clj-kondo --lint src --config '{:output {:analysis true :format :edn}}' | jet --pretty --query ':analysis :var-usages 1'
{:fixed-arities #{1},
:end-row 6,
:name-end-col 23,
:name-end-row 6,
:name-row 6,
:name sequential?,
:filename "src/antq/util/xml.clj",
:from antq.util.xml,
:col 11,
:name-col 12,
:from-var xml-ns,
:end-col 32,
:arity 1,
:row 6,
:to clojure.core}
これは以下のような情報になります。
-
https://github.com/liquidz/antq/blob/6c0d2173c0d445ab0e39b7174bc55cd4acb5f652/src/antq/util/xml.clj#L6
- src/antq/util/xml.clj (
:filename
) にある- antq.util.xml ネームスペース (
:from
) で定義されている - xml-ns 関数 (
:from-var
) 内の - の6行目 (
:row
) から
- antq.util.xml ネームスペース (
- clojure.core ネームスペース (
:to
) の- sequential? 関数 (
:name
) - を呼び出している
- sequential? 関数 (
- src/antq/util/xml.clj (
呼び出しの階層が調べられれば、その階層の最上位(一番最後に呼び出している箇所)から順にエラーハンドリングされているかどうかを調べることで目的を達成できそうです。
rewrite-clj
ではエラーハンドリングされているかどうかをいかにしてチェックするかですが、今回は rewrite-clj に注目しました。
rewrite-clj は Clojure/ClojureScript また EDN ファイルのインデントやコメントを保持したまま(元々のフォーマットを保持したまま)書き換えることを目的としたライブラリです。
なので本来の目的とは用途がちょっと異なりはするのですが、Zip API が使いやすい(慣れている)ので今回採用しました。
(用途的には tools.reader を使うのが正しいとは思いますが楽をするため今回は見送ります)
前述の clj-kondo の解析データからは関数の呼び出し階層が取得できているので、各階層がどのファイルのどの行、列にあるのかがわかります。
そして rewrite-clj では Zip API にて 場所の情報を取得することが可能です。
これらを組み合わせると各階層で呼び出している場所を Zip API の location として取得することができ、その location を元に例えば関数呼び出しが try フォーム内にあるのかどうか、またその try
フォームに catch
フォームが含まれるかどうかを調べることができます。
(require '[rewrite-clj.zip :as z])
(defn form?
[zloc sym-set]
(and (z/list? zloc)
(contains? sym-set (-> zloc z/down z/sexpr))))
(if-let [zloc (clj-kondoの解析結果から呼び出し場所のzlocを返す関数)]
;; 親のフォームに向かって try フォームを探す
(if-let [zloc (z/find zloc z/up #(form? % #{'try}))]
;; try フォームがあったら、そのフォーム内に catch フォームがあれば true
(some? (z/find (z/down zloc) z/right #(form? % #{'catch})))
;; try フォームがなければ false
false)
;; 呼び出し場所の zloc が取れなければ見つからなかった扱い
false)
サンプル実装
以上のことから簡単なサンプル実装を作ってみました。
git clone していただいて make tool-install
を実行してもらうと Clojure CLI のツールとしてインストールされます (make tool-remove
でアンインストールできます)
同リポジトリ内に example を用意しているので、その中で以下のコマンドを実行するとチェックが実行されます。
clojure -Terror-checker uncatched-exception
すると以下のような結果が出力されるかと思います。
フォーマットは ファイルパス:行:列: var名
です。
これは throw
が catch
されていない呼び出しが2つあることを示していて、呼び出し階層の上位から順に並べています。
./src/foo/core.clj:24:3: foo.core/-main
./src/foo/core.clj:6:3: foo.core/sample1
./src/foo/sub.clj:6:5: foo.sub/throwing
./src/foo/core.clj:26:3: foo.core/-main
./src/foo/core.clj:18:5: foo.core/sample3
./src/foo/sub.clj:6:5: foo.sub/throwing
あとは出力フォーマットを調整して mattn/efm-langserver などを使えば、コードを書きつつ catch
されていない throw
を検知することもできそうです。
サンプル実装の問題
ひとまず動くところまで実装したのみなので当然問題もたくさん残っています。
- 明示的に
throw
されている例外しかチェックできていない- 例えば Java ライブラリ側で
throw
しているケースや、NullPointerException
が投げられるケースは今の実装ではチェック対象に含まれないのでcatch
が漏れていても気付けません- clj-kondo の解析データにはメタデータを含めることができるので、例外を投げうる関数に
^{:throws 'Exception}
のようなメタデータをつけておいてチェック対象に含めることは可能です
- clj-kondo の解析データにはメタデータを含めることができるので、例外を投げうる関数に
- 例えば Java ライブラリ側で
- 厳密に
throw
された例外がcatch
されているかのチェックがない- 例えば
java.io.IOException
をthrow
しているケースで(catch NullPointerException ...)
があっても現状はcatch
の存在しかみていないのでパスしてしまいますが実際の挙動としては当然catch
されないのでNGです
- 例えば
- マクロ内で
catch
しているケースは判定できない- 暫定対処としてサンプル実装では
try
以外にもcatch
しているとみなすシンボルを指定できるようにはしています
- 暫定対処としてサンプル実装では
- パフォーマンスは考慮していない
- 部分的に
memoize
を使ったりしていますが、サンプル実装なので処理の効率については考慮してません - 一応
make native-image
にて GraalVM を使ったネイティブイメージを吐けるようにしていますが、大きいプロジェクトだとまだ遅いです(手元の業務アプリで10秒ほど)
- 部分的に
- 今回の
throw
+catch
の例であれば特に問題はなかったですが、liquidz/merr のようにエラーを値として扱うライブラリの場合は、エラーをlet
で束縛しておいて別の場所でエラーハンドリングするケースがあり、そういったエラーハンドリングは今回のロジックでは検知できません- clj-kondo の解析データでは
let
内で束縛されたものを扱う:locals
,:local-usages
もありますが、どんな値が束縛されているのかという情報までは無いので、一筋縄ではいきません
- clj-kondo の解析データでは
最後に
問題はまだいろいろと残っていますが、明示的な throw に対する catch 漏れに関しては検知することができたので小さな一歩は踏み出せたかなと思います。
実験のつもりだったのでどこまで改善していくかはわからないですが、liquidz/merr のチェックは欲しいのでコツコツ出来たら良いなと思っています。
Pull Request は大歓迎なので、これもチェックしたい、こうしたらもっと良いのでは?というアイデア/知見がありましたら Issue なりなんなりで報告いただけると幸いです!
Discussion