type family を使って再帰的 ADT をオープンにする

Posted on 日 17 11月 2019 in プログラミング

元ネタは Trees that grow . Haskell では代数的データ型 (ADT) を使ってプログミングに使うデータ構造を定義し,その構造を操作することによりプログラミングを行う. ADT はパターンマッチが容易で,再帰的に定義でき,基本的に閉じた構造になっている.そのため便利な反面,その機能が保守で仇となる場合もある.この問題は古くから知られており,いくつかの解決策も提案されてきた.今回はこのうち,現在 GHC で採用されつつある type family を使った解決方法を紹介する.

なお,環境として以下を想定している.

GHC のバージョン 8.8.1

The Expression Problem

プログラミング,特に Haskell を使用したプログラミングにおいて,データ型は非常に重要な役割を持つ.特に,一部のプログラムにおいては,根幹をなすデータ型がいくつか存在するような場合もある.この場合データ型の扱いをどうするかは設計段階において非常に重要になる.例えば,コンパイラを作る場合を想定すると,多くの場合 AST の定義とコンテキストの定義が非常に重要になるだろう.この場合に, AST に話を絞ると,プログラムのフェーズによって AST に対し少し情報を足したりしたい場合がある.この場合データ型自体は異なるが,ほぼ基盤は変わらない.このため,それぞれデータ型を定義するとなると,共通の処理関数やインターフェースなども再定義する必要に迫られ,ボイラープレートが大量に生まれることになる.また,元々あった構文を脱糖し,その後はその構文がない前提で話を進めたい場合などもあるだろう.この場合も,脱糖後用に新たにデータ型を定義し直すのは手間である.

一般に上のように,場合分け可能なデータ型に対し,既存の処理関数やデータ型を再定義することなく,新たな場合分けを追加できるかという問題は Expression Problem と名付けられている [1] .特に純粋な ADT しか持たないような Haskell などの言語は Expression Problem に弱く, Scala などのオブジェクト指向を採用してる言語はこのような問題にはかなり強い.例えば,足算を行う電卓の例を考えてみる. AST の定義とそれに対しての実行関数だけ書いてみると,それぞれ以下のようになるだろう:

1
2
3
4
5
6
7
8
9
-- Haskell

data Ast
  = Add Ast Ast
  | Num Int

eval :: AST -> Int
eval (Add x y) = eval x + eval y
eval (Num i)   = i
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// Scala

trait Ast

case class Add(x: Ast, y: Ast) extends Ast
case class Num(i: Int) extends Ast

def eval(t: Ast): Int = t match {
  case Add(x, y) => eval(x) + eval(y)
  case Num(i)    => i
}

これらのプログラムで遊び終えたら,多くの人はこの電卓に新たな機能を足したいと思うはずだ.例えば,掛け算を追加してみることを考えたい.上のプログラムを全く変えないで,行末に何かを追加するだけで,掛け算も計算できるようにならないだろうか? 残念ながら今回の場合は,不可能だ.しかし, Scala の場合少し工夫するだけでこの目標を達成できる.最初のプログラムを以下のように作っておくのだ:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// Scala

trait Ast {
  def eval(): Int
}

case class Add(x: Ast, y: Ast) extends Ast {
  def eval(): Int = x.eval() + y.eval()
}

case class Num(i: Int) extends Ast {
  def eval(): Int = i
}

この場合,掛け算を追加したかったら,行末に以下を追記すれば良い:

1
2
3
case class Mul(x: Ast, y: Ast) extends Ast {
  def eval(): Int = x.eval() * y.eval()
}

残念ながら, Haskell では同じことをやろうとすると,大規模な改築が必要になる.原因はデータ型が再帰的であることによる. Ast は再帰的であるため,単純に以下のようなことをしても掛け算を追加したことにはならない:

1
2
3
data Ast2
  = Orig Ast
  | Mul Ast2 Ast2

なぜなら, (2 * 3) + 1 のようなものをこのデータ型では表現できないからだ.つまり,元々の再帰構造自体はデータ型を定義した時点で既に確定してしまっており,後から付け入る隙がないのだ.逆に Scala では再帰部分をベースのトレイトで定義しており,後から派生クラスをいくらでも追加することができる.一般にオブジェクト指向型の言語では,再帰部分をオブジェクトの基底インターフェースにすることで,自然なプログラミングスタイルながら後から再帰部分をいくらでも派生させることができる.このような特性は,開いた再帰 (open recursion) と呼ばれる.これは,オブジェクト指向を搭載する言語の大きな強みだと個人的には思っている [2]

