type-convを使ったジェネレータ作成の覚書

入社から1ヶ月が経過しました。まぁまだ大変というほどではありませんが、一般的な企業ということで、これが普通の福利厚生なんだ、と噛み締めています。健康診断がこの年で初めてというのもねぇ。

現在の仕事では、seasar2やaxis、はたまたクライアントサイドJavaScriptという、今迄の現場では欠片も触りもしなかったものを触っており、それはそれで刺激になっていますが、趣味の時間ではそういうものとは違ったものを触っているようにしたいものです。言われんでも違うものを触りますが。

さて、OCamlをライブラリと一緒に使っていると、type-convというライブラリの名前がよくでてきます。type-convを利用しているライブラリとしては、sexp-convが有名所でしょうか。type-convは、型に対するコードジェネレータのためのフレームワークライブラリとなっていて、ボイラープレートとなっているコードを、ちょうどHaskellのderivingみたいな形で作成することができます。Haskellのderivingと違うのは、その部分が非常にプログラマブルになっている点です。
type-convを利用したライブラリを利用すると、様々な効果を得ることができます。sexp-convだと、型をS式の形で相互に変換できるようになったりします。

このtype-conv、Camlp4という、OCamlコンパイラに標準添付されているライブラリの拡張として作成されています。ところが、このCamlp4が非常に難解な代物で、ちゃんとしたドキュメントというものも(最新のは)あまりない、という困ったものです。さらに困ったのが、type-convについてのドキュメントというものがほとんどありません。英語ページを見たりしていても、ライブラリとしてコンパイルするときに必要なくらいで、使い方というものを説明したようなドキュメントは御目にかかったことがありません。

多分、Camlp4が普通に使えるレベルの人がtype-convを使う→Camlp4が使えない/わからない人には解読できないソースができる→使えればそれでいいから、特に困らないし、どういう原理か説明する必要も無い
みたいな感じになってんじゃないかなー、と思います。わかってる人にとっては自明なんで、今更という感覚もあるのでしょうし。

というわけで、個人的にCamlp4も勉強しようと思ってましたんで、type-convを利用したジェネレータの書き方というものを、非常に妖しいCamlp4の説明と共にまとめてみました。ほとんどの用途には、既存のライブラリで済んでしまい、わざわざtype-convを学習する必要が無い、というのはそのとおりですが、基礎くらい知っておいてもいいんではないかー、ということで。

Camlp4について

まずはCamlp4について、簡単かつ非常に妖しい説明をしてみます。自分で言うのもなんなんですが、ちゃんと理解できているかどうかが妖しいため、違う点がありましたら指摘など頂ければありがたいです。

***Camlp4とは

Camlp4とは、基本的には、OCaml構文木に対して操作を行うためのフレームワークです。構文木に対して操作することができるため、メタプログラミングの用途にも使えますし、既存の構文木から、新しいコードの構文木を作成して追加する、ということができたりします。
使い方としては、プリプロセッサとして利用するのが一般的です。実際には、Camlp4の中でも様々なステップがあるようですが、ここではあまり細かいことは書き(け)ません。

***Camlp4の二つのシンタックス

Camlp4が難解な理由として、二種類のシンタックスが存在する、という点があります。Rivised Syntax、Original Syntaxがそれぞれの名前となっています。
なんでそんなことになっているのかというと、Camlp4は、独自にOCamlシンタックスに体するParserを持っており、それを利用してOCamlの文法を定義しなおしたのが、Rivised Syntaxとなっています。
Original Syntaxはそのまま、OCamlのオリジナル文法となっています。

このRivised Syntaxは、Camlp4を使う上で避けては通れない上、Original Syntaxとある程度一緒なのですが、結構な部分が異なっているため、わざわざ差異を覚えないとなりません。どうも釈然としないこの形態が、Camlp4を難解なものにしていると思われます。

***Camlp4のコマンドについて

色々と細かいことはすっとばしていきますが、Camlp4は基本的にプリプロセッサなため、Camlp4をソースに適用した結果を確認するコマンドというのが、OCamlコンパイラをインストールしたときに一緒にインストールされます。

