LiquidHaskell 入門 その2
前回の記事ではLiquidHaskellの基礎を学びました。 今回はLiquidHaskellを用いて、とあるデータ型に対して、通常のHaskellの型の上に、実装者の暗黙の意図を反映したより細かい型をつけてみたいと思います。
スコープ付き環境
今回題材とするのはスコープ付きの言語の環境を表現する以下のようなデータ型です。
データとしては、環境に対するこれまでの操作を記録するスタックと、id
と値のリストを結びつけるマップEnv
を持っています。
操作ScopeOp
としては、スコープの開始を表すBegin
と環境への値の挿入を表すPush
があります。
この環境は各id
に対して値のリストを持っており、同じ記号に対する値の挿入が起きるとリストの先頭に値を挿入します。
スコープから出るときはスコープの開始前の環境に戻す必要があるのですが、これはスタックに積んだ操作を巻き戻すことによって実現します。
このデータ型はTiger本から拝借しました。
このデータ型をここではスコープ付き環境と呼ぶことにします。
スコープ付き環境はスコープの概念のあるプログラミング言語のインタプリタでの変数の環境や、型検査時の型環境を表すことが可能です。
Tiger本ではTiger言語の型検査器の実装等に実際に用いられていました。
type Id = String
data ScopeOp = Begin | Push Id deriving (Eq, Show)
data Env a = Env { stack :: [ScopeOp], env :: Map.Map Id [a] } deriving (Eq, Show)
empty, insert, lookup
は通常のマップと同様な関数です。
ただし、insert
時にはスタックにPush id
を積むことで、このスコープ内でid
に値が束縛されたということを記録します。
マップ標準の関数に加えて、beginScope
とendScope
という特徴的な関数があります。
これらは文字通りスコープの開始、終了を行う関数です。
beginScope
はスタックにBegin
を積みます。
endScope
はスタックの操作をBegin
まで巻き戻します。
スタックの先頭がPush id
ならばマップからid
のリストの先頭をpop
し、リストが空になったらマップからid
を消去します。
スタックの先頭がBegin
ならば、それをスタックからpop
して終了します。
empty :: Env a
empty = Env [] Map.empty
insert :: Id -> a -> Env a -> Env a
insert id a (Env s e) = Env { stack = Push id : s, env = Map.alter f id e }
where
f Nothing = Just [a]
f (Just as) = Just (a:as)
lookup :: Id -> Env a -> Maybe a
lookup id (Env _ e) = case Map.lookup id e of
Just (a:_) -> return a
Nothing -> Nothing
beginScope :: Env a -> Env a
beginScope e = e { stack = Begin : stack e }
endScope :: Env a -> Env a
endScope (Env (Push id : rest) e) = endScope $ Env rest (Map.update pop id e)
where
pop [] = Nothing
pop [_] = Nothing
pop (_:as) = Just as
endScope (Env (Begin : rest) e) = Env rest e
Env Int
を与えています。
testEnv :: IO ()
testEnv = flip evalStateT empty $ do
printState
modify (insert "x" 10) >> printState
modify beginScope >> printState
modify (insert "x" 12) >> printState
modify endScope >> printState
where
printState = get >>= lift . print
x = 12
が解放されていることが分かります。
また、スタックに行った操作が記録され、endScope
が呼ばれるとBegin
まで解放されていることも分かります。
$ stack ghci
...
*Main Env> testEnv
Env {stack = [], env = fromList []}
Env {stack = [Push "x"], env = fromList [("x",[10])]}
Env {stack = [Begin,Push "x"], env = fromList [("x",[10])]}
Env {stack = [Push "x",Begin,Push "x"], env = fromList [("x",[12, 10])]}
Env {stack = [Push "x"], env = fromList [("x",[10])]}
Refinement Typeをつけてみる
ところで先ほどのコードをコンパイルすると以下の警告が出ます。
警告はWincomplete-patterns
で、lookup
に対してはid
に束縛されたリストが空の場合、endScope
に対してはスタックが空の場合のパターンが足りていないようです。
/src/Env.hs:6:1: warning: [-Wincomplete-patterns]
Pattern match(es) are non-exhaustive
In a case alternative: Patterns not matched: (Just [])
|
12 | lookup id (Env _ e) = case Map.lookup id e of
| ^^^^^^^^^^^^^^^^^^^^^^^...
/src/Env.hs:6:1: warning: [-Wincomplete-patterns]
Pattern match(es) are non-exhaustive
In an equation for endScope: Patterns not matched: (Env [] _)
|
19 | endScope (Env (Push id : rest) e) = endScope $ Env rest (Map.update pop id e)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
...
id
に束縛されている値のリストは空にならないendScope
が呼ばれる前に必ずbeginScope
が呼ばれている。
1はendScope
内で定義されているpop
関数によって既に実現されています。
リストが空になる時は環境から削除されるため、パターンマッチの時にリストが空になることはありません。
2は、例えば言語処理系などでこれを用いる場合、構文解析の時点で{}
の数が合っていることが保証されていることが多いため妥当な仮定と言えるでしょう。
余計なモナドで包むことを避けるために、これらのルールをLiquid Haskellを用いて型で表現してみます。
まず1を表現するために、Env
の定義で値のリストの長さを0より大きいという型を与えてあげてみます。
{-@ data Env a = Env { stack :: [ScopeOp], env :: (Map Id { v: [a] | len v > 0 })} @-}
data Env a = Env { stack :: [ScopeOp], env :: Map.Map Id [a]} deriving (Eq, Show)
Env
のスコープ数を表現することができるときれいに型がつきそうです。
そこで、以下の補助関数を定義します。
beginNum
はスタック内のBegin
の数、つまり現在のスコープの深さを計算します。
前回は説明していなかったのですが、measure
となる関数には停止性を保証しておく必要があるため、メトリクスを書いておきます。
scopeNum
はEnv
のスタックをに対してbeginNum
を適用します。
これでEnv
のスコープ数を表現する準備が整いました。
NEEnv
はスタックのスコープが空でないEnv
、EnvN
はスタックのスコープ数が第二引数N
となっているEnv
、EnvX
はスコープ数が第二引数のEnv
と等しいEnv
を表します。
{-@ measure beginNum @-}
{-@ beginNum :: s: [ScopeOp] -> Nat / [len s] @-}
beginNum :: [ScopeOp] -> Int
beginNum [] = 0
beginNum (Begin:xs) = 1 + beginNum xs
beginNum (_:xs) = beginNum xs
{-@ measure scopeNum @-}
{-@ scopeNum :: e: Env a -> {n: Nat | n == beginNum (stack e) } @-}
scopeNum :: Env a -> Int
scopeNum (Env s _) = beginNum s
{-@ type NEEnv a = {e: Env a | scopeNum e > 0} @-}
{-@ type EnvN a N = {e: Env a | scopeNum e = N} @-}
{-@ type EnvX a E = {e: Env a | scopeNum e = scopeNum E} @-}
empty
はスコープが0のEnv
を返す、insert
はスコープの大きさを変えない、といったような型を与えることができました。
beginScope
はスコープの深さを1増やす、endScope
はスコープが空でないEnv
を受け取ってスコープの深さを1減らす、といったような型を与えることができます。
endScope
には無限ループしないことを示すためにメトリックを与えています。
スタックの長さは単調減少するので、無限ループしないことを示すためにはメトリックとしてスタックの長さ[len (stack e)]
を与えてあげれば十分です。
{-@ empty :: EnvN a 0 @-}
empty :: Env a
empty = Env [] Map.empty
{-@ insert :: Id -> a -> e:Env a -> EnvN a {scopeNum e} @-}
insert :: Id -> a -> Env a -> Env a
insert id a (Env s e) = Env { stack = Push id : s, env = Map.alter f id e }
where
f Nothing = Just [a]
f (Just as) = Just (a:as)
lookup :: Id -> Env a -> Maybe a
lookup id (Env _ e) = case Map.lookup id e of
Just (a:_) -> return a
Nothing -> Nothing
{-@ beginScope :: e: Env a -> EnvN a {1 + scopeNum e} @-}
beginScope :: Env a -> Env a
beginScope e = e { stack = Begin : stack e }
{-@ endScope :: e: NEEnv a -> EnvN a {scopeNum e - 1} / [len (stack e)] @-}
endScope :: Env a -> Env a
endScope (Env (Push id : rest) e) = endScope $ Env rest (Map.update pop id e)
where
pop [] = Nothing
pop [_] = Nothing
pop (_:as) = Just as
endScope (Env (Begin : rest) e) = Env rest e
ScopeOp
でバリアント型を用いたため、オプション--exactdc
を使う必要があります。
ファイルの先頭に{-@ LIQUID "--exactdc" @-}
を記述してもokです。
$ stack exec -- liquid src/Env.hs --exactdc
...
**** RESULT: SAFE **************************************************************
実践: 評価関数
それでは先ほど作ったスコープ付き環境を用いて簡単な評価関数eval
を実装してみます。
対象とする言語は、Tiger本で扱われていたプログラミング言語Linearの算術式を制限したもので、構文は以下です。
逐次実行、変数の割り当て、プリントができます。
Exp
のEScope Stm Exp
はスコープを作る構文で、左のStm
で定義した変数はそのスコープ内で有効で、右のExp
を評価した値を返します。
data Stm = SSeq Stm Stm
| SAssign Id Exp
| SPrint [Exp]
data Exp = EVar Id
| EInt Int
| EPlus Exp Exp
| EScope Stm Exp
eval
を実装したものが以下です。
SPrint
はによる副作用はIOモナドでもよいのですが、MonadWriterで代用します。
未定義変数へのアクセスが起こりうるので、MonadErrorを与えます。
簡単のため、停止性検査を無効にしておきます。
data EvalException = UndefinedVariableException deriving (Show)
{-@ lazy eval @-}
{-@ eval :: (MonadWriter [Int] m, MonadThrow m) => Stm -> e: Env Int -> m ((), EnvX Int e) @-}
eval :: (MonadWriter [Int] m, MonadError EvalException m) => Stm -> Env Int -> m ((), Env Int)
eval (s1 `SSeq` s2) env0 = do
(_, env1) <- eval s1 env0
eval s2 env1
eval (x `SAssign` e) env0 = do
(v, env1) <- evalExp e env0
return ((), insert x v env1)
eval (SPrint es) env0 = (,) () <$> foldl f (pure env0) es
where
f m e = do
(n, env') <- evalExp e =<< m
tell [n]
return env'
{-@ lazy evalExp @-}
{-@ evalExp :: (MonadWriter [Int] m, MonadThrow m) => Exp -> e: Env Int -> m (Int, EnvX Int e) @-}
evalExp :: (MonadWriter [Int] m, MonadError EvalException m) => Exp -> Env Int -> m (Int, Env Int)
evalExp (EVar x) env0 = case lookup x env0 of
Just v -> return (v, env0)
Nothing -> throwError UndefinedVariableException
evalExp (EInt n) env0 = return (n, env0)
evalExp (e1 `EPlus` e2) env0 = do
(v1, env1) <- evalExp e1 env0
(v2, env2) <- evalExp e2 env1
return (v1 + v2, env2)
evalExp (EScope s e) env0 = do
let env1 = beginScope env0
(_, env2) <- eval s env1
(v, env3) <- evalExp e env2
let env4 = endScope env3
return (v, env4)
evalExp
内のEScope
のパターンマッチの部分です。
beginScope
とendScope
で挟んであります。
例えば、ここのうちendScope
をコメントアウトすると、以下のようにちゃんとエラーを報告してくれます。
$ stack exec -- liquid src/Liquid/Env.hs
...
**** RESULT: UNSAFE ************************************************************
/src/Liquid/Env.hs:(111,29)-(116,18): Error: Liquid Type Mismatch
111 | evalExp (EScope s e) env0 = do
112 | let env1 = beginScope env0
113 | (_, env2) <- eval s env1
114 | (v, env3) <- evalExp e env2
115 | -- let env4 = endScope env3
116 | return (v, env3)
Inferred type
VV : {v : (Liquid.Env.Env GHC.Types.Int) | Liquid.Env.scopeNum v >= 0}
not a subtype of Required type
VV : {VV : (Liquid.Env.Env GHC.Types.Int) | Liquid.Env.scopeNum VV == Liquid.Env.scopeNum env0}
In Context
env0 : {env0 : (Liquid.Env.Env GHC.Types.Int) | Liquid.Env.scopeNum env0 >= 0}
a := 5 + 3; print [a, {a := 10, a}]
のようなプログラムをテストをしています。
run :: Stm -> Either EvalException (((), Env Int), [Int])
run s = runWriterT $ eval s empty
{-@ ignore testEval @-}
testEval :: IO ()
testEval = print $ run s
where
s = ("a" `SAssign` (EInt 5 `EPlus` EInt 3))
`SSeq` SPrint [ EVar "a",
EScope ("a" `SAssign` EInt 10) (EVar "a")]
Env
が終了時の環境で、一番右の[8, 10]
がSPrint
による出力結果です。
問題ないようです。
$ stack ghci
...
*Main Env> testEval
Right (((),Env {stack = [Push "a"], env = fromList [("a",[8])]}),[8,10])
まとめ
今回はスコープ付き環境というデータ型を題材にLiquidHaskellを用いてみました。 その中で、通常のHaskellでは表現できない、実装者の意図している暗黙の仕様をRefinemet Typeを用いて表現することができ、それをLiquidHaskllを用いて検証することができました。 さらに、検証された関数を用いて簡単な言語の評価関数の実装をしてみました。 実際に誤ったコードを書いたときに型検査に失敗するところも確認し、LiquidHaskellは確かに有用であることが分かりました。
次(々)回予告
先ほどのコードを読んでHaskellerの皆さんはもどかしい気持ちになったのではないでしょうか。
もう一度eval
の型を確認してみましょう。
eval :: (MonadWriter [Int] m, MonadError EvalException m) => Stm -> Env Int -> m ((), Env Int)
evalExp :: (MonadWriter [Int] m, MonadError EvalException m) => Exp -> Env Int -> m (Int, Env Int)
eval :: (MonadWriter [Int] m, MonadError EvalException m) => Stm -> StateT (Env Int) m ()
evalExp :: (MonadWriter [Int] m, MonadError EvalException m) => Exp -> StateT (Env Int) m Int