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」を手に入れたいと思います。