『F#を知ってほしい』を読んでF#に興味が湧いてきたので、F#を始めてみた。チュートリアルをやるだけだと面白くないので、手頃な習作として正規表現エンジンを作りながらF#の機能を学んでみることにした。
作るもの
ごく簡単な正規表現エンジンを作る。使えるメタ文字は以下の4つのみとする。
-
.任意の一文字にマッチ -
*0回以上の繰り返しにマッチ -
|左右どちらかにマッチ -
()グルーピング
効率は一切考えない。計算に時間がかかってもいいし、メモリを大量に使ってもいい。
ファーストステップ
正規表現を表す型 Pattern を書いていく。最初から完全なものを作るのは大変なので、まずは . か特定の一文字(例えば a) を表すものを定義する。
type Pattern =
| Char of char
| AnyChar
Pattern は判別共用体として定義される。正規表現 . は AnyChar として表現され、正規表現 a は Char 'a' と表現される。
次に与えられた文字列から Pattern にマッチする部分を取り出す関数を書こう。任意の場所にマッチさせる関数を考えるのは大変なので、文字列の先頭から始まる部分文字列 にマッチする場合のみに限定して考える。また、一般に正規表現にマッチする部分は複数個あるので、戻り値はひとつの値ではなく、値の列になる。
また、戻り値として部分文字列そのものを返す必要はない。先頭から始まる部分文字列であることはわかっているのだから、部分文字列の長さだけ返せば情報としては十分だ。
したがって、マッチ関数のシグネチャは以下のようになる。
// s の先頭から始まる部分文字列の中で pattern にマッチするものの長さを返す
let matchLengths (pattern: Pattern) (s: string) : seq<int> =
...
seq という見慣れないものが出てきたが、これは C# でいうところの IEnumerable だ。IEnumerable があるということは yield もある。後で出てくるのでお楽しみに。
では関数本体を書いていこう。
let matchLengths (pattern: Pattern) (s: string) : seq<int> =
match pattern with
| Char ch ->
if s.StartsWith ch then seq [ 1 ] else Seq.empty
| AnyChar ->
if s <> "" then seq [ 1 ] else Seq.empty
今のところ Char と AnyChar しかないのですごく簡単だ。Char の場合、s の先頭が所定の文字で始まっているならば、長さ1の文字列にマッチしたことになるので、列 [ 1 ] を返す。そうでないなら何にもマッチしないので Seq.empty を返す。AnyChar の場合、空文字列でなければ長さ1の文字列にマッチする。空文字列であれば何にもマッチしない。(等しくないことを判定する演算子は <> らしい)
(ところで、何故かこの関数は REPL ではコンパイルできなかった。s.StartsWith ch の部分で型が合わないと言われてしまう。REPL ではない普通のコンパイルではちゃんとコンパイルできた。s.StartsWith がオーバーロードされていることと関係がありそうな気がするがよくわからない。)
matchLengths を試してみよう。
matchLengths (Char 'a') "a"
|> Seq.toList
|> printfn "%A" // [1] が出力される
matchLengths (Char 'a') "b"
|> Seq.toList
|> printfn "%A" // [] が出力される
無事期待通りの結果になった。|> はパイプ演算子というらしい。これは左辺の値を右辺の関数に渡す演算子で、これを使うと Ruby のメソッドチェインのような雰囲気でコードを書くことができる。
Alternation
次に | を含む正規表現を使えるようにしよう。Pattern の定義を以下のように拡張する。
type Pattern =
| Char of char
| AnyChar
| Alter of Pattern * Pattern
Alter は Pattern を2つ取るような再帰的な定義になっている。
Alter lhs rhs にマッチする文字列 s の部分文字列は、lhs にマッチする部分文字列と rhs にマッチする部分文字列の和集合だ。これを matchLengths に実装すると以下のようになる。
let rec matchLengths (pattern: Pattern) (s: string) : seq<int> =
match pattern with
| Char ch ->
if s.StartsWith ch then seq [ 1 ] else Seq.empty
| AnyChar ->
if s <> "" then seq [ 1 ] else Seq.empty
| Alter (lhs, rhs) ->
seq {
yield! matchLengths lhs s
yield! matchLengths rhs s
}
seq { ... } はコンピュテーション式と呼ばれるものらしい。(私はまだコンピュテーション式をいまいちよく理解できていない)
seq { ... } の中では yield や yield! といった構文が使える。例えば seq { yield 1; yield 2 } を評価すると seq [1; 2] が返る。
一方、yield! は yield と似ているが一回 flatten する。例えば
let s1 = seq [1; 2]
let s2 = seq [3; 4]
seq { yield! s1; yield! s2 }
は seq [1; 2; 3; 4] を返す。
したがって、
seq {
yield! matchLengths lhs s
yield! matchLengths rhs s
}
は lhs にマッチした部分文字列と rhs にマッチした部分文字列を連結した列になる。
また、seq { ... } は C# の generator と同じように、値が要求されるまで計算が遅延させられる。これを使うと無限シーケンスを定義したりもできるらしい。(今回の例では有限のシーケンスしか登場しない)
Concatenation と Repeat
残るは連結と*だ。さらに空文字列にマッチするパターンも必要だ。Pattern の定義に Zero, Repeat, Concat を加えよう。
type Pattern =
| Zero
| Char of char
| AnyChar
| Alter of Pattern * Pattern
| Repeat of Pattern
| Concat of Pattern * Pattern
matchLengths の実装は以下のようになる。Repeat は空文字列にマッチする場合の処理が微妙にいやらしい。それ以外は結構直観的な実装になっていると思う。
let rec matchLengths (pattern: Pattern) (s: string) : seq<int> =
match pattern with
| Zero ->
seq [ 0 ]
| Char ch ->
if s.StartsWith ch then seq [ 1 ] else Seq.empty
| AnyChar ->
if s <> "" then seq [ 1 ] else Seq.empty
| Alter (lhs, rhs) ->
seq {
yield! matchLengths lhs s
yield! matchLengths rhs s
}
| Concat (head, tail) ->
seq {
for n1 in matchLengths head s do
for n2 in matchLengths tail (s.Substring n1) do
yield n1 + n2
}
| Repeat inner ->
seq {
for n1 in matchLengths inner s do
if n1 = 0 then
yield 0
else
for n2 in matchLengths pattern (s.Substring n1) do
yield n1 + n2
yield 0
}
seq { ... } の中では for .. in .. do .. という構文が使える。ネストもできる。この関数では使っていないが、if を使ってフィルタすることもできるらしい。
これで正規表現のマッチャーは完成した。試してみよう。
// <(.*)> を表す Pattern
let p = Concat (Char '<', Concat (Repeat AnyChar, Char '>'))
let s = "<hello>world</hello>"
matchLengths p s
|> Seq.toList
|> printfn "%A"
実行すると [20; 7] が出力された。<hello>world</hello> が20文字で <hello> が7文字なので正しく動いているようだ。やったね ![]()
次回コンパイル編?
現状では正規表現を使うためには Pattern を直接組み立てないといけない。これは不便だ。そこで ab*|c.d のような馴染みのある表記から Pattern にコンパイルするコードを書いたので、モチベが続けば次回紹介したい。