purescript-generics-repパッケージを使ってGenericなSerializer型クラスを作った。以下はそのメモ。
準備
プロジェクトを作成。
$ spago init
Arrayを使うので、パッケージをインストールしてsrc/Main.pursにimport文を書き込んでいく。
本記事の本命であるgenerics-rep入れる。
$ spago install arrays $ spago install generics-rep
| |
REPLで色々実験するので、あらかじめ起動しておく。
$ spago repl > import Main
以降はsrc/Main.pursに色々書き足していく。REPLで:r(もしくは:reload)とコマンドを打てばモジュールが再読み込みされるので、src/Main.pursを書き換える度にこのコマンドを打つと良い。
Serializer
そもそもSerializerとは何か。ここでは単に「データをビット列に変換するもの」程度の意味で捉えれば良い。 厳密にはJSONなどの階層を持つデータを,文字列などの平坦なデータに変換するという意味合いとしてシリアライズ(直列化)という言葉を使う。実際、本記事では最終的に木構造をシリアライズする。
まずビットは次のように定義する。
| |
Serializer型クラスを以下のように定義する。
| |
試しに適当な型をつくり、それをSerializer型クラスのインスタンスにしてみる。
| |
余談。今回はデシリアライザは実装しないので、シリアライズしたデータを同じ形に戻せるかは考えない。このあたりは情報理論の授業で「一意復号化可能性」などをやった気がするけど、忘れてしまった。
REPLで実験してみる。
> serialize Alice [I] > serialize Bob [O,I] > serialize Carol [O,O,I]
Tree型をSerializer型クラスのインスタンスにする(素朴な方法)
2分木のデータ構造であるTree型を作る。
| |
テスト用のデータを作ってREPLでシリアライズしてみる。
| |
> serialize tree [I,I,O,I,O,O,I,O,O,O,I]
一般化
さて、3分木なら例えば次のようにSerializer型クラスのインスタンスにできる。
| |
木に限らず、有名な型については例えば次のようにインスタンスにできるだろう。
| |
3つの値を持つデータ型なら例えば次のようにできる。
| |
上の例はアドホックなものであり、実装そのものに共通点はない。ただし、実装する上での気持ち「データの値ではなくデータの表現に注目する」では共通している。 ここでいう表現というのは、たとえば3つ目の例でいうと、
OneとTwoとThreeという値を持ち,
Oneはa型の値とb型の値を引数に持つ。Twoはb型の値を引数を持つ。Threeはc型の値,b型の値,a型の値を引数に持つ。
がT0の持つ表現である。
そのような、データの表現に応じてシリアライズの仕方を実装することができれば、いちいちMaybeやEitherやT0など個別にSerializer型クラスのインスタンス宣言を書かずに済む。
それを可能にするのが、purescript-generics-repパッケージである。これは、データ型の持つ表現そのものを型にする手段を提供する。
Data.Generic.Rep
モジュールをインポートする。
| |
Generic型クラス
Generic型クラスのインスタンスを導出させることで、Treeの表現が生成される。アンダーバーはコンパイラが自動で埋めてくれる。
| |
ドキュメントによると、Generic型クラスは次のように定義されている。
| |
repが、型aの表現を表す(repというのは、恐らくrepresentation(表現)の略)。fromを使えば、aの表現が得られる。REPLでTreeの表現の型を確認してみる。
> import Data.Generic.Rep > :t from tree Sum (Constructor "Node" (Product (Argument (Tree Person)) (Argument (Tree Person)))) (Constructor "Leaf" (Argument Person))
なので実際には、アンダーバーのところを埋めると次のようになるようだ(ただし、必ずアンダーバーでなければならないようで、これはコンパイルエラーになる)。
| |
表現の構成要素
表現が構成する型は次の4つ。
| |
Sumは直和型を表現する型。Productは直積型を表現する型。Constructor name aは値コンストラクタを表現する型。Argumentは値コンストラクタの引数を表現する型。NoConstructorsはコンストラクタが存在しないことを表現する型。NoArgumentsは値コンストラクタの引数が存在しないことを表現する型。
であることを踏まえると、
| |
は、次のように読める:この型は2つのコンストラクタがあることを表現している。1つ目の値コンストラクタ名はNodeで、その引数としてTree a型の値を2つ持つ。
2つめの値コンストラクタ名はLeafで、その引数としてa型の値を1つ持つ。
ちなみにderiveを使わないでTreeについてのインスタンス宣言をすると次のようになる。勿論deriveを使えばいい話なので、普通こんなことはやらない。
| |
応用例:Showクラスのインスタンスにする
PureScriptでは、Haskellと違って、Show型クラスのインスタンス導出ができない(参考)。しかし代わりに、Generic型クラスを利用すれば、導出と同じようなことができる。
まずは、関連モジュールをインポートする。
| |
試しに、Person型をShow型クラスのインスタンスにしてみる。
| |
genericShowは、Personの表現をもとに値を文字列に変換する関数。単にPersonをGeneric型クラスのインスタンスにするだけで利用できる。
> Alice Alice > Bob Bob > Carol Carol
Treeも同様にShow型クラスのインスタンスにできる。
| |
> tree (Node (Node (Leaf Alice) (Leaf Bob)) (Leaf Carol))
Data.Generic.Rep.Showのコードを読んでみると、どうやって表現を使って実装するのかがよく分かる。
補足:Showクラスのインスタンス化に関する注意点
次のように書くと、show関数を呼び出した際に実行時エラー:Maximum call stack size exceeded起こす。
| |
show x = genericShow xもshow = genericShowも本質的には同じ意味なのに、前者はうまくいき、後者は実行時エラーを吐くのはおかしい。
この問題についてgenerics-repのissueやpurescriptのissueでも上がっている。
後者のissueではClosedになってしまっているようだし、修正されないのだろうか。単にshow x = genericShow xと書けば防げる問題なので、そこまで大した問題ではないのかもしれないが…。
本題:Tree型をSerializer型クラスのインスタンスにする(表現を利用する方法)
Data.Generic.Rep.Showのコードの手法を真似て、次のように作る。
- 表現をシリアライズする型クラスは
Serializer'が担う。 TreeやPersonなどの普通の値をシリアライズする型クラスはSerializerが担う。
そこで、Serializer'型クラスを作る。
| |
表現に関連する部分は全てSerializer'型クラスのインスタンスにする。最後のArgumentだけ、引数に普通の型が含まれているため、型クラス制約がSerializer'ではなくSerializerであることに注意。
| |
最後に、serialize'を使ってTreeをSerializer型クラスのインスタンスにする。前に作成したインスタンス宣言は消す。
| |
素朴に作った場合と同じ結果が出ている。
> serialize tree [I,I,O,I,O,O,I,O,O,O,I]
Tree型の値そのものに注目せずにSerializerが実装できたことに注目してほしい。Tree aに限らず、例えば型T0 a b cがあったとき、
T0はGeneric型クラスのインスタンスであるa, b, cはSerializer型クラスのインスタンスである
という2つの条件が揃えば、T0はシリアライズできることになる。
補足:Tree以外の型をSerializer型クラスのインスタンスにする
自作の型なら、容易にSerizlizerのインスタンスにできる。
| |
Maybe aに関しては特別にData.Generic.Repでインスタンス宣言がなされているため、次のようにSerializerのインスタンスにできる。
| |
別モジュールで定義された型を、Genericを使ってSerializerのインスタンスにすることはできない。
なぜなら、今回の場合「Main外で定義された型」に対して「Main外で定義された型クラスGeneric」のインスタンス宣言をすることになり、これはOrphan instanceに当たるからだ
PureScriptではOrphan instanceは禁止されている(参考)。
この問題を解決するためにぱっと思いついた方法は、以下のようなもの。以下はEitherをSerializer型クラスのインスタンスにしている。Eitherの別表現としてEither'を用意する。
EitherはMain外のモジュールで定義されているため、直接Generic型インスタンスにすることはできない。しかし、Either'はGenericにすることはできる。
よって、一旦Either'を経由してEitherのSerializerインスタンス宣言をすることができる。
| |
ちなみに、最近EitherをGenericのインスタンスにする動きあるようだ。
参考文献
本記事を書くに当たって参考にした文献を挙げておく。
Haskell方面
PureScriptはHaskellの影響を受けているので、Haskellの資料が参考になることは結構ある。ただし、構文や型名、パッケージ名など違いがあるので、それらについては根気強く調べる必要がある。
- GHC.Genericsのドキュメント:そもそも、purescript-generics-repはこれにインスパイアされて作ったもの(参考)。
- Haskell wiki:Serializerという型クラスを実装する例を与えている。
- GHC.Genericsを利用したgeneric programming - tiqwablog:日本語の文献。こちらも同様、Serializableという型クラスを実装している(名前は違うが上と同じ機能を持つ型クラス)。
PureScript方面
あまり見つからなかった…。