- 投稿日:2019-08-19T23:05:20+09:00
Goの実行ファイルがどの OS/アーキテクチャ 向けにビルドされたか判別する
ちゃんとテストしてませんが、だいたいこれで行けると思います。
strings $(BIN_FILE_PATH) | grep -oP '(?<=rt0_).+(?=\.s)'解説
Goの実行ファイルにはビルド時に使用したソースファイルへのパスが含まれており、さらに Go をビルドする際には必ず
GOOS
とGOARCH
に対応したruntime/rt0_XXX.s
が使用されるため、この方法が使えます。補足
Windows 環境には
strings
もgrep
もデフォルトでは入っていないので、busybox を使いましょう。
- 投稿日:2019-08-19T18:54:46+09:00
WSLで最新バージョンのGo環境構築
tl;dr
- Windows Subsystem for Linuxにてapt経由でGoをインストールすると
1.10
が最新としてインストールされて困った
- (現時点でのGoの最新バージョンは
1.12
(2019年8月現在))- Goenvを導入することで最新版をインストール
環境
- Windows Subsystem for Linux (Ubuntu 18.04)
apt経由でGoをインストール
$ apt-get install golang
ちなみにバージョン指定する事も可能
$ apt-get install golang-1.xx
尚、1.12
を指定してみると下記メッセージが出力されるE: Unable to locate package golang-1.12 E: Couldn't find any package by glob 'golang-1.12' E: Couldn't find any package by regex 'golang-1.12'Goenv
Goenvについて
- Goのバージョン管理ツール
- プロジェクト管理でバージョンを変えたりできるので便利。
- 他の言語の
XXXenv
も存在する。 ex.)Pyenv
Rbenv
などなど導入手順
インストール
$ git clone https://github.com/syndbg/goenv.git ~/.goenv
or
URL: https://github.com/syndbg/goenv環境変数を追加する
下記を.bash_profile
などに下記を追加する~/.bash_profileexport GOENV_ROOT=$HOME/.goenv export PATH=$GOENV_ROOT/bin:$PATH eval "$(goenv init -)"
- 変更を反映させる
$ source ~/.bash_profile
Goenv経由でGoをインストールする
- インストール可能バージョンを確認する
$ goenv install -l
1.2.2 . . 1.12.9 1.13beta1
今回は
1.12
をインストールします。
$ goenv install 1.12.9
インストール済バージョンを確認する
$ goenv versions
* 1.12.9 (set by /path/to/.goenv/version)
Goの任意のバージョンを設定する
$ goenv global 1.12.9
バージョンの確認してみる
$ go version
go version go1.12.9 linux/amd64無事に最新版になっています。
おまけ
Proxy環境下でのGoenv経由のGoインストール
$ https_proxy=http://proxy.example.com:PORT goenv install 1.12.9
任意のプロジェクトのみバージョンを変更する
$ goenv local 1.x.x
goenv global
したのにバージョンが変わらない!
$ goenv rehash
参考
- 投稿日:2019-08-19T18:03:57+09:00
与えられた重みに従ってランダムに値を返す「Weighted Random Selection」をGoで実装する!
Goで 「Weighted Random Selection」 をしたくなる時があります。しかし、Goでは Pythonの
numpy
のように便利な関数が提供されていないので自分で作るしかありません。今回は Go で 「Weighted Random Selection」 の実装方法を紹介します。Weighted Random Selection とは
とある重み(確率分布)を元に要素をランダムに選択するやつです。numpyで言うと
numpy.random.choice
に当たります。下記は第一引数 5([0,1,2,3,4]) から3つを確率分布pでランダムに選択する関数です。>>> np.random.choice(5, 3, p=[0.1, 0, 0.3, 0.6, 0]) array([3, 3, 0])ランダムな選択に重複を許可しない場合は引数に
replace=False
を指定します。>>> np.random.choice(5, 3, replace=False, p=[0.1, 0, 0.3, 0.6, 0]) array([2, 3, 0])今回はGoでこの処理を行う際の実装を紹介します。
Go による Weighted Random Selection
今回は最もシンプルな Linear Scan アルゴリズムで実装します。やることは[0~weightの合計値]の間でランダムに基準となる値を選び、基準からweightを順に引いていき、0以下になったらそれが選択されます。
早速実装していきます。下記はvの中からwの確率分布に従って1つだけ値を取得する関数です。
// 0 ~ max までの範囲でランダムに値を返す var randGenerator = func(max float64) float64 { rand.Seed(time.Now().UnixNano()) r := rand.Float64() * max return r } func weightedChoiceOne(v int, w []float64) float64 { // v を slice に変換 // ex) 5 -> [0, 1, 2, 3, 4] vs := make([]int, 0, v) for i := 0; i < v; i++ { vs = append(vs, i) } // weightの合計値を計算 var sum float64 for _, v := range w { sum += v } // weightの合計から基準値をランダムに選ぶ r := randGenerator(sum) // weightを基準値から順に引いていき、0以下になったらそれを選ぶ for j, v := range vs { r -= w[j] if r < 0 { return v } } // should return error... return 0 }最後の
return 0
はたまたま1つも選ばれなかった(基準がweightの合計と丁度一致した時など)場合に到達します。確率的にほとんどありませんが、ここはこの関数を使う状況によってエラーハンドリングや空で返すなどの対策が考えられます。上記のコードを少し変更すれば選ぶ数の指定 & 重複排除も実装できます。ポイントは選ばれた物をスライスから排除しておくことです。
func weightedChoice(v, size int, w []float64) ([]float64, error) { // v を slice に変換 // ex) 5 -> [0, 1, 2, 3, 4] vs := make([]int, 0, v) for i := 0; i < v; i++ { vs = append(vs, i) } // weightの合計値を計算 var sum float64 for _, v := range w { sum += v } result := make([]float64, 0, size) for i := 0; i < size; i++ { r := randGenerator(sum) for j, v := range vs { r -= w[j] if r < 0 { result = append(result, float64(v)) // weightの合計値から選ばれたアイテムのweightを引く sum -= w[j] // 選択されたアイテムと重みを排除 w = append(w[:j], w[j+1:]...) vs = append(vs[:j], vs[j+1:]...) break } } } return result, nil }選択されたアイテムと重みの削除のコードが少し特殊に見えますが、下記の公式Wikiを参考に実装しています。
https://github.com/golang/go/wiki/SliceTricks#deleteこれを使えば与えられたweightにしtがってランダムに値を返します。
func main() { r1 := weightedChoiceOne(5, []float64{0.1, 0.1, 0.2, 0.9, 0.1}) r2, _ := weightedChoice(5, 4, []float64{0.1, 0.9, 0.2, 0.3, 0.1}) fmt.Println(r1) // 3 fmt.Println(r2) // [1 3 2 0] }これで Weighted Random Selection が実装できました! 今回は最もシンプルな Linear Scan での実装を紹介しましたが、そのほかのアルゴリズムはこのサイトが勉強になります。https://blog.bruce-hill.com/a-faster-weighted-random-choice
コードは Go Playground にあげておきます。
https://play.golang.org/p/-vqQEvwCi44
- 投稿日:2019-08-19T09:20:02+09:00
【VSCode】WindowsでGo言語の開発環境を構築した
環境
- Windows 10
- Golang(go1.12.9)
- VS Code
- Git
実装
Golangのダウンロード
https://golang.org/dl/
上記からDLしていく。
- Featured downloads
- Microsoft Windows go1.12.9.windows-amd64.msi
を選択する。
インストーラの進め方
ダウンロードが完了したら、インストーラが起動する。
今回はすべてチェックを変えずに、そのまま進めた。
(インストール場所もC:\Go)環境変数の設定
環境変数を編集する。
PowerShellを管理者で開き、以下のコマンド。Start C:\Windows\system32\rundll32.exe sysdm.cpl, EditEnvironmentVariablesGOPATHとGOROOTがあることを確認したら、
ユーザー環境変数のPathを変数値:%GOPATH%\binに設定する。
また、GitのPathが通っているか確認し、通っていない場合はPathを通しておく。次に、PowerShellを再起動させ、GoとGitのPathが通っていることを確認する。
go version git --versionGolangのデバッガをインストール
VS Code上でGolangのデバッグができるようにする。
go get -u -v github.com/derekparker/delve/cmd/dlvGolangの開発に必要な外部パッケージをインストールする。
VS Codeを開き、拡張機能のGoをインストールする。
VS Code上で、Ctrl+Shift+pでコマンドパレットを開き、GO: Install/Update toolsで検索する。
全てのツールを選択し、OKボタンをクリックしてインストールする。正常にインストールに成功した場合、以下の表示がでる。
All tools successfully installed. You're ready to Go :).終わりに
以上で、Go言語の開発環境を構築することができました。
自分がつまったところを以下に記載しましたので、よかったら参考にしてください。
VS Code上でGolangのパッケージをインストールする時にでたエラーを解決した
- 投稿日:2019-08-19T09:19:34+09:00
VS Code上でGolangのパッケージをインストールする時にでたエラーを解決した
環境
- windows 10
- Golang 1.12.9
- VS Code # 問題 VS Code上でGolangのパッケージをインストールする時に、表示されたエラーは以下。
//一部分のみ記載 Installing 16 tools at C:\Users\~\go\bin gocode Installing github.com/mdempsky/gocode FAILED 16 tools failed to install. gocode: Error: Command failed: C:\Program Files\Go\bin\go.exe get -u -v github.com/mdempsky/gocode github.com/mdempsky/gocode (download) go: missing Git command. See https://golang.org/s/gogetcmd package github.com/mdempsky/gocode: exec: "git": executable file not found in %PATH% github.com/mdempsky/gocode (download) go: missing Git command. See https://golang.org/s/gogetcmd package github.com/mdempsky/gocode: exec: "git": executable file not found in %PATH%解決策
エラーメッセージを読むと、
- Gitがインストールされていない
- GitのPATHが通っていない
いずれかが問題であるらしいので、コマンドプロンプトを開いて確認。
git --versiongitはインストールしてあるが認識しなかったので、PATHを通す。
Golangのパッケージのインストールを再度試し、以下の表示がでた。
All tools successfully installed. You're ready to Go :).以上で、正常にインストールが完了した。
- 投稿日:2019-08-19T06:45:57+09:00
GoでDSLを作りながらCのコンパイラを書いた話
はじめに
コンパイラ本の影響か最近巷ではコンパイラ自作がブームになっていますが、GoでCのコンパイラを書くというののN番煎じをやりました。その際に「Goの持つ表現能力を限界まで引き出す」というのをやってみたかったので、専用のDSLを作りならコンパイラを書いてみました。また、単体テストしやすい設計を採用しています。
今回作ったコンパイラの内部DSLや設計の特徴は以下のようになります。
型安全なアセンブリコード生成DSL
有効なインストラクション/アセンブリコードかどうかをコンパイル時にチェックするようなDSLを作りました。
パーサーコンビネータをベースとしたコンパイラ生成DSL
パーサーコンビネータは大きなパーサーを小さなパーサーから合成していくのに使われる高階関数ですが、これを使ってコンパイラを定義したり合成したりするDSLを作りました。
単体テスト可能な"小さなコンパイラ"を組み合わせていく設計
特定の構文に対応するパーサーとコードジェネレーターが一体化されたモジュール(=小さなコンパイラ)を作り、それを単体テストで動作検証した上でコンパイラ本体へとDSLを使って合成していくような設計にしました。
コンパイラの能力
現状のコンパイラの能力ですが、例えばクイックソートの実装をコンパイルして実行することができます。
クイックソートの例
int main(){ int size; size = 30; int array[30]; int mem[30]; setInputArray(array, size); printArray(array, size); qsort(array, 0, size, mem); printArray(array, size); return 0; } int qsort(int* array, int head, int tail, int* mem){ int size; size = tail - head; if(size < 2){ return 0; } int i; for(i=0; i<size; i=i+1){ mem[head+i]=array[head+i]; } int cmp; cmp = array[head]; int leftTail; leftTail = head; int rightHead; rightHead = tail; for(i=1; i<size; i=i+1){ int val; val = mem[head+i]; if(val<cmp) { array[leftTail] = val; leftTail = leftTail+1; } if(cmp<val+1) { array[rightHead-1] = val; rightHead = rightHead-1; } } array[leftTail] = cmp; qsort(array, head, leftTail, mem); qsort(array, rightHead, tail, mem); return 0; } int setInputArray(int* array, int size){ int x; x = 1; int i; for(i = 0; i<size; i=i+1){ x = x * 20021 + 1; x = x - (x/65536)*65536; array[i] = x - (x/size)*size; } return 0; } int printArray(int* array, int size){ int i; int v; for(i=0;i<size;i=i+1){ v = *(array + i); printInt(v); put(32); } put(10); return 0; } int printInt(int n){ int div; int rem; div = n / 10; rem = n - div*10; if(div != 0){ printInt(div); } put(48 + rem); return 0; } int put(int c) { syscall 1 1 &c 1; return 0; }コンパイル&実行
$ make exec-qsort SRC_PATH=./examples/quick_sort.c make exec docker-compose exec main /bin/bash -c "go run ./src < ./examples/quick_sort.c > ./tmp/out.s; gcc -o ./tmp/out.o ./tmp/out.s; ./tmp/out.o" 12 17 20 11 28 19 0 23 16 9 6 25 28 13 2 5 16 13 16 1 24 29 20 27 26 5 4 21 14 27 0 1 2 4 5 5 6 9 11 12 13 13 14 16 16 16 17 19 20 20 21 23 24 25 26 27 27 28 28 29実装済みの機能としては、四則演算/変数宣言・呼び出し/関数定義・呼び出し/制御構文(if/for/while/return)/ポインター/ポインターの加算/配列などです。まだまだ未実装の機能も多いのですが、特に目的もない(セルフホスト出来ない)ので、一旦このへんで区切りとしました。
DSLとモジュール設計の概要
DSLを使ってモジュールを書き、単体テストを行い、それをコンパイラ本体へと合成する様子を紹介します。
(1) DSLでモジュールを書く
特定の構文、例えばif文に対応するモジュールはDSLを使って以下のように定義できます。
if文のモジュールiferの定義// 構文: "if" "(" <condition> ")" "{" <body> "}" に対応するコンパイラモジュール func ifer(condition, body *Compiler) Compiler { // パーサーコンビネータDSLを使ってコンパイラ/パーサーの組み立てる return ai().Seq(If, LPar).And(condition).Seq(RPar, LBrc).And(body).Seq(RBrc). // アセンブリコード生成ルールをアタッチする SetEval(func(nodes []*ast.AST, code asm.Code) { nodes[0].Eval(code) label := fmt.Sprintf("iflabel_%d", ifcount) // アセンブリコード生成DSLでコードを書き出す code.Ins( i().Pop().Rax(), i().Cmp().Rax().Val(0), i().Je(label)) nodes[1].Eval(code) code.Ins(i().Label(label)) ifcount++ }) }パーサーコンビネータDSLとアセンブリコード生成DSLが使用されており、またパーサーとコードジェネレータが一体化されたモジュールとなっています。
(2) モジュールを単体テストする
各モジュールは単体テスト用に適当にインスタンス化して構文解析やコード生成を行うことができます。
単体テストは、生成されたコードをassertしたり、生成されたコードを実行することで行います。`ifer`をインスタンス化して実行ifCompiler := ifer(&numInt, &numInt) // "if" "(" <Int> ")" "{" <Int> "}" としてインスタンス化 ast, _ := ifCompiler.Call(tokenize("if(888){999}")) // パースしてコードジェネレータ付きASTを返す ast.Eval(insts) // コード生成を実行 fmt.Print(insts.Show()) // コードを表示 // => // push 888 // pop rax // cmp rax, 0 // je iflabel_0 // push 999 // iflabel_0:(3) モジュールをコンパイラ本体へと組み込む
モジュールをコンパイラ本体へとパーサーコンビネータDSLを使って組み込みます。
コンパイラ本体へのモジュールの合成bodies = ai().Rep(oi().Or( ifer(&expr, &bodies).P(), forer(&expr, &bodies).P(), // ここでiferを合成 whiler(&expr, &bodies).P(), returner(&semi).P(), ai().And(arrayDeclarer(varDeclarer(st).P(), st).P()).Seq(Semi).P(), ai().And(&semi, &popRax).P()).P())この記事の構成
まずこれらのDSL/設計の仕様を紹介し、次に実装方法について紹介します。
- 仕様紹介
- 実装方法紹介
型安全なアセンブリコード生成DSL
アセンブリコードを生成する際に、専用のDSLを介することでコンパイル時に有効なインストラクションかどうかチェックできるようにしました。
例えば以下のようなアセンブリコードを生成する場合
pop rdi pop rax add rax, rdi push rax以下のようなDSLで書けます。
code.Ins( i().Pop().Rdi(), i().Pop().Rax(), i().Add().Rax().Rdi(), i().Push().Rax())このDSLでは有効なインストラクションが書かれた時のみコンパイルが通り、それ以外はエラーになります。
code.Ins(i().Add().Rax().Rdi()) // OK code.Ins(i().Rax().Add().Rdi()) // エラー code.Ins(i().Rax().Rdi().Add()) // エラー code.Ins(i().Add().Rax()) // エラー code.Ins(i().Add()) // エラー code.Ins(i().Rax()) // エラーパーサーコンビネータをベースにしたコンパイラ生成DSL
コンパイラを定義したり合成するのにパーサーコンビネータを使用しています。
例えば
"if" "(" <condition> ")" "{" <body> "}"
という構文のパーサーは以下のように組み立てられます。combinedPsr := ai().Seq(If, LPar).And(condition).Seq(RPar, LBrc).And(body).Seq(RBrc)ここで
func (Compiler) And
とfunc (Compiler) Seq
はパーサーを連結するコンビネータで、前者はASTへ構文木を追加し、後者は追加をスキップします。他のコンビネータとしては、パースに成功するパーサーを選択する
func (Compiler) Or
やoi().Or(xxx, yyy, zzz) // <xxx> | <yyy> | <zzz>パーサーの繰り返しからなるパーサーを作る
func (Compiler) Rep
があります。ai().Rep(x) // ε | <x> | <x> <x> | <x> <x> <x> | ...これらのコンビネータを使い、以下のような感じでコンパイラを組み立てることができます。
bodies = ai().Rep(oi().Or( ifer(&expr, &bodies).P(), forer(&expr, &bodies).P(), whiler(&expr, &bodies).P(), returner(&semi).P(), ai().And(arrayDeclarer(varDeclarer(st).P(), st).P()).Seq(Semi).P(), ai().And(&semi, &popRax).P()).P())コンパイラを単体テスト可能なモジュールを合成して作る
そもそもモジュールを単体テストしたい理由ですが、モジュール内のバグをモジュールの単体テストで潰したいからです。
機能追加をしてバグが出たときに、その原因として
- 機能追加した分のコードに誤りがあった
- 追加したコードと既存コードとの相互作用でバグが生まれた
- 追加したコードによって既存のバグが明らかになった
などが考えられますが、1のような簡単なバグを原因箇所が限定された状態(=つまりモジュール内)で補足することでつまらないミスが原因のバグへの対処を短時間で済ませられます。
コンパイラモジュールを単体テストする方法
各モジュールの型は
Compiler
か、Compiler
型の値を生成する関数になっています。モジュールの単体テストはCompiler
型の値を作った上で実行する(コンパイルさせる)ことで行えます。小さなコンパイラ
numInt
を例に取るとこれは以下のように数字1つのコードから作られたトークン列をアセンブリコードへと変換します。tokens := tokenize("10") // コード"10"をトークン列化する ast, _ := numInt.Call(tokens) // トークン列をコンパイルしてASTを返す ast.Eval(insts) // ASTを評価してコード生成(instsへと書き出し) fmt.Print(insts.Show()) // 書き出されたインストラクションを表示 // => // push 10 // 書き出されたアセンブリコードもっと複雑な場合、例えばif文の場合はどうするかというと以下のようなシグネチャーを持った関数
ifer
としてモジュールを定義します。func ifer(condition, body *Compiler) Compilerこれは以下のような構文をコンパイルするコンパイラを出力する関数です。
"if" "(" <condition> ")" "{" <body> "}"
ifer
をインスタンス化して実行する場合、例えばcondition
とbody
としてnumInt
を渡すと以下のようになります。`ifer`をインスタンス化して実行ifCompiler := ifer(&numInt, &numInt) // "if" "(" <Int> ")" "{" <Int> "}" としてインスタンス化 ast, _ := ifCompiler.Call(tokenize("if(888){999}")) ast.Eval(insts) fmt.Print(insts.Show()) // => // push 888 // pop rax // cmp rax, 0 // je iflabel_0 // push 999 // iflabel_0:モジュールの単体テストは単体テスト用のコンパイラを作り、生成されたコードを実行することで行なえます。
生成されたコードを実行して単体テストfunc TestIfer(t *testing.T) { for _, c := range []psrTestCase{ { "if(0) { return 1} return 2", // コンパイル対象のコード []asm.Fin{}, // 期待される出力アセンブリコード(省略可能) true, // パースに成功すること "2", // コード実行後に`rax`に積まれている値 }, { "if(1) { return 1} return 2", []asm.Fin{}, true, "1", }, } { prologue, ret := prologuer(newST()), returner(&numInt) // 単体テスト用にコンパイラを組み立てる: "if" "(" <Int> ")" "{" "return" <Int> "}" compCode(t, ai().And(&prologue, ifer(&numInt, &ret).P(), &ret), c) } }コード実行だけではなく、吐かれたアセンブリコードをassertするような単体テストもできます。
想定通りのコードが生成されているか単体テストfunc TestWhiler(t *testing.T) { for _, c := range []psrTestCase{ { "while(0) { 1 }", // コンパイル対象のコード []asm.Fin{ // 期待される出力アセンブリコード i().Label("while_begin_0"), i().Push().Val(0), i().Pop().Rax(), i().Cmp().Rax().Val(0), i().Je("while_end_0"), i().Push().Val(1), i().Jmp("while_begin_0"), i().Label("while_end_0"), }, true, // パースに成功すること "", }, } { // "while" "(" <Int> ")" "{" <Int> "}" としてインスタンス化 compCode(t, whiler(&numInt, &numInt), c) } }DSL使ったコンパイラモジュールの定義
DSLを使ったモジュールの定義は、パーサーコンビネータDSLでコンパイラを組み立てた後に、それにコード生成ルールのアタッチをすることで行います。
DSLを使ってコンパイラモジュールを定義する様子を
numInt
とifer
を例に紹介します。var numInt Compiler = ai().And(Int). SetEval(func(nodes []*ast.AST, code asm.Code) { // func (ast.AST) Evalをセット n := nodes[0].Token.Vali() // Intの値を取り出す code.Ins(i().Push().Val(n)) // スタックへとpushするコード生成 }) func ifer(condition, body *Compiler) Compiler { // "if" "(" <condition> ")" "{" <body> "}" をパースする、ASTへの追加はconditionとbodyだけ return ai().Seq(If, LPar).And(condition).Seq(RPar, LBrc).And(body).Seq(RBrc). // nodesはconditionとbodyのASTが入っている配列 SetEval(func(nodes []*ast.AST, code asm.Code) { // func (ast.AST) Evalをセット nodes[0].Eval(code) // conditionのコードを書き出し label := fmt.Sprintf("iflabel_%d", ifcount) // 条件分岐のコード書き出し code.Ins( i().Pop().Rax(), // conditionのコードが実行された結果の値をスタックからpop i().Cmp().Rax().Val(0), i().Je(label)) // conditionの評価結果が0ならlabelへとジャンプ nodes[1].Eval(code) // bodyのコード書き出し code.Ins(i().Label(label)) // ラベル書き出し ifcount++ }) }
func (Compier) SetEval
はコード生成時に呼び出させる関数func (ast.AST) Eval
をセットする関数です。SetEval
に渡す値の型は`func(nodes []*ast.AST, code asm.Code)
ですが、引数のnodes
は子のASTで、nodes
の各要素のfunc (ast.AST) Eval
を適宜呼び出しながらcode
へのインストラクションの書き出しを行います。コンパイラ本体へとモジュールを合成する
コンパイラ本体はパーサーコンビネータDSLを使って
numInt
やifer
含め各モジュールがインスタンス化され合成されることで構築されます。コンパイラ本体の組み立てfunc Generator() Compiler { body := func(st *SymTable) Compiler { var term, muls, adds, expr, bodies Compiler ptrRef := ptrDeRefer(st, lvIdenter(st).P()) val := oi().Or(rvAddrer(lvIdenter(st).P()).P(), rvIdenter(st, &ptrRef).P()) adds, muls, ptrAdd := addsubs(&muls), muldivs(&term), ptrAdder(st, &val, &expr) term = oi().Or( ai().And(&ptrAdd, &deRefer).P(), &numInt, syscaller(&expr).P(), funcCaller(&expr).P(), &val, // ここでnumIntを合成 ai().Seq(LPar).And(&adds).Seq(RPar).Trans(ast.PopSingle).P()) expr = oi().Or(assigner(oi().Or(&ptrAdd, &ptrRef).P(), &expr).P(), eqneqs(&adds).P()) semi := ai().And(&expr).Seq(Semi) bodies = ai().Rep(oi().Or( ifer(&expr, &bodies).P(), forer(&expr, &bodies).P(), // ここでiferを合成 whiler(&expr, &bodies).P(), returner(&semi).P(), ai().And(arrayDeclarer(varDeclarer(st).P(), st).P()).Seq(Semi).P(), ai().And(&semi, &popRax).P()).P()) return ai().And(&bodies) } return ai().And(ai().Rep(funcDefiner(body).P()).P()).Seq(EOF) }型安全なアセンブリコード生成DSLの実装方法
アセンブリコードを生成するDSLは型によってコンパイル時にインストラクションの有効性をチェックしています。
例えばインストラクション
mov rax, 8
を書き出す以下のようなコードがあったとして、code.Ins(i().Mov().Rax().Vali(8))ここで型は以下のように変化しています。
i() // Ini i().Mov() // Oped i().Mov().Rax() // Dested i().Mov().Rax().Vali(8) // Fin code.Ins // func(...Fin)
Mov, Rax, Vali
はそれぞれ以下のような型を持ったメソッドで、レシーバーと返り値の型の指定で"型のステートマシン"のようなものを実装しています。func (i Ini) Mov() Oped // Ini => Oped func (i Oped) Rax() Dested // Oped => Dested func (i Dested) Vali(int) Fin // Dested => Finまた、
code.Ins
の型はfunc(...Fin)
なので、未完成なインストラクションを受け付けません。
正しい順番でインストラクションが組み立てられ、Vali
のようなフィニッシャーによって完成された(Fin
型になった)インストラクションのみ受理されます。実装ですが、
Ini, Opend, Dested, Fin
はそれぞれIns
のエイリアスとなっており、type Ini Ins type Oped Ins type Dested Ins type Fin Ins
Ins
はインストラクションを表現する構造体です。type Ins struct { ope Ope dest Reg srcI int ... }
Mov
などの実装では、構造体への値のセットと型の変換を行っています。func (i Ini) Mov() Oped { i.ope = OMov return Oped(i) }上記は説明のために単純化している部分もあるので、実装の詳細が知りたい人はこちらを参照。
パーサーコンビネータDSLの実装方法
コンパイラの定義や組み立てで使用するDSLは、パーサーコンビネータを使って実装しています。パーサーコンビネータ(Parser Combinator)とは複数のパーサーを合成して新しいパーサーを作る高階関数です。
まずパーサーコンビネータ一般について紹介し、その後でDSLの実装方法を紹介します。
パーサーの定義
パーサーの型
Parser
をトークン列[]Token
を消費して、タプル("パースに成功したか" bool, "構文木" SyntaxTree, "消費された残りのトークン列" []Token)
を返す関数とします。type Parser func([]token) (bool, SyntaxTree, []token)トークン列
[]Token
とは、プログラムの文字列を前処理して扱いやすい形にしたもので、例えばトークンの型を以下のように定義したとしてtype Token struct { Value string Type string }文字列をトークン列化する関数
tokenize(string) []Token
は以下のような処理を行います。tokenize("1+x") => []Token{{"1", "Int"}, {"+", "Operator"}, {"x", "Variable"}}構文木
SyntaxTree
はトークン列をパースした結果得られるもので、プログラムを木構造で表現するものです。構文木から余計な枝を落としたものが抽象構文木(Abstract Syntax Tree)=ASTで、ASTをトラバースする(舐める)ことでコード生成が行われます。パーサーがトークン列を消費して構文木を返す例です。
Int型を受け付けるパーサーintParserの例tokens := []Token{{"1", "Int"}, {"+", "Operator"}, {"x", "Variable"}} ok, st, rest := intParser(tokens) // パースを実行 // ok => true : パースに成功 // st => Int型の1つの値(=1)を表現する構文木 // rest => []Token{{"+", "Operator"}, {"x", "Variable"}} : `Token{"1", "Int"}`が消費されている ok, _, _ = intParser(rest) // 続けてパースを実行 // ok => false : パースに失敗パーサーコンビネータの定義
パーサーコンビネータとは、複数のパーサーを合成して新しいパーサーを返す関数です。
基本的な合成の仕方には二通りあり、1つは合成元のパーサーが受理するトークン列を連結したものを受け付けるパーサーを作るような合成で、もう1つは合成元のパーサーのどれか1つがパースに成功すれば、それを選択するような合成方法です。
andCombinedParser ::= leftParser rightParser // 連結的な合成(連結されたトークン列を受理する) orCombinedParser ::= leftParsr | rightParser // 並列的な合成(受理に成功するパーサーを選ぶ)合成元のパーサー
leftParse
とrightParser
をそれぞれトークン列leftTokens
とrightTokens
を消費して、構文木leftSyntaxTree
とrightSyntaxTree
を返すものだとします。ok, leftSyntaxTree, leftRest := leftParser(append(leftTokens, Rest...)) // ok => true, leftRest => Rest ok, rightSyntaxTree, rightRest := rightParser(append(rightTokens, Rest...)) // ok => true, rightRest => Restこのとき連結的な合成を行うパーサーコンビネータ
andParserCombinator
はleftParser
とrightParser
を合成し新しいパーサーcombinedParser
を返します。combinedParser
はleftTokens
とrightTokens
を連結したトークン列を先頭に持つトークン列をパースできます。combinedParser := andParserCombinator(leftParser, rightParser) // パーサーの合成 combinedTokens := append(append(leftTokens, rightTokens...), Rest...) // トークン列の連結 ok, combinedSyntaxTree, rest := combinedParser(combinedTokens) // 連結されたトークン列をパースできる // ok => true // combinedSyntaxTree => Combine(leftSyntaxTree, rightSyntaxTree) // rest => Rest合成されたパーサー
combinedParser
はそれぞれのパーサーが消費するトークン列の連結append(leftTokens, rightTokens...)
を消費し、合成された構文木Combine(leftSyntaxTree, rightSyntaxTree)
を返します。構文木の合成Combine
はパーサーコンビネータとはあまり関係ないのですが、例えば片方の構文木の子ノードにもう片方を加えるなどをして返す関数です(実装の都合に寄ります)。並列的な合成を行うパーサーコンビネータ
orParserCombinator
は2つパーサーの能力を合わせたようなパーサーを合成します。combinedParser := orParserCombinator(leftParser, rightParser) // パーサーの合成 // leftParserと同じパースが可能 ok, leftSyntaxTree, leftRest := combinedParser(append(leftTokens, Rest...)) // ok => true, leftRest => Rest // rightParserと同じパースが可能 ok, rightSyntaxTree, rightRest := combinedParser(append(rightTokens, Rest...)) // ok => true, rightRest => Restパーサーコンビネータの実装方法
andParserCombinator
とorParserCombinator
はそれぞれ戻り値のパーサーをクロージャーとして作る以下のような関数として実装できます。func andParserCombinator(leftParser, rightParser Parser) Parser { return func(tokens[] Token) (bool, SyntaxTree, []Token) { // パーサーをクロージャーとして返す // 最初にleftParserを実行 ok, leftSyntaxTree, restTokens := leftParser(tokens) if !ok { return false, nil, nil // パースに失敗したらリターンする } // 続けてrightParserを実行(引数にrestTokensを渡しているのに注意) ok, rightSyntaxTree, restOfRestTokens := rightParser(restTokens) if !ok { return false, nil, nil // パースに失敗したらリターンする } // 両方のパーサーに消費されたトークン列restOfRestTokensを返す return true, Combine(leftSyntaxTree, rightSyntaxTree), restOfRestTokens } } func orParserCombinator(leftParser, rightParser Parser) Parser { return func(tokens[] Token) (bool, SyntaxTree, []Token) { // パーサーをクロージャーとして返す // 最初にleftParserを実行 if ok, leftSyntaxTree, restTokens := leftParser(tokens); ok return true, leftSyntaxTree, restTokens // パースに成功したらリターンする } // leftParserが失敗したらrightParserを実行 if ok, rightSyntaxTree, restTokens := rightParser(tokens); ok return true, rightSyntaxTree, restTokens // パースに成功したらリターンする } // 両方のパーサーとも失敗したらパース失敗 return false, nil, nil } }パーサーコンビネータを使ったDSLの実装
パーサーを合成するメソッド
func (Parser) And
とfunc (Parser) Or
ですが、上記で説明したパーサーコンビネータのほぼそのままで、変更点としては、
- パーサーを構造体の中に関数
Parser.Call
として入れている(メソッドチェーンしたかったため)func (ast.AST) AppendNode
によって構文木の合成(子ノードとして追加)を行っている- パースの失敗を型
ast.AST
の定数ast.Fail
を返すことで表現しているAnd
は構文木をASTに追加するかどうか引数addNode
で選択できる- 繰り返し合成
Rep
はパースに失敗するまでAnd
と同じ操作を繰り返すなどです。
// lhspがleftParser, rhspはrightParser, addNodeでASTへ追加するか選択 func (lhsp Parser) And(rhsp *Parser, addNode bool) Parser { call := func(t []tok.Token) (ast.AST, []tok.Token) { lhs, lhst := lhsp.Call(t) if lhs.Fail() { return ast.Fail, t // パースに失敗したのでリターン } rhs, rhst := rhsp.Call(lhst) if rhs.Fail() { return ast.Fail, t // パースに失敗したのでリターン } if addNode { lhs.AppendNode(rhs) // 構文木の合成 } return lhs, rhst } return Parser{Call: call} // 構造体`Parser`に包んで返す } func (lhsp Parser) Or(rhsp *Parser) Parser { call := func(t []tok.Token) (ast.AST, []tok.Token) { if lhs, lhst := lhsp.Call(t); !lhs.Fail() { return lhs, lhst // パースに成功したのでリターン } if rhs, rhst := rhsp.Call(t); !rhs.Fail() { return rhs, rhst // パースに成功したのでリターン } return ast.Fail, t // パースに失敗 } return Parser{Call: call} } func (lhsp Parser) Rep(rhsp *Parser) Parser { call := func(rest []tok.Token) (ast.AST, []tok.Token) { var node ast.AST lhs, lhst := lhsp.Call(rest) if lhs.Fail() { return lhs, rest } node, rest = lhs, lhst for { // パースに失敗するまで繰り返す rhs, rhst := rhsp.Call(rest) if rhs.Fail() { return node, rest // パースに失敗したら1つ前の結果をリターン } rest = rhst node.AppendNode(rhs) } } return Parser{Call: call} }これらを
Compiler
の型を返すようにラップしたものが実際に使用するDSLとなります。func (c Compiler) And(cps ...*Compiler) Compiler { for _, cp := range cps { c = Compiler(p(c).And(pp(cp), true)) } return c } func (c Compiler) Seq(cps ...*Compiler) Compiler { for _, cp := range cps { c = Compiler(p(c).And(pp(cp), false)) } return c } func (c Compiler) Or(cps ...*Compiler) Compiler { for _, cp := range cps { c = Compiler(p(c).Or(pp(cp))) } return c } func (c Compiler) Rep(cp *Compiler) Compiler { return Compiler(p(c).Rep(pp(cp))) }ASTからコードを生成するまでの流れ
コンパイラが返すASTにはコードジェネレーターが付属しており、このジェネレーター
func (AST) Eval
を呼ぶことでコードが生成されますが、この仕組について説明します。コンパイラがトークン列をパースしてアセンブリコードを生成するプロセスは以下のようになりますが、
ast, _ := compiler.Call(tokens) // トークン列をパースしてASTを返す ast.Eval(insts) // astを評価してコード生成(instsへ書き出し)ここでコード生成を行っている
ast
のメソッドfunc (ast.AST) Eval
は以下のように定義されています。Eval
は子ノードのASTのEval
を再帰的に呼び出してAST全体をトラバースすることでコードを生成します。func (a AST) Eval(code asm.Code) { if a.eval == nil { for _, node := range a.nodes { node.Eval(code) // もしevalがnilなら子のASTを評価する } } else { a.eval(a.nodes, code) // evalがセットされていたらそれを評価 } }ここでコードジェネレータ
eval
や子ノードnodes
は構造体ast.AST
のフィールドです。type AST struct { nodes []*AST // 子ノード eval func(nodes []*AST, code asm.Code) // コードジェネレータ ... }コードジェネレータ
eval
が受け取る2つの引数のうちの1つcode
はfunc (ast.AST) Eval
から渡されます。
もう1つの引数nodes
はコンパイラを組み立てる要素、つまりAnd
の引数に渡されるコンパイラの返すASTを配列にしたものです。
例えばASTとしてast1
、ast2
を返すようなコンパイラcomp1
、comp2
があったとして、それらの合成ai().And(comp1, comp2)が返すASTは、
ast.AST.nodes
としてast1
とast2
をアペンドしたものとなります。ast, _ := ai().And(comp1, comp2).Call(tokens) ast.nodes // => []ast.AST{ast1, ast2}
ast.Eval
を呼ぶとast.eval
がnil
の場合コードジェネレータast1.Eval
とast2.Eval
が順番に呼びされることになります。
一方で独自のコードジェネレータast.eval
がセットされている場合はそれを使ってコード生成が行われます。
構造体ast.AST
のフィールドeval
に値をセットするのがメソッドfunc (Compiler) SetEval
です。SetEvalの使用例var c = ai().And(comp1, comp2). SetEval(func(nodes []*ast.AST, code asm.Code) { nodes[0].Eval(code) // comp1の返したASTのEvalを呼び出す nodes[1].Eval(code) // comp2の返したASTのEvalを呼び出す code.Ins(i().Push().Rax()) // 独自のコードを書き出す })
func (Compiler) SetEval
の実装ですが、アイディアとしては以下のような感じです。func (c Compiler) SetEval(eval func(nodes []*ast.AST, code asm.Code)) Compiler { call := func(t []tok.Token) (ast.AST, []tok.Token) { ast, rest := c.Call(t) ast.eval = eval // 戻り地のASTに介入してevalをセットしている return f(ast), rest } return Compiler{Call: call} }このDSLや設計を採用した感想
- 良かった点
このような設計にしたそもそもの目的は、バグを事前に、あるいは出来るだけ速い段階で潰すことで、これについては成功したと思います。
なぜバグを抑えたかったかというと、コンパイラのような比較的複雑なコードを書く場合、バグを潰すのにかかる時間が最も開発時間の多くを占めるだろうと考えられたからです。
DSLにはコードの表現能力を「制限する」ことで有効でないコード=バグったコードはそもそも書けないようにする効果があります。またロジックをシンプルに書けるので、その意味でもバグが混入しにくくなったのではないかと思います。
実際、DSLを使ってモジュールを書き、単体テストを通した段階でほとんどすべてのバグは潰せてたと思います。本体へとモジュールを合成した後の結合テストでバグが出る場合もありましたが、ここで出てくるバグは本質的に根深いもの、アルゴリズムや実装の根本的なあやまりに起因するものが多かったような気がします。そういったバグはどっちにしても時間を掛けて解決する必要/価値のあるものだったと思います。
- 悪かった点
DSLの設計で想定していなかったケース、モジュール間での情報のやり取りをさせるのに苦労しました。
モジュール間の関係性は「コード実行時にスタックに何個値を積むか」だけで、各モジュールはお互いのことを知りません。なので、モジュール間で情報のやり取りをさせる必要があるケース、たとえばポインターの足し算で、足される変数の型を変数モジュールから足し算モジュールへと伝えるのが素朴にはできません。
最終的にはシンボルテーブルを使うことである程度解消されましたが、モジュール間の情報のやり取りをサポートする基本的な仕組みを用意する必要があったのかも知れません。DSLで開発プロセスを固めると、うまく行っている内はとてもうまくいくが、DSLで扱えないものが出てくると途端に面倒くさくなり、その意味でDSLの設計は根本的に難しいのかも知れません。
- 投稿日:2019-08-19T06:45:57+09:00
GoでDSL�を作りながらCのコンパイラを開発する
はじめに
コンパイラ本の影響か最近巷ではコンパイラ自作がブームになっていますが、GoでCのコンパイラを書くというののN番煎じをやりました。その際に「Goの持つ表現能力を限界まで引き出す」というのをやってみたかったので、専用のDSLを作りならコンパイラを書いてみました。また、単体テストしやすいモジュール設計を採用しています。
今回作ったコンパイラの内部DSLや設計の特徴は以下のようになります。
型安全なアセンブリコード生成DSL
有効なインストラクション/アセンブリコードかどうかをコンパイル時にチェックするようなDSLを作りました。
パーサーコンビネータをベースとしたコンパイラ生成DSL
パーサーコンビネータは大きなパーサーを小さなパーサーから合成していくのに使われる高階関数ですが、これを使ってコンパイラを定義したり合成したりするDSLを作りました。
単体テスト可能な"小さなコンパイラ"を組み合わせていく設計
特定の構文に対応するパーサーとコードジェネレーターが一体化されたモジュール(=小さなコンパイラ)を作り、それを単体テストで動作検証した上でコンパイラ本体へとDSLを使って合成していくような設計にしました。
コンパイラの能力
現状のコンパイラの能力ですが、例えばクイックソートの実装をコンパイルして実行することができます。
クイックソートの例
int main(){ int size; size = 30; int array[30]; int mem[30]; setInputArray(array, size); printArray(array, size); qsort(array, 0, size, mem); printArray(array, size); return 0; } int qsort(int* array, int head, int tail, int* mem){ int size; size = tail - head; if(size < 2){ return 0; } int i; for(i=0; i<size; i=i+1){ mem[head+i]=array[head+i]; } int cmp; cmp = array[head]; int leftTail; leftTail = head; int rightHead; rightHead = tail; for(i=1; i<size; i=i+1){ int val; val = mem[head+i]; if(val<cmp) { array[leftTail] = val; leftTail = leftTail+1; } if(cmp<val+1) { array[rightHead-1] = val; rightHead = rightHead-1; } } array[leftTail] = cmp; qsort(array, head, leftTail, mem); qsort(array, rightHead, tail, mem); return 0; } int setInputArray(int* array, int size){ int x; x = 1; int i; for(i = 0; i<size; i=i+1){ x = x * 20021 + 1; x = x - (x/65536)*65536; array[i] = x - (x/size)*size; } return 0; } int printArray(int* array, int size){ int i; int v; for(i=0;i<size;i=i+1){ v = *(array + i); printInt(v); put(32); } put(10); return 0; } int printInt(int n){ int div; int rem; div = n / 10; rem = n - div*10; if(div != 0){ printInt(div); } put(48 + rem); return 0; } int put(int c) { syscall 1 1 &c 1; return 0; }コンパイル&実行
$ make exec-qsort SRC_PATH=./examples/quick_sort.c make exec docker-compose exec main /bin/bash -c "go run ./src < ./examples/quick_sort.c > ./tmp/out.s; gcc -o ./tmp/out.o ./tmp/out.s; ./tmp/out.o" 12 17 20 11 28 19 0 23 16 9 6 25 28 13 2 5 16 13 16 1 24 29 20 27 26 5 4 21 14 27 0 1 2 4 5 5 6 9 11 12 13 13 14 16 16 16 17 19 20 20 21 23 24 25 26 27 27 28 28 29実装済みの機能としては、四則演算/変数宣言・呼び出し/関数定義・呼び出し/制御構文(if/for/while/return)/ポインター/ポインターの加算/配列などです。まだまだ未実装の機能も多いのですが、特に目的もない(セルフホスト出来ない)ので、一旦このへんで区切りとしました。
DSLとモジュール設計の概要
DSLを使ってモジュールを書き、単体テストを行い、それをコンパイラ本体へと合成する様子を紹介します。
(1) DSLでモジュールを書く
特定の構文、例えばif文に対応するモジュールはDSLを使って以下のように定義できます。
if文のモジュールiferの定義// 構文: "if" "(" <condition> ")" "{" <body> "}" に対応するコンパイラモジュール func ifer(condition, body *Compiler) Compiler { // パーサーコンビネータDSLを使ってコンパイラ/パーサーの組み立てる return ai().Seq(If, LPar).And(condition).Seq(RPar, LBrc).And(body).Seq(RBrc). // アセンブリコード生成ルールをアタッチする SetEval(func(nodes []*ast.AST, code asm.Code) { nodes[0].Eval(code) label := fmt.Sprintf("iflabel_%d", ifcount) // アセンブリコード生成DSLでコードを書き出す code.Ins( i().Pop().Rax(), i().Cmp().Rax().Val(0), i().Je(label)) nodes[1].Eval(code) code.Ins(i().Label(label)) ifcount++ }) }パーサーコンビネータDSLとアセンブリコード生成DSLが使用されており、またパーサーとコードジェネレータが一体化されたモジュールとなっています。
(2) モジュールを単体テストする
各モジュールは単体テスト用に適当にインスタンス化して構文解析やコード生成を行うことができます。
単体テストは、生成されたコードをassertしたり、生成されたコードを実行することで行います。`ifer`をインスタンス化して実行ifCompiler := ifer(&numInt, &numInt) // "if" "(" <Int> ")" "{" <Int> "}" としてインスタンス化 ast, _ := ifCompiler.Call(tokenize("if(888){999}")) // パースしてコードジェネレータ付きASTを返す ast.Eval(insts) // コード生成を実行 fmt.Print(insts.Show()) // コードを表示 // => // push 888 // pop rax // cmp rax, 0 // je iflabel_0 // push 999 // iflabel_0:(3) モジュールをコンパイラ本体へと組み込む
モジュールをコンパイラ本体へとパーサーコンビネータDSLを使って組み込みます。
コンパイラ本体へのモジュールの合成bodies = ai().Rep(oi().Or( ifer(&expr, &bodies).P(), forer(&expr, &bodies).P(), // ここでiferを合成 whiler(&expr, &bodies).P(), returner(&semi).P(), ai().And(arrayDeclarer(varDeclarer(st).P(), st).P()).Seq(Semi).P(), ai().And(&semi, &popRax).P()).P())この記事の構成
まずこれらのDSL/設計の仕様を紹介し、次に実装方法について紹介します。
- 仕様紹介
- 実装方法紹介
型安全なアセンブリコード生成DSL
アセンブリコードを生成する際に、専用のDSLを介することでコンパイル時に有効なインストラクションかどうかチェックできるようにしました。
例えば以下のようなアセンブリコードを生成する場合
pop rdi pop rax add rax, rdi push rax以下のようなDSLで書けます。
code.Ins( i().Pop().Rdi(), i().Pop().Rax(), i().Add().Rax().Rdi(), i().Push().Rax())このDSLでは有効なインストラクションが書かれた時のみコンパイルが通り、それ以外はエラーになります。
code.Ins(i().Add().Rax().Rdi()) // OK code.Ins(i().Rax().Add().Rdi()) // エラー code.Ins(i().Rax().Rdi().Add()) // エラー code.Ins(i().Add().Rax()) // エラー code.Ins(i().Add()) // エラー code.Ins(i().Rax()) // エラーパーサーコンビネータをベースにしたコンパイラ生成DSL
コンパイラを定義したり合成するのにパーサーコンビネータを使用しています。
例えば
"if" "(" <condition> ")" "{" <body> "}"
という構文のパーサーは以下のように組み立てられます。combinedPsr := ai().Seq(If, LPar).And(condition).Seq(RPar, LBrc).And(body).Seq(RBrc)ここで
func (Compiler) And
とfunc (Compiler) Seq
はパーサーを連結するコンビネータで、前者はASTへ構文木を追加し、後者は追加をスキップします。他のコンビネータとしては、パースに成功するパーサーを選択する
func (Compiler) Or
やoi().Or(xxx, yyy, zzz) // <xxx> | <yyy> | <zzz>パーサーの繰り返しからなるパーサーを作る
func (Compiler) Rep
があります。ai().Rep(x) // ε | <x> | <x> <x> | <x> <x> <x> | ...これらのコンビネータを使い、以下のような感じでコンパイラを組み立てることができます。
bodies = ai().Rep(oi().Or( ifer(&expr, &bodies).P(), forer(&expr, &bodies).P(), whiler(&expr, &bodies).P(), returner(&semi).P(), ai().And(arrayDeclarer(varDeclarer(st).P(), st).P()).Seq(Semi).P(), ai().And(&semi, &popRax).P()).P())コンパイラを単体テスト可能なモジュールを合成して作る
そもそもモジュールを単体テストしたい理由ですが、モジュール内のバグをモジュールの単体テストで潰したいからです。
機能追加をしてバグが出たときに、その原因として
- 機能追加した分のコードに誤りがあった
- 追加したコードと既存コードとの相互作用でバグが生まれた
- 追加したコードによって既存のバグが明らかになった
などが考えられますが、1のような簡単なバグを原因箇所が限定された状態(=つまりモジュール内)で補足することでつまらないミスが原因のバグへの対処を短時間で済ませられます。
コンパイラモジュールを単体テストする方法
各モジュールの型は
Compiler
か、Compiler
型の値を生成する関数になっています。モジュールの単体テストはCompiler
型の値を作った上で実行する(コンパイルさせる)ことで行えます。小さなコンパイラ
numInt
を例に取るとこれは以下のように数字1つのコードから作られたトークン列をアセンブリコードへと変換します。tokens := tokenize("10") // コード"10"をトークン列化する ast, _ := numInt.Call(tokens) // トークン列をコンパイルしてASTを返す ast.Eval(insts) // ASTを評価してコード生成(instsへと書き出し) fmt.Print(insts.Show()) // 書き出されたインストラクションを表示 // => // push 10 // 書き出されたアセンブリコードもっと複雑な場合、例えばif文の場合はどうするかというと以下のようなシグネチャーを持った関数
ifer
としてモジュールを定義します。func ifer(condition, body *Compiler) Compilerこれは以下のような構文をコンパイルするコンパイラを出力する関数です。
"if" "(" <condition> ")" "{" <body> "}"
ifer
をインスタンス化して実行する場合、例えばcondition
とbody
としてnumInt
を渡すと以下のようになります。`ifer`をインスタンス化して実行ifCompiler := ifer(&numInt, &numInt) // "if" "(" <Int> ")" "{" <Int> "}" としてインスタンス化 ast, _ := ifCompiler.Call(tokenize("if(888){999}")) ast.Eval(insts) fmt.Print(insts.Show()) // => // push 888 // pop rax // cmp rax, 0 // je iflabel_0 // push 999 // iflabel_0:モジュールの単体テストは単体テスト用のコンパイラを作り、生成されたコードを実行することで行なえます。
生成されたコードを実行して単体テストfunc TestIfer(t *testing.T) { for _, c := range []psrTestCase{ { "if(0) { return 1} return 2", // コンパイル対象のコード []asm.Fin{}, // 期待される出力アセンブリコード(省略可能) true, // パースに成功すること "2", // コード実行後に`rax`に積まれている値 }, { "if(1) { return 1} return 2", []asm.Fin{}, true, "1", }, } { prologue, ret := prologuer(newST()), returner(&numInt) // 単体テスト用にコンパイラを組み立てる: "if" "(" <Int> ")" "{" "return" <Int> "}" compCode(t, ai().And(&prologue, ifer(&numInt, &ret).P(), &ret), c) } }コード実行だけではなく、吐かれたアセンブリコードをassertするような単体テストもできます。
想定通りのコードが生成されているか単体テストfunc TestWhiler(t *testing.T) { for _, c := range []psrTestCase{ { "while(0) { 1 }", // コンパイル対象のコード []asm.Fin{ // 期待される出力アセンブリコード i().Label("while_begin_0"), i().Push().Val(0), i().Pop().Rax(), i().Cmp().Rax().Val(0), i().Je("while_end_0"), i().Push().Val(1), i().Jmp("while_begin_0"), i().Label("while_end_0"), }, true, // パースに成功すること "", }, } { // "while" "(" <Int> ")" "{" <Int> "}" としてインスタンス化 compCode(t, whiler(&numInt, &numInt), c) } }DSL使ったコンパイラモジュールの定義
DSLを使ったモジュールの定義は、パーサーコンビネータDSLでコンパイラを組み立てた後に、それにコード生成ルールのアタッチをすることで行います。
DSLを使ってコンパイラモジュールを定義する様子を
numInt
とifer
を例に紹介します。var numInt Compiler = ai().And(Int). SetEval(func(nodes []*ast.AST, code asm.Code) { // func (ast.AST) Evalをセット n := nodes[0].Token.Vali() // Intの値を取り出す code.Ins(i().Push().Val(n)) // スタックへとpushするコード生成 }) func ifer(condition, body *Compiler) Compiler { // "if" "(" <condition> ")" "{" <body> "}" をパースする、ASTへの追加はconditionとbodyだけ return ai().Seq(If, LPar).And(condition).Seq(RPar, LBrc).And(body).Seq(RBrc). // nodesはconditionとbodyのASTが入っている配列 SetEval(func(nodes []*ast.AST, code asm.Code) { // func (ast.AST) Evalをセット nodes[0].Eval(code) // conditionのコードを書き出し label := fmt.Sprintf("iflabel_%d", ifcount) // 条件分岐のコード書き出し code.Ins( i().Pop().Rax(), // conditionのコードが実行された結果の値をスタックからpop i().Cmp().Rax().Val(0), i().Je(label)) // conditionの評価結果が0ならlabelへとジャンプ nodes[1].Eval(code) // bodyのコード書き出し code.Ins(i().Label(label)) // ラベル書き出し ifcount++ }) }
func (Compier) SetEval
はコード生成時に呼び出させる関数func (ast.AST) Eval
をセットする関数です。SetEval
に渡す値の型は`func(nodes []*ast.AST, code asm.Code)
ですが、引数のnodes
は子のASTで、nodes
の各要素のfunc (ast.AST) Eval
を適宜呼び出しながらcode
へのインストラクションの書き出しを行います。コンパイラ本体へとモジュールを合成する
コンパイラ本体はパーサーコンビネータDSLを使って
numInt
やifer
含め各モジュールがインスタンス化され合成されることで構築されます。コンパイラ本体の組み立てfunc Generator() Compiler { body := func(st *SymTable) Compiler { var term, muls, adds, expr, bodies Compiler ptrRef := ptrDeRefer(st, lvIdenter(st).P()) val := oi().Or(rvAddrer(lvIdenter(st).P()).P(), rvIdenter(st, &ptrRef).P()) adds, muls, ptrAdd := addsubs(&muls), muldivs(&term), ptrAdder(st, &val, &expr) term = oi().Or( ai().And(&ptrAdd, &deRefer).P(), &numInt, syscaller(&expr).P(), funcCaller(&expr).P(), &val, // ここでnumIntを合成 ai().Seq(LPar).And(&adds).Seq(RPar).Trans(ast.PopSingle).P()) expr = oi().Or(assigner(oi().Or(&ptrAdd, &ptrRef).P(), &expr).P(), eqneqs(&adds).P()) semi := ai().And(&expr).Seq(Semi) bodies = ai().Rep(oi().Or( ifer(&expr, &bodies).P(), forer(&expr, &bodies).P(), // ここでiferを合成 whiler(&expr, &bodies).P(), returner(&semi).P(), ai().And(arrayDeclarer(varDeclarer(st).P(), st).P()).Seq(Semi).P(), ai().And(&semi, &popRax).P()).P()) return ai().And(&bodies) } return ai().And(ai().Rep(funcDefiner(body).P()).P()).Seq(EOF) }型安全なアセンブリコード生成DSLの実装方法
アセンブリコードを生成するDSLは型によってコンパイル時にインストラクションの有効性をチェックしています。
例えばインストラクション
mov rax, 8
を書き出す以下のようなコードがあったとして、code.Ins(i().Mov().Rax().Vali(8))ここで型は以下のように変化しています。
i() // Ini i().Mov() // Oped i().Mov().Rax() // Dested i().Mov().Rax().Vali(8) // Fin code.Ins // func(...Fin)
Mov, Rax, Vali
はそれぞれ以下のような型を持ったメソッドで、レシーバーと返り値の型の指定で"型のステートマシン"のようなものを実装しています。func (i Ini) Mov() Oped // Ini => Oped func (i Oped) Rax() Dested // Oped => Dested func (i Dested) Vali(int) Fin // Dested => Finまた、
code.Ins
の型はfunc(...Fin)
なので、未完成なインストラクションを受け付けません。
正しい順番でインストラクションが組み立てられ、Vali
のようなフィニッシャーによって完成された(Fin
型になった)インストラクションのみ受理されます。実装ですが、
Ini, Opend, Dested, Fin
はそれぞれIns
のエイリアスとなっており、type Ini Ins type Oped Ins type Dested Ins type Fin Ins
Ins
はインストラクションを表現する構造体です。type Ins struct { ope Ope dest Reg srcI int ... }
Mov
などの実装では、構造体への値のセットと型の変換を行っています。func (i Ini) Mov() Oped { i.ope = OMov return Oped(i) }上記は説明のために単純化している部分もあるので、実装の詳細が知りたい人はこちらを参照。
パーサーコンビネータDSLの実装方法
コンパイラの定義や組み立てで使用するDSLは、パーサーコンビネータを使って実装しています。パーサーコンビネータ(Parser Combinator)とは複数のパーサーを合成して新しいパーサーを作る高階関数です。
まずパーサーコンビネータ一般について紹介し、その後でDSLの実装方法を紹介します。
パーサーの定義
パーサーの型
Parser
をトークン列[]Token
を消費して、タプル("パースに成功したか" bool, "構文木" SyntaxTree, "消費された残りのトークン列" []Token)
を返す関数とします。type Parser func([]token) (bool, SyntaxTree, []token)トークン列
[]Token
とは、プログラムの文字列を前処理して扱いやすい形にしたもので、例えばトークンの型を以下のように定義したとしてtype Token struct { Value string Type string }文字列をトークン列化する関数
tokenize(string) []Token
は以下のような処理を行います。tokenize("1+x") => []Token{{"1", "Int"}, {"+", "Operator"}, {"x", "Variable"}}構文木
SyntaxTree
はトークン列をパースした結果得られるもので、プログラムを木構造で表現するものです。構文木から余計な枝を落としたものが抽象構文木(Abstract Syntax Tree)=ASTで、ASTをトラバースする(舐める)ことでコード生成が行われます。パーサーがトークン列を消費して構文木を返す例です。
Int型を受け付けるパーサーintParserの例tokens := []Token{{"1", "Int"}, {"+", "Operator"}, {"x", "Variable"}} ok, st, rest := intParser(tokens) // パースを実行 // ok => true : パースに成功 // st => Int型の1つの値(=1)を表現する構文木 // rest => []Token{{"+", "Operator"}, {"x", "Variable"}} : `Token{"1", "Int"}`が消費されている ok, _, _ = intParser(rest) // 続けてパースを実行 // ok => false : パースに失敗パーサーコンビネータの定義
パーサーコンビネータとは、複数のパーサーを合成して新しいパーサーを返す関数です。
基本的な合成の仕方には二通りあり、1つは合成元のパーサーが受理するトークン列を連結したものを受け付けるパーサーを作るような合成で、もう1つは合成元のパーサーのどれか1つがパースに成功すれば、それを選択するような合成方法です。
andCombinedParser ::= leftParser rightParser // 連結的な合成(連結されたトークン列を受理する) orCombinedParser ::= leftParsr | rightParser // 並列的な合成(受理に成功するパーサーを選ぶ)合成元のパーサー
leftParse
とrightParser
をそれぞれトークン列leftTokens
とrightTokens
を消費して、構文木leftSyntaxTree
とrightSyntaxTree
を返すものだとします。ok, leftSyntaxTree, leftRest := leftParser(append(leftTokens, Rest...)) // ok => true, leftRest => Rest ok, rightSyntaxTree, rightRest := rightParser(append(rightTokens, Rest...)) // ok => true, rightRest => Restこのとき連結的な合成を行うパーサーコンビネータ
andParserCombinator
はleftParser
とrightParser
を合成し新しいパーサーcombinedParser
を返します。combinedParser
はleftTokens
とrightTokens
を連結したトークン列を先頭に持つトークン列をパースできます。combinedParser := andParserCombinator(leftParser, rightParser) // パーサーの合成 combinedTokens := append(append(leftTokens, rightTokens...), Rest...) // トークン列の連結 ok, combinedSyntaxTree, rest := combinedParser(combinedTokens) // 連結されたトークン列をパースできる // ok => true // combinedSyntaxTree => Combine(leftSyntaxTree, rightSyntaxTree) // rest => Rest合成されたパーサー
combinedParser
はそれぞれのパーサーが消費するトークン列の連結append(leftTokens, rightTokens...)
を消費し、合成された構文木Combine(leftSyntaxTree, rightSyntaxTree)
を返します。構文木の合成Combine
はパーサーコンビネータとはあまり関係ないのですが、例えば片方の構文木の子ノードにもう片方を加えるなどをして返す関数です(実装の都合に寄ります)。並列的な合成を行うパーサーコンビネータ
orParserCombinator
は2つパーサーの能力を合わせたようなパーサーを合成します。combinedParser := orParserCombinator(leftParser, rightParser) // パーサーの合成 // leftParserと同じパースが可能 ok, leftSyntaxTree, leftRest := combinedParser(append(leftTokens, Rest...)) // ok => true, leftRest => Rest // rightParserと同じパースが可能 ok, rightSyntaxTree, rightRest := combinedParser(append(rightTokens, Rest...)) // ok => true, rightRest => Restパーサーコンビネータの実装方法
andParserCombinator
とorParserCombinator
はそれぞれ戻り値のパーサーをクロージャーとして作る以下のような関数として実装できます。func andParserCombinator(leftParser, rightParser Parser) Parser { return func(tokens[] Token) (bool, SyntaxTree, []Token) { // パーサーをクロージャーとして返す // 最初にleftParserを実行 ok, leftSyntaxTree, restTokens := leftParser(tokens) if !ok { return false, nil, nil // パースに失敗したらリターンする } // 続けてrightParserを実行(引数にrestTokensを渡しているのに注意) ok, rightSyntaxTree, restOfRestTokens := rightParser(restTokens) if !ok { return false, nil, nil // パースに失敗したらリターンする } // 両方のパーサーに消費されたトークン列restOfRestTokensを返す return true, Combine(leftSyntaxTree, rightSyntaxTree), restOfRestTokens } } func orParserCombinator(leftParser, rightParser Parser) Parser { return func(tokens[] Token) (bool, SyntaxTree, []Token) { // パーサーをクロージャーとして返す // 最初にleftParserを実行 if ok, leftSyntaxTree, restTokens := leftParser(tokens); ok return true, leftSyntaxTree, restTokens // パースに成功したらリターンする } // leftParserが失敗したらrightParserを実行 if ok, rightSyntaxTree, restTokens := rightParser(tokens); ok return true, rightSyntaxTree, restTokens // パースに成功したらリターンする } // 両方のパーサーとも失敗したらパース失敗 return false, nil, nil } }パーサーコンビネータを使ったDSLの実装
パーサーを合成するメソッド
func (Parser) And
とfunc (Parser) Or
ですが、上記で説明したパーサーコンビネータのほぼそのままで、変更点としては、
- パーサーを構造体の中に関数
Parser.Call
として入れている(メソッドチェーンしたかったため)func (ast.AST) AppendNode
によって構文木の合成(子ノードとして追加)を行っている- パースの失敗を型
ast.AST
の定数ast.Fail
を返すことで表現しているAnd
は構文木をASTに追加するかどうか引数addNode
で選択できる- 繰り返し合成
Rep
はパースに失敗するまでAnd
と同じ操作を繰り返すなどです。
// lhspがleftParser, rhspはrightParser, addNodeでASTへ追加するか選択 func (lhsp Parser) And(rhsp *Parser, addNode bool) Parser { call := func(t []tok.Token) (ast.AST, []tok.Token) { lhs, lhst := lhsp.Call(t) if lhs.Fail() { return ast.Fail, t // パースに失敗したのでリターン } rhs, rhst := rhsp.Call(lhst) if rhs.Fail() { return ast.Fail, t // パースに失敗したのでリターン } if addNode { lhs.AppendNode(rhs) // 構文木の合成 } return lhs, rhst } return Parser{Call: call} // 構造体`Parser`に包んで返す } func (lhsp Parser) Or(rhsp *Parser) Parser { call := func(t []tok.Token) (ast.AST, []tok.Token) { if lhs, lhst := lhsp.Call(t); !lhs.Fail() { return lhs, lhst // パースに成功したのでリターン } if rhs, rhst := rhsp.Call(t); !rhs.Fail() { return rhs, rhst // パースに成功したのでリターン } return ast.Fail, t // パースに失敗 } return Parser{Call: call} } func (lhsp Parser) Rep(rhsp *Parser) Parser { call := func(rest []tok.Token) (ast.AST, []tok.Token) { var node ast.AST lhs, lhst := lhsp.Call(rest) if lhs.Fail() { return lhs, rest } node, rest = lhs, lhst for { // パースに失敗するまで繰り返す rhs, rhst := rhsp.Call(rest) if rhs.Fail() { return node, rest // パースに失敗したら1つ前の結果をリターン } rest = rhst node.AppendNode(rhs) } } return Parser{Call: call} }これらを
Compiler
の型を返すようにラップしたものが実際に使用するDSLとなります。func (c Compiler) And(cps ...*Compiler) Compiler { for _, cp := range cps { c = Compiler(p(c).And(pp(cp), true)) } return c } func (c Compiler) Seq(cps ...*Compiler) Compiler { for _, cp := range cps { c = Compiler(p(c).And(pp(cp), false)) } return c } func (c Compiler) Or(cps ...*Compiler) Compiler { for _, cp := range cps { c = Compiler(p(c).Or(pp(cp))) } return c } func (c Compiler) Rep(cp *Compiler) Compiler { return Compiler(p(c).Rep(pp(cp))) }ASTからコードを生成するまでの流れ
コンパイラが返すASTにはコードジェネレーターが付属しており、このジェネレーター
func (AST) Eval
を呼ぶことでコードが生成されますが、この仕組について説明します。コンパイラがトークン列をパースしてアセンブリコードを生成するプロセスは以下のようになりますが、
ast, _ := compiler.Call(tokens) // トークン列をパースしてASTを返す ast.Eval(insts) // astを評価してコード生成(instsへ書き出し)ここでコード生成を行っている
ast
のメソッドfunc (ast.AST) Eval
は以下のように定義されています。Eval
は子ノードのASTのEval
を再帰的に呼び出してAST全体をトラバースすることでコードを生成します。func (a AST) Eval(code asm.Code) { if a.eval == nil { for _, node := range a.nodes { node.Eval(code) // もしevalがnilなら子のASTを評価する } } else { a.eval(a.nodes, code) // evalがセットされていたらそれを評価 } }ここでコードジェネレータ
eval
や子ノードnodes
は構造体ast.AST
のフィールドです。type AST struct { nodes []*AST // 子ノード eval func(nodes []*AST, code asm.Code) // コードジェネレータ ... }コードジェネレータ
eval
が受け取る2つの引数のうちの1つcode
はfunc (ast.AST) Eval
から渡されます。
もう1つの引数nodes
はコンパイラを組み立てる要素、つまりAnd
の引数に渡されるコンパイラの返すASTを配列にしたものです。
例えばASTとしてast1
、ast2
を返すようなコンパイラcomp1
、comp2
があったとして、それらの合成ai().And(comp1, comp2)が返すASTは、
ast.AST.nodes
としてast1
とast2
をアペンドしたものとなります。ast, _ := ai().And(comp1, comp2).Call(tokens) ast.nodes // => []ast.AST{ast1, ast2}
ast.Eval
を呼ぶとast.eval
がnil
の場合コードジェネレータast1.Eval
とast2.Eval
が順番に呼びされることになります。
一方で独自のコードジェネレータast.eval
がセットされている場合はそれを使ってコード生成が行われます。
構造体ast.AST
のフィールドeval
に値をセットするのがメソッドfunc (Compiler) SetEval
です。SetEvalの使用例var c = ai().And(comp1, comp2). SetEval(func(nodes []*ast.AST, code asm.Code) { nodes[0].Eval(code) // comp1の返したASTのEvalを呼び出す nodes[1].Eval(code) // comp2の返したASTのEvalを呼び出す code.Ins(i().Push().Rax()) // 独自のコードを書き出す })
func (Compiler) SetEval
の実装ですが、アイディアとしては以下のような感じです。func (c Compiler) SetEval(eval func(nodes []*ast.AST, code asm.Code)) Compiler { call := func(t []tok.Token) (ast.AST, []tok.Token) { ast, rest := c.Call(t) ast.eval = eval // 戻り地のASTに介入してevalをセットしている return f(ast), rest } return Compiler{Call: call} }このDSLや設計を採用した感想
- 良かった点
このような設計にしたそもそもの目的は、バグを事前に、あるいは出来るだけ速い段階で潰すことで、これについては成功したと思います。
なぜバグを抑えたかったかというと、コンパイラのような比較的複雑なコードを書く場合、バグを潰すのにかかる時間が最も開発時間の多くを占めるだろうと考えられたからです。
DSLにはコードの表現能力を「制限する」ことで有効でないコード=バグったコードはそもそも書けないようにする効果があります。またロジックをシンプルに書けるので、その意味でもバグが混入しにくくなったのではないかと思います。
実際、DSLを使ってモジュールを書き、単体テストを通した段階でほとんどすべてのバグは潰せてたと思います。本体へとモジュールを合成した後の結合テストでバグが出る場合もありましたが、ここで出てくるバグは本質的に根深いもの、アルゴリズムや実装の根本的なあやまりに起因するものが多かったような気がします。そういったバグはどっちにしても時間を掛けて解決する必要/価値のあるものだったと思います。
- 悪かった点
DSLの設計で想定していなかったケース、モジュール間での情報のやり取りをさせるのに苦労しました。
モジュール間の関係性は「コード実行時にスタックに何個値を積むか」だけで、各モジュールはお互いのことを知りません。なので、モジュール間で情報のやり取りをさせる必要があるケース、たとえばポインターの足し算で、足される変数の型を変数モジュールから足し算モジュールへと伝えるのが素朴にはできません。
最終的にはシンボルテーブルを使うことである程度解消されましたが、モジュール間の情報のやり取りをサポートする基本的な仕組みを用意する必要があったのかも知れません。DSLで開発プロセスを固めると、うまく行っている内はとてもうまくいくが、DSLで扱えないものが出てくると途端に面倒くさくなり、その意味でDSLの設計は根本的に難しいのかも知れません。
- 投稿日:2019-08-19T06:45:57+09:00
GoでDSLを作りながらCのコンパイラを開発する
はじめに
コンパイラ本の影響か最近巷ではコンパイラ自作がブームになっていますが、GoでCのコンパイラを書くというののN番煎じをやりました。その際に「Goの持つ表現能力を限界まで引き出す」というのをやってみたかったので、専用のDSLを作りならコンパイラを書いてみました。また、単体テストしやすいモジュール設計を採用しています。
今回作ったコンパイラの内部DSLや設計の特徴は以下のようになります。
型安全なアセンブリコード生成DSL
有効なインストラクション/アセンブリコードかどうかをコンパイル時にチェックするようなDSLを作りました。
パーサーコンビネータをベースとしたコンパイラ生成DSL
パーサーコンビネータは大きなパーサーを小さなパーサーから合成していくのに使われる高階関数ですが、これを使ってコンパイラを定義したり合成したりするDSLを作りました。
単体テスト可能な"小さなコンパイラ"を組み合わせていく設計
特定の構文に対応するパーサーとコードジェネレーターが一体化されたモジュール(=小さなコンパイラ)を作り、それを単体テストで動作検証した上でコンパイラ本体へとDSLを使って合成していくような設計にしました。
コンパイラの能力
現状のコンパイラの能力ですが、例えばクイックソートの実装をコンパイルして実行することができます。
クイックソートのコードint main(){ int size; size = 30; int array[30]; int mem[30]; setInputArray(array, size); printArray(array, size); qsort(array, 0, size, mem); printArray(array, size); return 0; } int qsort(int* array, int head, int tail, int* mem){ int size; size = tail - head; if(size < 2){ return 0; } int i; for(i=0; i<size; i=i+1){ mem[head+i]=array[head+i]; } int cmp; cmp = array[head]; int leftTail; leftTail = head; int rightHead; rightHead = tail; for(i=1; i<size; i=i+1){ int val; val = mem[head+i]; if(val<cmp) { array[leftTail] = val; leftTail = leftTail+1; } if(cmp<val+1) { array[rightHead-1] = val; rightHead = rightHead-1; } } array[leftTail] = cmp; qsort(array, head, leftTail, mem); qsort(array, rightHead, tail, mem); return 0; } int setInputArray(int* array, int size){ int x; x = 1; int i; for(i = 0; i<size; i=i+1){ x = x * 20021 + 1; x = x - (x/65536)*65536; array[i] = x - (x/size)*size; } return 0; } int printArray(int* array, int size){ int i; int v; for(i=0;i<size;i=i+1){ v = *(array + i); printInt(v); put(32); } put(10); return 0; } int printInt(int n){ int div; int rem; div = n / 10; rem = n - div*10; if(div != 0){ printInt(div); } put(48 + rem); return 0; } int put(int c) { syscall 1 1 &c 1; return 0; }コンパイル&実行$ make exec-qsort SRC_PATH=./examples/quick_sort.c make exec docker-compose exec main /bin/bash -c "go run ./src < ./examples/quick_sort.c > ./tmp/out.s; gcc -o ./tmp/out.o ./tmp/out.s; ./tmp/out.o" 12 17 20 11 28 19 0 23 16 9 6 25 28 13 2 5 16 13 16 1 24 29 20 27 26 5 4 21 14 27 0 1 2 4 5 5 6 9 11 12 13 13 14 16 16 16 17 19 20 20 21 23 24 25 26 27 27 28 28 29実装済みの機能としては、四則演算/変数宣言・呼び出し/関数定義・呼び出し/制御構文(if/for/while/return)/ポインター/ポインターの加算/配列などです。まだまだ未実装の機能も多いのですが、特に目的もない(セルフホスト出来ない)ので、一旦このへんで区切りとしました。
DSLとモジュール設計の概要
DSLを使ってモジュールを書き、単体テストを行い、それをコンパイラ本体へと合成する様子を紹介します。
(1) DSLでモジュールを書く
特定の構文、例えばif文に対応するモジュールはDSLを使って以下のように定義できます。
if文のモジュールiferの定義// 構文: "if" "(" <condition> ")" "{" <body> "}" に対応するコンパイラモジュール func ifer(condition, body *Compiler) Compiler { // パーサーコンビネータDSLを使ってコンパイラ/パーサーの組み立てる return ai().Seq(If, LPar).And(condition).Seq(RPar, LBrc).And(body).Seq(RBrc). // アセンブリコード生成ルールをアタッチする SetEval(func(nodes []*ast.AST, code asm.Code) { nodes[0].Eval(code) label := fmt.Sprintf("iflabel_%d", ifcount) // アセンブリコード生成DSLでコードを書き出す code.Ins( i().Pop().Rax(), i().Cmp().Rax().Val(0), i().Je(label)) nodes[1].Eval(code) code.Ins(i().Label(label)) ifcount++ }) }パーサーコンビネータDSLとアセンブリコード生成DSLが使用されており、またパーサーとコードジェネレータが一体化されたモジュールとなっています。
(2) モジュールを単体テストする
各モジュールは単体テスト用に適当にインスタンス化して構文解析やコード生成を行うことができます。
単体テストは、生成されたコードをassertしたり、生成されたコードを実行することで行います。`ifer`をインスタンス化して実行ifCompiler := ifer(&numInt, &numInt) // "if" "(" <Int> ")" "{" <Int> "}" としてインスタンス化 ast, _ := ifCompiler.Call(tokenize("if(888){999}")) // パースしてコードジェネレータ付きASTを返す ast.Eval(insts) // コード生成を実行 fmt.Print(insts.Show()) // コードを表示 // => // push 888 // pop rax // cmp rax, 0 // je iflabel_0 // push 999 // iflabel_0:(3) モジュールをコンパイラ本体へと組み込む
モジュールをコンパイラ本体へとパーサーコンビネータDSLを使って組み込みます。
コンパイラ本体へのモジュールの合成bodies = ai().Rep(oi().Or( ifer(&expr, &bodies).P(), forer(&expr, &bodies).P(), // ここでiferを合成 whiler(&expr, &bodies).P(), returner(&semi).P(), ai().And(arrayDeclarer(varDeclarer(st).P(), st).P()).Seq(Semi).P(), ai().And(&semi, &popRax).P()).P())この記事の構成
まずこれらのDSL/設計の仕様を紹介し、次に実装方法について紹介します。
- 仕様紹介
- 実装方法紹介
型安全なアセンブリコード生成DSL
アセンブリコードを生成する際に、専用のDSLを介することでコンパイル時に有効なインストラクションかどうかチェックできるようにしました。
例えば以下のようなアセンブリコードを生成する場合
pop rdi pop rax add rax, rdi push rax以下のようなDSLで書けます。
code.Ins( i().Pop().Rdi(), i().Pop().Rax(), i().Add().Rax().Rdi(), i().Push().Rax())このDSLでは有効なインストラクションが書かれた時のみコンパイルが通り、それ以外はエラーになります。
code.Ins(i().Add().Rax().Rdi()) // OK code.Ins(i().Rax().Add().Rdi()) // エラー code.Ins(i().Rax().Rdi().Add()) // エラー code.Ins(i().Add().Rax()) // エラー code.Ins(i().Add()) // エラー code.Ins(i().Rax()) // エラーパーサーコンビネータをベースにしたコンパイラ生成DSL
コンパイラを定義したり合成するのにパーサーコンビネータを使用しています。
例えば
"if" "(" <condition> ")" "{" <body> "}"
という構文のパーサーは以下のように組み立てられます。combinedPsr := ai().Seq(If, LPar).And(condition).Seq(RPar, LBrc).And(body).Seq(RBrc)ここで
func (Compiler) And
とfunc (Compiler) Seq
はパーサーを連結するコンビネータで、前者はASTへ構文木を追加し、後者は追加をスキップします。他のコンビネータとしては、パースに成功するパーサーを選択する
func (Compiler) Or
やoi().Or(xxx, yyy, zzz) // <xxx> | <yyy> | <zzz>パーサーの繰り返しからなるパーサーを作る
func (Compiler) Rep
があります。ai().Rep(x) // ε | <x> | <x> <x> | <x> <x> <x> | ...これらのコンビネータを使い、以下のような感じでコンパイラを組み立てることができます。
bodies = ai().Rep(oi().Or( ifer(&expr, &bodies).P(), forer(&expr, &bodies).P(), whiler(&expr, &bodies).P(), returner(&semi).P(), ai().And(arrayDeclarer(varDeclarer(st).P(), st).P()).Seq(Semi).P(), ai().And(&semi, &popRax).P()).P())コンパイラを単体テスト可能なモジュールを合成して作る
そもそもモジュールを単体テストしたい理由ですが、モジュール内のバグをモジュールの単体テストで潰したいからです。
機能追加をしてバグが出たときに、その原因として
- 機能追加した分のコードに誤りがあった
- 追加したコードと既存コードとの相互作用でバグが生まれた
- 追加したコードによって既存のバグが明らかになった
などが考えられますが、1のような簡単なバグを原因箇所が限定された状態(=つまりモジュール内)で補足することでつまらないミスが原因のバグへの対処を短時間で済ませられます。
コンパイラモジュールを単体テストする方法
各モジュールの型は
Compiler
か、Compiler
型の値を生成する関数になっています。モジュールの単体テストはCompiler
型の値を作った上で実行する(コンパイルさせる)ことで行えます。小さなコンパイラ
numInt
を例に取るとこれは以下のように数字1つのコードから作られたトークン列をアセンブリコードへと変換します。tokens := tokenize("10") // コード"10"をトークン列化する ast, _ := numInt.Call(tokens) // トークン列をコンパイルしてASTを返す ast.Eval(insts) // ASTを評価してコード生成(instsへと書き出し) fmt.Print(insts.Show()) // 書き出されたインストラクションを表示 // => // push 10 // 書き出されたアセンブリコードもっと複雑な場合、例えばif文の場合はどうするかというと以下のようなシグネチャーを持った関数
ifer
としてモジュールを定義します。func ifer(condition, body *Compiler) Compilerこれは以下のような構文をコンパイルするコンパイラを出力する関数です。
"if" "(" <condition> ")" "{" <body> "}"
ifer
をインスタンス化して実行する場合、例えばcondition
とbody
としてnumInt
を渡すと以下のようになります。`ifer`をインスタンス化して実行ifCompiler := ifer(&numInt, &numInt) // "if" "(" <Int> ")" "{" <Int> "}" としてインスタンス化 ast, _ := ifCompiler.Call(tokenize("if(888){999}")) ast.Eval(insts) fmt.Print(insts.Show()) // => // push 888 // pop rax // cmp rax, 0 // je iflabel_0 // push 999 // iflabel_0:モジュールの単体テストは単体テスト用のコンパイラを作り、生成されたコードを実行することで行なえます。
生成されたコードを実行して単体テストfunc TestIfer(t *testing.T) { for _, c := range []psrTestCase{ { "if(0) { return 1} return 2", // コンパイル対象のコード []asm.Fin{}, // 期待される出力アセンブリコード(省略可能) true, // パースに成功すること "2", // コード実行後に`rax`に積まれている値 }, { "if(1) { return 1} return 2", []asm.Fin{}, true, "1", }, } { prologue, ret := prologuer(newST()), returner(&numInt) // 単体テスト用にコンパイラを組み立てる: "if" "(" <Int> ")" "{" "return" <Int> "}" compCode(t, ai().And(&prologue, ifer(&numInt, &ret).P(), &ret), c) } }コード実行だけではなく、吐かれたアセンブリコードをassertするような単体テストもできます。
想定通りのコードが生成されているか単体テストfunc TestWhiler(t *testing.T) { for _, c := range []psrTestCase{ { "while(0) { 1 }", // コンパイル対象のコード []asm.Fin{ // 期待される出力アセンブリコード i().Label("while_begin_0"), i().Push().Val(0), i().Pop().Rax(), i().Cmp().Rax().Val(0), i().Je("while_end_0"), i().Push().Val(1), i().Jmp("while_begin_0"), i().Label("while_end_0"), }, true, // パースに成功すること "", }, } { // "while" "(" <Int> ")" "{" <Int> "}" としてインスタンス化 compCode(t, whiler(&numInt, &numInt), c) } }DSL使ったコンパイラモジュールの定義
DSLを使ったモジュールの定義は、パーサーコンビネータDSLでコンパイラを組み立てた後に、それにコード生成ルールのアタッチをすることで行います。
DSLを使ってコンパイラモジュールを定義する様子を
numInt
とifer
を例に紹介します。var numInt Compiler = ai().And(Int). SetEval(func(nodes []*ast.AST, code asm.Code) { // func (ast.AST) Evalをセット n := nodes[0].Token.Vali() // Intの値を取り出す code.Ins(i().Push().Val(n)) // スタックへとpushするコード生成 }) func ifer(condition, body *Compiler) Compiler { // "if" "(" <condition> ")" "{" <body> "}" をパースする、ASTへの追加はconditionとbodyだけ return ai().Seq(If, LPar).And(condition).Seq(RPar, LBrc).And(body).Seq(RBrc). // nodesはconditionとbodyのASTが入っている配列 SetEval(func(nodes []*ast.AST, code asm.Code) { // func (ast.AST) Evalをセット nodes[0].Eval(code) // conditionのコードを書き出し label := fmt.Sprintf("iflabel_%d", ifcount) // 条件分岐のコード書き出し code.Ins( i().Pop().Rax(), // conditionのコードが実行された結果の値をスタックからpop i().Cmp().Rax().Val(0), i().Je(label)) // conditionの評価結果が0ならlabelへとジャンプ nodes[1].Eval(code) // bodyのコード書き出し code.Ins(i().Label(label)) // ラベル書き出し ifcount++ }) }
func (Compier) SetEval
はコード生成時に呼び出させる関数func (ast.AST) Eval
をセットする関数です。SetEval
に渡す値の型は`func(nodes []*ast.AST, code asm.Code)
ですが、引数のnodes
は子のASTで、nodes
の各要素のfunc (ast.AST) Eval
を適宜呼び出しながらcode
へのインストラクションの書き出しを行います。コンパイラ本体へとモジュールを合成する
コンパイラ本体はパーサーコンビネータDSLを使って
numInt
やifer
含め各モジュールがインスタンス化され合成されることで構築されます。コンパイラ本体の組み立てfunc Generator() Compiler { body := func(st *SymTable) Compiler { var term, muls, adds, expr, bodies Compiler ptrRef := ptrDeRefer(st, lvIdenter(st).P()) val := oi().Or(rvAddrer(lvIdenter(st).P()).P(), rvIdenter(st, &ptrRef).P()) adds, muls, ptrAdd := addsubs(&muls), muldivs(&term), ptrAdder(st, &val, &expr) term = oi().Or( ai().And(&ptrAdd, &deRefer).P(), &numInt, syscaller(&expr).P(), funcCaller(&expr).P(), &val, // ここでnumIntを合成 ai().Seq(LPar).And(&adds).Seq(RPar).Trans(ast.PopSingle).P()) expr = oi().Or(assigner(oi().Or(&ptrAdd, &ptrRef).P(), &expr).P(), eqneqs(&adds).P()) semi := ai().And(&expr).Seq(Semi) bodies = ai().Rep(oi().Or( ifer(&expr, &bodies).P(), forer(&expr, &bodies).P(), // ここでiferを合成 whiler(&expr, &bodies).P(), returner(&semi).P(), ai().And(arrayDeclarer(varDeclarer(st).P(), st).P()).Seq(Semi).P(), ai().And(&semi, &popRax).P()).P()) return ai().And(&bodies) } return ai().And(ai().Rep(funcDefiner(body).P()).P()).Seq(EOF) }型安全なアセンブリコード生成DSLの実装方法
アセンブリコードを生成するDSLは型によってコンパイル時にインストラクションの有効性をチェックしています。
例えばインストラクション
mov rax, 8
を書き出す以下のようなコードがあったとして、code.Ins(i().Mov().Rax().Vali(8))ここで型は以下のように変化しています。
i() // Ini i().Mov() // Oped i().Mov().Rax() // Dested i().Mov().Rax().Vali(8) // Fin code.Ins // func(...Fin)
Mov, Rax, Vali
はそれぞれ以下のような型を持ったメソッドで、レシーバーと返り値の型の指定で"型のステートマシン"のようなものを実装しています。func (i Ini) Mov() Oped // Ini => Oped func (i Oped) Rax() Dested // Oped => Dested func (i Dested) Vali(int) Fin // Dested => Finまた、
code.Ins
の型はfunc(...Fin)
なので、未完成なインストラクションを受け付けません。
正しい順番でインストラクションが組み立てられ、Vali
のようなフィニッシャーによって完成された(Fin
型になった)インストラクションのみ受理されます。実装ですが、
Ini, Opend, Dested, Fin
はそれぞれIns
のエイリアスとなっており、type Ini Ins type Oped Ins type Dested Ins type Fin Ins
Ins
はインストラクションを表現する構造体です。type Ins struct { ope Ope dest Reg srcI int ... }
Mov
などの実装では、構造体への値のセットと型の変換を行っています。func (i Ini) Mov() Oped { i.ope = OMov return Oped(i) }上記は説明のために単純化している部分もあるので、実装の詳細が知りたい人はこちらを参照。
パーサーコンビネータDSLの実装方法
コンパイラの定義や組み立てで使用するDSLは、パーサーコンビネータを使って実装しています。パーサーコンビネータ(Parser Combinator)とは複数のパーサーを合成して新しいパーサーを作る高階関数です。
まずパーサーコンビネータ一般について紹介し、その後でDSLの実装方法を紹介します。
パーサーの定義
パーサーの型
Parser
をトークン列[]Token
を消費して、タプル("パースに成功したか" bool, "構文木" SyntaxTree, "消費された残りのトークン列" []Token)
を返す関数とします。type Parser func([]token) (bool, SyntaxTree, []token)トークン列
[]Token
とは、プログラムの文字列を前処理して扱いやすい形にしたもので、例えばトークンの型を以下のように定義したとしてtype Token struct { Value string Type string }文字列をトークン列化する関数
tokenize(string) []Token
は以下のような処理を行います。tokenize("1+x") => []Token{{"1", "Int"}, {"+", "Operator"}, {"x", "Variable"}}構文木
SyntaxTree
はトークン列をパースした結果得られるもので、プログラムを木構造で表現するものです。構文木から余計な枝を落としたものが抽象構文木(Abstract Syntax Tree)=ASTで、ASTをトラバースする(舐める)ことでコード生成が行われます。パーサーがトークン列を消費して構文木を返す例です。
Int型を受け付けるパーサーintParserの例tokens := []Token{{"1", "Int"}, {"+", "Operator"}, {"x", "Variable"}} ok, st, rest := intParser(tokens) // パースを実行 // ok => true : パースに成功 // st => Int型の1つの値(=1)を表現する構文木 // rest => []Token{{"+", "Operator"}, {"x", "Variable"}} : `Token{"1", "Int"}`が消費されている ok, _, _ = intParser(rest) // 続けてパースを実行 // ok => false : パースに失敗パーサーコンビネータの定義
パーサーコンビネータとは、複数のパーサーを合成して新しいパーサーを返す関数です。
基本的な合成の仕方には二通りあり、1つは合成元のパーサーが受理するトークン列を連結したものを受け付けるパーサーを作るような合成で、もう1つは合成元のパーサーのどれか1つがパースに成功すれば、それを選択するような合成方法です。
andCombinedParser ::= leftParser rightParser // 連結的な合成(連結されたトークン列を受理する) orCombinedParser ::= leftParsr | rightParser // 並列的な合成(受理に成功するパーサーを選ぶ)合成元のパーサー
leftParse
とrightParser
をそれぞれトークン列leftTokens
とrightTokens
を消費して、構文木leftSyntaxTree
とrightSyntaxTree
を返すものだとします。ok, leftSyntaxTree, leftRest := leftParser(append(leftTokens, Rest...)) // ok => true, leftRest => Rest ok, rightSyntaxTree, rightRest := rightParser(append(rightTokens, Rest...)) // ok => true, rightRest => Restこのとき連結的な合成を行うパーサーコンビネータ
andParserCombinator
はleftParser
とrightParser
を合成し新しいパーサーcombinedParser
を返します。combinedParser
はleftTokens
とrightTokens
を連結したトークン列を先頭に持つトークン列をパースできます。combinedParser := andParserCombinator(leftParser, rightParser) // パーサーの合成 combinedTokens := append(append(leftTokens, rightTokens...), Rest...) // トークン列の連結 ok, combinedSyntaxTree, rest := combinedParser(combinedTokens) // 連結されたトークン列をパースできる // ok => true // combinedSyntaxTree => Combine(leftSyntaxTree, rightSyntaxTree) // rest => Rest合成されたパーサー
combinedParser
はそれぞれのパーサーが消費するトークン列の連結append(leftTokens, rightTokens...)
を消費し、合成された構文木Combine(leftSyntaxTree, rightSyntaxTree)
を返します。構文木の合成Combine
はパーサーコンビネータとはあまり関係ないのですが、例えば片方の構文木の子ノードにもう片方を加えるなどをして返す関数です(実装の都合に寄ります)。並列的な合成を行うパーサーコンビネータ
orParserCombinator
は2つパーサーの能力を合わせたようなパーサーを合成します。combinedParser := orParserCombinator(leftParser, rightParser) // パーサーの合成 // leftParserと同じパースが可能 ok, leftSyntaxTree, leftRest := combinedParser(append(leftTokens, Rest...)) // ok => true, leftRest => Rest // rightParserと同じパースが可能 ok, rightSyntaxTree, rightRest := combinedParser(append(rightTokens, Rest...)) // ok => true, rightRest => Restパーサーコンビネータの実装方法
andParserCombinator
とorParserCombinator
はそれぞれ戻り値のパーサーをクロージャーとして作る以下のような関数として実装できます。func andParserCombinator(leftParser, rightParser Parser) Parser { return func(tokens[] Token) (bool, SyntaxTree, []Token) { // パーサーをクロージャーとして返す // 最初にleftParserを実行 ok, leftSyntaxTree, restTokens := leftParser(tokens) if !ok { return false, nil, nil // パースに失敗したらリターンする } // 続けてrightParserを実行(引数にrestTokensを渡しているのに注意) ok, rightSyntaxTree, restOfRestTokens := rightParser(restTokens) if !ok { return false, nil, nil // パースに失敗したらリターンする } // 両方のパーサーに消費されたトークン列restOfRestTokensを返す return true, Combine(leftSyntaxTree, rightSyntaxTree), restOfRestTokens } } func orParserCombinator(leftParser, rightParser Parser) Parser { return func(tokens[] Token) (bool, SyntaxTree, []Token) { // パーサーをクロージャーとして返す // 最初にleftParserを実行 if ok, leftSyntaxTree, restTokens := leftParser(tokens); ok return true, leftSyntaxTree, restTokens // パースに成功したらリターンする } // leftParserが失敗したらrightParserを実行 if ok, rightSyntaxTree, restTokens := rightParser(tokens); ok return true, rightSyntaxTree, restTokens // パースに成功したらリターンする } // 両方のパーサーとも失敗したらパース失敗 return false, nil, nil } }パーサーコンビネータを使ったDSLの実装
パーサーを合成するメソッド
func (Parser) And
とfunc (Parser) Or
ですが、上記で説明したパーサーコンビネータのほぼそのままで、変更点としては、
- パーサーを構造体の中に関数
Parser.Call
として入れている(メソッドチェーンしたかったため)func (ast.AST) AppendNode
によって構文木の合成(子ノードとして追加)を行っている- パースの失敗を型
ast.AST
の定数ast.Fail
を返すことで表現しているAnd
は構文木をASTに追加するかどうか引数addNode
で選択できる- 繰り返し合成
Rep
はパースに失敗するまでAnd
と同じ操作を繰り返すなどです。
// lhspがleftParser, rhspはrightParser, addNodeでASTへ追加するか選択 func (lhsp Parser) And(rhsp *Parser, addNode bool) Parser { call := func(t []tok.Token) (ast.AST, []tok.Token) { lhs, lhst := lhsp.Call(t) if lhs.Fail() { return ast.Fail, t // パースに失敗したのでリターン } rhs, rhst := rhsp.Call(lhst) if rhs.Fail() { return ast.Fail, t // パースに失敗したのでリターン } if addNode { lhs.AppendNode(rhs) // 構文木の合成 } return lhs, rhst } return Parser{Call: call} // 構造体`Parser`に包んで返す } func (lhsp Parser) Or(rhsp *Parser) Parser { call := func(t []tok.Token) (ast.AST, []tok.Token) { if lhs, lhst := lhsp.Call(t); !lhs.Fail() { return lhs, lhst // パースに成功したのでリターン } if rhs, rhst := rhsp.Call(t); !rhs.Fail() { return rhs, rhst // パースに成功したのでリターン } return ast.Fail, t // パースに失敗 } return Parser{Call: call} } func (lhsp Parser) Rep(rhsp *Parser) Parser { call := func(rest []tok.Token) (ast.AST, []tok.Token) { var node ast.AST lhs, lhst := lhsp.Call(rest) if lhs.Fail() { return lhs, rest } node, rest = lhs, lhst for { // パースに失敗するまで繰り返す rhs, rhst := rhsp.Call(rest) if rhs.Fail() { return node, rest // パースに失敗したら1つ前の結果をリターン } rest = rhst node.AppendNode(rhs) } } return Parser{Call: call} }これらを
Compiler
の型を返すようにラップしたものが実際に使用するDSLとなります。func (c Compiler) And(cps ...*Compiler) Compiler { for _, cp := range cps { c = Compiler(p(c).And(pp(cp), true)) } return c } func (c Compiler) Seq(cps ...*Compiler) Compiler { for _, cp := range cps { c = Compiler(p(c).And(pp(cp), false)) } return c } func (c Compiler) Or(cps ...*Compiler) Compiler { for _, cp := range cps { c = Compiler(p(c).Or(pp(cp))) } return c } func (c Compiler) Rep(cp *Compiler) Compiler { return Compiler(p(c).Rep(pp(cp))) }ASTからコードを生成するまでの流れ
コンパイラが返すASTにはコードジェネレーターが付属しており、このジェネレーター
func (AST) Eval
を呼ぶことでコードが生成されますが、この仕組について説明します。コンパイラがトークン列をパースしてアセンブリコードを生成するプロセスは以下のようになりますが、
ast, _ := compiler.Call(tokens) // トークン列をパースしてASTを返す ast.Eval(insts) // astを評価してコード生成(instsへ書き出し)ここでコード生成を行っている
ast
のメソッドfunc (ast.AST) Eval
は以下のように定義されています。Eval
は子ノードのASTのEval
を再帰的に呼び出してAST全体をトラバースすることでコードを生成します。func (a AST) Eval(code asm.Code) { if a.eval == nil { for _, node := range a.nodes { node.Eval(code) // もしevalがnilなら子のASTを評価する } } else { a.eval(a.nodes, code) // evalがセットされていたらそれを評価 } }ここでコードジェネレータ
eval
や子ノードnodes
は構造体ast.AST
のフィールドです。type AST struct { nodes []*AST // 子ノード eval func(nodes []*AST, code asm.Code) // コードジェネレータ ... }コードジェネレータ
eval
が受け取る2つの引数のうちの1つcode
はfunc (ast.AST) Eval
から渡されます。
もう1つの引数nodes
はコンパイラを組み立てる要素、つまりAnd
の引数に渡されるコンパイラの返すASTを配列にしたものです。
例えばASTとしてast1
、ast2
を返すようなコンパイラcomp1
、comp2
があったとして、それらの合成ai().And(comp1, comp2)が返すASTは、
ast.AST.nodes
としてast1
とast2
をアペンドしたものとなります。ast, _ := ai().And(comp1, comp2).Call(tokens) ast.nodes // => []ast.AST{ast1, ast2}
ast.Eval
を呼ぶとast.eval
がnil
の場合コードジェネレータast1.Eval
とast2.Eval
が順番に呼びされることになります。
一方で独自のコードジェネレータast.eval
がセットされている場合はそれを使ってコード生成が行われます。
構造体ast.AST
のフィールドeval
に値をセットするのがメソッドfunc (Compiler) SetEval
です。SetEvalの使用例var c = ai().And(comp1, comp2). SetEval(func(nodes []*ast.AST, code asm.Code) { nodes[0].Eval(code) // comp1の返したASTのEvalを呼び出す nodes[1].Eval(code) // comp2の返したASTのEvalを呼び出す code.Ins(i().Push().Rax()) // 独自のコードを書き出す })
func (Compiler) SetEval
の実装ですが、アイディアとしては以下のような感じです。func (c Compiler) SetEval(eval func(nodes []*ast.AST, code asm.Code)) Compiler { call := func(t []tok.Token) (ast.AST, []tok.Token) { ast, rest := c.Call(t) ast.eval = eval // 戻り地のASTに介入してevalをセットしている return f(ast), rest } return Compiler{Call: call} }このDSLや設計を採用した感想
- 良かった点
このような設計にしたそもそもの目的は、バグを事前に、あるいは出来るだけ速い段階で潰すことで、これについては成功したと思います。
なぜバグを抑えたかったかというと、コンパイラのような比較的複雑なコードを書く場合、バグを潰すのにかかる時間が最も開発時間の多くを占めるだろうと考えられたからです。
DSLにはコードの表現能力を「制限する」ことで有効でないコード=バグったコードはそもそも書けないようにする効果があります。またロジックをシンプルに書けるので、その意味でもバグが混入しにくくなったのではないかと思います。
実際、DSLを使ってモジュールを書き、単体テストを通した段階でほとんどすべてのバグは潰せてたと思います。本体へとモジュールを合成した後の結合テストでバグが出る場合もありましたが、ここで出てくるバグは本質的に根深いもの、アルゴリズムや実装の根本的なあやまりに起因するものが多かったような気がします。そういったバグはどっちにしても時間を掛けて解決する必要/価値のあるものだったと思います。
- 悪かった点
DSLの設計で想定していなかったケース、モジュール間での情報のやり取りをさせるのに苦労しました。
モジュール間の関係性は「コード実行時にスタックに何個値を積むか」だけで、各モジュールはお互いのことを知りません。なので、モジュール間で情報のやり取りをさせる必要があるケース、たとえばポインターの足し算で、足される変数の型を変数モジュールから足し算モジュールへと伝えるのが素朴にはできません。
最終的にはシンボルテーブルを使うことである程度解消されましたが、モジュール間の情報のやり取りをサポートする基本的な仕組みを用意する必要があったのかも知れません。DSLで開発プロセスを固めると、うまく行っている内はとてもうまくいくが、DSLで扱えないものが出てくると途端に面倒くさくなり、その意味でDSLの設計は根本的に難しいのかも知れません。
- 投稿日:2019-08-19T06:45:57+09:00
内部DSLとモジュール設計で作るGoによるCのコンパイラ
はじめに
コンパイラ本の影響か最近巷ではコンパイラ自作がブームになっていますが、GoでCのコンパイラを書くというののN番煎じをやりました。その際に「Goの持つ表現能力を限界まで引き出す」というのをやってみたかったので、専用のDSLを作りならコンパイラを書いてみました。また、単体テストしやすいモジュール設計を採用しています。
今回作ったコンパイラの内部DSLや設計の特徴は以下のようになります。
型安全なアセンブリコード生成DSL
有効なインストラクション/アセンブリコードかどうかをコンパイル時にチェックするようなDSLを作りました。
パーサーコンビネータをベースとしたコンパイラ生成DSL
パーサーコンビネータは大きなパーサーを小さなパーサーから合成していくのに使われる高階関数ですが、これを使ってコンパイラを定義したり合成したりするDSLを作りました。
単体テスト可能な"小さなコンパイラ"を組み合わせていく設計
特定の構文に対応するパーサーとコードジェネレーターが一体化されたモジュール(=小さなコンパイラ)を作り、それを単体テストで動作検証した上でコンパイラ本体へとDSLを使って合成していくような設計にしました。
コンパイラの能力
現状のコンパイラの能力ですが、例えばクイックソートの実装をコンパイルして実行することができます。
クイックソートのコードint main(){ int size; size = 30; int array[30]; int mem[30]; setInputArray(array, size); printArray(array, size); qsort(array, 0, size, mem); printArray(array, size); return 0; } int qsort(int* array, int head, int tail, int* mem){ int size; size = tail - head; if(size < 2){ return 0; } int i; for(i=0; i<size; i=i+1){ mem[head+i]=array[head+i]; } int cmp; cmp = array[head]; int leftTail; leftTail = head; int rightHead; rightHead = tail; for(i=1; i<size; i=i+1){ int val; val = mem[head+i]; if(val<cmp) { array[leftTail] = val; leftTail = leftTail+1; } if(cmp<val+1) { array[rightHead-1] = val; rightHead = rightHead-1; } } array[leftTail] = cmp; qsort(array, head, leftTail, mem); qsort(array, rightHead, tail, mem); return 0; } int setInputArray(int* array, int size){ int x; x = 1; int i; for(i = 0; i<size; i=i+1){ x = x * 20021 + 1; x = x - (x/65536)*65536; array[i] = x - (x/size)*size; } return 0; } int printArray(int* array, int size){ int i; int v; for(i=0;i<size;i=i+1){ v = *(array + i); printInt(v); put(32); } put(10); return 0; } int printInt(int n){ int div; int rem; div = n / 10; rem = n - div*10; if(div != 0){ printInt(div); } put(48 + rem); return 0; } int put(int c) { syscall 1 1 &c 1; return 0; }コンパイル&実行$ make exec-qsort SRC_PATH=./examples/quick_sort.c make exec docker-compose exec main /bin/bash -c "go run ./src < ./examples/quick_sort.c > ./tmp/out.s; gcc -o ./tmp/out.o ./tmp/out.s; ./tmp/out.o" 12 17 20 11 28 19 0 23 16 9 6 25 28 13 2 5 16 13 16 1 24 29 20 27 26 5 4 21 14 27 0 1 2 4 5 5 6 9 11 12 13 13 14 16 16 16 17 19 20 20 21 23 24 25 26 27 27 28 28 29実装済みの機能としては、四則演算/変数宣言・呼び出し/関数定義・呼び出し/制御構文(if/for/while/return)/ポインター/ポインターの加算/配列などです。まだまだ未実装の機能も多いのですが、特に目的もない(セルフホスト出来ない)ので、一旦このへんで区切りとしました。
DSLとモジュール設計の概要
DSLを使ってモジュールを書き、単体テストを行い、それをコンパイラ本体へと合成する様子を紹介します。
(1) DSLでモジュールを書く
特定の構文、例えばif文に対応するモジュールはDSLを使って以下のように定義できます。
if文のモジュールiferの定義// 構文: "if" "(" <condition> ")" "{" <body> "}" に対応するコンパイラモジュール func ifer(condition, body *Compiler) Compiler { // パーサーコンビネータDSLを使ってコンパイラ/パーサーの組み立てる return ai().Seq(If, LPar).And(condition).Seq(RPar, LBrc).And(body).Seq(RBrc). // アセンブリコード生成ルールをアタッチする SetEval(func(nodes []*ast.AST, code asm.Code) { nodes[0].Eval(code) label := fmt.Sprintf("iflabel_%d", ifcount) // アセンブリコード生成DSLでコードを書き出す code.Ins( i().Pop().Rax(), i().Cmp().Rax().Val(0), i().Je(label)) nodes[1].Eval(code) code.Ins(i().Label(label)) ifcount++ }) }パーサーコンビネータDSLとアセンブリコード生成DSLが使用されており、またパーサーとコードジェネレータが一体化されたモジュールとなっています。
(2) モジュールを単体テストする
各モジュールは単体テスト用に適当にインスタンス化して構文解析やコード生成を行うことができます。
単体テストは、生成されたコードをassertしたり、生成されたコードを実行することで行います。`ifer`をインスタンス化して実行ifCompiler := ifer(&numInt, &numInt) // "if" "(" <Int> ")" "{" <Int> "}" としてインスタンス化 ast, _ := ifCompiler.Call(tokenize("if(888){999}")) // パースしてコードジェネレータ付きASTを返す ast.Eval(insts) // コード生成を実行 fmt.Print(insts.Show()) // コードを表示 // => // push 888 // pop rax // cmp rax, 0 // je iflabel_0 // push 999 // iflabel_0:(3) モジュールをコンパイラ本体へと組み込む
モジュールをコンパイラ本体へとパーサーコンビネータDSLを使って組み込みます。
コンパイラ本体へのモジュールの合成bodies = ai().Rep(oi().Or( ifer(&expr, &bodies).P(), forer(&expr, &bodies).P(), // ここでiferを合成 whiler(&expr, &bodies).P(), returner(&semi).P(), ai().And(arrayDeclarer(varDeclarer(st).P(), st).P()).Seq(Semi).P(), ai().And(&semi, &popRax).P()).P())この記事の構成
まずこれらのDSL/設計の仕様を紹介し、次に実装方法について紹介します。
- 仕様紹介
- 実装方法紹介
型安全なアセンブリコード生成DSL
アセンブリコードを生成する際に、専用のDSLを介することでコンパイル時に有効なインストラクションかどうかチェックできるようにしました。
例えば以下のようなアセンブリコードを生成する場合
pop rdi pop rax add rax, rdi push rax以下のようなDSLで書けます。
code.Ins( i().Pop().Rdi(), i().Pop().Rax(), i().Add().Rax().Rdi(), i().Push().Rax())このDSLでは有効なインストラクションが書かれた時のみコンパイルが通り、それ以外はエラーになります。
code.Ins(i().Add().Rax().Rdi()) // OK code.Ins(i().Rax().Add().Rdi()) // エラー code.Ins(i().Rax().Rdi().Add()) // エラー code.Ins(i().Add().Rax()) // エラー code.Ins(i().Add()) // エラー code.Ins(i().Rax()) // エラーパーサーコンビネータをベースにしたコンパイラ生成DSL
コンパイラを定義したり合成するのにパーサーコンビネータを使用しています。
例えば
"if" "(" <condition> ")" "{" <body> "}"
という構文のパーサーは以下のように組み立てられます。combinedPsr := ai().Seq(If, LPar).And(condition).Seq(RPar, LBrc).And(body).Seq(RBrc)ここで
func (Compiler) And
とfunc (Compiler) Seq
はパーサーを連結するコンビネータで、前者はASTへ構文木を追加し、後者は追加をスキップします。他のコンビネータとしては、パースに成功するパーサーを選択する
func (Compiler) Or
やoi().Or(xxx, yyy, zzz) // <xxx> | <yyy> | <zzz>パーサーの繰り返しからなるパーサーを作る
func (Compiler) Rep
があります。ai().Rep(x) // ε | <x> | <x> <x> | <x> <x> <x> | ...これらのコンビネータを使い、以下のような感じでコンパイラを組み立てることができます。
bodies = ai().Rep(oi().Or( ifer(&expr, &bodies).P(), forer(&expr, &bodies).P(), whiler(&expr, &bodies).P(), returner(&semi).P(), ai().And(arrayDeclarer(varDeclarer(st).P(), st).P()).Seq(Semi).P(), ai().And(&semi, &popRax).P()).P())コンパイラを単体テスト可能なモジュールを合成して作る
そもそもモジュールを単体テストしたい理由ですが、モジュール内のバグをモジュールの単体テストで潰したいからです。
機能追加をしてバグが出たときに、その原因として
- 機能追加した分のコードに誤りがあった
- 追加したコードと既存コードとの相互作用でバグが生まれた
- 追加したコードによって既存のバグが明らかになった
などが考えられますが、1のような簡単なバグを原因箇所が限定された状態(=つまりモジュール内)で補足することでつまらないミスが原因のバグへの対処を短時間で済ませられます。
コンパイラモジュールを単体テストする方法
各モジュールの型は
Compiler
か、Compiler
型の値を生成する関数になっています。モジュールの単体テストはCompiler
型の値を作った上で実行する(コンパイルさせる)ことで行えます。小さなコンパイラ
numInt
を例に取るとこれは以下のように数字1つのコードから作られたトークン列をアセンブリコードへと変換します。tokens := tokenize("10") // コード"10"をトークン列化する ast, _ := numInt.Call(tokens) // トークン列をコンパイルしてASTを返す ast.Eval(insts) // ASTを評価してコード生成(instsへと書き出し) fmt.Print(insts.Show()) // 書き出されたインストラクションを表示 // => // push 10 // 書き出されたアセンブリコードもっと複雑な場合、例えばif文の場合はどうするかというと以下のようなシグネチャーを持った関数
ifer
としてモジュールを定義します。func ifer(condition, body *Compiler) Compilerこれは以下のような構文をコンパイルするコンパイラを出力する関数です。
"if" "(" <condition> ")" "{" <body> "}"
ifer
をインスタンス化して実行する場合、例えばcondition
とbody
としてnumInt
を渡すと以下のようになります。`ifer`をインスタンス化して実行ifCompiler := ifer(&numInt, &numInt) // "if" "(" <Int> ")" "{" <Int> "}" としてインスタンス化 ast, _ := ifCompiler.Call(tokenize("if(888){999}")) ast.Eval(insts) fmt.Print(insts.Show()) // => // push 888 // pop rax // cmp rax, 0 // je iflabel_0 // push 999 // iflabel_0:モジュールの単体テストは単体テスト用のコンパイラを作り、生成されたコードを実行することで行なえます。
生成されたコードを実行して単体テストfunc TestIfer(t *testing.T) { for _, c := range []psrTestCase{ { "if(0) { return 1} return 2", // コンパイル対象のコード []asm.Fin{}, // 期待される出力アセンブリコード(省略可能) true, // パースに成功すること "2", // コード実行後に`rax`に積まれている値 }, { "if(1) { return 1} return 2", []asm.Fin{}, true, "1", }, } { prologue, ret := prologuer(newST()), returner(&numInt) // 単体テスト用にコンパイラを組み立てる: "if" "(" <Int> ")" "{" "return" <Int> "}" compCode(t, ai().And(&prologue, ifer(&numInt, &ret).P(), &ret), c) } }コード実行だけではなく、吐かれたアセンブリコードをassertするような単体テストもできます。
想定通りのコードが生成されているか単体テストfunc TestWhiler(t *testing.T) { for _, c := range []psrTestCase{ { "while(0) { 1 }", // コンパイル対象のコード []asm.Fin{ // 期待される出力アセンブリコード i().Label("while_begin_0"), i().Push().Val(0), i().Pop().Rax(), i().Cmp().Rax().Val(0), i().Je("while_end_0"), i().Push().Val(1), i().Jmp("while_begin_0"), i().Label("while_end_0"), }, true, // パースに成功すること "", }, } { // "while" "(" <Int> ")" "{" <Int> "}" としてインスタンス化 compCode(t, whiler(&numInt, &numInt), c) } }DSL使ったコンパイラモジュールの定義
DSLを使ったモジュールの定義は、パーサーコンビネータDSLでコンパイラを組み立てた後に、それにコード生成ルールのアタッチをすることで行います。
DSLを使ってコンパイラモジュールを定義する様子を
numInt
とifer
を例に紹介します。var numInt Compiler = ai().And(Int). SetEval(func(nodes []*ast.AST, code asm.Code) { // func (ast.AST) Evalをセット n := nodes[0].Token.Vali() // Intの値を取り出す code.Ins(i().Push().Val(n)) // スタックへとpushするコード生成 }) func ifer(condition, body *Compiler) Compiler { // "if" "(" <condition> ")" "{" <body> "}" をパースする、ASTへの追加はconditionとbodyだけ return ai().Seq(If, LPar).And(condition).Seq(RPar, LBrc).And(body).Seq(RBrc). // nodesはconditionとbodyのASTが入っている配列 SetEval(func(nodes []*ast.AST, code asm.Code) { // func (ast.AST) Evalをセット nodes[0].Eval(code) // conditionのコードを書き出し label := fmt.Sprintf("iflabel_%d", ifcount) // 条件分岐のコード書き出し code.Ins( i().Pop().Rax(), // conditionのコードが実行された結果の値をスタックからpop i().Cmp().Rax().Val(0), i().Je(label)) // conditionの評価結果が0ならlabelへとジャンプ nodes[1].Eval(code) // bodyのコード書き出し code.Ins(i().Label(label)) // ラベル書き出し ifcount++ }) }
func (Compier) SetEval
はコード生成時に呼び出させる関数func (ast.AST) Eval
をセットする関数です。SetEval
に渡す値の型は`func(nodes []*ast.AST, code asm.Code)
ですが、引数のnodes
は子のASTで、nodes
の各要素のfunc (ast.AST) Eval
を適宜呼び出しながらcode
へのインストラクションの書き出しを行います。コンパイラ本体へとモジュールを合成する
コンパイラ本体はパーサーコンビネータDSLを使って
numInt
やifer
含め各モジュールがインスタンス化され合成されることで構築されます。コンパイラ本体の組み立てfunc Generator() Compiler { body := func(st *SymTable) Compiler { var term, muls, adds, expr, bodies Compiler ptrRef := ptrDeRefer(st, lvIdenter(st).P()) val := oi().Or(rvAddrer(lvIdenter(st).P()).P(), rvIdenter(st, &ptrRef).P()) adds, muls, ptrAdd := addsubs(&muls), muldivs(&term), ptrAdder(st, &val, &expr) term = oi().Or( ai().And(&ptrAdd, &deRefer).P(), &numInt, syscaller(&expr).P(), funcCaller(&expr).P(), &val, // ここでnumIntを合成 ai().Seq(LPar).And(&adds).Seq(RPar).Trans(ast.PopSingle).P()) expr = oi().Or(assigner(oi().Or(&ptrAdd, &ptrRef).P(), &expr).P(), eqneqs(&adds).P()) semi := ai().And(&expr).Seq(Semi) bodies = ai().Rep(oi().Or( ifer(&expr, &bodies).P(), forer(&expr, &bodies).P(), // ここでiferを合成 whiler(&expr, &bodies).P(), returner(&semi).P(), ai().And(arrayDeclarer(varDeclarer(st).P(), st).P()).Seq(Semi).P(), ai().And(&semi, &popRax).P()).P()) return ai().And(&bodies) } return ai().And(ai().Rep(funcDefiner(body).P()).P()).Seq(EOF) }型安全なアセンブリコード生成DSLの実装方法
アセンブリコードを生成するDSLは型によってコンパイル時にインストラクションの有効性をチェックしています。
例えばインストラクション
mov rax, 8
を書き出す以下のようなコードがあったとして、code.Ins(i().Mov().Rax().Vali(8))ここで型は以下のように変化しています。
i() // Ini i().Mov() // Oped i().Mov().Rax() // Dested i().Mov().Rax().Vali(8) // Fin code.Ins // func(...Fin)
Mov, Rax, Vali
はそれぞれ以下のような型を持ったメソッドで、レシーバーと返り値の型の指定で"型のステートマシン"のようなものを実装しています。func (i Ini) Mov() Oped // Ini => Oped func (i Oped) Rax() Dested // Oped => Dested func (i Dested) Vali(int) Fin // Dested => Finまた、
code.Ins
の型はfunc(...Fin)
なので、未完成なインストラクションを受け付けません。
正しい順番でインストラクションが組み立てられ、Vali
のようなフィニッシャーによって完成された(Fin
型になった)インストラクションのみ受理されます。実装ですが、
Ini, Opend, Dested, Fin
はそれぞれIns
のエイリアスとなっており、type Ini Ins type Oped Ins type Dested Ins type Fin Ins
Ins
はインストラクションを表現する構造体です。type Ins struct { ope Ope dest Reg srcI int ... }
Mov
などの実装では、構造体への値のセットと型の変換を行っています。func (i Ini) Mov() Oped { i.ope = OMov return Oped(i) }上記は説明のために単純化している部分もあるので、実装の詳細が知りたい人はこちらを参照。
パーサーコンビネータDSLの実装方法
コンパイラの定義や組み立てで使用するDSLは、パーサーコンビネータを使って実装しています。パーサーコンビネータ(Parser Combinator)とは複数のパーサーを合成して新しいパーサーを作る高階関数です。
まずパーサーコンビネータ一般について紹介し、その後でDSLの実装方法を紹介します。
パーサーの定義
パーサーの型
Parser
をトークン列[]Token
を消費して、タプル("パースに成功したか" bool, "構文木" SyntaxTree, "消費された残りのトークン列" []Token)
を返す関数とします。type Parser func([]token) (bool, SyntaxTree, []token)トークン列
[]Token
とは、プログラムの文字列を前処理して扱いやすい形にしたもので、例えばトークンの型を以下のように定義したとしてtype Token struct { Value string Type string }文字列をトークン列化する関数
tokenize(string) []Token
は以下のような処理を行います。tokenize("1+x") => []Token{{"1", "Int"}, {"+", "Operator"}, {"x", "Variable"}}構文木
SyntaxTree
はトークン列をパースした結果得られるもので、プログラムを木構造で表現するものです。構文木から余計な枝を落としたものが抽象構文木(Abstract Syntax Tree)=ASTで、ASTをトラバースする(舐める)ことでコード生成が行われます。パーサーがトークン列を消費して構文木を返す例です。
Int型を受け付けるパーサーintParserの例tokens := []Token{{"1", "Int"}, {"+", "Operator"}, {"x", "Variable"}} ok, st, rest := intParser(tokens) // パースを実行 // ok => true : パースに成功 // st => Int型の1つの値(=1)を表現する構文木 // rest => []Token{{"+", "Operator"}, {"x", "Variable"}} : `Token{"1", "Int"}`が消費されている ok, _, _ = intParser(rest) // 続けてパースを実行 // ok => false : パースに失敗パーサーコンビネータの定義
パーサーコンビネータとは、複数のパーサーを合成して新しいパーサーを返す関数です。
基本的な合成の仕方には二通りあり、1つは合成元のパーサーが受理するトークン列を連結したものを受け付けるパーサーを作るような合成で、もう1つは合成元のパーサーのどれか1つがパースに成功すれば、それを選択するような合成方法です。
andCombinedParser ::= leftParser rightParser // 連結的な合成(連結されたトークン列を受理する) orCombinedParser ::= leftParsr | rightParser // 並列的な合成(受理に成功するパーサーを選ぶ)合成元のパーサー
leftParse
とrightParser
をそれぞれトークン列leftTokens
とrightTokens
を消費して、構文木leftSyntaxTree
とrightSyntaxTree
を返すものだとします。ok, leftSyntaxTree, leftRest := leftParser(append(leftTokens, Rest...)) // ok => true, leftRest => Rest ok, rightSyntaxTree, rightRest := rightParser(append(rightTokens, Rest...)) // ok => true, rightRest => Restこのとき連結的な合成を行うパーサーコンビネータ
andParserCombinator
はleftParser
とrightParser
を合成し新しいパーサーcombinedParser
を返します。combinedParser
はleftTokens
とrightTokens
を連結したトークン列を先頭に持つトークン列をパースできます。combinedParser := andParserCombinator(leftParser, rightParser) // パーサーの合成 combinedTokens := append(append(leftTokens, rightTokens...), Rest...) // トークン列の連結 ok, combinedSyntaxTree, rest := combinedParser(combinedTokens) // 連結されたトークン列をパースできる // ok => true // combinedSyntaxTree => Combine(leftSyntaxTree, rightSyntaxTree) // rest => Rest合成されたパーサー
combinedParser
はそれぞれのパーサーが消費するトークン列の連結append(leftTokens, rightTokens...)
を消費し、合成された構文木Combine(leftSyntaxTree, rightSyntaxTree)
を返します。構文木の合成Combine
はパーサーコンビネータとはあまり関係ないのですが、例えば片方の構文木の子ノードにもう片方を加えるなどをして返す関数です(実装の都合に寄ります)。並列的な合成を行うパーサーコンビネータ
orParserCombinator
は2つパーサーの能力を合わせたようなパーサーを合成します。combinedParser := orParserCombinator(leftParser, rightParser) // パーサーの合成 // leftParserと同じパースが可能 ok, leftSyntaxTree, leftRest := combinedParser(append(leftTokens, Rest...)) // ok => true, leftRest => Rest // rightParserと同じパースが可能 ok, rightSyntaxTree, rightRest := combinedParser(append(rightTokens, Rest...)) // ok => true, rightRest => Restパーサーコンビネータの実装方法
andParserCombinator
とorParserCombinator
はそれぞれ戻り値のパーサーをクロージャーとして作る以下のような関数として実装できます。func andParserCombinator(leftParser, rightParser Parser) Parser { return func(tokens[] Token) (bool, SyntaxTree, []Token) { // パーサーをクロージャーとして返す // 最初にleftParserを実行 ok, leftSyntaxTree, restTokens := leftParser(tokens) if !ok { return false, nil, nil // パースに失敗したらリターンする } // 続けてrightParserを実行(引数にrestTokensを渡しているのに注意) ok, rightSyntaxTree, restOfRestTokens := rightParser(restTokens) if !ok { return false, nil, nil // パースに失敗したらリターンする } // 両方のパーサーに消費されたトークン列restOfRestTokensを返す return true, Combine(leftSyntaxTree, rightSyntaxTree), restOfRestTokens } } func orParserCombinator(leftParser, rightParser Parser) Parser { return func(tokens[] Token) (bool, SyntaxTree, []Token) { // パーサーをクロージャーとして返す // 最初にleftParserを実行 if ok, leftSyntaxTree, restTokens := leftParser(tokens); ok return true, leftSyntaxTree, restTokens // パースに成功したらリターンする } // leftParserが失敗したらrightParserを実行 if ok, rightSyntaxTree, restTokens := rightParser(tokens); ok return true, rightSyntaxTree, restTokens // パースに成功したらリターンする } // 両方のパーサーとも失敗したらパース失敗 return false, nil, nil } }パーサーコンビネータを使ったDSLの実装
パーサーを合成するメソッド
func (Parser) And
とfunc (Parser) Or
ですが、上記で説明したパーサーコンビネータのほぼそのままで、変更点としては、
- パーサーを構造体の中に関数
Parser.Call
として入れている(メソッドチェーンしたかったため)func (ast.AST) AppendNode
によって構文木の合成(子ノードとして追加)を行っている- パースの失敗を型
ast.AST
の定数ast.Fail
を返すことで表現しているAnd
は構文木をASTに追加するかどうか引数addNode
で選択できる- 繰り返し合成
Rep
はパースに失敗するまでAnd
と同じ操作を繰り返すなどです。
// lhspがleftParser, rhspはrightParser, addNodeでASTへ追加するか選択 func (lhsp Parser) And(rhsp *Parser, addNode bool) Parser { call := func(t []tok.Token) (ast.AST, []tok.Token) { lhs, lhst := lhsp.Call(t) if lhs.Fail() { return ast.Fail, t // パースに失敗したのでリターン } rhs, rhst := rhsp.Call(lhst) if rhs.Fail() { return ast.Fail, t // パースに失敗したのでリターン } if addNode { lhs.AppendNode(rhs) // 構文木の合成 } return lhs, rhst } return Parser{Call: call} // 構造体`Parser`に包んで返す } func (lhsp Parser) Or(rhsp *Parser) Parser { call := func(t []tok.Token) (ast.AST, []tok.Token) { if lhs, lhst := lhsp.Call(t); !lhs.Fail() { return lhs, lhst // パースに成功したのでリターン } if rhs, rhst := rhsp.Call(t); !rhs.Fail() { return rhs, rhst // パースに成功したのでリターン } return ast.Fail, t // パースに失敗 } return Parser{Call: call} } func (lhsp Parser) Rep(rhsp *Parser) Parser { call := func(rest []tok.Token) (ast.AST, []tok.Token) { var node ast.AST lhs, lhst := lhsp.Call(rest) if lhs.Fail() { return lhs, rest } node, rest = lhs, lhst for { // パースに失敗するまで繰り返す rhs, rhst := rhsp.Call(rest) if rhs.Fail() { return node, rest // パースに失敗したら1つ前の結果をリターン } rest = rhst node.AppendNode(rhs) } } return Parser{Call: call} }これらを
Compiler
の型を返すようにラップしたものが実際に使用するDSLとなります。func (c Compiler) And(cps ...*Compiler) Compiler { for _, cp := range cps { c = Compiler(p(c).And(pp(cp), true)) } return c } func (c Compiler) Seq(cps ...*Compiler) Compiler { for _, cp := range cps { c = Compiler(p(c).And(pp(cp), false)) } return c } func (c Compiler) Or(cps ...*Compiler) Compiler { for _, cp := range cps { c = Compiler(p(c).Or(pp(cp))) } return c } func (c Compiler) Rep(cp *Compiler) Compiler { return Compiler(p(c).Rep(pp(cp))) }ASTからコードを生成するまでの流れ
コンパイラが返すASTにはコードジェネレーターが付属しており、このジェネレーター
func (AST) Eval
を呼ぶことでコードが生成されますが、この仕組について説明します。コンパイラがトークン列をパースしてアセンブリコードを生成するプロセスは以下のようになりますが、
ast, _ := compiler.Call(tokens) // トークン列をパースしてASTを返す ast.Eval(insts) // astを評価してコード生成(instsへ書き出し)ここでコード生成を行っている
ast
のメソッドfunc (ast.AST) Eval
は以下のように定義されています。Eval
は子ノードのASTのEval
を再帰的に呼び出してAST全体をトラバースすることでコードを生成します。func (a AST) Eval(code asm.Code) { if a.eval == nil { for _, node := range a.nodes { node.Eval(code) // もしevalがnilなら子のASTを評価する } } else { a.eval(a.nodes, code) // evalがセットされていたらそれを評価 } }ここでコードジェネレータ
eval
や子ノードnodes
は構造体ast.AST
のフィールドです。type AST struct { nodes []*AST // 子ノード eval func(nodes []*AST, code asm.Code) // コードジェネレータ ... }コードジェネレータ
eval
が受け取る2つの引数のうちの1つcode
はfunc (ast.AST) Eval
から渡されます。
もう1つの引数nodes
はコンパイラを組み立てる要素、つまりAnd
の引数に渡されるコンパイラの返すASTを配列にしたものです。
例えばASTとしてast1
、ast2
を返すようなコンパイラcomp1
、comp2
があったとして、それらの合成ai().And(comp1, comp2)が返すASTは、
ast.AST.nodes
としてast1
とast2
をアペンドしたものとなります。ast, _ := ai().And(comp1, comp2).Call(tokens) ast.nodes // => []ast.AST{ast1, ast2}
ast.Eval
を呼ぶとast.eval
がnil
の場合コードジェネレータast1.Eval
とast2.Eval
が順番に呼びされることになります。
一方で独自のコードジェネレータast.eval
がセットされている場合はそれを使ってコード生成が行われます。
構造体ast.AST
のフィールドeval
に値をセットするのがメソッドfunc (Compiler) SetEval
です。SetEvalの使用例var c = ai().And(comp1, comp2). SetEval(func(nodes []*ast.AST, code asm.Code) { nodes[0].Eval(code) // comp1の返したASTのEvalを呼び出す nodes[1].Eval(code) // comp2の返したASTのEvalを呼び出す code.Ins(i().Push().Rax()) // 独自のコードを書き出す })
func (Compiler) SetEval
の実装ですが、アイディアとしては以下のような感じです。func (c Compiler) SetEval(eval func(nodes []*ast.AST, code asm.Code)) Compiler { call := func(t []tok.Token) (ast.AST, []tok.Token) { ast, rest := c.Call(t) ast.eval = eval // 戻り地のASTに介入してevalをセットしている return f(ast), rest } return Compiler{Call: call} }このDSLや設計を採用した感想
- 良かった点
このような設計にしたそもそもの目的は、バグを事前に、あるいは出来るだけ速い段階で潰すことで、これについては成功したと思います。
なぜバグを抑えたかったかというと、コンパイラのような比較的複雑なコードを書く場合、バグを潰すのにかかる時間が最も開発時間の多くを占めるだろうと考えられたからです。
DSLにはコードの表現能力を「制限する」ことで有効でないコード=バグったコードはそもそも書けないようにする効果があります。またロジックをシンプルに書けるので、その意味でもバグが混入しにくくなったのではないかと思います。
実際、DSLを使ってモジュールを書き、単体テストを通した段階でほとんどすべてのバグは潰せてたと思います。本体へとモジュールを合成した後の結合テストでバグが出る場合もありましたが、ここで出てくるバグは本質的に根深いもの、アルゴリズムや実装の根本的なあやまりに起因するものが多かったような気がします。そういったバグはどっちにしても時間を掛けて解決する必要/価値のあるものだったと思います。
- 悪かった点
DSLの設計で想定していなかったケース、モジュール間での情報のやり取りをさせるのに苦労しました。
モジュール間の関係性は「コード実行時にスタックに何個値を積むか」だけで、各モジュールはお互いのことを知りません。なので、モジュール間で情報のやり取りをさせる必要があるケース、たとえばポインターの足し算で、足される変数の型を変数モジュールから足し算モジュールへと伝えるのが素朴にはできません。
最終的にはシンボルテーブルを使うことである程度解消されましたが、モジュール間の情報のやり取りをサポートする基本的な仕組みを用意する必要があったのかも知れません。DSLで開発プロセスを固めると、うまく行っている内はとてもうまくいくが、DSLで扱えないものが出てくると途端に面倒くさくなり、その意味でDSLの設計は根本的に難しいのかも知れません。