👻

幽霊型で配列のコンパイル時境界検査を実装する

2022/11/13に公開

今回は論文 "Lightweight Static Capabilities" (Kiselyov and Shan) の第 3 章で紹介されている配列の境界検査の話を取り上げます。ちょっとプログラムが複雑というか色々な技術が組み合わさっているので、慣れていない人にとってはやや読みにくいと思います。少し丁寧に説明したいと思います。

Oleg Kiselyov and Chung-chieh Shan.
Lightweight static capabilities.
Electr. Notes Theor. Comput. Sci, 174(7), pp. 79-104, 2007.
(PDF)

今までの記事ではリストについて取り扱ってきましたが、今回は配列についてのお話です。論文ではソート済みの配列に対して二分探索を行うプログラムに境界検査を導入していますが、この記事では、もっと簡単な線形探索のプログラムに境界検査を導入するところから始めます。

線形探索に境界検査を導入

アイディア

細かい実装は後でしっかりやるとして、まずは大雑把なアイディアについて触れておきましょう。型レベル自然数の記事 を読んでくれた人ならば、

  • 'n snat (= int) ・・・ 値が丁度 'n であるような自然数の型 (snat = sized nat)
  • ('a, 'n) sarray (= 'a array) ・・・ サイズが丁度 'n であるような配列の型 (sarray = sized array)

のように、値やサイズなどを型と対応させることには馴染があるでしょう。今回は添字に型を付けたいのですが、「丁度 'n」は少し条件が強すぎて扱いづらいので、

  • 'n bindex (= int) ・・・ 値が「サイズ 'n の配列の添字として使用可能」=「0 以上かつ 'n 未満」の自然数の型

という型を考えます(ちなみに、bindex は bound index の略で、下限 lower bound と上限 upper bound で縛られている、という気持ちを表しています)。これは 'n snat とは別物で、例えば 1z s s snat という型を与えることはできませんが、
z s s bindex という型を与えることは可能です。この型を使って添字アクセス関数に

val bget : ('a, 'n) sarray -> 'n bindex -> 'a

という型付けを行えば、安全性をコンパイル時に検査できます(0 以上かつ 'n 未満を満たさない値に 'n bindex の型は付かないので型エラー)。

添字アクセスをするためには、'n bindex 型の値を作る方法が必要です。しかし、添字上の演算に対する型付けは注意が必要です。例えば、後者関数は配列末尾の添字を渡すと有効範囲をオーバーした整数を返すので、'n bindex -> 'n bindex という型を与えることができません。そこで 'n bindex の条件を更に緩めて、

  • 'n bindexL (= int) ・・・ 値が 0 以上の自然数の型(下限のみ保証するので、末尾の L は lower bound のこと)

という型を導入して、

val bsucc : 'n bindex -> 'n bindexL

という型付けを行えば、ひとまず型と値が矛盾することはなくなります。ちなみに、後者 bsucc に対してゼロには

val bzero : 'n bindexL

という型を付けます。空の配列に対して、ゼロは有効な添字ではないため 'n bindex の上限に関する条件を満たさないためです。しかし、これでは bzerobsucc に渡したり、bget に渡したりできないので、'n bindexL 型の値を 'n bindex に変換する関数

val index_cmpL : ('a, 'n) sarray -> 'n bindexL ->
                 (unit -> 'b) ->      (* i >= Array.length arr *)
                 ('n bindex -> 'b) -> (* i < Array.length arr *)
                 'b

let index_cmpL arr i f g = if i >= Array.length arr then f () else g i

を用意します。index_cmpL arr i f gi'n bindex の上限の条件を満すかどうか検査し、もしも満たすならば、i の型を 'n bindexL から 'n bindex に付け替えます。ポイントは、条件を満たす場合だけ型の付け直しを行うということです。このため、then 節 f : unit -> 'a(条件を満たさない場合)と else 節 g : 'n bindex -> 'a(条件を満たす場合)で異なる型を与えています。ちなみに「index_cmpL 内の実行時検査は境界検査じゃないの?」という疑問については、後で答えるので今はこういうものだと思ってください。

ここまでのことを踏まえると、線形探索は以下のように書けそうです。

let find arr x = (* mem : ('a, 'n) sarray -> 'a -> 'a option *)
  let rec aux i = (* aux : 'n bindexL -> 'a option *)
    index_cmpL arr i (fun () -> None)
      (fun i -> if bget arr i = x then Some x else aux (bsucc i))
  in
  aux bzero

実装

module M1 : sig
  type z
  type 'n s

  type 'n snat
  val zero : z snat
  val succ : 'n snat -> 'n s snat

  type ('a, 'n) sarray
  val init : 'n snat -> (int -> 'a) -> ('a, 'n) sarray (* 配列を作る *)

  type 'n bindex
  type 'n bindexL
  val bget : ('a, 'n) sarray -> 'n bindex -> 'a
  val bzero : 'n bindexL
  val bsucc : 'n bindex -> 'n bindexL
  val index_cmpL : ('a, 'n) sarray -> 'n bindexL -> (unit -> 'b) -> ('n bindex -> 'b) -> 'b
