goでstructの定義を見てあれこれするのに、ast.Fileを直接見るよりtypes.Packageを参照したほうが楽(かもしれない)

goでstructの定義を見てあれこれしたいことがある。特にコード生成などの文脈が多い。このような時に巷の記事や情報などではASTをがんばって解析するという方法が紹介されている事が多い。それよりもtypes.Package経由で*types.Objectを触ったほうが便利だよという話。

types.Objectとは?

go/typesの中に記述されているinterface。以下のような定義になっている(長いけれど実質使うのはName,Type,Stringくらい(ASTへ反映したいときなどにはPosも使う))。

go/types/object.go(定義は長いし読み飛ばしても構わない)

// TODO(gri) Document factory, accessor methods, and fields. General clean-up.

// An Object describes a named language entity such as a package,
// constant, type, variable, function (incl. methods), or label.
// All objects implement the Object interface.
//
type Object interface {
    Parent() *Scope // scope in which this object is declared; nil for methods and struct fields
    Pos() token.Pos // position of object identifier in declaration
    Pkg() *Package  // nil for objects in the Universe scope and labels
    Name() string   // package local object name
    Type() Type     // object type
    Exported() bool // reports whether the name starts with a capital letter
    Id() string     // object name if exported, qualified name if not exported (see func Id)

    // String returns a human-readable string of the object.
    String() string

    // order reflects a package-level object's source order: if object
    // a is before object b in the source, then a.order() < b.order().
    // order returns a value > 0 for package-level objects; it returns
    // 0 for all other objects (including objects in file scopes).
    order() uint32

    // setOrder sets the order number of the object. It must be > 0.
    setOrder(uint32)

    // setParent sets the parent scope of the object.
    setParent(*Scope)

    // sameId reports whether obj.Id() and Id(pkg, name) are the same.
    sameId(pkg *Package, name string) bool

    // scopePos returns the start position of the scope of this Object
    scopePos() token.Pos

    // setScopePos sets the start position of the scope for this Object.
    setScopePos(pos token.Pos)
}

何が嬉しいのかというと以下のような機能が無料で使えるようになる点。go/typesパッケージの便利なapi群がそのまま使えるようになる。

  • ある型がある型に変換可能か?(ConvertableTo)
  • ある型がある型の変数に代入可能化?(AssinableTo)
  • ある型があるinterfaceを満たしているか?(Implements)

その他いろいろ。

go/typesのObjectの使用感

go/typesのObjectの使用感は、感覚的には、reflectパッケージで値を触るのとASTを直接触るものとの中間くらいのイメージ。少なくともASTを直接触るのよりはずっと楽。

例えば、あるstructのfield定義とそこに含まれているタグを見て何かをするということをしたい場合には以下の様な形になる。

あるstructの定義

package p
type S struct {
    Name string `json:"name"`
    Age int `json:"age"`
    i  int
}

タグを見て何かするコード。

    // pkgからSという名前のobjectを取り出す (pkg (*types.Package) の取得方法は後述)
    S := pkg.Scope().Lookup("S")

    // `type S struct {...}` のstruct定義部分を取り出す
    internal := S.Type().Underlying().(*types.Struct)

    // fieldをiterateする
    for i := 0; i < internal.NumFields(); i++ {
        // tagを取り出してjsonタグに関する情報を取り出す (need import "reflect")
        jsonname, found := reflect.StructTag(internal.Tag(i)).Lookup("json")
        field := internal.Field(i)
        fmt.Printf("%v (exported=%t, jsonname=%s, found=%t)\n", field, field.Exported(), jsonname, found)
    }

例えば上のコードを実行した結果は以下のようになる。

field Name string (exported=true, jsonname=name, found=true)
field Age int (exported=true, jsonname=age, found=true)
field i int (exported=false, jsonname=, found=false)

このようなstructのfieldをただparseするだけの作業なら。ASTをがんばって解析するよりもずっと楽。 加えて適切な型情報も一緒についてくる(後述)ので何らかの型(例えばtime.Timeだとか自分で定義した型だとか)を見た変換みたいなものも書くのが非常に楽。

time.Timeを加えてみる

field定義にtime.timeを加えてみると。以下の様になる。

package p

import "time"

type S struct {
    Name string `json:"name"`
    Age int `json:"age"`
    Birth time.Time `json:"birth`
    i  int
}

依存したpackageの情報もふつうに解釈できて便利。

field Name string (exported=true, jsonname=name, found=true)
field Age int (exported=true, jsonname=age, found=true)
field Birth time.Time (exported=true, jsonname=birth, found=true)
field i int (exported=false, jsonname=, found=false)

primiteveな型の値が取り出したい場合は?

primiteveな型の値が取り出したい場合は、go/typesの中にUniverseという変数が存在していて。これがglobal scopeの値。なのでprimitiveな値がほしければuniverseを覗けば良い。宇宙。

types.Universe.Lookup("int64")

types.Packageを取り出す

types.PackageがあればScopeを取り出して色々できるということが分かった。それでは今度はこのtypes.Packageというものを取り出すにはどうすれば良いか?という話。詳しく話すと別の記事になりそうなのでここでは1つだけあげる。

go/typesのapiにはConfigというstructが定義されており。これを使うのが楽かもしれない。

   // need import "go/types"
    // need import "go/importer"

    conf := types.Config{
        Importer: importer.Default(),
        Error: func(err error) {
            fmt.Printf("!!! %#v\n", err)
        },
    }
    pkg, err := conf.Check("p", fset, []*ast.File{file}, nil)

Importerにimportを解釈するobject(defaultで使われるimporterはgo/importerのDefaultから取り出せる)。Errorに関しては型チェックや何らかの不整合が生じたときのエラー処理を渡してあげれば良い。

conf.Check()はCheckという名前の通りチェックもしてくれる。そしてtypeError的なものが起きたらErrorに渡した関数に渡してくれる。

conf.Check()で発生するエラー

どういうエラーが発生するかというと以下の様な感じ。例えばintをinとタイポした場合だとか。

!!! types.Error{Fset:(*token.FileSet)(0xc420050280), Pos:76, Msg:"undeclared name: in", Soft:false}

time.Timeではなくtime.timeを指定してそんなsymbol存在しないよという場合とか。

!!! types.Error{Fset:(*token.FileSet)(0xc420050280), Pos:103, Msg:"time not declared by package time", Soft:false}

soft:true というのは無視しても良い些細なエラーということ(soft:false は逆なのでヤバイエラーということ)。

とりあえずこれでtypes.Packageは作れる様になったはず。あとはast.Fileが存在すれば。*ast.Fileの作り方に関してはいろんな記事で触れてると思うので省略。

おわりに

(types.Packageの作成にgolang.org/x/tools/go/loaderを使うと便利という話しは別の記事として書く)

ちなみに完全に動くコードの例のgistは以下です。