Haskell では,この問題の解決が結構昔から取り組まれており, tagless final [3] , data types a la carte [4] などの手法が存在する.今回は,これらの提案の手法のうち,比較的新しく GHC で使われている, type family を使った手法について紹介する.

Trees That Grow

この手法の面白いところは,他の手法と比べ,かなり自然な Haskell プログラミングの形でデータ型を拡張できることにある.つまりかなり単純な手法で,拡張性を持つデータ型を扱え,オープン性を type family のオープン性を使って担保するだけだ.

type family とは, GHC の言語拡張で提供される機能で,その名の通り型の族,つまりある型に対して別の型を結びつけるような写像を定義できる機能だ.例えば,

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE PolyKinds    #-}

import Data.Kind

type family ElemType (c :: Type) :: Type

type instance ElemType [a]        = a
type instance ElemType (Maybe a)  = a
type instance ElemType Text       = Char
type instance ElemType ByteString = Word8

のように書くと, ElemType StringElemType TextChar 型のエイリアスとして使えるようになる.型でパターンマッチできる,型エイリアスだと思っても良いだろう.ただ,この機能の面白いところは,パターンマッチを後からいくらでも足せるところにある [5] .この機能を使うと,上の電卓の例を次のように修正することができる.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications    #-}

data Ast p
  = Add (Ast p) (Ast p)
  | Num Integer
  | XAst (XAst p)

type family XAst (p :: Type) :: Type

class EvalXAst p where
  evalXAst :: (Ast p -> Integer) -> XAst p -> Integer

eval :: forall p. EvalXAst p => Ast p -> Integer
eval = go
  where
    goXAst = evalXAst @p go

    go (Add x y) = go x + go y
    go (Num i)   = i
    go (XAst x)  = goXAst x


data OldAst
type instance XAst OldAst = Void

instance EvalXAst OldAst where
  evalXAst _ x = absurd x


data WithMul p = Mul (Ast p) (Ast p)

data NewAst
type instance XAst NewAst = WithMul NewAst

instance EvalXAst NewAst where
  evalXAst go (Mul x y) = go x * go y

ちょっと複雑に見えるのが,基幹部は Ast pXAst というデータコンストラクタだ. XAst データコンストラクタは type family で定義されたデータ型を受け取るようになっており, type family のインスタンスを後から挿入できるようになっている. EvalXAst は後から挿入するコンストラクタの,パターンマッチ部分を受けとるようになっていて, eval はそいつを受け取って完成するようになっている.その下が実際のパターンマッチを後付けしてる部分で, Ast OldAst は元々の足し算しかない電卓の動作, Ast NewAst は掛け算も追加した電卓の動作が行えるようになっている.このように, type family をデータ型に埋め込むことで,データ型を一部オープンにすることができるようになる.さらに,この手法は上の Scala の例と異なり,様々なバリエーションのデータ型を双方共存させることができる.

この手法は, Expression Problem 以外にも応用できる.最初にあげた,フェーズごとに異なる情報を入れるようなデータ型にも対応できる.例えば,

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
data Type
  = NumType
  | ArrowType Type Type

data Ast p
  = App (Ast p) (Ast p)
  | Abs String (Ast p)
  | Var (XVar p) String
  | Num Integer

type family XVar p

data Parsed
type instance XVar Parsed = NoExt

data Renamed
type instance XVar Renamed = String

data TypeChecked
type instance XVar TypeChecked = (XVar Renamed, Type)

みたいなデータ型を作ると,パース時は何の情報もないのが,リネーム時に元々の変数名を,型検査時に変数の型を, AST のデータ型に付与することができる.

さらに, type family のインスタンスはモジュールを超えて定義できるため,インポートするモジュールを変更することでデータ型に付加する拡張を変更することもできる.かなり応用が効くだろう.

GHC での利用

GHC での移行計画は, GHC Wiki に記載されている.ゴールとして,

  • GHC で使っている AST
  • Template Haskell で使っている AST
  • haskell-src-exts で使っている AST

