Clojure
ClojureDay 2

Clojureにも型の恩恵を〜Typed Clojureの体験〜

More than 1 year has passed since last update.

はじめに

本記事はClojure Advent Calendar 2014の二日目の記事です.
ここではTyped Clojureについて軽く紹介し,その後nilに絡むエラーで悩まされていた簡単な関数をTyped Clojureにより安全化するまでの経験を紹介します.

Typed Clojureとは

Typed Clojureプロジェクトとは,ClojureにGradual Typingを適用し,
部分的な静的型付けを導入しようとするプロジェクトであり,主にclojure.core.typedの開発に貢献しています. core.typedにより変数や関数等への型宣言の導入,そして型チェックを行うことができます.本稿ではTyped Clojureの詳しい部分は以下の記事に任せ,私が以前嵌って苦労したバグをTyped Clojureで予防するプロセスについて紹介します.

参考1: [Clojure] core.typedで静的型チェック
参考2: User Guide

実践例

私が以前嵌ったのが(merge-with +)に関わるバグでした.2つのマップの要素を足し込むだけの単純なものですが,与えるマップ次第であっさりと実行時エラーをもたらします.

nontyped.clj
=> (merge-with + {:a 1 :b 2} {:a nil :b 2})

NullPointerException   clojure.lang.Numbers.ops (Numbers.java:961)

これは2つ目のマップの:aの値がnilなため,(+ 1 nil)を実行しようとして失敗してヌルポを起こしています.現在のClojureの問題点として,エラーメッセージがソースコードと対応しておらず意味不明であるという点があるために,このような単純なバグでも発見するのに苦労しました.このバグをcore.typedにより退治していきたいと思います.

最初の型チェック

core.typedの中には,cfという式の型をチェックする関数が存在します.(cfはcheck-formの略語のようです)これでmerge-with +の型をチェックしてみましょう.簡単化のため,引数のマップは2つとします.