個人的に特に頻用する、というかほとんどの場合、以下のコマンドを利用して結果を確認します。

# camlp4of -printer o <file>

printer o というオプションは、出力としてOriginal Syntaxを利用する、というものになります。printer r とすると、Rivised Syntaxで出力されます。

ちなみにこのcamlp4*というコマンドですが、結構な種類があります。

  • camlp4
  • camlp4o
  • camlp4of
  • camlp4oof
  • camlp4r
  • camlp4rf
  • camlp4orf

基本的に、camlp4以外のコマンドは、camlp4に対してよく利用されるオプションを指定した形で実行するためのhelper commandといった風情のようです。

***Quotation/Antiquotation

Camlp4は、ある構文木についての処理を記述するためのフレームワークですが、実際にCamlp4から提供されている構文木を弄ろうとする、非常に面倒なことになります。

たとえば、

let f x = x

という式は、Camlp4における構文木では、

  Ast.StSem (_loc,
    (Ast.StVal (_loc, Ast.ReNil,
       (Ast.BiEq (_loc, (Ast.PaId (_loc, (Ast.IdLid (_loc, "f")))),
          (Ast.ExFun (_loc,
             (Ast.McArr (_loc, (Ast.PaId (_loc, (Ast.IdLid (_loc, "x")))),
                (Ast.ExNil _loc), (Ast.ExId (_loc, (Ast.IdLid (_loc, "x")))))))))))),
    (Ast.StNil _loc))

というもので表わされます。ぶっちゃけこれを素でいじくりたおせるような人は、超人か変人のどちらかでしょう。

そのため、これを楽に扱うために、Quotationsという仕組みが用意されています。Quotationは、<< >>で囲まれたものを示し、Camlp4を通すことで、この部分を構文木に置換してくれます。

例えば、上記の構文木と同様の構文木が欲しい場合、

let q = <:str_item< let f x = x >>

として、先程のコマンドにこの内容を記述したファイルを指定すると、

    let q =
  Ast.StSem (_loc,
    (Ast.StVal (_loc, Ast.ReNil,
       (Ast.BiEq (_loc, (Ast.PaId (_loc, (Ast.IdLid (_loc, "f")))),
          (Ast.ExFun (_loc,
             (Ast.McArr (_loc, (Ast.PaId (_loc, (Ast.IdLid (_loc, "x")))),
                (Ast.ExNil _loc), (Ast.ExId (_loc, (Ast.IdLid (_loc, "x")))))))))))),
    (Ast.StNil _loc))

という出力を得ることができます。ちなみに、これは正当なOCamlソースなので、このまま普通のOCamlの枠組みで扱うこともできます。普通はそんなことしないようですが。

さて、quotationでstr_itemとしている部分がありましたが、これはquotation expanderと呼ばれるものを指定しており、str_item以外にも

  • ident
  • patt
  • binding
  • expr
  • ctyp

のようなものがあります。これ以外にもあります。つまり、quotation内部を、どのような構文要素として解釈するのか、というのを指定するものとなっていて、もしquotation内部の文字列がその構文要素としてそぐわない場合、Parse errorとなります。

quotationはCamlp4を使う上で必須ですが、そのうち困ったことが起こります。Quotationには、基本的には字句をそのまま渡すしかないのですが、そのときの要素によって、構文を変化させたいことがあります。そんなときに使われるのがAntiquotationとなります。

<:str_item<
type t =
    $Ast.TySum (_loc,
                Ast.tyOr_of_list
                  (List.map
                     (fun c -> <:ctyp< $uid:c$ >>) cons))$
 >>

これは、string listから、variantを動的に作成するための処理ですが、$$で囲まれた部分が、antiquotationとなります。quotationの中のこの部分は、式として実行した結果が設定されることになります。さっきの文と比較すると、普通の文とAst.*が混在するような形で大丈夫なのか?と感じますが、この辺は問題ありません。

また、↑の文には、$uid:$という記述があります。まだこの辺の理解が甘いですが、これはantiquotationの式の結果を、指定した構文要素に変換するためのものとして使えるようです。

type-convの基本

