View on GitHub

雑記

技術メモなど

Tagless-final は難しい

Tagless-final とは

DSL を実装するとき, 一般には AST を書くと思いますが, 特殊な実装手法として Tagless-final がありまして, 最近触っていたので紹介します.

(Tagged-)Initial

まずは一般的な AST による実装方法を考えてみます. 例として Int と Bool があって, 加算と符号反転とif分岐がある DSL を考えます. 以下は Haskell による実装例です.

data IExp =
    ConstInt Int
  | ConstBool Bool
  | Add IExp IExp
  | Neg IExp
  | If IExp IExp IExp

data IValue = VI Int | VB Bool deriving Show

eval :: IExp -> Maybe IValue
eval (ConstInt i) = return (VI i)
eval (ConstBool b) = return (VB b)
eval (Neg e) = do
  VI v <- eval e
  return $ VI $ -v
  
eval (Add e0 e1) = do
  VI i0 <- eval e0
  VI i1 <- eval e1
  return $ VI $ i0 + i1

eval (If e0 e1 e2) = do
  VB b <- eval e0
  v0 <- eval e1
  v1 <- eval e2
  return $ if b then v0 else v1

複数の種類の値を含む言語では, 加算や符号反転は Int 同士でないといけない反面条件分岐では Bool が来ないといけない等, 式に制約があります. こういういわゆる型を表現するために評価した値にタグ付けが必要になってくると思います. 例えば加算の場合は両方の引数を評価した値が共に Int であるかを確認し, そうでなければエラーを送出するみたいなことをするわけですね. 上の例ではエラーを Maybe 型で表現し, エラーの場合は Nothing が返るようになっています.

Tagless-initial

GADT を使うと, このタグ付けが必要なくなります. すごい!