end = struct
  type z
  type 'n s

  type 'n snat = int
  let zero = 0
  let succ n = n + 1

  type ('a, 'n) sarray = 'a array
  let init = Array.init

  type 'n bindex = int
  type 'n bindexL = int
  let bget = Array.unsafe_get
  let bzero = 0
  let bsucc n = n + 1
  let index_cmpL arr i f g = if i >= Array.length arr then f () else g i
end

以下のように zerosucc でサイズ型付き自然数を作り、それを init 関数に渡して配列を作ります。

# open M1;;
# let five = succ (succ (succ (succ (succ zero))));;
val five : z s s s s s snat = <abstr>
# let arr = init five (fun i -> i * 2);;
val arr : (int, z s s s s s) sarray = <abstr>

前節で定義した静的境界検査付きの mem もきちんと動作します。

# let find arr x = (* mem : ('a, 'n) sarray -> 'a -> 'a option *)
    let rec aux i = (* aux : 'n bindexL -> 'a option *)
      index_cmpL arr i (fun () -> None)
        (fun i -> if bget arr i = x then Some x else aux (bsucc i))
    in
    aux bzero;;
val mem : ('a, 'b) sarray -> 'a -> bool = <fun>
# find arr 4;;
- : bool = Some 4
# find arr 3;;
- : bool = None

ここまでのまとめ

ここまでのポイントをおさらいしておきます。

  • 「丁度 'n」ではなく、「0 以上 'n 未満 ('n bindex)」や「0 以上のみ ('n bindexL)」など、int よりも強く、'n snat より緩い条件を満たす整数の型を使って、添字やその演算で満たされる性質をエンコードする。
  • 添字の演算の途中で、「'n bindex の条件を満たさなくなったけど、添字として使いたい」という場合は、実行時検査を併用し条件を満たす場合だけ 'n bindex の型に付け替える。

さて、ここで「実行時検査を減らすために幽霊型を導入してるのに、index_cmpL が添字の実行時検査をしているのは良いの?」という疑問に答えます。まずは、今回の線形探索のコードをもう一度見てみると、

let find arr x = (* find : ('a, 'n) sarray -> 'a -> 'a option *)
  let rec aux i = (* aux : 'n bindexL -> 'a option *)
    index_cmpL arr i (fun () -> None)
      (fun i -> if bget arr i = x then Some x else aux (bsucc i))
  in
  aux bzero

index_cmpL の実行時検査は「境界条件」というより、むしろ「再帰の終了条件」としての役割を担っています。前者は(本来は必ず)満たされるべきなのに対して、後者は再帰の途中で満たされたり満たされなくなったりします。再帰の終了条件の真偽は、実行時に変化しないと意味がないので、そもそもコンパイル時に検査するものではありません。

しかし、(今回のコードではなく)一般のプログラムにおいて、index_cmpL が境界検査に近い使い方をされる可能性はあります。でも、境界検査では上限と下限 (0 <= i < Array.length arr) を検査する必要があるのに対して、index_cmpL は上限しか検査していないので、無駄な計算が減って嬉しいですね。下限については幽霊型で保証しているので、コンパイルが通った時点で実行時に検査する必要がなくなります。なので、上手に使えば普通のプログラムより実行時検査を減らすことが可能です(当然、下手な使い方をすれば、逆に増えることもありますが間違いは減るかもしれません)。

二分探索に境界検査を導入

論文ではソート済みの配列に対する二分探索を実装していますが、ここまでの話を理解できたなら、そんなに難しくはないはずです。

