Ramune という言語を作ろうとしているんですが、そこに First-Class Patterns を導入することを妄想しているので、それについて書きます
まだ妄想でしかないので、よく分からないようなことを口走っていると思います(すみません!)
First-Class Patterns については、こういうの(slideshare)とかこういうの(hackage)とか見るといいかもしれない
first-class patterns の入った言語作りてー
— zer0-star (@0x_zer0star) February 19, 2021
こんな気の抜けたことも言っています(アホなので)
Pattern as Function
要は、パターンというのは関数a -> Maybe bとみなせて、関数を適用してJustならマッチ成功、Nothingなら失敗とすればパターンマッチが実現できますよね、ということ
先ほど挙げた hackage のやつでは、マッチした結果を右辺の関数に渡すことで束縛の代用とされているが、Ramune では First-Class Patterns をネイティブにサポートして、レコード型(Haskell のようにデータ型の一部ではなく、PureScript のように単体で存在するもの)を介して識別子を束縛したい({ x :: Int }が返ってきたらxがInt型の値に束縛されるように)
パターンの型は Haskell 風に書くとnewtype Pattern a r = Pattern { runPattern :: a -> Maybe r }(rはレコード型)と定義できる。例えば、座標平面上の点を受け取ってx座標をxに、y座標をyに束縛するパターンpointは、疑似 Haskell でpoint = Pattern $ \(x, y) -> Just { x = x, y = y }と書ける。このときpointはPattern (a, b) { x :: a, y :: b }というような型を持つ値になるだろう。
記述を平易にするため、いくつか特別な記法を導入しようと思う。
1つが、variable pattern と呼ばれる(予定の)構文である。#xと書くと、受け取った値をそのままxに束縛するパターン(疑似 Haskell ではPattern $ \x -> Just {x = x})を表す。当然これは式であるため、他の式が書けるところならどこにでも書ける。
1-- トップレベルの識別子に束縛しちゃったり
2xPat :: Pattern a { x :: a }
3xPat = #x
4
5-- 単なる値なのでタプルに突っ込むことができる
6a :: (Int, Pattern a { x :: a })
7a = (42, #x)
8
9-- 型を制限することもできる
10intX :: Pattern Int { x :: Int }
11intX = #x
もう1つは、constructor pattern と呼ばれる(かもしれない)構文である。data Pair a b = Pair a b のように型Pair a bを定義したとき、コンストラクタPair :: a -> b -> Pair a bに対して、いわば「パターン・コンストラクタ」とでも呼べるような、パターンを受け取ってパターンを返す関数#Pair :: Pattern a r1 -> Pattern b r2 -> Pattern (Pair a b) (r1 +++ r2)(ただし、+++はレコードの併合)が定義される。使い方はご想像の通り、#Pair #a #bのようにすれば良い。演算子になっているコンストラクタにも同様。
ちなみに、constructor pattern の派生として、#(p1, p2, p3)や#[p1, p2, p3, p4]といったものも考えている。これを使えば、先ほどのpointは#(#x, #y)と簡単に書くことができる。
1-- 疑似Haskellで書くと、こう
2newtype Pattern a r = Pattern (a -> Maybe r)
3
4pair :: Pattern (a, b) { x :: a, y :: b }
5pair = Pattern $ \(x, y) -> Just { x = x, y = y }
6
7-- しかし、単純なパターンの合成は簡潔に書ける(こともある)
8pair :: Pattern (a, b) { x :: a, y :: b }
9pair = #(#x, #y)
Matching as MonadPlus
パターンについて書いたのだから、マッチングについても書かなくてはいけないだろう。
見出しの通り、マッチングはMonadPlusの演算を繰り返していくことと解釈することができる。
1-- 以下のように定義したとき、
2f p1 p2 ... pn = body
3f q1 q2 ... qn = body
4
5-- 以下の式は、
6f a1 a2 ... an
7
8-- 以下のような式と等価({..} はHaskellのRecordWildCardsをイメージ)
9let
10 pClause = do
11 {..} <- runPattern p1 a1
12 {..} <- runPattern p2 a2
13 ...
14 {..} <- runPattern pn an
15 return body
16
17 qClause = do
18 {..} <- runPattern q1 a1
19 {..} <- runPattern q2 a2
20 ...
21 {..} <- runPattern qn an
22 return body
23in
24 fromJust $ pClause `mplus` qClause
ちなみに、p1で束縛した識別子はp2以降で利用可能だったりする予定。
ところで、パターンマッチは関数の引数以外でも使うことができる。
1f #p = let #(#x, #y) = p
2 in x + y
3
4g #x = case x of
5 #Just #v -> show v
6 #Nothing -> "Nothing"
まあ説明も必要ないだろうとは思う。
問題点・悩んでいるところなど
そもそも、パターンが値と言うことはパターンに束縛された識別子が存在しうるわけで、そうなったときにf x = ...などと書くと非常に紛らわしくて分かりづらい。let x = ...のようなケースは、0引数関数の宣言とみなすことでlet #x = ...と等価になるのだが、f x = ...をf #x = ...とみなすのは無理がある。せいぜい warning を出して、推奨スタイルをf (x) = ...のように単一の識別子はカッコで囲むこととするくらいしかできないような気がする。
これは仕方がないのだが、パターンマッチの網羅性検査がめちゃくちゃ難しくなる。そもそも他の言語でもガード節がある時点で厳密な網羅性検査はできなくて、First-Class Patterns を採用するとより厳しくなるのは当然の道理ではある。まあ、がんばってコンパイラ内部で網羅性の情報を持ち回ってそれなりの検査はできるかもしれない。
あと、value pattern(値が等しければマッチするパターン)というのを考えていて、簡潔に書きたいのだけど、その構文が思いつかない
ところで……
ところで、ユーザーが定義したパターンに対して、独自のエラーメッセージを持たせたいことがあるかもしれない。そこまでいかなくとも、マッチの結果をMaybeで表していては心もとないこともある。実際面では、Eitherにエラーの型を持たせるとか、独自のデータ型を定義するとか、そういう方針がいいかもしれない。そうなってくると、そういった型をまとめるために独自にMonadMatchとかを作るのもアリかもしれない。
あと、私は思いつかなかったが、もしかしたらLensと連携して何かができるかもしれない。
使用例
ここのコードは、あくまでもイメージです
1is :: (a -> Bool) -> Pattern a {}
2is p = Pattern $ \#x -> if p x then Just {} else Nothing
3
4which :: Pattern a xs -> Pattern a ys -> Pattern a (xs +++ ys)
5which p1 p2 = do
6 r1 <- p1
7 r2 <- p2
8 return $ r1 +++ r2
9
10collatz :: Int -> Int
11collatz (#n `which` is even) = n `div` 2
12collatz #n = 3 * n + 1
1reversed :: Pattern [a] r -> Pattern [a] r
2reversed (Pattern p) = Pattern $ p . reverse
3
4last :: [a] -> Maybe a
5last (reversed $ #x #: _) = Just x
6last _ = Nothing