Haskell (その3) Advent Calendar 2017 11日目の記事。(予約投稿知らなかったのでフライングになった)

GHCがコンパイルの途中で中間表現として用いるCoreの生成っぷりを観察する。

観察して、あーはいはいなるほどね(わかってない)、と言うだけになりそう。

はじめに

GHCはHaskellのソースコードを低レベルなコードへとコンパイルする過程で様々なpass(コンパイルのステージ)を通じてプログラムデータを変換する。 俯瞰図は下記のリンクに詳しい。

Compiling one module: HscMain

僕がGHCの話をどこかで聞きかじってかっこいいな、と思ったのは、 GHCがコンパイラの中間言語として定義しているCoreを知った時。

このCoreと名付けられた中間言語はDesugar passにて生成され、下記のような性質を持っている。

小さな構文 3つのデータ型と15の値コンストラクタ

束縛変数には全て型がついている 前段で推論されている

全て型がついているため高速に型検査ができる もう推論は終わっているので検査が高速

単純だが大きな表現力を持つ Haskellのソースプログラムがこれに変換される



GHCはリリースのたびに様々な言語拡張が増えていて、 表面上の構文は多様になってきている。 それにも関わらずこのCoreという中間言語は下記のような小ささを保っている。

3つのデータ型

15の値コンストラクタ

ghc-8.2.2 CoreSyn

data Expr b = Var Id | Lit Literal | App (Expr b) (Arg b) | Lam b (Expr b) | Let (Bind b) (Expr b) | Case (Expr b) b Type [Alt b] | Cast (Expr b) Coercion | Tick (Tickish Id) (Expr b) | Type Type | Coercion Coercion deriving Data data AltCon = DataAlt DataCon | LitAlt Literal | DEFAULT deriving (Eq, Data) data Bind b = NonRec b (Expr b) | Rec [(b, (Expr b))] deriving Data type Arg b = Expr b type Alt b = (AltCon, [b], Expr b)

各値コンストラクタが依存している更に細かいデータ型はあるにせよ、 Haskellのソースコードは上記のデータ型にdesugar(脱糖)されて単純化される。

正直、僕もすべてのコンストラクタの意味が解っているわけではない。 しかしあの多彩な表現力を持ったHaskellの構文が この小さなCoreに変換可能である ことに大きく驚いた。

ここではこれらのデータ型の詳細には立ち入らず、 実際にHaskellのプログラム書きながらこのdesugarされたCoreがどう変化しているかを見てみようと思う。

観察してみる

この節ではGHCのデバッグオプションを使って、 parseされたプログラムがDesugar passを経た後の結果を確認してみる。

どんな感じで見えるんだろ。

Setup

stack.yamlにオプションをつけておこう。

ghc-options: "*": -ddump-to-file -ddump-ds -ddump-simpl -dsuppress-idinfo -dsuppress-coercions -dsuppress-uniques -dsuppress-module-prefixes

長い。長いけれどポイントは -ddump-ds のみ。 -dsuppres 系は冗長な出力を減らすために指定しているだけ。

このオプションをつけておくとstackのビルドの成果物を格納する .stack-work ディレクトリの下にレポートが出力される。

今回 src/Lib.hs に定義を書き下しているため出力結果は

.stack-work/dist/x86_64-linux-nix/Cabal-1.24.2.0/build/src/Lib.dump-ds

というファイルに出力される。

定数

stringConst :: String stringConst = "Hello"

stringConst :: String stringConst = unpackCString # "Hello" #

まあ、なんか、うん。そうだよね。

関数適用

falsy :: Bool falsy = not True

falsy :: Bool falsy = not True

変化なし。単純過ぎたか。

Infix

two :: Int two = 1 + 1

