技術memo

関数型ゴースト

SML# の「多相レコードによる多相ヴァリアントの表現」がわからなかった話

この投稿は、ML Advent Calendar 2014の22日目の記事です。前日はkawada_syogo225さんのAlice MLの仮想マシンについて | 小さいモノは美しいでした。

多相ヴァリアントの話

ラベル付きヴァリアントの多相版、です。詳しくは以下あたりを見ましょう。

雰囲気的には、ラベルの属する型に関係なく、複数ラベルを一纏めにして扱ったりできるようなイメージです。

多相でないラベル付きヴァリアントなら、F# の判別共用体やHaxeenumのように、幾つかの言語に導入されていますが、多相バージョンは現状ほとんどOCaml専用のようです。

多相レコードの話

SML# のキラーコンテンツ、もとい主力機能です。詳しくは公式の解説を読みましょう。

と言っても、これだけでは普通のレコードと何が違うのか今ひとつわからないので、簡単にまとめておくと

  • 通常のレコード型
    • 型に属するフィールドを全て一度に宣言する (例(こちらから借用): type Point = { x : float; y: float; z: float; })
    • フィールド取り出し操作は「ある特定の単相的なレコード型 -> 指定されたフィールド名のフィールド型」の関数になる
  • 多相レコード型
    • 型宣言しなくても使える
    • フィールド取り出し操作は「指定されたフィールドを持つ任意の(多相的な)レコード型 -> 指定されたフィールド名のフィールド型」の関数になる

多相レコードによる多相ヴァリアントの表現

SML# の公式ドキュメントに、次のページがあります。

抜粋すると

  • レコードが名前付きのデータの集合であるのに対して,ラベル付きバリアントは,名前付きの処理の集合の下での処理要求ラベルのついたデータと考えることができます.

  • つまりタグTをもつバリアントは,タグTを処理するメソッドスイートを受け取り,自分自身にその処理を呼び出す行うオブジェクトと表現します. あとは,必要な処理をそれぞれの表現に応じて書き,タグに応じた名前をもつレコードにすればよいわけです.

  • レコードによるバリアント表現の基本は,そのデータを処理する関数を引数とする高階の関数として表現することです.型システムが,その関数の多相性を正確に表現できれば,多相型バリアントも自然に表現できます.

  • fn M => #Age M 21

  • この関数は,種々のバリアントを処理するメソッドスイート Mを引数として受け取り,そのなかから"Age"用のメッソドをディスパッチします.

... つまり、どういうことでしょうか?

例としては極座標と直交座標が挙げられていますが、正直よくわかりません。多分私の理解力が不足しているのだと思います。それに、この程度なら多相ヴァリアントでなくても単相で十分に見えます。

ということで

もうちょっと複雑で実践的な例をやってみました。

題材

例として、webの登録画面の入力値検証を考えてみます。 入力項目は思いつかなかったので単純に、以下のような形とします。

年齢 : [    ]歳
名前 : [        ]

ここで、

  • 年齢は整数値だけに制限する
  • 年齢は0から100までにする
  • 名前は長さを3文字から20文字までとする。
  • 以上の条件を満たさなかったら、エラーの内容を適宜表示する。(満たした場合は成功として次の処理に進むイメージ)

というような入力検証を考えます。

実装

上から順に作っていきましょう。

