astutil.Applyで抽象構文木を置き換える #golang
December 2, 2017
この記事はQiitaの記事をエクスポートしたものです。内容が古くなっている可能性があります。
はじめに
Goには、標準パッケージ以外にも準標準にあたるgolang.org/x/
以下で管理されているパッケージが存在します。ここで管理されているパッケージは、Go本体と同様にGoチームによって管理され、コントリビュートの方法も概ね同じです。
この準標準パッケージには、さまざまなパッケージが存在します。これらは標準パッケージになるほど汎用的だったり、完成度は高くないはないですが、それでも十分に有用です。golang.org/x/tools/go/ast/astutilパッケージ(以降、astutil
パッケージと表記)もそのひとつです。
astutil
パッケージは静的解析で使用すると便利なパッケージで、抽象構文木(Abstract Syntax Tree, AST)に関連する便利な機能を提供します。静的解析や抽象構文木については、”goパッケージで簡単に静的解析して世界を広げよう“という記事を書いていますので、そちらをご覧ください。
さて、この記事ではastutil
パッケージに最近(2017年12月現在)導入された(tools#77811)、抽象構文木のノードを操作するためのApply
関数について解説します。
ソースコードの変更と抽象構文木
Goの開発ツールを作成している際に、与えられたソースコードの一部を変更したい場合があります。
ぱっと簡単に思いつく方法は、文字列として置換することですが、複雑な変更の場合には正規表現を駆使して黒魔術のようなコードを書く必要があります。
そういった黒魔術は、聖なる夜に向けて書くアドベントカレンダーには不向きです。また、Goに静的解析を行うためのパッケージであるgo
パッケージが標準として存在するため、それを用いない手はありません。
go
パッケージを用いることで、ソースコードを構造化された抽象構文木として扱うこともできます。
複雑な変更の場合は、構造化されていた方が扱いやすいですし、クリスマスには黒魔術よりツリーの方が都合が良いでしょう。
そこでここでは、抽象構文木で用いることを考えてみます。
たとえば、x+y
という式があった場合に、これを10+20
にしたいとしましょう。
x+y
は次のような抽象構文木としてパースすることができます。
*ast.BinaryExpr (+)
├── *ast.Ident (x)
└── *ast.Ident (y)
これを10+20
に変更したい場合は、”抽象構文木(AST)をいじってフォーマットをかける“という記事でも解説しているように、次のような抽象構文木に変換してあげればよいでしょう。
*ast.BinaryExpr (+)
├── *ast.BasicLit (10)
└── *ast.BasicLit (20)
先の記事でも紹介しているように、この場合は単純に抽象構文木のノードを入れ替えることでも実現が可能です。 しかし、特定の条件にマッチするノードをツリー上から探し出し、そのノードを別のノードに入れ替える場合、この方法では難しくなってきます。 難しくなる理由を抽象構文木の探索について解説しながら見ていくことにしましょう。
抽象構文木の探索
抽象構文木をその名の通り、ツリー構造をとっています。
ルートとなるノードが1つあり、そこから子ノードへと枝分かれしていきます。
抽象構文木のノードは、前述した2項演算式を表す*ast.BinaryExpr
やリテラルを表す*ast.BasicLit
などがあります。
これらの型はast
パッケージで定義されており、組み合わせてツリー構造を作ることで抽象構文木を形成します。
さて、ツリー構造のデータを探索する場合、通常は再帰的な関数をルートノードから葉ノードへと、親ノードから子ノードへと適用していくことで探索することが多いでしょう。
ast
パッケージにも抽象構文木を探索する方法がいくつか用意されており、”抽象構文木(AST)をトラバースする“という記事で解説したast.Inspect
関数もそのひとつです。
ast.Inspect
関数は引数にルートノードと各ノードに適用する関数を渡すことで、深さ優先でノードを探索し、渡した関数を適用させることができます。
たとえば、ast.Inspect
関数は次のように実行することができます。
expr, err := parser.ParseExpr(`1+1`)
if err != nil {
log.Fatalln("Error:", err)
}
ast.Inspect(expr, func(n ast.Node) bool {
fmt.Printf("%[1]T %[1]v\n", n)
return true
})
第1引数にルートノード、第2引数に各ノードに適用する関数が渡されていることが分かるでしょう。 このように、非常に簡単に抽象構文木を探索することができます。
また、ast.Inspect
関数内で仕様しているast.Walk
関数を使うとノードの適用する処理をast.Visitor
インタフェースを用いて抽象化することできます。
そして、ast.Visitor
インタフェースのVisit
メソッドは、次のようにast.Visitor
を返すため、ノードごとに適用する処理を変更していくことができます。
type Visitor interface {
Visit(node Node) (w Visitor)
}
さて、ast.Inspect
関数やast.Walk
関数を用いることで、ある程度の処理は行うことができそうです。
特に、抽象構文木の中を探索し、特定の条件にマッチするノードを取得するという処理であれば、これらの関数で十分行うことができるでしょう。
しかし、ここでは抽象構文木のノードを変更する必要があります。
ノードの変更を伴う場合、ast.Inspect
関数やast.Walk
関数では不十分な場合があります。
たとえば、とあるノードが特定の条件を満たす場合に、親のノードに変更をいれたいとします。
しかし、ast.Node
インタフェースは次のように定義されているため、親のノードを取得することができません。
また、ast.Node
インタフェースを実装した、各具象的な型についても子ノードは取ることができても、親のノードは取得することはできません。
type Node interface {
Pos() token.Pos // position of first character belonging to the node
End() token.Pos // position of first character immediately after the node
}
そのため、ast.Inspect
関数やast.Walk
関数を用いて、親ノードの変更を行うためには、自身で親ノードを記録しておく必要がありました。
ast.Inspect
関数やast.Walk
関数に渡す処理の引数は決まっていますから、メソッドにしてフィールドに状態を保存するなどして、親のノードを記録していく必要があります。
煩雑な場合は、ast
パッケージの関数では事足りず、自身で再帰的な処理を実装する必要がありました。
この問題は静的解析を行う場合に頻繁に発生する共通のものでした。
そのため、go/ast: provide AST Rewrite functionality (ast.Walk is not good enough)というissueが立てられ、ast.Walk
関数に変わる機能が必要という議論が始まりました。
2016年の9月に作られたissueでしたが、1年以上たってようやく実装が完成し、ついにgolang.org/x/tools/go/ast/astutil
パッケージにApply
関数として入ることになりました。
astutil.Apply関数でノードを操作する
さて、ここではastutil.Apply
関数によって抽象構文木を探索し操作する方法について説明しましょう。
astutil.Apply
関数は次のようなシグニチャを持ちます。
func Apply(root ast.Node, pre, post ApplyFunc) (result ast.Node)
引数は探索を行うルートノードと2つの各ノードに適用するための関数です。
そして戻り値に変更後の抽象構文木を返します。
戻り値にresult
という名前で結果を返すようにはなっていますが、astutil.Apply
関数は副作用のある関数で抽象構文木がコピーされてノードの変更が行われる訳ではありません。
そのため、ポインタで保持されている抽象構文木のルートノードには当然ながら変更が加えられます。
しかし、ルートノード自体を置き換えた場合などは、新しく作られたルートノードが必要となるので、必ず受け取るようにしておきましょう。
引数にpre
とpost
という2つの関数を取る理由ですが、pre
は子ノードに処理を適用する前に、そのノードに適用する関数で、一方、post
は子ノードの後に処理が適用される関数です。
2つある理由は、前述のように破壊的な変更を抽象構文木に加える必要があるため、子ノードの変更を反映してから処理したい場合かそうでないかでpre
とpost
を使い分ける必要があるためです。
pre
とpost
の型はastutil.ApplyFunc
型となっています。
そして、astutil.ApplyFunc
型は次のように定義されています。
type ApplyFunc func(*Cursor) bool
これは*astutil.Cursor
型の値を引数に取り、bool
値を返すような関数です。
詳細は後述しますが、*astutil.Cursor
は現在注目しているノードを表す型です。
また、戻り値のbool
値がfalse
の場合は、astutil.Apply
関数はすぐに処理を停止します。
さて、*astutil.Cursor
について見てみましょう。
astutil.Cursor
型は構造体ですが、フィールドは公開されていません。
そのため、通常はメソッドのみを使うことを想定して設計されているようです。
*astutil.Cursor
型は次の8つのメソッドを持っています。
func (c *Cursor) Node() ast.Node
メソッドfunc (c *Cursor) Parent() ast.Node
メソッドfunc (c *Cursor) Name() string
メソッドfunc (c *Cursor) Index() int
メソッドfunc (c *Cursor) Replace(n ast.Node)
メソッドfunc (c *Cursor) InsertAfter(n ast.Node)
メソッドfunc (c *Cursor) InsertBefore(n ast.Node)
メソッドfunc (c *Cursor) Delete()
メソッド
Node
メソッド、Index
メソッド、Parent
メソッド、Name
メソッドは、現在注目しているノードの情報を取得します。
Node
メソッドはその名の通り、現在注目しているノードを、Parent
メソッドはその親ノードを取得します。
Name
メソッドは、注目しているノードが親ノードにどういう役目で保持されているかを表す文字列を取得します。
たとえば、2項演算式を表す*ast.BinaryExpr
型の場合、2つの子ノードを持っており、第1項がX
、第2項がY
という名前のフィールドで管理されています。
そのため、第1項にあたるノードが注目しているノードである場合に、Name
メソッドはX
という文字列を返します。
Index
メソッドは、親ノードが注目しているノードをスライスで管理している場合に、何番目のノードかを表す値が入っています。
たとえば、引数はスライスでなお、スライスで管理していない場合は0より小さい値が返ってきます。
スライスでノードが管理されていると、Name
メソッドが返す値だけでは一意にノードを特定できないため、Index
メソッドを使う必要がでてきます。
たとえば、何番目の引数であるか取得したい場合は次のように取得できます。
expr, err := parser.ParseExpr(`func(x, y int){}(10, 20)`)
if err != nil { log.Fatal(err) }
n := astutil.Apply(expr, func(cr *astutil.Cursor) bool {
if cr.Name() == "Args" {
fmt.Println(cr.Name(), cr.Index())
}
return true
}, nil)
実行すると次のように表示されます。
Args 0
Args 1
さて、*astutil.Cursor
型の残りのReplace
メソッド、Delete
メソッド、InsertBefore
メソッド、InsertAfter
メソッドは抽象構文木を操作するメソッドです。
Replace
メソッドはその名の通り、ノードを入れ替えるです。
次のように入れ替えたいノードを指定するとすぐにノードを入れ替えてくれます。
expr, err := parser.ParseExpr(`x+y`)
if err != nil { log.Fatal(err) }
n := astutil.Apply(expr, func(cr *astutil.Cursor) bool {
switch cr.Name() {
case "X":
cr.Replace(&ast.BasicLit{
Kind: token.INT,
Value: "10",
})
case "Y":
cr.Replace(&ast.BasicLit{
Kind: token.INT,
Value: "20",
})
}
return true
}, nil)
if err := format.Node(os.Stdout, token.NewFileSet(), n); err != nil {
log.Fatalln("Error:", err)
}
fmt.Println()
実行すると次のように表示されます。
10 + 20
Delete
メソッドは、注目しているノードを削除します。
次のように削除したいノードに対してDelete
メソッドを呼び出します。
expr, err := parser.ParseExpr(`func(x, y int){}(10, 20)`)
if err != nil { log.Fatal(err) }
n := astutil.Apply(expr, func(cr *astutil.Cursor) bool {
if cr.Name() == "Args" && cr.Index() == 0 {
cr.Delete()
}
return true
}, nil)
if err := format.Node(os.Stdout, token.NewFileSet(), n); err != nil {
log.Fatalln("Error:", err)
}
fmt.Println()
実行すると次のように表示されます。
func(x, y int) {
}()
ここで注意したいのは、変更は直ちに行われるという点です。 そのため、引数の0番目を削除したい場合は、次のように1度だけ実行されるようにしたほうが良いでしょう。
expr, err := parser.ParseExpr(`func(x, y int){}(10, 20)`)
if err != nil { log.Fatal(err) }
var once sync.Once
n := astutil.Apply(expr, func(cr *astutil.Cursor) bool {
if cr.Name() == "Args" && cr.Index() == 0 {
once.Do(cr.Delete)
}
return true
}, nil)
if err := format.Node(os.Stdout, token.NewFileSet(), n); err != nil {
log.Fatalln("Error:", err)
}
fmt.Println()
実行すると次のように表示されます。
func(x, y int) {
}(20)
InsertBefore
メソッドとInsertAfter
メソッドは、注意のノードがスライスで管理されている場合に、その前後にノードを挿入することができます。
たとえば、引数リストにあたらしい引数を追加したい場合は次のように行います。
expr, err := parser.ParseExpr(`func(x, y int){}(10, 20)`)
if err != nil { log.Fatal(err) }
n := astutil.Apply(expr, func(cr *astutil.Cursor) bool {
if cr.Name() == "Args" && cr.Index() == 0 {
cr.InsertBefore(&ast.BasicLit{
Kind: token.STRING,
Value: "hi",
})
cr.InsertAfter(&ast.BasicLit{
Kind: token.STRING,
Value: "gopher",
})
}
return true
}, nil)
if err := format.Node(os.Stdout, token.NewFileSet(), n); err != nil {
log.Fatalln("Error:", err)
}
fmt.Println()
実行すると次のように表示されます。
func(x, y int) {
}(hi, 10, gopher, 20)
このように*astutil.Cursor
型のメソッドを使うと簡単に親ノードに関する情報を取得することができたり、抽象構文木の操作することができます。
おわりに
この記事では、最近(2017年12月2日現在)導入されたastutil.Apply
関数について説明を行いました。
この関数は静的解析を行ってツールを使っている人たちには待望の機能だったのではないでしょうか。
ノードの操作が即時反映だったり、副作用を伴ったりをするため、まだまだ使い勝手としてはイマイチな点もありますが、ast.Walk
関数よりも多くの事ができるため、活躍の場は多そうです。
実際に、@vvakameさんが開発しているgo.mercari.io/datastoreというライブラリでも早速使われているようです。
go.mercari.io/datastore
については、@vvakameさんの”ぼくが かんがえた さいきょうの でーたすとあ らっぱー“という記事に詳しく書いてありますので、そちらも合わせてどうぞ。