を共通化するという壮大な計画のようだ.現在は, trees that grow 用の type family は, HsExtension モジュールに纏まっている.そして,内部の AST に関するデータ型は,次のようになっている:

1
2
3
4
5
6
7
8
data HsExpr p
  = HsVar (XVar p) (Located (IdP p))
  | ...
  | XExpr (XXExpr p)

type instance XVar (GhcPass _) = NoExt
...
type instance XXExpr (GhcPass _) = NoExt

それぞれの基幹となるデータ型は,次のようになっている:

1
2
3
4
5
6
7
8
9
{-# LANGUAGE DataKinds #-}

data NoExt = NoExt
data GhcPass (c :: Pass)
data Pass = Parsed | Renamed | Typechecked

type GhcPs = GhcPass 'Parsed
type GhcRn = GhcPass 'Renamed
type GhcTc = GhcPass 'Typechecked

例えば,演算子適用を表すコンストラクタは,次のようになっている:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
type LHsExpr p = Located (HsExpr p)

data HsExpr p
  = ...
  | OpApp (XOpApp p) (LHsExpr p) (LHsExpr p) (LHsExpr p)
  | ...

type instance XOpApp GhcPs = NoExt
type instance XOpApp GhcRn = Fixity
type instance XOpApp GhcTc = Fixity

演算子適用は,パース時は全て優先順位同じで左結合として扱われ,リネーム時に結合や優先順位が解決される.その解決された情報が,リネーム時から入ってるというわけだ.で,拡張部分については随時制約が用意されていて,例えば HsExpr 用には,

1
2
3
4
5
type ForallXExpr (c :: * -> Constraint) (x :: *) =
     ( c (XVar            x)
     , ...
     , c (XXExpr          x)
     )

みたいなエイリアスが HsExtension モジュールにあったりする.クラスインスタンスを作りたいときは,このエイリアスを使って作っていくという感じになるだろう.その書き換えとともにパーサやリネーム部分のリファクタリング計画もあるようで,今現在遂行中という感じっぽい.とりあえず, GHC で X と付くデータ型が出てきたら, tree that grows のものと思っていいと思う.内部的にはそこまで本格的な対応は入っていなくて, XExpr コンストラクタなどは来ない前提で来たら panic にするという処理になっているっぽいけど.

まとめ

というわけで,今回は type family による拡張性を持ったデータ型の定義方法について紹介した.この手法は結構示唆に富んでいると思う. Haskell では通常その定義内で閉じたデータ型しか作れない訳だけど, type family を使うことで定義を外から容易に拡張できるようにできる訳だ.つまり,オープン性を type family により調整できる訳だ.最もオブジェクト指向では,多くの場合もっと細かく権限が制御できたりする訳だけど,残念ながら type family だとそこまで細かく制御はできない.細かい制御がそこまでいるかというのは議論の余地があるかもしれないけど,そこらへんも現在の機能でなんとかできるか考えてみると面白いかもなと思ったりした.

とりあえず, GHC で本格的に用いられるようになってきた機能なので, GHC のコードを読む時用と Haskell プログラミングの技術の一つとしてまとめておいた.実はそこまで真面目に元論文を読んでないので,機会があればもうちょっと真面目に元論文読んでおきたい.今回は以上.

[1]名付け親は Wadler 先生で,大学のプログラミング言語開発チームの ML で初めて使ったとされている.ML に投稿されたメールは, http://homepages.inf.ed.ac.uk/wadler/papers/expression/expression.txt で公開されている.なお,厳密にはここでの定義は元々の定義と違っていて,元々の定義は「元々あるコードの再コンパイルやキャストなどの型変換操作を必要とせずに,新たな場合分けを追加できるか」というもの.
[2]デフォルトで open か close かは,それぞれ一長一短でもあり, open は拡張性がある反面,第三者が契約外の拡張を加えてしまう可能性があり,それらを保守の際きちんと管理する責任が生まれる.逆に close の場合そのような管理責任は生まれないが,拡張性のなさをボイラープレートなど冗長な作業により埋めなければならない.
[3]http://okmij.org/ftp/tagless-final/index.html
[4]https://dl.acm.org/citation.cfm?id=1394795
[5]なお,パターンマッチを後から足せないようにする機能も用意されており, type family 宣言の際 where を書くと,その後に書いたインスタンス以外はインスタンスを登録できないようになる.