PureScriptで多相バリアントを扱う
はじめに
OCamlを使ってる人にはお馴染みなのかもしれませんが、PureScriptにも多相バリアント(のライブラリ)があるので紹介したいと思います。
扱う内容としては、バリアントが何かから始めて、OCamlでの例も交えながらPureScriptの多相バリアントの説明までしていきます。
ちなみに余談ですが、PureScriptのExtensible Effects(拡張可能作用)のライブラリは、内部的に多相バリアントを使っており、実用されております。
バリアント
多相バリアントの話をする前に、まずバリアントの話をしましょう。
バリアントとは
多相バリアントは多相なんて言ってるくらいだから多相なバリアントなんだろうと想像がつくと思うのですが、そもそもバリアントとは何なのでしょうか?
書籍『型システム入門』での説明
書籍『型システム入門』では次のように書かれています。
多くのプログラムは異種の値の混在した集まりを扱う必要がある。例えば、二分木のノードは葉もしくは2つの子を持った内部ノードである。同様に、リストのセルはnilもしくは、先頭の要素と残りの部分とを持つconsセルである。同様に、コンパイラにおける抽象構文木のノードは、変数、ラムダ抽象、関数適用などを表しうる。この種のプログラミングをサポートする型理論的な機構がバリアント型である。
要約するとバリアント型とは『異種の値の混在した集まりを扱うプログラミングをサポートする型理論的な機構』ということです。
異種の値の混在した集まりの例としては次のような例が挙げられていますね。
- 二分木のノード(葉もしくは2つの子を持つノード)
- リストの要素(
nil
もしくはcons
) - コンパイラにおける抽象構文木のノード(変数、ラムダ抽象、関数適用)
また
バリアント型は直和と呼ばれることがある
とも書かれています。
更にバリアントの具体例として、Optionや列挙型が挙げられていました。
(これらは一般的には直和として説明されることが多い印象です)
WikipediaのTagged UnionのページでもTagged Union
はバリアントや和型などとも呼ばれると書かれており、少なくとも型システム的な文脈ではバリアントを直和と同一視してよいと思います。
OCamlでのバリアント
OCamlは言語的にバリアント型をサポートしているので、その具体例として簡単なコードを書いてみます。
こんな感じです。
type foo_bar = Foo | Bar
let foo_bar_to_string = function
| Foo -> "Foo"
| Bar -> "Bar"
let () = foo_bar_to_string Foo |> print_string (* Foo *)
PureScriptでのバリアント
PureScriptには言語的にバリアント型と表現されるものはありません。
ただ、バリアントは直和であったので、直和型であるMaybe
やEither
はバリアントと言っても差し支えないでしょう。
また代数的データ型を使ってもバリアントを表現できると思います。
上記のOCamlのバリアントの例をPureScriptで書くとこんな感じになるでしょう。
data FooBar = Foo | Bar
showFooBar :: FooBar -> String
showFooBar = case _ of
Foo -> "Foo"
Bar -> "Bar"
main :: Effect Unit
main = do
logShow $ showFooBar Foo -- "Foo"
多相バリアント
まんまですが、多相なバリアントが多相バリアントです。
これもOCamlでは言語としてサポートしていますが、PureScriptにはありません。
ありませんが多相バリアントを名乗るライブラリはあります。
今回はその紹介をしたかったのです。
OCamlでの多相バリアント
先に、言語的にサポートしている(≒無理なく自然に書ける)OCamlの例から見てみます。
type foo_bar = [ `Foo | `Bar ] (* 多相バリアント型 *)
type bar_baz = [ `Bar | `Baz ] (* 多相バリアント型 *)
let bar_to_string = function
| `Bar -> "Bar"
| _ -> "other" (* `Bar以外だったら other を返す *)
let () = bar_to_string `Bar |> print_string (* Bar *)
上記のbar_to_string
は、foo_bar
とかbar_baz
という型に依存せず、とにかく`Bar
さえ持っていれば呼び出せる関数です。
つまり多相になっています。
PureScriptの多相バリアント
PureScriptではOCamlのような多相バリアント型は持ち合わせていません。
代わりにPureScriptには多相バリアントのライブラリを名乗るpurescript-variant
なるものがあります。
こいつが何者なのかというと、Variant
という外部型を定義しており、そいつを使った関数を多相にできるのです。
これから説明していきますが、OCamlの例とは大分異なります。
まずこれがVariant
の定義です。
foreign import data Variant ∷ Row Type → Type
上記の定義を見ると種(kind
)がRow Type
になっていることから分かるように、このVariant
は(foo :: Int)
のようなkind
がRow Type
になるものから作られる型です。
someFoo :: forall v. Variant (foo :: Int | v)
someFoo = inj (Proxy :: Proxy "foo") 42
-- Proxyは、タグが何であるかを型レベルでコンパイラに伝える
なぜRow Type
を使っているかというと、このVariant
型はPureScriptのレコード型が多相になっていることを利用しているからです。
-- foo :: Int を持つレコードなら何でも渡せる
showFoo :: forall r. {foo :: Int | r} -> String
showFoo { foo } = show foo
main :: Effect Unit
main = do
-- barとかbazがあっても渡せる
logShow $ showFoo {foo: 100, bar: "bar", baz: true} -- "100"
このVariant
型を利用して、OCamlの多相バリアント型の例と同じように、barを持っていなかったらotherを返すような関数を作ってみます。
showBar :: forall r. Variant (bar :: Int | r) -> String
showBar = on (Proxy :: Proxy "bar") show (\_ -> "other")
main :: Effect Unit
main = do
logShow $ showBar $ inj (Proxy :: Proxy "bar") 100 -- "100"
レコード型を利用している都合上、OCamlのようにラベルなしで`Bar
のような値を受けることはできません。
今度はfoo
だけではなく、bar
やbaz
というラベルの値も受けられる関数を作ってみます。
それ以外のラベルの場合は"other"
を返すようにします。
-- Proxyを作るのがめんどい
_foo = Proxy :: Proxy "foo"
_bar = Proxy :: Proxy "bar"
_baz = Proxy :: Proxy "baz"
_hoge = Proxy :: Proxy "hoge"
showFooBarBaz
:: forall r
. Variant (foo :: String, bar :: Int, baz :: Boolean | r)
-> String
showFooBarBaz =
default "other"
# on _foo identity -- fooは文字列なのでそのまま返せばいい
# on _bar show
# on _baz show
main :: Effect Unit
main = do
logShow $ showFooBarBaz $ inj _foo "Foo" -- "Foo"
logShow $ showFooBarBaz $ inj _bar 100 -- "100"
logShow $ showFooBarBaz $ inj _baz true -- "true"
logShow $ showFooBarBaz $ inj _hoge "Hoge" -- "other"
型がチェックされているので、例えばinj _foo 100
のように指定した場合はコンパイルエラーになります。foo
というラベルの値の型はString
型だからです。
またhoge
というラベルの値には対応していないため、"other"
が返されます。
このVariant型はホントにバリアントなの?
バリアント型とは直和型でした。
そしてこのVariant
型はレコード型を利用しています。
レコード型は直積型です。
・・・つまり?
直積型使ってんだから、こいつ直和型じゃねえじゃん!
と思うかもしれませんが、ちょっと待ってください。
まず関数inj
で作ることができるVariant
型は単一のラベルしか持てなくなっています。
つまり、Variant (foo :: String, bar :: Int, baz :: Boolean | r)
という型には複数のラベルに紐づく値と型が含まれており一見直積っぽいですが、上記の制約から実際に渡ってくるのは、これらのうちいずれかです。
ゆえに、実質直和のように扱われることになります。
なので、モヤるかもしれまんせんが、ちゃんとバリアントになっていると言ってよいと思います。
ライブラリの使い方
ライブラリのドキュメントを訳しただけですが、おまけとして使い方を載せておきます。
inj
inj
にタグを指定することで、バリアントに値を取り込む。
Proxy
は、型レベルでのタグをコンパイラに伝えるための手段に過ぎない。
import Type.Proxy (Proxy(..))
someFoo :: forall v. Variant (foo :: Int | v)
someFoo = inj (Proxy :: Proxy "foo") 42
様々な例(この例は後々の関数の説明でも使う)
someFoo :: forall v. Variant (foo :: Int | v)
someFoo = inj (Proxy :: Proxy "foo") 42
someBar :: forall v. Variant (bar :: Boolean | v)
someBar = inj (Proxy :: Proxy "bar") true
someBaz :: forall v. Variant (baz :: String | v)
someBaz = inj (Proxy :: Proxy "baz") "Baz"
on
on
関数は、成功した場合に内側の値を処理する関数と、失敗した場合に残りを処理する関数を受け取る。
fooToString :: forall v. Variant (foo :: Int | v) -> String
fooToString = on (Proxy :: Proxy "foo") show (\_ -> "not foo")
-- ↑はfooがあったらshowを適用、なかったら"not foo"を返す。
fooToString someFoo == "42"
fooToString someBar == "not foo"
caseとdefault
case_
とdefault
を使うことで、通常のcase
を使ったパターンマッチのようなことができる。
またdefault
を使うことで、失敗した場合のデフォルト値を提供できる。
code:haskell
_foo = Proxy :: Proxy "foo"
_bar = Proxy :: Proxy "bar"
_baz = Proxy :: Proxy "baz"
allToString :: Variant (foo :: Int, bar :: Boolean, baz :: String) -> String
allToString =
case_
# on _foo show
# on _bar (if _ then "true" else "false")
# on _baz (\str -> str)
-- それぞれラベルが存在した場合の処理
someToString :: forall v. Variant (foo :: Int, bar :: Boolean | v) -> String
someToString =
default "unknown" -- fooもbarも存在しなかったら "unknown" を返す
# on _foo show
# on _bar (if _ then "true" else "false")
-- テスト
allToString someBaz == "Baz" -- someBazはbazを持っているのでそのままの値が返る
someToString someBaz == "unknown" -- onでbazを見ていないのでdefaultの値が返る
onの合成
onを持つハンドラーは関数合成をして異なる文脈で再利用することができる。
onFooOrBar :: forall v. (Variant v -> String) -> Variant (foo :: Int, bar :: Boolean | v) -> String
onFooOrBar =
on _foo show >>> on _bar (if _ then "true" else "false")
-- fooがあったらshow、なかったらbarを見てbarがあったら後続のif文を呼ぶ
allToString :: Variant (foo :: Int, bar :: Boolean, baz :: String) -> String
allToString =
case_
# onFooOrBar
# on _baz (\str -> str)
onMatch
onMatch
を使うと、上記のonFooOrBar
のような関数をレコードの形式で記述できる。
(showやidのような多相関数は、レコードの不完全性のために、注釈をつけるか、eta展開をする必要がある)
onFooOrBar :: forall v. (Variant v -> String) -> Variant (foo :: Int, bar :: Boolean | v) -> String
onFooOrBar = onMatch
{ foo: show :: Int -> String -- showは多相関数なので明示
, bar: if _ then "true" else "false"
}
match
onMatch
はon
と同じようにcase_
やdefault
と一緒に使うことができるが、一般的なcase
と同じトータルマッチングのためのmatchもある(つまりすべてのパターンを記述する場合はこちらが使える)。
allToString :: Variant (foo :: Int, bar :: Boolean, baz :: String) -> String
allToString = match
{ foo: \a -> show a
, bar: \a -> if a then "true" else "false"
, baz: \a -> a
}
-- case_とonとProxyを使った場合はこうなる。
allToString :: Variant (foo :: Int, bar :: Boolean, baz :: String) -> String
allToString =
case_
# on _foo show
# on _bar (if _ then "true" else "false")
# on _baz (\str -> str)
VariantF
VariantF
を使えば、多相バリアントをFunctor
と組み合わせることもできる。
VariantF
は、Variant
と同じ関数(onとかinjとか)をすべてサポートしている。
-- fooにMyabeを紐付ける
someFoo :: forall v. VariantF (foo :: Maybe | v) Int
someFoo = inj (Proxy :: Proxy "foo") (Just 42)
-- barにTupleを紐付ける
someBar :: forall v. VariantF (bar :: Tuple String | v) Int
someBar = inj (Proxy :: Proxy "bar") (Tuple "bar" 42)
-- bazにEitherを紐付ける
someBaz :: forall v a. VariantF (baz :: Either String | v) a
someBaz = inj (Proxy :: Proxy "baz") (Left "Baz")
Discussion