PureScriptで多相バリアントを扱う

2023/07/09に公開

はじめに

OCamlを使ってる人にはお馴染みなのかもしれませんが、PureScriptにも多相バリアント(のライブラリ)があるので紹介したいと思います。
扱う内容としては、バリアントが何かから始めて、OCamlでの例も交えながらPureScriptの多相バリアントの説明までしていきます。

ちなみに余談ですが、PureScriptのExtensible Effects(拡張可能作用)のライブラリは、内部的に多相バリアントを使っており、実用されております。

バリアント

多相バリアントの話をする前に、まずバリアントの話をしましょう。

バリアントとは

多相バリアントは多相なんて言ってるくらいだから多相なバリアントなんだろうと想像がつくと思うのですが、そもそもバリアントとは何なのでしょうか?

書籍『型システム入門』での説明

書籍『型システム入門』では次のように書かれています。

多くのプログラムは異種の値の混在した集まりを扱う必要がある。例えば、二分木のノードは葉もしくは2つの子を持った内部ノードである。同様に、リストのセルはnilもしくは、先頭の要素と残りの部分とを持つconsセルである。同様に、コンパイラにおける抽象構文木のノードは、変数、ラムダ抽象、関数適用などを表しうる。この種のプログラミングをサポートする型理論的な機構がバリアント型である。

要約するとバリアント型とは『異種の値の混在した集まりを扱うプログラミングをサポートする型理論的な機構』ということです。

異種の値の混在した集まりの例としては次のような例が挙げられていますね。

  • 二分木のノード(葉もしくは2つの子を持つノード)
  • リストの要素(nilもしくはcons)
  • コンパイラにおける抽象構文木のノード(変数、ラムダ抽象、関数適用)

また

バリアント型は直和と呼ばれることがある

とも書かれています。

更にバリアントの具体例として、Option列挙型が挙げられていました。
(これらは一般的には直和として説明されることが多い印象です)

WikipediaのTagged UnionのページでもTagged Unionはバリアントや和型などとも呼ばれると書かれており、少なくとも型システム的な文脈ではバリアントを直和と同一視してよいと思います。

OCamlでのバリアント

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には言語的にバリアント型と表現されるものはありません。
ただ、バリアントは直和であったので、直和型であるMaybeEitherはバリアントと言っても差し支えないでしょう。
また代数的データ型を使ってもバリアントを表現できると思います。
上記のOCamlのバリアントの例をPureScriptで書くとこんな感じになるでしょう。

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の定義です。

Variantの定義
foreign import data Variant  Row Type  Type

上記の定義を見ると種(kind)がRow Typeになっていることから分かるように、このVariant(foo :: Int)のようなkindRow Typeになるものから作られる型です。

Variant型の値を作る例
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だけではなく、barbazというラベルの値も受けられる関数を作ってみます。
それ以外のラベルの場合は"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

onMatchonと同じように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