two :: Int two = + $ fNumInt (I # 1 # ) (I # 1 # )

なにか起きた。。。

二項演算子も結局は関数なので、 + 1 1 のようなS式っぽい見た目になるのはわかる。

$fNumInt という謎のシンボルが出てきた。

後でも出てくるが型クラス Num の Int インスタンス定義を渡している模様。

関数合成

notNot :: Bool -> Bool notNot = not . not

notNot :: Bool -> Bool notNot = . @ Bool @ Bool @ Bool not not

x . y が . x y に変換された。 これもまた二項演算子が2引数の関数に変換されている。

だけではなくて @ Bool なる記号が出てくる。 これは . が持つ多相性に関連する。 次で説明。

多相関数

identity :: a -> a identity x = x

identity :: forall a . a -> a identity = \ ( @ a) (x :: a) -> x

ちょっと形が変わった。大事なところにきた。

Haskellで匿名関数と作る時は

\ x -> x

とする。

なので

\ (x :: a) -> x

となるなら解る。 「aという型を持つxという値を受けとり、そのxを返す」というような意味で読める。

しかし実際は

\ ( @ a) (x :: a) -> x

こう。

( @ a)

匿名関数に引数が増えている。

これは 型変数が関数の仮引数として定義されている ことを表す。

とても不思議。

\ ( @ a) (x :: a) -> x

型と値が同列の引数として扱われていることになる。

Coreでは型の引数と値の引数が同列に扱われて関数に適用される。

なのでこの関数に引数を適用する場合は、

identity Int 1

のようにして型引数が決定され、値引数が決定されているものと思われる。

補足: forall について

identity :: forall a . a -> a

forall が表れるが意味的にはもとの a -> a となんら変わらない。 糖衣構文として forall の省略が許容されていたものが、 脱糖を経て明示化されただけ。

補足: Core上の表現

この関数がCore上でどう表現されているかというと

Lam (TyVar "a" ) (Lam (Id "x" ) (Var (Id "x" )))

ラムダ計算っぽく書くと

λ a. λ x: a. x

こんな感じ? (解らないけど a にはkindとして * でもつくのかな？)

1つめのラムダ抽象の引数は型で、 2つめのラムダ抽象の引数はa型の値xとなる。

この2つの引数はCore言語内で Var という型を持つ。

Var

型と値が同列で引数になる仕組みは簡単で、 関数の引数に束縛されるデータ型 Var が下記のようになっているから。

data Var = TyVar ... | TcTyVar ... | Id ...

この関数の引数に与えられるデータが

TyVar : 型

: 型 Id : 値

どちらも受け付けるようになっている。

多相関数の適用 (型変数が決定されるのか？)

本当に型も引数として関数に適用されているのかを観察。 先程の多相関数に引数を適用してみる。

one :: Int one = identity 1

one :: Int one = identity @ Int (I # 1 # )

予想通り。

@ Int で確かに型を適用している。

おなじみの関数合成。

comp :: (b -> c) -> (a -> b) -> a -> c comp f g x = f (g x)

comp :: forall b c a . (b -> c) -> (a -> b) -> a -> c comp = \ ( @ b) ( @ c) ( @ a) (f :: b -> c) (g :: a -> b) (x :: a) -> f (g x)

引数がお化け。。。。

だけれど、型変数の抽出ルールはやはり明確だ。

型変数は b c a の順で登場する。 それに合わせて forall b c a の順で定義される。

さらに forall に続く型変数はCoreのラムダ抽象で引数になる。

パターンマッチ

hasValue :: Maybe a -> Bool hasValue (Just _) = True hasValue Nothing = False

hasValue :: forall a . Maybe a -> Bool hasValue = \ ( @ a) (ds :: Maybe a) -> case ds of _ { Nothing -> False; Just _ -> True }

関数定義部におけるパターンパッチはcase of構文に変換されている。

CoreのCaseコンストラクタに変換されているらしい。

Case (Expr b) b Type [Alt b]

実はこのコンストラクタ b と Type の部分がまだ何者か判明していない。

b が Expr b を束縛しており、 Type が [Alt b] の式の型を注釈している？

型クラス制約

型クラスつきの関数を定義するとどうなるだろうか。

join :: (Monad m) => m (m a) -> m a join = ( >>= id)

join :: forall (m :: * -> * ) a . Monad m => m (m a) -> m a join = \ ( @ (m :: * -> * )) ( @ a) ( $ dMonad :: Monad m) (ds :: m (m a)) -> >>= @ m $ dMonad @ (m a) @ a ds (id @ (m a))

斬新な変数が出てきた。 引数部分を分解して一つ一つ読み解こう。

( @ (m :: * -> * )) ( @ a) ( $ dMonad :: Monad m) (ds :: m (m a))

join に表れる型変数は m と a 。

なのでその2つは最初に (@ (m :: * -> *)) と @ a として束縛される。 (ds :: m (m a)) は実際の関数の引数なので疑問なし。 問題は ($dMonad :: Monad m) というどこから出てきたのか解らない束縛。

これは型クラスのインスタンスも関数の引数として受け取るための束縛らしい。

ということは、型クラスのインスタンスを渡しているところも見られるかもしれない。。。

型クラスのインスタンス適用

さきほど定義した join を使ってみよう。

maybeOne :: Maybe Int maybeOne = join (Just (Just 1 ))

maybeOne :: Maybe Int maybeOne = join @ Maybe @ Int $ fMonadMaybe (Just @ (Maybe Int) (Just @ Int (I # 1 # )))

コメントで先程の join の定義と対照してみた。

Monad のインスタンス定義を受け取る部分には

$fMonadMaybe

が。

名前から察するにどうやらMaybeのインスタンス定義が渡されているようだ。 (Scalaが型クラスのインスタンスとしてimplicitパラメータで渡しているものと、ほぼ同じものだと思われる。)

最後にモナドを含むdo記法がどのようにCoreに変換されるのかを見てみる。

printArgs :: IO () printArgs = do args <- System.Environment . getArgs print args

printArgs :: IO () printArgs = >>= @ IO $ fMonadIO @ [String] @ () getArgs ( \ (args :: [String]) -> print @ [String] $ dShow args)

doは糖衣構文なので脱糖後は >>= を使った式に変換されるのは予想できた。

型周りは思ったよりいろいろ混ざってきて混乱。 上から見ていく。

bind関数の定義。(型制約は除く)

>>= :: m a -> (a -> m b) -> m b

これは forall つきで表現すると

>>= :: forall m a b . m a -> (a -> m b) -> m b

となる。

よって

( @ (m :: * -> * )) ( @ a) ( @ b)

が型変数として関数の引数に抽出される。 実際の対応をみてみると

@ IO $ fMonadIO @ [String] @ ()

これらを使うと >>= は下記のように具象化される。

>>= :: IO [String] -> ([String] -> IO ()) -> IO ()

型変数だった部分全てに具体的な型が当てはまった。

まとめ

Haskellのプログラムはdesugar(脱糖)後にCoreという中間言語に変換される。

Coreは基本的に型付きラムダ計算(の変種)なので

変数

関数の定義

関数の適用

その他 Let, Case ...

などのわずかな定義で構成される。

さらに値と型が同レベルで束縛されるラムダ抽象を用いることで

型クラスのインスタンス渡し

具象型の決定

などの操作が ただの関数適用 で実現されている。

少ない規則で多彩なユースケースを実現している好例がGHCの中に潜んでいることを知ることができてよかった。

Less is more.

Yoshiko is Yohane.

Reference

下記、余談

モチベーション

Haskell Day 2016が日本で開催された時にSimon Peyton Jonesさんが"Into the Core"というタイトルでプレゼンされたらしい。 残念ながら僕は都合がつかず聞きにいけなかったけれど、同じテーマの講演が動画に収められていたのでそれをリンクしておく。

Into the Core - Squeezing Haskell into Nine Constructors by Simon Peyton Jones

早口過ぎて99%何を言っているのか僕には解らない。 けれどところどころなんとなく伝わる気がする。

プレゼンで使ったスライドは こちら

これをぼんやり聞いていて「Coreってなんだか面白いな」と思ったのがきっかけ。

これから

Coreの理論的背景になっているSystemFというラムダ計算の一種が何者なのか気になる。

GHCで用いられているSystemFCという変種については下記のリンクが参考になりそうだけど。

System F with type equality coercions

僕はそもそもラムダ計算素人なので下記の書籍を読み進める必要がありそう。

型システム入門 プログラミング言語と型の理論

最短で

3章: 型無し算術式

8章: 型付き算術式

9章: 単純型付きラムダ計算

23章: 全称型

を読めば辿り着けるように見える。

いやーほんとかなあ。。。