Camlp4については、とりあえず知っておけばなんとかなる?かも?という程度の記述しかできませんので勘弁願います。やっとこさtype-convについての記述です。

type-convは、OCamlの文法を拡張して、
type x = Hoge with 〜
のようにして、型についてジェネレータを指定することで、関数の自動生成などを行うための機構を提供しています。

type-convにおいて、コードジェネレータ関係として、以下の関数が用意されています。

  • add_generator
  • add_generator_with_arg
  • rm_generator
  • add_sig_generator
  • add_sig_generator_with_arg
  • rm_sig_generator
  • add_record_field_generator
  • add_record_field_generator_with_arg

今回はこのうち、add_generatorだけを扱います。他でも大体使い方は一緒のようです。

add_generatorは、以下のようなシグネチャをもっています。

add_generator: ?is_exn:bool -> string -> (ctyp -> str_item)

is_exnは、そのジェネレータの対象が型なのか例外なのかを指定するもので、デフォルトはfalseとなっています。stringはジェネレータ名で、with について指定する名前となります。最後がジェネレータで、型情報から、生成するコードの構文木を返すようなものになっています。

この情報だけでさっくり記述できる人はすばらしいのですが、私は凡人ですので、これだけじゃどうにも書けません。というわけで、非常に簡単なジェネレータを作成していってみることにします。

type-conv実践編

ここでは、以下のような仕様でジェネレータを作成してみることにします。

  • ジェネレータ名はshowで、「型名_to_string」という関数を自動的に生成する
  • _to_stringでの文字列への変換ルールは以下
    • variantは、"Variant"のような形で
    • プリミティブとかHoge of stringとかarrayとかlistとかtupleとかは、簡単なので扱わない。

***実践編1 まずはtype-convのコンパイル方法

type-convについて記述するまえに、まずはコンパイル方法をちゃんとしておく必要があります。Camlp4を使う上で、この部分が非常に面倒なものとなりますので、omakeでもMakefileでもいいので、書いておくことをお勧めします。

ここでは、test.mlというファイルにジェネレータを記述するという前提で、次のようにコマンドを発行します。ocamlfindはもはや必携なので、インストールしていない方はインストールしましょう。

ocamlfind ocamlc -package camlp4.quotatioins.o,camlp4.lib,type_conv -syntax camlp4o -c test.ml
ocamlfind ocamlc -package camlp4.quotatioins.o,camlp4.lib,type_conv -syntax camlp4o -a -o test.cma test.cmo

test.cmaというのが、Camlp4で利用するモジュールになります。type-convもそうですが、これらはCamlp4のモジュールとして使い、これを利用した.mlをコンパイルする際にリンクするわけではありません。

***実践編2 type-convに簡単なジェネレータを追加してみる

コンパイル方法がわかったら、次に簡単なジェネレータを追加してみます。

open Camlp4.PreCast
open Pa_type_conv

let () =
  let _loc = Loc.ghost in
  add_generator "test" (fun is_exn typ -> <:str_item< let hoge = "hoge" >>)

これをtest.mlとして作成し、先程の手順でコンパイルします。

この内容としては、testというジェネレータを追加し、結果として let hoge = "hoge" という定義を追加するようなものになります。

実際にどんな結果になるのかを確認したい場合、以下のような内容でファイルを作成して、

type a = A with test

camlp4コマンドを実行することで、実際にコンパイルする際のソースとして利用されるソースを確認できます。

# camlp4of ~/.opam/4.00.1+annot/lib/type_conv/pa_type_conv.cma  test.cma -printer o tmp.ml

普通にコンパイルする場合は、以下のコマンドでできます。

# ocamlfind ocamlc -package type_conv -syntax camlp4o -ppopt 'test.cma' tmp.ml

***実践編3 ちゃんとしたジェネレータにしてみる

次はいよいよちゃんとしたものにしてみましょう。少し長いですが、以下のような感じになります。

open Camlp4.PreCast
open Pa_type_conv

