TL; DR
- 本家(Go)は関数呼び出しのみ遅延
- 自作言語の実装では式の評価全体を遅延してしまった
はじめに
Go言語の defer 文を使うと、関数を抜ける際に必ず処理を実行することが可能です。
panicした場合も後処理の実行が保証され重宝しています。
file, err := os.Open("file.go")
if err != nil {
// ...
}
defer f.Close()
便利なので拙作の言語 Pangaea でも defer 文を導入しています。
(Pangaea言語自体は過去の記事でも紹介しておりますので、よろしければご覧ください)
しかし、先日 「Go Quizzes 101」というGoの文法のクイズ1を解いている際に Pangaeaのdeferの実装が間違っていることに気づきました2。
そこで本記事では、Pangaeaにdeferを実装した方法と、本家を再現できていない部分について紹介したいと思います。
まだ想定する実装に修正はできていませんが、反面教師としてdeferを自作言語に導入したい方の参考になれば幸いです。
deferを実装してみる
defer文に指定した式のASTを保存しておき、関数呼び出しの終了時に評価しています。
構文が似ている return 文と同じような実装方法にしました。
return文の実装
本題に入る前に return の実装についてです。「Go言語でつくるインタプリタ」の設計を参考にしています。
return 文を評価する際、式の結果そのものではなく、 object.ReturnObj にラップして返しています。そして、関数呼び出し全体を評価している箇所でアンラップしています。
ラップしないと 早期returnがブロックの中にある場合に正しく評価されないためです3。
以下のソースコードで、If文は ReturnObj{0} として評価されます。そして、関数呼び出し全体はいずれかの文が ReturnObj に評価された時点で処理を止め、中身をアンラップして関数呼び出しの評価値とします。
# NOTE: 以下は疑似言語。Pangaeaにif文は存在しない
div := {|x, y|
if y == 0 {
return 0 # この行の評価時に結果をラップ
}
return x / y
}
もしラップをしない場合、if文が0と評価され、早期returnが無視されてしまいます。
# NOTE: 以下は疑似言語。Pangaeaにif文は存在しない
div := {|x, y|
if y == 0 {
return 0 # returnが見つかり、ブロック(=if文)を0と評価。しかし関数全体を脱出できていないので、後続も評価されてしまう...
}
return x / y
}
defer文の実装
本題の defer 文です。return 同様、式を DeferObj でラップしています。ラップしたオブジェクトは関数を抜ける際、 return or 例外発生 (Pangaeaには大域脱出があります)の後に評価します。
ただし、defer 文は関数呼び出しの最後に評価する必要があるため、式は評価せずASTのままDeferObj でラップしています。
func evalJumpStmt(node *ast.JumpStmt, env *object.Env) object.PanObject {
// defer文の式は評価せずASTのまま返す
if node.JumpType == ast.DeferJump {
return &object.DeferObj{Node: node.Val}
}
// 式を評価
val := Eval(node.Val, env)
// ...
switch node.JumpType {
case ast.ReturnJump: // 一方、return文の場合は評価した値をラップして返す
return &object.ReturnObj{PanObject: val}
// ...
}
func evalStmts(stmts []ast.Stmt, env *object.Env) object.PanObject {
// Stmtsの評価として、文全体を評価した値(=戻り値)とdefer文のAST一覧を返す
ret, deferObjs := _evalStmts(stmts, env)
err := evalDefer(deferObjs, env)
if err != nil {
return err
}
return ret
}
func _evalStmts(
stmts []ast.Stmt,
env *object.Env,
) (object.PanObject, []object.DeferObj) {
// defer文で評価を後回しにしている式のキュー
deferObjs := []object.DeferObj{}
for _, stmt := range stmts {
// 次の行を評価
val = Eval(stmt, env)
if err, ok := val.(*object.PanErr); ok {
// 例外発生時もdeferを直後に評価したいので、deferObjsも返す
return appendStackTrace(err, stmt.Source()), deferObjs
}
// return文の評価。ReturnObjは単純にアンラップ(deferを直後に評価したいので、deferObjsも返す)
if ret, ok := val.(*object.ReturnObj); ok {
return ret.PanObject, deferObjs
}
// defer文の評価。キューdeferObjsに追加
if _defer, ok := val.(*object.DeferObj); ok {
deferObjs = append(deferObjs, *_defer)
}
}
return val, deferObjs
}
Goのdeferの実際の挙動
しかし、上記の実装はGoのdefer文の再現にはなっていませんでした。
deferの実際の挙動は、「関数呼び出しの評価をreturn/panicの後まで遅延させる」です。関数の引数はdeferを定義した時点で評価されます。
func main() {
x := 1
defer fmt.Println(x) // 1
x = 2
}
Pangaeaではdefer文の式まるごと評価を遅らせているため、変数の中身が変わってしまっています。
{
x := 1
defer x.p # 2
x := 2
}()
設計をさぼってreturnを流用したのがあだとなりました。ちゃんと実装する場合は以下の手順を踏む必要がありそうです。
- defer文の式のASTを、定義された時点で評価
- 式が関数呼び出し1つになるまで評価出来たら、引数のみ評価し
Stmtsのキューに保存 - Stmtsの評価が終わった時点でキューから関数呼び出しを取り出し評価
おわりに
以上、deferの実装の紹介と、仕様を誤解し挙動が異なってしまった箇所の振り返りでした。
言語を作るのであれば、仕様はコーナーケースまで理解すべきだと痛感しました...うまく改修して「真のdefer」を手に入れたいと思います。