二分探索のために、幾つかの関数を追加しておきましょう。まず、二分探索では 2 つの添字 i, j を受け取り、その中央の添字 (i + j) / 2 を得る関数 bmiddle が必要になりますが、

val bmiddle : 'n bindex -> 'n bindex -> 'n bindex

と型付けできます(引数が上限下限を満たすなら、戻り値も満たす)。

添字上の前者関数 bpred については負の値を返す可能性があり、戻り値は下限を満たすとは限りません。そこで、bsucc と同じような手順で解決します。「上限('n 未満)だけ満たす整数の型 'n bindexH」 を導入して、

type 'n bindexH (* = int *)
val bpred : 'n bindex -> 'n bindexH

と型付けできます。また、配列の最後の添字は

val blast : ('a, 'n) sarray -> 'n bindexH
let blast arr = Array.length arr - 1

で取得します(空の配列では、-1 を返すため、下限を満たすとは限らない)。

最後に、index_cmp

val index_cmp : 'n bindexL -> 'n bindexH ->
                (unit -> 'b) ->                   (* i > j *)
                ('n bindex -> 'n bindex -> 'b) -> (* i <= j *)
                'b

let index_cmp i j f g = if i > j then f () else g i j

とします。今までの index_cmpL arr i f gindex_cmpL i (blast arr) f g で代用できます。

ここまで定義した関数を使うと、二分探索は以下のようになります。

let bsearch arr x = (* bsearch : ('a, 'n) sarray -> 'a -> 'a option *)
  let rec look lo hi = (* look : 'n bindexL -> 'n bindexH -> 'a option *)
    index_cmp lo hi (fun () -> None)
      (fun lo' hi' ->
        let m = bmiddle lo' hi' in
        let y = bget arr m in
        if x < y then look lo (bpred m)      (* x は前半にある *)
        else if x > y then look (bsucc m) hi (* x は後半にある *)
        else Some y)
  in
  look bzero (blast arr)

実装

module M2 : sig
  type z
  type 'n s

  type 'n snat
  val zero : z snat
  val succ : 'n snat -> 'n s snat

  type ('a, 'n) sarray
  val init : 'n snat -> (int -> 'a) -> ('a, 'n) sarray (* 配列を作る *)

  type 'n bindex
  type 'n bindexL
  type 'n bindexH
  val bget : ('a, 'n) sarray -> 'n bindex -> 'a
  val bzero : 'n bindexL
  val blast : ('a, 'n) sarray -> 'n bindexH
  val bsucc : 'n bindex -> 'n bindexL
  val bpred : 'n bindex -> 'n bindexH
  val bmiddle : 'n bindex -> 'n bindex -> 'n bindex
  val index_cmp : 'n bindexL -> 'n bindexH -> (unit -> 'b) -> ('n bindex -> 'n bindex -> 'b) -> 'b
end = struct
  type z
  type 'n s

  type 'n snat = int
  let zero = 0
  let succ n = n + 1

  type ('a, 'n) sarray = 'a array
  let init = Array.init

  type 'n bindex = int
  type 'n bindexL = int
  type 'n bindexH = int
  let bget = Array.unsafe_get
  let bzero = 0
  let blast arr = Array.length arr - 1
  let bsucc n = n + 1
  let bpred n = n - 1
  let bmiddle i j = (i + j) / 2
  let index_cmp i j f g = if i > j then f () else g i j
end

配列の作り方は、線形探索のコードと同じです。

# open M2;;
# let five = succ (succ (succ (succ (succ zero))));;
val five : z s s s s s snat = <abstr>
# let arr = init five (fun i -> i * 2);;
val arr : (int, z s s s s s) sarray = <abstr>

bsearch もちゃんと動きます。

# let bsearch arr x =
    let rec look lo hi =
      index_cmp lo hi (fun () -> None)
        (fun lo' hi' ->
          let m = bmiddle lo' hi' in
          let y = bget arr m in
          if x < y then look lo (bpred m)
          else if x > y then look (bsucc m) hi
          else Some y)
    in
    look bzero (blast arr);;
val bsearch : ('a, 'b) sarray -> 'a -> 'a option = <fun>
# bsearch arr 4;;
# bsearch arr 4;;
- : bool = Some 4
# bsearch arr 3;;
- : bool = None

Discussion