Goで直和型風データ構造を生成するDSLをつくってみた
概要
簡単な文法を持つDSLであるIno[1]を作りました。Inoは直和型のデータを定義することができ、それをGoのコードに変換できます。これにより、Goの世界で直和型風のデータ構造を操作できるようになります。
この記事では、主にInoの使い方とInoがどのようにしてGoのコードで直和型風のデータ構造を実現しているのかを説明します。
リポジトリ:github.com/nihei9/ino
動機
Ino開発の動機は、趣味で開発したGo用のパーサジェネレータであるvartan[2]の使い勝手を試したかったからです(が、この記事ではvartanの話はしません)。vartanには構文解析の結果として構文木を出力する機能があるので、単に文法を定義する以外に、構築された構文木を利用して何かする簡単な言語処理系を作ってみたかったのです。
Inoはまだまだbuggyですがせかっくなので(?)この記事で紹介します。
Inoの使い方
インストール
go install github.com/nihei9/ino/cmd/ino@latest
Inoが生成するコードはジェネリクスを使用するため、Go 1.18以上の環境が必要です。動作確認はgo 1.19.4で行っているので、もしお試しの際はgo 1.19.4以上の環境を用意することをお勧めします。
最も簡単な定義
Inoでの定義とGoへの変換
現在のInoにできることはdata
キーワードによる直和型の定義のみです。
最も簡単な定義は以下のとおり、data
、型名、=
、タグ、;
の順で配置します。タグは|
で区切ることで複数記述できます。
data Color
= Red
| Green
| Blue
;
InoではソースコードはUTF-8でエンコードし、.ino
という拡張子をもつファイルに保存します。
上記のソースコードをexample.ino
として保存するとします。以下のコマンドを実行することで、カレントディレクトリ内の.ino
をまとめてGoのコードに変換します。
ino --package main
--package main
は生成するGoのコードのpacakge
をmain
にするための指定です。--package
オプションは省略可で、省略された場合は--package main
が指定された場合と同様のコードを出力しますが、上記はオプションの例示のためにあえて指定しています。
コマンド実行後、以下のファイルが作成されます。
example.go
ino_builtin.go
値の比較
GoでどのようなAPIが利用できるかを以下の実例を見ながら紹介します。
この例ではmain
関数内でInoが生成したAPIを利用しています。このコードを上記のInoが生成したファイルと一緒にgo build
するかgo run
することで実行結果を確認できます。
//go:generate ino --package main
package main
import "fmt"
func main() {
r := Red() // var r Color
g := Green() // var g Color
b := Blue() // var b Color
fmt.Println("`r` is Red:", r.Maybe().Red().OK()) // true
fmt.Println("`r` is Green:", r.Maybe().Green().OK()) // false
fmt.Println("`r` is Blue:", r.Maybe().Blue().OK()) // false
fmt.Println("`g` is Green:", g.Maybe().Green().OK()) // true
fmt.Println("`b` is Blue:", b.Maybe().Blue().OK()) // true
fmt.Println("`r` = `r`:", r.Eq(r)) // true
fmt.Println("`r` = another Red:", r.Eq(Red())) // true
fmt.Println("`r` = `g`:", r.Eq(g)) // false
fmt.Println("`r` = `b`:", r.Eq(b)) // false
}
data
で定義した型からは同名のinterfaceが、タグからは同名の関数(コンストラクタ)が生成されます。タグに対応するstructも生成されますが、これをユーザーが直接扱うことは想定していません。
上記の例では、data Color
からtype Color interface
が、Red | Green | Blue
からはそれぞれfunc Red() Color
、func Green() Color
、func Blue() Color
が生成されています。
コンストラクタが生成する値は、r.Maybe().Red().OK()
のようにすることで、あるタグの値であるか否かをチェックできます(左記の例ではr
がRed
であるか否かをチェックしています)。
また、r.Eq(Red())
のようにEq
メソッドによって等価性をチェックできます。
上記の例を実行すると以下のような結果になります。
`r` is Red: true
`r` is Green: false
`r` is Blue: false
`g` is Green: true
`b` is Blue: true
`r` = `r`: true
`r` = another Red: true
`r` = `g`: false
`r` = `b`: false
パターンマッチ
InoはパターンマッチのためのAPIも生成します。使用例を以下のコードで説明します。
//go:generate ino --package main
package main
import (
"fmt"
"os"
)
func main() {
cases, err := NewColorCaseSet(
CaseRed(Red(), func() string {
return "🍎"
}),
CaseGreen(Green(), func() string {
return "🍏"
}),
CaseBlue(Blue(), func() string {
return "I have never seen blue apples"
}),
)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
apple, _ := cases.Match(Red())
fmt.Println(apple)
apple, _ = cases.Match(Green())
fmt.Println(apple)
apple, _ = cases.Match(Blue())
fmt.Println(apple)
}
パターンマッチを利用するには、NewColorCaseSet
関数によってパターンマッチのケースを定義し、Match
メソッドによって検査を実行します。
各ケースは、タグに対応する専用の関数、CaseRed
、CaseGreen
、CaseBlue
によって生成します。Case*
関数は第一引数に条件値を、第二引数にパターンマッチに成功した際に実行されるコールバック関数を指定します。コールバック関数の戻り値は多相化されているため、任意の型の値を返すことができます(ただし、同一のNew*CaseSet
呼び出し内では同じ型に統一される必要があります)。
New*CaseSet
関数は、渡されたケース群が網羅的でない場合はerror
を返します。
実行結果は以下のとおりです。
🍎
🍏
I have never seen blue apples
どのパターンにもマッチしない場合をハンドリングするためにCaseColorDefault
関数が利用できます。
Case*Default
関数は条件値はとらず、コールバック関数のみをとります。また、コールバック関数にはMatch
メソッドに渡された検査対象の値が渡されます。Case*Default
関数はケース群の末尾で一度だけ利用できます。
//go:generate ino --package main
package main
import (
"fmt"
"os"
)
func main() {
cases, err := NewColorCaseSet(
CaseRed(Red(), func() string {
return "🍎"
}),
CaseGreen(Green(), func() string {
return "🍏"
}),
CaseColorDefault(func(c Color) string {
return "Please 🍎 or 🍏"
}),
)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
apple, _ := cases.Match(Blue())
fmt.Println(apple)
}
実行結果は以下のとおりです。
Please 🍎 or 🍏
タグ毎の関数実行
値があるタグに対応する場合だけある関数を呼びたいという場合は、パターンマッチではなく、もっと手軽なApplyTo*
関数を利用できます。
//go:generate ino --package main
package main
import "fmt"
func main() {
apple, ok := ApplyToRed(Red(), func() string {
return "🍎"
})
if ok {
fmt.Println(apple)
}
}
ApplyToRed
関数は、第一引数がRed
の場合にのみ、第二引数の関数を実行します。ApplyTo*
関数も多相化されているため、第二引数の関数の戻り値は任意の型にできます。また、ApplyTo*
関数の第二の戻り値は、関数が実行され場合はtrue
に、実行されなかった場合はfalse
になります。
実行結果は以下のとおりです。
🍎
パラメータをもつコンストラクタ
コンストラクタはパラメータをとることができます。パラメータはタグに続けてスペース区切りで複数記述できます。
以下の例ではColor
型に加えてPen
型を定義しています。ボールペンのコンストラクタであるBallpointPen
はペン先のサイズを表すInt
型[3]の値と色を表すColor
型の値をとります。
Int
はInoの組み込み型でGoのint
に対応します。他にはString
型が利用可能で、これもそのままGoのstring
に対応します。残念ながら現在のInoでは他の型は利用できません。
data Color
= Red
| Green
| Blue
;
data Pen
= FountainPen
| BallpointPen Int Color
;
コンストラクタがパラメータをとる場合に生成されるAPIは、パラメータ無しの場合と比べていくつか異なる点があります。
それぞれ例を見ながら説明します。まずは以下の例です。
//go:generate ino --package main
package main
import "fmt"
func main() {
bR := BallpointPen(1, Red())
pointSize, color, ok := bR.Maybe().BallpointPen().Fields()
if ok {
cases, _ := NewColorCaseSet(
CaseRed(Red(), func() string { return "Red" }),
CaseBlue(Blue(), func() string { return "Blue" }),
CaseGreen(Green(), func() string { return "Green" }),
)
c, _ := cases.Match(color)
fmt.Printf("Ballpoint %vmm %v\n", pointSize, c)
}
}
コンストラクタがパラメータをとる場合、bR.Maybe().BallpointPen().Fields()
のようにFields()
メソッドが利用可能です。このメソッドは、コンストラクタに渡された引数を展開します。戻り値の最後のbool
値は、bR.Maybe().BallpointPen()
としたときにbR
がBallpointPen
である場合はtrue
、そうでない場合はfalse
になります。
実行結果は以下のとおりです。変数pointSize
とcolor
にパラメータ値が展開されているのがわかります。
Ballpoint 1mm Red
次はパターンマッチの例です。
//go:generate ino --package main
package main
import (
"fmt"
"os"
)
func main() {
bR := BallpointPen(1, Red())
bG := BallpointPen(1, Blue())
cases, err := NewPenCaseSet(
CaseBallpointPen(BallpointPen(1, Red()), func(pointSize Int, color Color) string {
return fmt.Sprintf("%vmm Red", pointSize)
}),
CaseFountainPen(FountainPen(), func() string {
return "N/A"
}),
)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
result, err := cases.Match(bR)
if err != nil {
fmt.Println("error:", err)
} else {
fmt.Println(result)
}
result, err = cases.Match(bG)
if err != nil {
fmt.Println("error:", err)
} else {
fmt.Println(result)
}
}
CaseBallpointPen
ケースを生成する際、第二引数のコールバック関数がBallpointPen
コンストラクタと同じパラメータをとるようになっています。
実行結果は以下のとおりです。コールバック関数にパラメータが展開されていること、そして1回目のMatch
呼び出しは成功して2回目は失敗していることが確認できます。
1mm Red
error: non-exhaustive patterns
コールバック関数がパラメータをとるようになるのは、パターンマッチ用のAPIだけでなく、ApplyTo*
関数も同様です。以下の例でそれがわかります。
//go:generate ino --package main
package main
import "fmt"
func main() {
bR := BallpointPen(1, Red())
result, ok := ApplyToBallpointPen(bR, func(pointSize Int, color Color) string {
cases, _ := NewColorCaseSet(
CaseRed(Red(), func() string { return "Red" }),
CaseBlue(Blue(), func() string { return "Blue" }),
CaseGreen(Green(), func() string { return "Green" }),
)
c, _ := cases.Match(color)
return fmt.Sprintf("Ballpoint %vmm %v", pointSize, c)
})
if ok {
fmt.Println(result)
}
}
実行結果は以下のとおりです。ApplyToBallpointPen
関数から呼ばれるコールバック関数にパラメータが展開されていることがわかります。
Ballpoint 1mm Red
多相化されたdata
data
は型名の後ろに型変数を置くことができます。型変数はスペースで区切ることで複数記述できます。この型変数をコンストラクトのパラメータとすることで、多相型を定義することができます。
以下は、多相化された線形リストを定義する例です。パラメータとして多相型をとる場合は、型名と引数の型を(
・)
で囲んで記述します。
data List a
= Nil
| Cons a (List a)
;
多相型のコンストラクタでパラメータをとらないものは、以下の例のNil[String]()
のように型を明示的に指定することで具体化して利用します。
//go:generate ino --package main
package main
import "fmt"
func main() {
l := Nil[String]()
l = Cons("!", l)
l = Cons("world", l)
l = Cons("Hello", l)
for ; !l.Maybe().Nil().OK(); l = tail(l) {
fmt.Println(head(l))
}
}
func head[T Eqer](l List[T]) T {
if e, _, ok := l.Maybe().Cons().Fields(); ok {
return e
}
panic("list is Nil")
}
func tail[T Eqer](l List[T]) List[T] {
if _, t, ok := l.Maybe().Cons().Fields(); ok {
return t
}
panic("list is Nil")
}
実行結果は以下のとおりです。
Hello
world
!
Goで直和型風のデータ構造を実現する仕組み
要点
ここまでの説明でほぼ説明してしまいましたが、ここではInoが生成するGoコードについて簡単に説明します。要点は以下のとおりです。
- 型はinterfaceとして表現する。
- タグはstructとして表現する。
- コンストラクタは関数として表現する。
以下はdata List a = Nil | Cons a (List a);
から生成されたGoコードの抜粋です。
型とコンストラクトは単純にList
interface、Nil
関数、Cons
関数に対応することがわかります。また、タグ名を持ったtag_*
structも生成されています。tag_*
structはコンストラクタに渡されたパラメータを保持します。
コンストラクタの戻り値はtag_*
型ではなくList
型なので、Goの世界でもコンストラクタが生成する値は例えばList[String]
のように、Inoで定義した型と見た目も一致します。
type List[T1 Eqer] interface {
Eqer
Maybe() *matcher_List[T1]
...
}
type tag_List_Nil[T1 Eqer] struct{}
func Nil[T1 Eqer]() List[T1] {
return &tag_List_Nil[T1]{}
}
type tag_List_Cons[T1 Eqer] struct {
p1 T1
p2 List[T1]
}
func Cons[T1 Eqer](p1 T1, p2 List[T1]) List[T1] {
return &tag_List_Cons[T1]{p1: p1, p2: p2}
}
Eqer
interface
型制約としてEqer
というinterfaceが登場しますが、これはパターンマッチを実現するために導入したもので、Eqer
同士の等価性判定のためのEq
メソッドを定義します。
type Eqer interface{
Eq(Eqer) bool
}
Inoが生成したGoのAPIでは、Go組み込みのint
やstring
型は、Eqer
を実装したInt
とString
に変換して利用します(単にtype Int int
と定義されるだけなので、Int(100)
やint(Int(100))
のように相互に変換可能です)。
多相化されたパラメータに渡せる値をGoの組み込み型だけに制限するのであれば、型制約としてcomparable
を利用できるのですが[4]、ユーザー定義の任意の型を渡せるようにしたかったので独自のEqer
interfaceを導入しました。
Maybe
メソッドの必要性
ところで、List
interfaceはMaybe
メソッドを定義しており、値がどのタグに一致するかの確認にはl.Maybe().Nil().OK()
のようなやや長い記述が必要です。
List
interfaceに直接IsNil() bool
のようなメソッドを定義することもできるのですが、この方法だとタグが多い型を定義する際にすべてのtag_*
structが他のタグに対応するIs*
メソッドを実装する必要があります。つまり、タグ固有の処理を行うメソッドがList
interfaceに定義される度にtag_*
structにはタグの数の二乗個のメソッドを新たに生成することになります。個人的にこれが嫌でした。そこで、tag_*
structにはタグ固有の処理を行う必要のあるメソッドは実装せず、別なstructに任せることにしました。
まとめ
Ino開発の動機であった自作パーサジェネレータの使い勝手を確認するという点では今のところは非常に簡単な文法のDSLを定義しただけなので未達成。
Goで直和型風のデータ操作を実現する点については、さすがにネイティブにサポートする言語のような流暢な表現はできませんが、まあ妥当なAPIに落とし込めたのではないかと思います。パターンマッチは網羅性判定が半端なのでこれじゃあ使えないですね。
-
Inoの名前の由来は、ドラマ「孤独のグルメ」の主人公、井之頭五郎です。理由は開発中に孤独のグルメを観ていたからです。最初は五郎の方からもらって「Gorou」にしようと思ったのですが、これはgoroutineの接頭辞であることに気づいたのでなんとなくやめました。 ↩︎
-
vartanはLALRのパーサジェネレータで、文法定義から字句解析・構文解析の状態遷移表などを言語非依存な中間形式で生成し、その中間形式からGoのコードを生成します。Go以外のコードも出力できるようする目的で中間形式を介していますが、現在はGoにしか対応していません。 ↩︎
-
小数を表現できる型を用いる方が適切ですが、現在のInoではGoの
int
とstring
相当の型しかサポートしていないため整数型を用いています。 ↩︎ -
正確にはstructでも条件を満たせば
comparable
になることができます。 ↩︎
Discussion