check.clj
=> (cf #(merge-with + %1 %2))


Type Error (/private/var/folders/l1/mqqs9xb51knfqhpy_4c2p4nm0000gn/T/form-init5751590471988957507.clj:1:8) Polymorphic function merge-with could not be applied to arguments:
Polymorphic Variables:
        k
        v

Domains:
        [v v -> v] nil *
        [v v -> v] (Map k v) *
        [v v -> v] (Option (Map k v)) *

Arguments:
        (IFn [java.lang.Long * -> java.lang.Long] [(U java.lang.Double java.lang.Long) * -> java.lang.Double] [AnyInteger * -> AnyInteger] [java.lang.Number * -> java.lang.Number]) Any Any

Ranges:
        nil
        (Map k v)
        (Option (Map k v))

with expected type:
        Any

in: (merge-with + p1__3929# p2__3930#)
in: (merge-with + p1__3929# p2__3930#)

ExceptionInfo Type Checker: Found 1 error  clojure.core/ex-info (core.clj:4403)

以上のようにじゃんじゃかエラーが吐かれます.これをなんとかしていきましょう.

戦略: +を置き換える 〜準備〜

ランタイムエラーを引き起こしているのは(+ 1 nil)なので,引数がnilでも動くような+を定義すればいいと思われます.そこでまずは,数値をそのまま返し,nilを0に変換するような関数toNumberを作成します.

core.typedでは関数に型アノーテーションをつけることで型を表現します.型アノーテーションにはannマクロを用います.そして型のチェックについてはcfの他,check-ns(check namespaceの略)によりnamespaceの関数全ての型チェックを行えます.

toNumberの型アノーテーションは,(nilまたは数値型)から数値型を返す,となるはずです.core.typedでは,数値型はNumberを使います.AまたはBの型といういわゆる直和型を表すにはUを用いて,(U A B)と書きます.

以上より,ソースコードは以下のようになります.

core.clj(1)
(ns hellotyped.core
  (:require [clojure.core.typed :as t]))

(t/ann toNumber [(t/U nil Number) -> Number])
(defn toNumber
  [x]
  (if (nil? x) 0 x))

(t/check-ns)

このファイルをロードするとcheck-nsも実行され,このnamespaceの型チェックが行われます.実際に実行すると,型チェックの結果OKをが出てきます.

+を置き換える

toNumberを用いて,nilを許容する+であるplusを作成します.+は可変長引数をとるので,plusも可変長引数をとることにします.

plusの実装ですが,これは引数にmap toNumberして結果を+してやればいいはずです.

型アノーテーションの方は(nilまたはNumber)の可変長に対して,Numberを返す,となるはずです. なお,型アノーテーション内で可変長引数を表すには*を用います.

よってソースコードは以下のとおりです.

plus
(t/ann plus [(t/U nil Number) * -> Number])
(defn plus
  [& xs]
  (apply + (map toNumber xs)))

merge-withとの統合

以上を用いて,merge-with +の型安全版として,merge-with-plusを作ります.実装はmerge-with plusをするだけで簡単ですが,問題は型アノーテーションです.

引数は明らかにマップ型の可変長引数であり,返り値は単一のマップです.マップの構成については,キーは+する部分とは関係無いためなんでもいいですが,値については作成したplusが受け入れる(nilまたは数値)となっていなければ困ります.

以上を満たす実装と型アノーテーションを書くと,以下のソースコードのようになりました.

merge-with-plus
(t/ann merge-with-plus [(t/Map t/Any (t/U nil Number)) * -> (t/Map t/Any (t/U nil Number))])
(defn merge-with-plus 
  [& xs]
  (apply merge-with plus xs))

これで値のnilを許容するmerge-with +ができました! (check-ns)を実行すると型チェックが通るのが分かるかと思います.

型エラーのチェック

merge-with-plusの誤用でエラーを出してくれるかどうか試してみましょう.例として,値にStringを持つマップを与えてみます.残念ながらdefで使うとcheck-nsをやるまえにそもそも実行時エラーが出てしまうのでlet内でやってみましょう.

hello
(defn hello [] (merge-with-plus {:a "S"} {:a 1}))
(t/check-ns)

とやってみると以下のようにエラーが吐かれ,実際にチェックされていることがわかります.

error
Type Error (hellotyped/core.clj:21:16) Function merge-with-plus could not be applied to arguments:


Domains:
    (t/Map t/Any (t/U nil java.lang.Number)) *

Arguments:
    (t/HMap :mandatory {:a (t/Val "S")} :complete? true) (t/HMap :mandatory {:a (t/Val 1)} :complete? true)

Ranges:
    (t/Map t/Any (t/U nil java.lang.Number))

with expected type:
    t/Any

in: (merge-with-plus {:a "S"} {:a 1})
in: (merge-with-plus {:a S} {:a 1})


CompilerException clojure.lang.ExceptionInfo: Type Checker: Found 1 error {:type-error :top-level-error, :errors (#<ExceptionInfo clojure.lang.ExceptionInfo: Function merge-with-plus could not be applied to arguments:


Domains:
    (t/Map t/Any (t/U nil java.lang.Number)) *

Arguments:
    (t/HMap :mandatory {:a (t/Val "S")} :complete? true) (t/HMap :mandatory {:a (t/Val 1)} :complete? true)

Ranges:
    (t/Map t/Any (t/U nil java.lang.Number))

with expected type:
    t/Any

in: (merge-with-plus {:a "S"} {:a 1}) {:env {:file "hellotyped/core.clj", :column 16, :line 21}, :form (merge-with-plus {:a "S"} {:a 1}), :type-error :clojure.core.typed.errors/tc-error-parent}>)}, compiling:(hellotyped/core.clj:21:50) 

"S"をnilにして型チェックするとOKと表示されることも確認していただけると幸いです.

まとめと感想

merge-with +を値のnilの許容かつ型安全に定義するということをやってみましたがいかがでしたでしょう.ひと通り体験してみましたが,Typed Clojureはclojureのつらいところである,エラーメッセージが弱く実行時バグを引き起こしやすい,という点を補う大きなポテンシャルを秘めていると思います. 安全なClojureプログラムを書く場合には是非検討するべきものだと思います.

ただやはり辛い面も多いです.特に,clojureはmerge-withのような多くの型を受け付ける関数群により構成されているため,型エラーが理不尽です.また,外部の型アノーテーションが加えられてない関数については,自前で型アノーテーションを加える必要があり,これも中々辛いです.やはり型でがっちり固めるならScala,ML系,Haskell等々には勝てないなぁ,と思いました,ハイ.

Appendix

最後に,本稿で書いた関数群をまとめたソースコードを付録として添えておきます.なお,check-nsが通るように"S"はnilになっています.

core.clj
(ns hellotyped.core
  (:require [clojure.core.typed :as t]))


(t/ann toNumber [(t/U nil Number) -> Number])
(defn toNumber
  [x]
  (if (nil? x) 0 x))

(t/ann plus [(t/U nil Number) * -> Number])
(defn plus
  [& xs]
  (apply + (map toNumber xs)))


(t/ann merge-with-plus [(t/Map t/Any (t/U nil Number)) * -> (t/Map t/Any (t/U nil Number))])
(defn merge-with-plus 
  [& xs]
  (apply merge-with plus xs))

(defn hello [] (merge-with-plus {:a nil} {:a 1}))

(t/check-ns)