(* 年齢の入力値を検証します。 *)
fun validate_age age =
    let val i = Int.fromString age in
        case i of
            NONE => (fn m => #ageParseError m { actual = age })
          | SOME x => if x < 0 orelse 100 < x
                      then (fn m => #ageRangeError m { min = 0, max = 100, actual = x })
                      else (fn m => #success m ())
    end
(* 名前の入力値を検証します。 *)
fun validate_name name =
    let val len = size name in
        if len < 3 orelse 20 < len
        then (fn m => #nameLengthError m { min = 3, max=20, actual = len })
        else (fn m => #success m ())
    end

年齢用のチェック関数と名前用のチェック関数を作りました。 どちらも文字列としてチェック対象を受け取って、ダメだったら

    (fn m => #ダメだった理由フィールド名 m { 理由の詳細データ })

というような形の関数を返しています。また成功時は理由の詳細データがないのでユニット()としています。

次に、入力をレコード{ age = "年齢の入力値", name = "名前の入力値" }として受け取って、判定結果を表示する関数を作ってみます。

(* 検証の成功または失敗を保持するデータ型です。失敗時はエラーメッセージを保持します。 *)
datatype Result = SUCCESS | FAILURE of { message: string }
(* 入力値を検証し、結果を画面に表示します。 *)
fun validate input =
    let
        val pattern =
            {
              ageParseError =
              (fn x =>
                  FAILURE {
                      message =
                      ("age error. the value is required integer, but it was "
                       ^ (#actual x)
                  )}
              ),
              ageRangeError =
              (fn x =>
                  FAILURE {
                      message =
                      ("age error. the value is required in "
                       ^ (Int.toString (#min x)) ^ " to " ^ (Int.toString (#max x))
                       ^ " but it was " ^ (Int.toString (#actual x))
                  )}
              ),
              nameLengthError =
              (fn x =>
                  FAILURE {
                      message =
                      ("name error. length of the value is required in "
                       ^ (Int.toString (#min x)) ^ " to " ^ (Int.toString (#max x)) ^ " but it was " ^ (Int.toString (#actual x))
                  )}
              ),
              success = (fn () => SUCCESS)
            };
        val age_result = (validate_age (#age input)) pattern;
        val name_result = (validate_name (#name input)) pattern;
        fun println str = print (str ^ "\n")
    in
      case (age_result, name_result) of
          (SUCCESS, SUCCESS) => println "ok."
        | (FAILURE x1, FAILURE x2) => (println (#message x1); println (#message x2))
        | (FAILURE x, _) => println (#message x)
        | (_, FAILURE x) => println (#message x)
    end

pattern変数が、「ダメだった理由フィールド名 = 理由の詳細データを受け取って文字列を組み上げる関数」をまとめたレコードになっているのがわかると思います。 つまりこれが「タグTを処理するメソッドスイート」です。

そして、年齢ageと名前nameについてvalidate_age, validate_nameを呼び出し、結果として取得した関数に上記の「タグTを処理するメソッドスイート」なるレコードを渡します。 すると、先ほどの#ダメだった理由フィールド名セレクタメソッドスイートの理由ごとの処理を選択してくれて、{ 理由の詳細データ }が引き渡される、というわけです。

使ってみるとこんな感じです。

(* 使ってみる *)
val input1 = { age = "120", name = "hoge" }
val _ = validate input1
(* age error. the value is required in 0 to 100 but it was 120 *)

val input2 = { age = "80", name = "jugemujugemugokounosurikireikaryaku" }
val _ = validate input2
(* name error. length of the value is required in 3 to 20 but it was 35 *)

val input3 = { age = "piyo", name = "fuga" }
val _ = validate input3
(* age error. the value is required integer, but it was piyo *)

val input4 = { age = "90", name = "ab" }
val _ = validate input4
(* name error. length of the value is required in 3 to 20 but it was 2 *)

val input5 = { age = "25", name = "coolname" }
val _ = validate input5
(*  ok. *)

望んだとおりの入力検証ができているような気がします。

多相ヴァリアントである必要はあったのか

例えばこれが

type AgeValidationResult = Success | AgeParseError of { Actual:string } | AgeRangeError of { Actual:int; Min:int, Max:int}
type NameValidationResult = Success | NameLengthError of { Actual:int; Min:int, Max:int }

というような形で単相に定義されていた場合、

type ValidationResult =
      Success | AgeParseError of { Actual:string } | AgeRangeError of { Actual:int; Min:int, Max:int}
    | NameLengthError of { Actual:int; Min:int, Max:int }

というような型は得られませんが、上記の例で見たように、pattern変数では両方のラベルを織り交ぜてラベル判定を行えています。

感想

  • SML# のコンパイルエラーを読み解くのが大変でした。
    • syntax errorが出るのは不慣れだから仕方ないとしても、エラーメッセージが素っ気なさすぎてつらいです。
    • 型エラーは「受け入れる型と実際渡された型が違ってるよ、推論内容こうなってるよ」という形式なので、内容は目視比較です。
  • 多相ヴァリアントは多相レコードで表現できるよ!ってオフィシャルは言ってますが、こうして置き換えを考えるとそれなりに理解も記述も面倒に思えます。
    • OCamlみたいに[> ][< ]みたいなのは無くても、むしろ多相レコード並みに好き勝手にラベルを作っても良い感じに型チェックしてくれるようになってくれたら嬉しいです。
      • 現実的に難しそうなのはわかります。
  • でも多相レコードは素敵です。実際便利。SML# スバラシイ。

といったところで今回は以上です。

補足

SML# 以外のSML処理系では、「レコードは宣言しなくても使えるが、型が一意に定まらない(多相性が必要になる)箇所でコンパイルエラーとなる」というのが正しいようです。多相性と事前の型宣言の必要性が必ずしも一致しないこともあるということで、指摘を頂きました。

追記

dico_lequeさんがこんな記事を書かれていました。合わせて読みたい

SML# - 多相レコードで多相バリアントを表現する - Qiita