let () =
  let _loc = Loc.ghost in
  let rec matcher = function
    | <:ctyp< $uid:t$ >> -> <:match_case< $uid:t$ -> $`str:t$ >>
    (* for variant with type *)
    | Ast.TyOr (loc, t1, t2) -> Ast.McOr (loc, matcher t1, matcher t2)
    | Ast.TySum (_, t) -> matcher t
    | _ -> <:match_case< >> in
  add_generator "test" (fun _ (Ast.TyDcl (loc, name, _, variants, _)) ->
    <:str_item<
    let $lid:name ^ "_to_string"$ = function
      $let ors = Ast.mcOr_of_list [matcher variants] in
        Ast.McOr (_loc,
                  ors,
                  <:match_case< _ -> failwith "can not convert to string">>)$
       >>)

この形になるまで、非常に試行錯誤しなければなりませんでした。まず一番困ったのが、add_generatorに渡す関数(以下ジェネレータ)に、どんな形で渡されてくるのか、というドキュメントが何も無いことでした。
んなもん自明だろう、と言われても困りますし、一見さんお断り度が高すぎるのもどうかと思います。

それはおいておいて、ジェネレータに渡されてくる型の構文木ですが、上記のようにAst.TyDclというコンストラクタで渡されてきます。この構文木ですが、どの部分まであらわすのかというと、ちょうどtype hoge = Hogeの全体になります。つまり、型として必要な情報はすべて手にはいっている状態です。

TyDclコンストラクタは、見ての通り5つの引数をもっていますが、さしあたって必要なのは3つです。1つめはソースコード中の位置、2つめは型名、そして3つめ(上記ではvariants)が、= の右側部分を構成する構文木となります。

処理のメインとしては、variantsをパターンマッチングと文字列変換することがメインとなります。

variantsですが、一つしかない場合でも2つ以上ある場合でも、必ずTySumというコンストラクタに包まれています。ですので、上記のmatcher関数でTySumを剥ぎ取った結果を再帰的に処理しています。
複数のvariantが存在している場合、TySum(_, TyOr (_, t1, t2))のような形になっています。TyOrコンストラクタは、ちょうど Hoge | Huga | Foobarのような定義があった場合、TyOr(_, Hoge, TyOr(_, Huga, Foobar))のような構成になりますので、再帰処理がちょうどあてはまります。

さて、このジェネレータを適用すると、次のような状態になることを想定しています。

type hoge = Hoge | Huga

let hoge_to_string = function
| Hoge -> "Hoge"
| Huga -> "Huga"

このfunction 以降のパターンマッチ部分を作成しているのが、$let ors 〜 $のAntiquotationの部分です。パターンマッチの構文木は、Mcという接頭語を持つコンストラクタで構築されており、それぞれのパターンマッチは、McOrというコンストラクタで構築できます。
が、当然ながら手作業ですと非常にめんどいので、Ast.mcOr_of_listという関数を使うことで、個々のパターンマッチをMcOrで構成したものとして返してくれるので、これを利用します。

パターンマッチとしては、<:match_case< >>というquotationを使います。この中身としては、パターンマッチにおける 〜 -> 〜の部分を書くことになります。

上のmatcher関数において、パターンマッチに <:ctyp< $uid:t$>>というquotationとAntiquotationの組合せがでています。また、これに続く式の中で、パターンマッチで出てきたtを利用していることが確認できます。

ちなみにAnti quotationでよくでてくる、lid:とuid:の違いですが、lidは先頭がlittle caseな型、つまりプリミティブや他の型名の部分となります。uidはupper caseな型、つまりヴァリアントを指すものとなります。

ひとまず終了

今回、type-convについて自分で調べながら実装していった過程で、こんな情報があればこんな苦労せんですむのに、と思った部分について、自分なりにまとめてみました。
type-convのジェネレータを公開しているような方々は、OCamlを使われている方々でもレベルの高い人ばかりで、こんな情報はすぐに理解できてしまうのかもしれませんが、自分のような凡人には、こういうドキュメントがまとまってくれているのがありがたいのです。

個人的にまとめたメモをベースにして一気に書いたので、ところどころ間違っているところもあるかと思いますが、これでtype-convに興味を持って、Camlp4の理解について一助となれば幸いです。