{-# LANGUAGE GADTs #-}

data TIExp t where
    ConstInt :: Int -> TIExp Int
    ConstBool :: Bool -> TIExp Bool
    Add :: TIExp Int -> TIExp Int -> TIExp Int
    Neg :: TIExp Int -> TIExp Int
    If :: TIExp Bool -> TIExp a -> TIExp a -> TIExp a

eval :: TIExp a -> a
eval (ConstInt i) = i
eval (ConstBool b) = b
eval (Neg e) = -(eval e)
eval (Add e0 e1) = eval e0 + eval e1
eval (If e0 e1 e2) = if eval e0 then eval e1 else eval e2

最初の例だと型制約に違反するような式を書くことができ, 評価時に型検査をしていましたが, この例では DSL の型検査をホスト言語 haskell にやってもらっているので, そもそも型制約に違反する式は書けなくなります.

この表現方法では値にタグが必要なくなることから Tagless, また圏論の用語の initial1から Tagless-initial と呼ばれています.

Tagless-final

initial スタイルではコンストラクタとして表現していた式を, Tagless-final スタイルでは関数として表します. 分かる人には式を denotational に書くと言うと分かりやすいかもしれません.

class TFExp repr where
    constint :: Int -> repr Int
    constbool :: Bool -> repr Bool
    add :: repr Int -> repr Int -> repr Int
    neg :: repr Int -> repr Int
    if_ :: repr Bool -> repr a -> repr a -> repr a

newtype Wrap a = Wrap {unWrap :: a}

instance TFExp Wrap where
    constint = Wrap
    constbool = Wrap
    add i0 i1 = Wrap $ unWrap i0 + unWrap i1
    neg i = Wrap $ -(unWrap i)
    if_ b e0 e1 = if unWrap b then e0 else e1

eval = unWrap

newtype は型シノニムで, haskell のコンパイル時には除去されます. 今回は Int と Bool の両方の値を表現する型として用意しています.

この場合もホスト言語が型検査をしてくれます. 人によっては Wrap が健全性の構築的証明に見えるかもしれません2.

tagless はともかく, final は initial と対称的に見えることから名付けられたらしいです(えぇ…). 圏論的に終対象では無くむしろ tagless-initial と tagless-final は全単射ぽいです. 相互の変換が容易に書けそう, ですよね?

initial_to_final (ConstInt i) = constint i
:

instance TFExp TIExp where
    constint = ConstInt
    :

final_to_initial :: TIExp t -> TIExp t
final_to_initial = id

Tagless-final の活用

単なる評価や式の文字列への変換はもちろん, 最適化を始めとするいろんな変換が書けます. Oleg 先生の cookbook3 を見ると高度な例がたくさん並んでいて, 例えば

はできます.

Tagless-final 活用例

活用例を出しておきます. 上の例を少し拡張して関数抽象 lam と 関数適用 app を加えます.

class Exp repr where
  int :: Int -> repr Int
  bool :: Bool -> repr Bool
  add :: repr Int -> repr Int -> repr Int
  neg :: repr Int -> repr Int
  if_ :: repr Bool -> repr a -> repr a -> repr a
  lam :: (repr a -> repr b) -> repr (a -> b)
  app :: repr (a -> b) -> repr a -> repr b

newtype Wrap a = Wrap {unWrap :: a}

instance Exp Wrap where
  int = Wrap
  bool = Wrap
  add (Wrap i0) (Wrap i1) = Wrap $ i0 + i1
  neg (Wrap i) = Wrap $ -i
  if_ (Wrap b) e0 e1 = if b then e0 else e1
  lam f = Wrap (unWrap . f . Wrap)
  app (Wrap f) (Wrap e) = Wrap $ f e

文字列

今度は式を評価する前の文字列を得ることを考えます. Exp の各関数に対して文字列への変換を定義してあげればいいんですが, lam を導入したことで fresh な変数をどうやって得るかという問題が出てきます.

今回は State モナドを噛ませて実装してみました. lam の引数の変数が x0, x1 というように順に生成されて出力されるようになっています.

import Control.Monad.State
newtype View a = View (State Int String)

instance Exp View where
  int i = View $ return $ show i
  bool b = View $ return $ show b
  add (View e0) (View e1) =
    View $ do
      i0 <- e0
      i1 <- e1
      return (i0 ++ " + " ++ i1)

  neg (View e) =
    View $ do
      i <- e
      return ("-" ++ i)

  if_ (View e0) (View e1) (View e2) =
    View $ do
      b <- e0
      v0 <- e1
      v1 <- e2
      return ("if " ++ b ++ " then " ++ v0 ++ " else " ++ v1)

  lam f =
    View $ do
      c <- get
      let var = "x" ++ show c
      let (View fb) = f (View (return var))
      put (c + 1)
      b <- fb
      return ("(\\" ++ var ++ " -> " ++ b ++ ")")

  app (View f) (View e) =
    View $ do
      fn <- f
      exp <- e
      return (fn ++ " " ++ exp)

view :: View a -> String
view (View exp) = evalState exp 0

括弧の有無が適当ですがまぁ置いておきます.

定数畳み込み

次にこの式の定数畳み込みによる最適化を考えてみます. 上の例ではプリミティブな値へ変換すれば良かったのですが, 今度はあくまで最適化なので得たいものは式です.

出来上がったのがこちらになります.

data ConstantFold repr a where
  CInt :: Int -> ConstantFold repr Int
  CBool :: Bool -> ConstantFold repr Bool
  CRepr :: repr a -> ConstantFold repr a

prjCF :: (Exp repr) => ConstantFold repr a -> repr a
prjCF (CInt i) = int i
prjCF (CBool b) = bool b
prjCF (CRepr r) = r

instance (Exp repr) => Exp (ConstantFold repr) where
  int = CInt
  bool = CBool
  add (CInt i0) (CInt i1) = CInt $ i0 + i1
  add i0 i1 = CRepr $ add (prjCF i0) (prjCF i1)
  neg (CInt i) = CInt (-i)
  neg i = CRepr $ neg $ prjCF i
  if_ (CBool True) e0 e1 = e0
  if_ (CBool False) e0 e1 = e1
  if_ b e0 e1 = CRepr $ if_ (prjCF b) (prjCF e0) (prjCF e1)
  lam f = CRepr $ lam (prjCF . f . CRepr)
  app f e = CRepr $ app (prjCF f) (prjCF e)

ややこしいですが, やってることとしては

eval :: ConstantFold View a -> String
eval = view . prjCF

expr :: View (Int -> Int)
expr = lam (\y -> add y (add (add (int 5) (int 2)) (int 2)))

str = eval expr
-- "(\x0 -> x0 + 9)"
-- 部分式 add (add (int 5) (int 2)) (int 2) が畳み込まれて 9 に評価されている

ベータ簡約

次の例はベータ簡約です. app の第一引数が lam だけ簡約できるので, 中間状態として lam に相当するものを持てば良いんですね.

data BetaNormalize repr a where
  BNLam :: (repr a -> repr b) -> BetaNormalize repr (a -> b)
  BNRepr :: repr a -> BetaNormalize repr a

prjBN :: (Exp repr) => BetaNormalize repr a -> repr a
prjBN (BNLam f) = lam f
prjBN (BNRepr r) = r

instance (Exp repr) => Exp (BetaNormalize repr) where
  int i = BNRepr $ int i
  bool b = BNRepr $ bool b
  add i0 i1 = BNRepr $ add (prjBN i0) (prjBN i1)
  neg i = BNRepr $ neg $ prjBN i
  if_ b e0 e1 = BNRepr $ if_ (prjBN b) (prjBN e0) (prjBN e1)
  lam f = BNLam $ prjBN . f . BNRepr
  app (BNLam f) e = BNRepr $ f (prjBN e)
  app (BNRepr f) e = BNRepr $ app f (prjBN e)

合成

先に定義した定数畳み込みとベータ簡約は, 型クラス Exp に属していれば定義されます. つまり順不同で自由に組み替えられます.

eval' :: ConstantFold (BetaNormalize View) a -> String
eval' = view . prjBN . prjCF

expr' :: ConstantFold (BetaNormalize View) (Int -> Int)
expr' = lam (\y -> add y (add (app (lam (\x -> add x (int 3))) (add (int 5) (int 2))) (app (lam (\x -> add x (int 2))) (int 1))))

str' = eval' expr'
-- "(\x0 -> x0 + 7 + 3 + 1 + 2)"

eval'' :: BetaNormalize (ConstantFold View) a -> String
eval'' = view . prjCF . prjBN

expr'' = lam (\y -> add y (add (app (lam (\x -> add x (int 3))) (add (int 5) (int 2))) (app (lam (\x -> add x (int 2))) (int 1))))

str'' = eval'' expr''
-- "(\x0 -> x0 + 13)"

この場合は, 定数畳み込みを先にやってしまうとベータ簡約した後に更に畳み込める部分式が現れる可能性があるので, ベータ簡約を先にやった方が良いですね.

こういうふうに変換をガチャガチャ組み替えられるのは便利で綺麗ですね (別に Tagless-initial でもできますが).

所感

活用例を見て便利さや可能性は感じてもらえたかもしれません. しかし暫く格闘してみて感じた厳しさを挙げます.

逆に良いところとしては, haskell の場合は型クラスで DSL の項が定義されるので, 別の型クラスを定義して項を拡張できて, 拡張性に優れるとかですかね. ocaml だと module functor を使いますが, 同様に AST を書くよりは拡張しやすいと思います.

Footnote

  1. https://serokell.io/blog/introduction-tagless-final DSLで表現したいことは全てASTで表現できる(AST から変換できる, AST が始対象)ってことか? 

  2. https://okmij.org/ftp/tagless-final/course/lecture.pdf 

  3. https://okmij.org/ftp/tagless-final/cookbook.html 

  4. 表示的意味論が使われなくなった(要出典)のに通づる気がする. 人類には早すぎたかもしれない