概要
先日メルカリの短期インターンに参加した際にGo1.18の機能に焦点を当てたOSS開発をする機会があったので、そこで得られた知見をまとめようと思います。
3/16にGo1.18がリリースされ、様々な機能が追加されました。その中の一つである型パラメタ(ジェネリクス)について、機能追加に伴い新たに型セット(Type Set)という概念も追加されました。
本記事では、型セットがGoの中でどのようになっているのかを、Goの標準パッケージで提供されている静的解析機能を用いて見ていきたいと思います。
静的解析ってなに?
静的コード解析 (せいてきコードかいせき、static code analysis) または静的プログラム解析 (static program analysis)とは、コンピュータのソフトウェアの解析手法の一種であり、実行ファイルを実行することなく解析を行うこと。
引用元:静的コード解析 - Wikipedia
要は、プログラムを実行せずに解析すること。
その言語の文法上問題はなくコンパイルエラーは起きないけど、実行時にランタイムエラーなどが発生する可能性があるようなバグを、実行せずに解析することができる。
このあたりの詳しい話は@tenntennさんがこの資料にわかりやすくまとめてくださっているので、こちらを見ていただければと思います。
抽象構文木ってなに?
抽象構文木(英: abstract syntax tree、AST)は、通常の構文木(具象構文木あるいは解析木とも言う)から、言語の意味に関係ない情報を取り除き、意味に関係ある情報のみを取り出した(抽象した)木構造の木である。
引用元:抽象構文木 - Wikipedia
初めて聞く方は???ってなると思います。自分もそうでした。
おそらく文章で説明するより、実例を見た方がわかりやすいと思うので、簡単な例を挙げます。
以下のようなGoのソースコードをASTで表してみます。
package main
import (
"fmt"
)
func main() {
fmt.Println("Hello, World")
}
File
├── Doc
├── Package = main.go:2:1
├── Name
│ └── Ident
│ ├── NamePos = main.go:2:9
│ ├── Name = main
│ └── Obj
├── Decls (length=2)
│ ├── GenDecl
│ │ ├── Doc
│ │ ├── Tok = import
│ │ └── Specs (length=1)
│ │ └── ImportSpec
│ │ ├── Doc
│ │ ├── Name
│ │ ├── Path
│ │ │ └── BasicLit
│ │ │ ├── Kind = STRING
│ │ │ └── Value = "fmt"
│ │ ├── Comment
│ │ └── EndPos = -
│ └── FuncDecl
│ ├── Doc
│ ├── Recv
│ ├── Name
│ │ └── Ident
│ │ ├── NamePos = main.go:8:6
│ │ ├── Name = main
│ │ └── Obj
│ │ └── Object
│ │ ├── Kind = func
│ │ ├── Name = main
│ │ ├── Decl = &ast.FuncDecl{Doc:(*ast.CommentGroup)(nil), Recv:(*ast.FieldList)(nil), Name:(*ast.Ident)(0xc000060060), Type:(*ast.FuncType)(0xc0000600e0), Body:(*ast.BlockStmt)(0xc0000762a0)}
│ │ ├── Data = <nil>
│ │ └── Type = <nil>
│ ├── Type
│ │ └── FuncType
│ │ ├── Func = main.go:8:1
│ │ ├── TypeParams
│ │ ├── Params
│ │ │ └── FieldList
│ │ │ ├── Opening = main.go:8:10
│ │ │ ├── List (length=0)
│ │ │ └── Closing = main.go:8:11
│ │ └── Results
│ └── Body
│ └── BlockStmt
│ ├── Lbrace= main.go:8:13
│ ├── List (length=1)
│ │ └── ExprStmt
│ │ └── X
│ │ └── CallExpr
│ │ ├── Fun
│ │ │ └── SelectorExpr
│ │ │ ├── X
│ │ │ │ └── Ident
│ │ │ │ ├── NamePos = main.go:9:2
│ │ │ │ ├── Name = fmt
│ │ │ │ └── Obj
│ │ │ └── Sel
│ │ │ └── Ident
│ │ │ ├── NamePos = main.go:9:6
│ │ │ ├── Name = Println
│ │ │ └── Obj
│ │ ├── Lparen = main.go:9:13
│ │ ├── Args (length=1)
│ │ │ └── BasicLit
│ │ │ ├── Kind = STRING
│ │ │ └── Value = "Hello, World"
│ │ ├── Ellipsis = -
│ │ └── Rparen = main.go:9:28
│ └── Rbrace= main.go:10:1
...
使用ライブラリ:https://github.com/knsh14/astree
このように、ASTとはソースコードの情報を木構造で表したものになります。
GoではASTの情報やそこから型の情報を取得するための機能が標準・準標準パッケージとして提供されているため、今回はそれらを用いてコードを解析していきます。
実際にやってみる
今回は、以下の型セットについて解析していきたいと思います。
type I interface {
int | string | float64
}
1. 静的解析をするための準備
Goで静的解析を行う場合、x/tools/go/analysisを使うと便利なのですが、1から自分で書くのはけっこう難しいです。
そのため、先述のtenntennさんが公開してくださっている、skeletonという静的解析を行うためのテンプレートを生成してくれるライブラリを使います。詳しい使い方はリポジトリを見てください。
skeletonを使うと、自分が変更する部分は以下のrun(...)
関数の中になります。(めっちゃ簡単!)
// この中だけ変更すれば動く!
func run(pass *analysis.Pass) (interface{}, error) {
// 欲しい情報ややりたい処理などをここに書いていく
return nil, nil
}
2. 抽象構文木(AST)を見てみる
まず、解析対象のソースコードをASTで表すとどのようになっているか見てみます。
全てを書くと長くなってしまうので、型セットの情報を取得するのに必要な部分のみ抜粋しました。
...
└── InterfaceType
├── Interface = main.go:4:8
├── Methods
│ └── FieldList
│ ├── Opening = main.go:4:18
│ ├── List (length=1)
│ │ └── Field
│ │ ├── Doc
│ │ ├── Names (length=0)
│ │ ├── Type
│ │ │ └── BinaryExpr
│ │ │ ├── X
│ │ │ │ └── BinaryExpr
│ │ │ │ ├── X
│ │ │ │ │ └── Ident
│ │ │ │ │ ├── NamePos = main.go:5:2
│ │ │ │ │ ├── Name = int
│ │ │ │ │ └── Obj
│ │ │ │ ├── Op = |
│ │ │ │ └── Y
│ │ │ │ └── Ident
│ │ │ │ ├── NamePos = main.go:5:8
│ │ │ │ ├── Name = string
│ │ │ │ └── Obj
│ │ │ ├── Op = |
│ │ │ └── Y
│ │ │ └── Ident
│ │ │ ├── NamePos = main.go:5:17
│ │ │ ├── Name = float64
│ │ │ └── Obj
...
ASTを見ると、型セットの情報はInterfaceType
というノードに入っているようです。
また、型セットを構成している型集合の情報はそのノード内のMethods -> FieldList -> List -> Type
の中に入っているようです。ちょっとネストしすぎてわかりづらいですね。
そして、各型の情報はその中のBinaryExpr -> Ident
というノードの中にまとめられている、ということがわかりました。
そのため、run(...)
関数の中ではまず、InterfaceType
ノードの情報をのみを取り出します。
ノード内のMethods -> FieldList -> List
はスライスになっているので、for
で情報を順番に取り出していきます。
型セットの情報を出力するprintBinaryExpr()
関数については次節で解説します。
func run(pass *analysis.Pass) (interface{}, error) {
inspect := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)
nodeFilter := []ast.Node{
(*ast.InterfaceType)(nil),
}
inspect.Preorder(nodeFilter, func(n ast.Node) {
switch n := n.(type) {
case *ast.InterfaceType:
for _, field := range n.Methods.List {
// 3. で解説
printBinaryExpr(field)
}
}
})
return nil, nil
}
3. 抽象構文木(AST)のtraverse
ASTをtraverseしていく方法についてもtenntennさんがこちらの記事で解説してくださっており、本記事でもそれを応用したものを紹介します。
2.でも書いたように、各型の情報はBinaryExpr -> Ident
ノードの中に入っているため、このノードについてtraverseしていき、情報を取得していくのがいいと思います。
前述の記事を参考に、ast.Walk()
に渡す関数の中身を少し変えて実装してみました。
ast.BinaryExpr
ノードに出会うたびに、再帰的にast.Walk()
の第一引数に渡した関数の引数としてそれぞれノードのX, Y
を指定して、ノードをtraverseしていく。
ast.Ident
or ast.UnaryExpr
まで辿れば型セットの中に含まれる型名がわかるので、それらを出力する。
という流れになります。
type visitorFunc func(node ast.Node) (w ast.Visitor)
func (f visitorFunc) Visit(node ast.Node) (w ast.Visitor) {
return f(node)
}
func printBinaryExpr(n *ast.Field) {
var v visitorFunc
v = visitorFunc(func(node ast.Node) (w ast.Visitor) {
if node == nil {
return nil
}
switch n := node.(type) {
// 今見ているノードが*ast.Identならば、その型名を出力する
case *ast.Ident:
fmt.Println(n.Name)
// 今見ているノードが*ast.UnaryExpr (ex. ~string)ならば、その型名を出力する
case *ast.UnaryExpr:
ident, _ := n.X.(*ast.Ident)
fmt.Println(ident.Name)
// 今見ているノードが*ast.BinaryExprならば、再帰的にその中のノードをvに渡す
case *ast.BinaryExpr:
ast.Walk(v, n.X)
ast.Walk(v, n.Y)
}
return nil
})
ast.Walk(v, n.Type)
}
まとめ
- 今回は、静的解析を用いて簡単に型セットの中身を出力してみました。
- 静的解析を行うことで型の中身などの情報を簡単に取得することができるため、型セットがどのような集合なのかを可視化するベン図を描画したり、型セットが有効なものかどうか(空集合になっていないか?)など、様々な解析に応用できる可能性を感じました。
- skeletonのおかげでかなり静的解析を行うためのハードルは低いと感じました。今回のインターンで初めて静的解析を行ったのですが、それでもかなりいろんなことができたと思います。
- また、静的解析の進め方がなんとなくわかってきたので、今後も「ここエラーでないの気持ち悪くない?」とか「こういう機能あったら便利だなぁ」と思うようなことがあったときに、自分でそれを解決するためのツールを書いてみようと思いました。
色々書きましたが、表現が間違っていたり理解しづらい部分があれば、是非コメントしていただけるとありがたいです。