Zenn
🖇️

[Go]deep-clone method generatorを実装する

2024/12/29に公開

deep-clone method generatorを実装する

こんにちは

この記事ではGoのpackage pattern(e.g. ./...)を受けとって見つかった各型にdeep clone methodを生成するcode generatorの実装に際して気を付けどころや方法などについて述べます。

deep cloneとはここでは、ある値aとそれのdeep cloneであるbがあるとき、ab一方へのデータアクセスが(そうであることを意図しない限り)もう一方に影響しないことをさします。

大雑把に言って以下のことを説明します。

  • Rationale: どうしてcode generatorを実装するする必要があるか?
  • 生成するmethodのsignatureについて(i.e. type paramをどうcloneするか)
  • どういうコードを生成するかの事前想定
  • Clone methodの実装方針
  • code generatorの実装方針、考慮事項
    • golang.org/x/tools/go/packagesによるast, type informationの読み込み
    • 生成対象の型依存関係のグラフ化、グラフを逆にたどることで生成対象の型を列挙する。
    • 型情報を使った各型の判別
      • Cloneを実装する型(implementor)か
      • NoCopy(assignによってコピーするとgo vetcopies lock valueと警告する型)か
      • assignによってcloneできる型か
    • field unwrapper: []map[string]*[5]Tのような型があるとき、T以外の部分のcloneは共通の処理を実装できるので、これをfield unwrapperと呼んで実装します。
    • など

対象読者のレベル感を会社の同僚に置くため基本的な概念の説明を多く含めようと考えています。(A Tour of Goは最低限こなしている)
そのためGoやcomputer-scienceに長じた読者はある程度とばしながら読んでいただければと思います。
ただしasttype infoそのものの説明は省きます。別記事に分けようという判断です(現時点では何も書いていないですが)。そこからかなり難易度が高くなってしまうかも。すみません。
型周りの説明はgithub.com/golang/exampleのgotypesを読んでいただくほうが早いかと思います(といいつつ私はこれを読んだだけだと全く訳が分からなかったですが。)

対象環境

  • linux/amd64
    • ただしOS/archの差は外部ライブラリによって吸収されるので影響しないものと想定します。

検証はgo 1.23.2、リンクとして貼るドキュメントは1.23.4のものになります。

# go version
go version go1.23.2 linux/amd64

各種ライブラリは以下のバージョンを用います。

require (
    github.com/dave/dst v0.27.3
    github.com/google/go-cmp v0.6.0
    github.com/ngicks/go-iterator-helper v0.0.18
    github.com/ngicks/und v1.0.0-alpha6
    github.com/spf13/cobra v1.8.1
    github.com/spf13/pflag v1.0.5
    golang.org/x/tools v0.28.0
    gotest.tools/v3 v3.5.1
)

github.com/ngicksから始まるgo moduleは自作のものです。地産地消です。これらはあんまり気にしないでください。

Go 1.24からgeneric type aliasが導入されますが、この記事はこれを全く考慮していません。なんかそのままでうまく動きそうな気もしてる。

Rationale: どうしてcode generatorを実装するする必要があるか?

型とassignによるコピー

pointerが含まれる型はassignでコピーしきれないdeep cloneに特別な方法がいるよねっていう基本的な話をします。
説明が不要な方はdeep-cloneを実現する方法まで飛ばしてください。

他のプログラミング言語でもそうである通り、Goではassign(a = b)によって値のコピーが起こります。
int, string, boolなどのプリミティブな型は単純なassignのみで情報をコピーできるため、例えばc := aとした後、cへの変更はaに影響しません(and vice versa)。
(ただし言語によってはstringがimmutableでないことがあるので気を付ける。)

さらに、struct, arrayなどが単にこれらのprimitiveな型のみを含む場合でもassignによってコピーが起きます。

type sample struct { foo int; bar string }
s1 := sample{bar: "foo"}
s2 := s1
s2.bar = "bar"
fmt.Printf("s1.bar=%q, s2.bar=%q\n", s1.bar, s2.bar)
// s1.bar="foo", s2.bar="bar"

ただし、型がpointer(*T)を含む場合は話が変わっています。
「含む」というのは下記をさします。

  • (1) 型がpointerであるとき
    • *T
    • slice, map, channel, func, interface
      • これらは暗黙的にpointerを含む
  • (2) (1)ないしは(1)へのtype aliasをunderlying typeとするとき
    • type A Bとするとき、Bunderlying typeです
    • ただしpointer, interfaceunderlyingとした型にはmethodを定義できません。
  • (3) (1),(2)をstruct field, array elementに含むとき

これらの場合、単純なassignでは値の完全なコピーは行われません。
なぜなら、pointerがassignされた場合、そのアドレス値がコピーされるためです。
pointerは(リンク先のA Tour of Goで説明されている通り)メモリー上のある位置を指し示すアドレスの値です。いってしまえばuintです。
*Tは実際上はuintなのだからassignによってuintの値がコピーされます。uintが指し示したさきの値をコピーしないことはこう考えると当たり前に聞こえるはず。

pointerはdereferenceすることでassignによるコピーが行うことができます

num   /*  int */ := 10
nump  /* *int */ := &num
deref /*  int */ := *nump
num = 12
fmt.Printf("num=%d\nnump=%d\nderef=%d\n", num, *nump, deref)
// num=12
// nump=12
// deref=10

Goはmoduleやpackageによるコードの分割に対応しています。
Goにおける公開性のコントロールは、そのpackageのもっとも外側のスコープで定義されるident(=identifier、識別子、関数名とか変数名とかのこと)の先頭1文字がunicode upper caseになっているか否かで決まります。
upper caseなら公開されているのでpackage外からでもアクセスできます。逆ならアクセスできません。module local的な概念のあるモジュールシステムを備えた言語もありますが、Goにはありません。
これはstructのfieldに関しても同様です。
この何が問題かというと型がexportされないfieldにpointerを含むとき、そのfieldにアクセスしてderefすることができないのでコピーが行うことができません。

ある値へのアクセスが他の値に影響しないでほしいことはよくあります。
例えばプログラム実行時のときどきの状態を保存して比較したいときや、data raceを防ぎながら同じデータを複数のgoroutineに渡して処理したいときなどです。

deep cloneを実現する方法

deep cloneを実現する一般的な方法について述べます。
知ってる人はどうしてcode-generatorを実装する必要があるのかまで飛ばしてください。

普通にデータをdeep cloneするには以下のような方法を用います。

  • データ構造をmarshal(serialize)してunmarshal(deserialize)する
  • reflectによって動的なコピーを実装する
  • code generatorによってdeep clonerを実装する

データ構造をmarshal(serialize)してunmarshal(deserialize)する

javascriptにstructuredCloneがあるので読者にはなじみのある方法なのではないでしょうか。あるいはJSON.stringifyしてJSON.parseすることでdeep cloneを行ったことがあるかもしれません。

最初にPros(いいとこ) Cons(わるいとこ)を述べます。

  • Pros:
    • 実装が容易
    • code generation不要
    • stdのみで終始する
      • サードパーティライブラリを用いる場合はそのライブラリに対する開発者による慣れの差ができやすいはずです
      • go.modが何行か軽量になります。脆弱性スキャンとかDependabotによる頻繁なアラートが少し減るわけです。
  • Cons:
    • バイナリを経由することによるパフォーマンス低下
    • encoding/jsonreflectによって実装されるため、exportされたフィールドしかコピーできない
      • Goの各フィールドはunicode upper caseで始まらないとパッケージ外からアクセスできません。
      • reflectはこのルールにのっとりexportされたident(identifier)にしかアクセスしません。
    • バイナリフォーマットに変換するため、各フォーマットごとに表現力の限界がある。

表現力の限界の例はJSONにはpointerに当たる機能がないというものがあります。そのためJSONではring bufferのような循環構造を表現できません。
循環構造の表現が必須であればYAMLを用いるのが一つの解決方法かもしれません。YAMLにはanchor仕様がありますのでうまいことやれば循環グラフを表現可能です。

Goencoding/*以下でいくつかのデータ変換パッケージを提供します。例えば以下のようなものがあります。

  • encoding/json: JSONGoデータ構造の相互変換機能
  • encoding/gob: self-describingなバイナリとGoデータ構造の相互変換機能

gobは多分Go objectの略ですかね。gobは使ったことがないため筆者には特に何かを述べることができません。

例としてencoding/jsonを用いたdeep cloneを以下に示します。

playground

package main

import (
    "encoding/json"
    "fmt"
)

type Foo struct {
    Foo string
    Bar *Bar
}

type Bar struct {
    Baz int
    Qux float64
}

func main() {
    org := Foo{
        Foo: "foo",
        Bar: &Bar{
            Baz: 12,
            Qux: 10.24,
        },
    }

    bin, err := json.Marshal(org)
    if err != nil {
        // handle error. here, I just simply let it panic
        panic(err)
    }

    var cloned Foo
    err = json.Unmarshal(bin, &cloned)
    if err != nil {
        panic(err)
    }

    printFoo := func(f Foo) string {
        return fmt.Sprintf(`Foo{Foo:%q, Bar:Bar{Baz:%d, Qux:%f}}`, f.Foo, f.Bar.Baz, f.Bar.Qux)
    }

    fmt.Printf("org = %s\ncloned = %s\n", printFoo(org), printFoo(cloned))
    // org = Foo{Foo:"foo", Bar:Bar{Baz:12, Qux:10.240000}}
    // cloned = Foo{Foo:"foo", Bar:Bar{Baz:12, Qux:10.240000}}

    // modification to one does not affect the other.
    org.Bar.Baz = -20
    fmt.Printf("org = %s\ncloned = %s\n", printFoo(org), printFoo(cloned))
    // org = Foo{Foo:"foo", Bar:Bar{Baz:-20, Qux:10.240000}}
    // cloned = Foo{Foo:"foo", Bar:Bar{Baz:12, Qux:10.240000}}
}

reflectによって動的なコピーを実装する

The Go Blog: The Laws of Reflectionで説明されている通り、Goreflectパッケージを備え、runtime(プログラム実行時)に変数から型情報を取り出して動的な処理を実現することができます。

reflectgodocを見れば分かりますが、動的な変数の宣言を行うことができるため、当然これを用いればdeep cloneも実現できます。

Pros, Consは以下になります。

  • Pros:
    • code generation不要
    • (ライブラリによるが)細かいコピーのルールを設定して動的に変更することができる。
  • Cons:
    • それそのものな機能はstdにないため、自前実装を行うか、ライブラリを使用する必要があります。
    • reflectはexportされたデータにしかアクセスできないというルールがあるため、unexportなフィールドのdeep copyを行いたい場合には利用できません。
      • unsafeパッケージを使うことでこのルールを迂回できますが、名前の通り不安全であるので割愛します。

以下などでreflectベースのdeep cloneが実装されています。

実装例を以下に示します。
ただし、この例は完全な実装ではなく、かなり雑なものであるので雰囲気がわかる以上のものではないことを留意してください。

playground

package main

import (
    "fmt"
    "reflect"
)

type Foo struct {
    Foo string
    Bar *Bar
}

type Bar struct {
    Baz int
    Qux float64
}

func cloneReflect(org any) reflect.Value {
    orgRv := reflect.ValueOf(org)
    clonedRv := reflect.New(orgRv.Type()).Elem()

    for i := range orgRv.NumField() {
        fOrg := orgRv.Field(i)
        fCloned := clonedRv.Field(i)

        switch fOrg.Kind() {
        case reflect.String, reflect.Int, reflect.Float64:
            fCloned.Set(fOrg)
        case reflect.Pointer:
            if fOrg.IsNil() {
                continue
            }
            rv := reflect.New(fOrg.Type().Elem())
            rv.Elem().Set(cloneReflect(fOrg.Elem().Interface()))
            fCloned.Set(rv)
        }
    }
    return clonedRv
}

func main() {
    org := Foo{
        Foo: "foo",
        Bar: &Bar{
            Baz: 12,
            Qux: 10.24,
        },
    }

    printFoo := func(f Foo) string {
        return fmt.Sprintf(`Foo{Foo:%q, Bar:Bar{Baz:%d, Qux:%f}}`, f.Foo, f.Bar.Baz, f.Bar.Qux)
    }

    cloned := cloneReflect(org).Interface().(Foo)

    fmt.Printf("org = %s\ncloned = %s\n", printFoo(org), printFoo(cloned))
    // org = Foo{Foo:"foo", Bar:Bar{Baz:12, Qux:10.240000}}
    // cloned = Foo{Foo:"foo", Bar:Bar{Baz:12, Qux:10.240000}}

    // modification to one does not affect the other.
    org.Bar.Baz = -20
    fmt.Printf("org = %s\ncloned = %s\n", printFoo(org), printFoo(cloned))
    // org = Foo{Foo:"foo", Bar:Bar{Baz:-20, Qux:10.240000}}
    // cloned = Foo{Foo:"foo", Bar:Bar{Baz:12, Qux:10.240000}}
}

本題ではないため、reflectでこれが実現できることだけ示し、深い解説はしないものとします。
見てのとおり、自分で実装すると大変なので、ライブラリを使用したほうが良いと思います。

code generatorによってdeep clonerを実装する

The Go Blog: Generating codeなどからわかる通り、Goにはcode generatorを呼び出すための//go:generate directiveが存在します。
(directiveというのは//のあとに (space)を含まないコメントのことです。これはgo docなどに表示されなくなるような特別扱いがあります。ここなどを参照。)
このdirective commentが書きこまれたgo source codeを指定してgo generate ./path/to/file.goを実行すると、//go:generateの後に書かれたコマンドが実行される仕組みになっています。

Go自身も//go:generateを活用しており、

https://github.com/search?q=repo%3Agolang%2Fgo %2F%2Fgo%3Agenerate&type=code

と多用されていることがわかります。

このように、Goにとってcode generatorを使用するのは一般的なことです。

そもそもマクロが現状(Go1.24時点)ありませんし、それに類するツールもありません。Go1.18までgenericsもなかったため、code generatorを使わざるを得ないことも多くありました。

Pros, Consは以下になります。

  • Pros:
    • exportされていないfieldのコピーが可能
    • 高速: marshal/unmarshalのオーバーヘッドや、reflectによるメモリアロケーションコストを回避できます。
  • Cons:
    • コード変更のたびにcode generatorを動作させる必要がある。

Go1.23までではcode generatorのバージョン管理が面倒というconsも存在していますが、Go1.24以降はgo.modにtool directive付きで記録することが可能になるため、若干取り扱いがよくなりました。

これらの機能を提供するライブラリは

などがあります。

どうしてcode generatorを実装する必要があるのか

前述から

  • marshal/unmarshal用いる方法はバイナリを経由することのオーバーヘッドがかかる
  • reflectを用いる方法ではexportされていないfieldのコピーができない

というデメリットがそれぞれあります。

code generatorを用いる方法は、ソース変更を行うたびに再生成が必要というデメリットはあるものの、上記の二つを克服しうるものであることは述べました。

ではなぜすでにcode generatorライブラリが存在している今の状況で新規に開発を行う必要があるのでしょうか?

    1. 作りたかったからです
    1. どうもtype paramのある型に対するdeep cloneの生成をうまくこなすgeneratorがないっぽい?

1.に関しては何も言うことはありません。趣味なんだから作ってしまえばいいです。

2.に関してなのですが、例えば下記のようなコードがあるとき

package paramcb

type A[T any] struct {
    A B[string, T]
    B C[[]string]
    C B[C[string], []C[string]]
}

type B[T, U any] struct {
    T T
    U U
}

type C[T any] struct {
    T T
}

これに対して前述のgithub.com/ulule/deepcopierを実行します。

go run github.com/globusdigital/deep-copy@latest -type A -type B -type C ./path/to/paramcb/

下記がstdoutに出力されます。

// generated by /tmp/go-build4222039145/b001/exe/deep-copy -type A -type B -type C ./generator/cloner/internal/testtargets/paramcb/; DO NOT EDIT.

package paramcb

// DeepCopy generates a deep copy of A
func (o A) DeepCopy() A {
        var cp A = o
        if o.B.T != nil {
                cp.B.T = make([]string, len(o.B.T))
                copy(cp.B.T, o.B.T)
        }
        cp.C = o.C.DeepCopy()
        return cp
}

// DeepCopy generates a deep copy of B
func (o B) DeepCopy() B {
        var cp B = o
        if o.U != nil {
                cp.U = make([]C[string], len(o.U))
                copy(cp.U, o.U)
        }
        return cp
}

// DeepCopy generates a deep copy of C
func (o C) DeepCopy() C {
        var cp C = o
        return cp
}

type paramを無視していますね。

2年ほど前(2023年1月あたり)にこれらを含めてさらにもう1つか2つのcode generatorを試したことがあるんですが、セットアップがややこしいか正しいコードを吐いてくれなかった(*map[string]Fooのpointerの部分が無視されててコンパイルできないコードが吐かれたり)ため、作りたいなあという漠然とした思いだけが残っていました。
前回の記事: [Go]ast(dst)と型情報からコードを生成する(partial-json patcher etc)で、ほぼdeep clonerのようなものを副作用的に作っていたためせっかくなので作ってみようということで作り始めました。

生成するdeep clone methodのsignature

生成するdeep clone methodのsignatureを以下のように定めます。

func (Type) Clone() Type
func (Type[T, U, V,...]) CloneFunc(cloneT func(T) T, cloneU func(U) U, cloneV func(V) V,...) Type[T, U, V,...]

Copy, DeepCopy, Clone, DeepCloneなど色々派閥あると思いますが今回はCloneで行きます。

シンプルな型にはCloneメソッドを生成します。Typeはnon-pointer type(*TでなくT)とします。これは各々メリット、デメリットあると思いますがdeep cloneの意図がデータの複製であるのでpointerは返さないものとします。

type parameterのある型にはCloneFuncを生成します。こちらも同じくnon-pointer typeを返します。
それぞれのtype paramを複製するためのコールバック関数をclone+{type param name}で受け取ります。

type parameterやgenericsについてはA Tour of GoのType parametersで説明されているので説明は割愛します。

生成されるメソッドのイメージ

以下のような型があるとき

type A struct {
    A string
    B int
    C *int
}

理想的には下記が生成されます。

func (a A) Clone() A {
    return A{
        A: a.A,
        B: a.B,
        C: func(v *int) *int {
            if v == nil {
                return nil
            }
            vv := *v
            return &vv
        }(a.C),
    }
}

さらに、各フィールドは[]Tmap[K]Vがいくつネストしてもいいものとします。
つまり以下のような型があるとき、

type B struct {
    A [][]string
    B map[string]map[string]int
}

理想的には下記のようなメソッドが生成されます。

func (v B) Clone() B {
    return B{
        A: func(v [][]string) [][]string {
            if v == nil {
                return
            }
            out := make([][]string, len(v), cap(v))
            for k, v := range v {
                if v == nil {
                    continue
                }
                vv := make([]string, len(v), cap(v))
                copy(vv, v)
                out[k] = vv
            }
            return out
        }(b.A),
        B: func(v map[string]map[string]int) map[string]map[string]int {
            if v == nil {
                return
            }
            out := make(map[string]map[string]int)
            for k, v := range v {
                out[k] = maps.Clone(v)
            }
            return out
        }(b.B),
    }
}

加えて、type paramがある場合はCloneFunc(cloneT func(T) T, cloneU func(U) U, ...)のような形で各type paramをcloneするためのコールバック関数を受けとります。
type paramを指定されたfield、ないしはtype paramでinstantiateされた型のfieldのcloneにはこれらのコールバック関数を使用します。

もちろん、生成対象となっている型(A[T any])が別のtype paramを持つ型(B[T, U any])を含み、それがtype param以外でinstantiateされている(B[string, T])こともあり得ます。その場合は、instantiateに使われた型に対応したclonerをコールバック関数として渡します(func(s string) string, cloneT)。

つまり下記のような型があるとき、

type A[T any] struct {
    A B[string, T]
}

type B[T, U any] struct {
    T T
    U U
}

下記が生成されます。

func (a A[T]) CloneFunc(cloneT func(T) T) A[T] {
    return A[T]{
        A: a.A.CloneFunc(
            func(v string) string {
                return v
            },
            cloneT,
        ),
    }
}

func (b B[T, U]) CloneFunc(cloneT func(T) T, cloneU func(U) U) B[T, U] {
    return B[T, U]{
        T: cloneT(b.T),
        U: cloneU(b.U),
    }
}

Clone/CloneFuncの実装方針

  • 単なる代入でコピーできるものに関してはフィールドにその値を代入します。
  • 各フィールドが単純な関数呼び出し(e.g. maps.Cloneなど)ですまない時、無名の関数を作成して即座に呼び出します。
    • scopeを分けることでコードを単純にします(生成されるコード、生成するコード両方を)
    • 呼び出さずにCloneFuncに渡すこともできます。
    • inline, devirtualizeなどでコンパイラが最適化してくれることを期待します。
      • 実際にコンパイラがどういうコードを生成するのかは確認していません・・・
    • 全くな同じ定義の無名関数が複数あったら一つにまとめるような最適化もどこかにあるだろうと予測しています。(すみません。これは全く確認してないです。)
    • 定義が膨れることで読みにくくなるデメリットはありますが、生成物のまとまりがよくなるメリットがあります
    • 外部moduleにcommon partsをまとめてそれを呼び出す方法も考えられますが、バージョン管理が複雑になるため避けます。
  • 生成時に読み込んだパッケージ群外で生成されたnamed typeに関しては、fieldとそれが指定する型がすべてexportされている場合に限ってad-hocな無名関数を生成してcloneします。

code generator実装の基本方針

ここからは前回の記事: [Go]ast(dst)と型情報からコードを生成する(partial-json patcher etc)との重複がありますが特に内容が前提となっているわけではありません。

実装前にあった当初の方針が下記になります。

    1. 型情報を収集する
    • 高度な型の判別には型情報が不可欠です。
    • この手のcode generatorは大抵型情報を使っている印象です。
    • 型情報を使って、ある型にmethod(Clone/CloneFunc)を実装してもよいかを判別します
      • 例えば以下のような型のみを含む型は生成対象になりません。
        • channelを含む
        • NoCopyである(e.g. *sync.Mutex, *sync.RWMutex)もしくは
        • named typeであり
        • 生成対象のパッケージ群で定義されたものでなく
        • implementor(methodを実装する)ではなく
        • clone-by-assign(non-pointerのみを含む型)でないとき
    1. 型情報をグラフ化する
    • named typeをnodeとし、node間にedgeを描くことでグラフとします
    • グラフを辿ることでClone/CloneFuncを実装することになる生成対象の型を含む型を探索します。
    • A(ある生成対象の型)を含むB(別の型)のClone/CloneFunc実装内でACloneを呼び出していいのかを判断するには、グラフをたどって行く必要があります。
    • Aを含むBを含むCという型がある場合、ソースコードからわかる依存関係はC -> B -> Aですが、やりたい判別には逆順のA -> B -> Cで辿る必要があります。
    • そのためグラフを事前に作っておく必要があります。
    1. field unwrapper: []map[string]*[5]Tという型があるとき、[]map[string]*[5]の部分と、Tに分けて考え、前者側向けの共通処理を用意します。
    • 前者はfor-loopなどで展開することでコピー可能です
    • Tの部分だけclone方法が型によって変わります。
    1. channel, NoCopy type(e.g. *sync.Mutex, *sync.RWMutex), funcの取り扱いをユーザーに決めさせるためにConfigを受けとれるようにする

型情報を収集する

高度な型の判別のために型情報を収集します。go/ast, go/typesで定義された各型がast, 型情報に対応しており、これらを使うことでgo source codeを解析した結果の型情報を取り扱うことができます。

golang.org/x/tools/go/packagesによるast, type infoの読み込み

golang.org/x/tools/go/packagesを用いるとGo source code解析してast、type info、そのほかのメタデータなどを得ることができます。

go/parser, go/typesがそれぞれast解析、astからtype info解析を行う機能を提供しているのですが、対象となるGo source codeが外部モジュールをimportしているとき、これをうまく読み込む手段をこれらは用意していません。
そのため、go moduleのdependency graphを解析して末端のnodeとなる、自分以外に何もimportしていないmoduleから順番に解析してimporterとしてtype infoを返すようなものを自作する必要があります。それをやってくれるのがgolang.org/x/tools/go/packagesなのです。

golang.org/x/tools/go/packagesでastを解析して、type checkした結果を受けとるには以下のようにします

import "golang.org/x/tools/go/packages"

func main() {
    cfg := &packages.Config{
        Mode: packages.NeedName |
            packages.NeedTypes |
            packages.NeedSyntax |
            packages.NeedTypesInfo |
            packages.NeedTypesSizes,
        Context: ctx,
        Dir:     dir,
    }
    pkgs, err := packages.Load(cfg, "variadic", "package/match", "patterns")
    if err != nil {
        // handle error
    }
}

packages.Loadでpackage patternを受けとり、マッチするmoduleの各種情報を[]*packages.Packageとして取得します。

内部的にはgo list -deps -json package/patternによって依存をリストアップします。他のgo module-awareなツールと同じように使う必要があります。
package patternが相対パスの場合はcwdから評価されます。Dirでcwdを変更できます。

*packages.ConfigModeがビットフラグでロードする情報をコントロールします。上記はPkgPath(string), Types(*types.Package), Syntax([]*ast.File), TypeInfo(*types.Info), TypesSizes(types.Sizes)がロードされます。
ModeビットはNeed{field name}という名前(になっていないものもいくつかあるが)で各種定義されていますのでほしい情報に合わせてbitwise-ORをとります。

型情報を使った各型の判別

複雑な型や、interfaceで表現することができない型の条件はgo/types以下で定義される型情報を直接走査して判定を行います。

NoCopy(assignによってコピーするとgo vetcopies lock valueと警告する型)の判別

Lock methodを備える型を直接(pointerによってindirectされずに)含む型はno-copyなどと言われて、代入や関数の引数に渡すことでコピーが起きるとgo vetで警告を受けます。
code generatorはこれらをコピーしないようなコードを生成する配慮が必要なので判別する必要があります。

type noCopy struct{}
func(noCopy) Lock()

type noCopy2 struct {
    l noCopy
}

type notNoCopy struct {
    l *noCopy
}

上記の

  • noCopyLock methodを実装するためNoCopyです。
  • noCopy2はnon-pointerとしてnoCopyを含むためNoCopyです。
  • notNoCopyはpointerとしてnoCopyを含むためNoCopyではありません。

pointerと言えるのは、interface, map[K]V, []Tを含みます。array([n]T)はpointerではないので以下もnoCopyです

type noCopyArray struct {
    l [3]noCopy
}

Clone/CloneFuncはこれらをコピーしないように単にフィールドをzero valueのままほおっておくとか、含まれる型はそもそも生成対象から外すとか、pointerならpointerをコピーするとかをユーザーに選ばせたいので、no-copyの判定を行う必要があります。

以下のように実装します。

https://github.com/ngicks/go-codegen/blob/6a0b75516f057f51967eb566eaf255890f975192/codegen/matcher/matcher.go#L9-L49

findMethodの実装は以下

https://github.com/ngicks/go-codegen/blob/6a0b75516f057f51967eb566eaf255890f975192/codegen/matcher/matcher.go#L101-L108

asNamed, asInterface, as[T]の実装は以下

https://github.com/ngicks/go-codegen/blob/6a0b75516f057f51967eb566eaf255890f975192/codegen/matcher/matcher.go#L64-L77

この実装はgo vetのそれとは異なり、sync.Lockerのようなinterfacestruct embeddingすることでLockを実装しているnon-interface型もpointerではないとみなし、no-copy typeとして判定します。

つまり以下はこの実装ではNoCopyとして取り扱われますが、go vetは警告しません。

type notNoCopy2 struct {
    sync.Locker
}

implementor(Clone/CloneFuncを実装する型)の判別

ある型Aが内部に型Bを持つとき、Bimplementor(Clone/CloneFuncを実装する型)であるならばAmethod(Clone/CloneFunc)はBmethodを呼び出します。
Cloneはともかく、CloneFuncinterfaceとして表現できない複雑な条件であるため、型情報を用いた判別を行います。

Clone

Cloneの実装は以下のように判定します。

https://github.com/ngicks/go-codegen/blob/6a0b75516f057f51967eb566eaf255890f975192/codegen/matcher/method_checker.go#L101-L123

引数がfunc (Type) Clone() Typefunc (*Type) Clone() Typeというmethodを持つときtrueを返します。

Clone以外のmethod nameでもいいように、Nameフィールドでパラメータ化してありますが実際の呼び出しはName: "Clone"以外ですることはありません。

asPointerの実装は以下。

https://github.com/ngicks/go-codegen/blob/6a0b75516f057f51967eb566eaf255890f975192/codegen/matcher/matcher.go#L79-L90

types.NewMethodSetで、ある型が実装するmethod setを得ることができますが、Goの通常のinterfaceのルールと同じくnon-pointer型にはreceiverがnon-pointer型のmethodしか見せなくなっています。
すべてのmethodを見つけるために、型がpointerでない場合はtypes.NewPointerでラップすることでpointerに変換します。
ただしinterfaceのpointerをとると逆にtypes.NewMethodSetはmethodを返さなくなるため、interfaceである型はpointerに包まないようにします。
*types.Named(named type),*types.Aliasもしくはinterface literal以外はmethodを実装することはないため、それ以外の型の場合はそのまま引数を返します。

noArgSingleValueの実装は以下

https://github.com/ngicks/go-codegen/blob/6a0b75516f057f51967eb566eaf255890f975192/codegen/matcher/matcher.go#L110-L131

unwrapPointerの実装は以下

https://github.com/ngicks/go-codegen/blob/6a0b75516f057f51967eb566eaf255890f975192/codegen/matcher/matcher.go#L92-L99

CloneFunc

CloneFuncの実装は以下のように判別します。

https://github.com/ngicks/go-codegen/blob/6a0b75516f057f51967eb566eaf255890f975192/codegen/matcher/method_checker.go#L125-L196

前述通り、CloneFunc(cloneT func(T) T, cloneU func(U) U, ...)というシグネチャであるかを判別します。処理の単純性のためにtype paramとclonerコールバック関数の順序は一致することを必須とします。
判定する型によってはA[string, T]のような感じで具体的な型だけでなく、さらに別のtype paramでinstantiateされていることがあります。
types.Identicalはtype paramに対して単なるpointer同士の比較以上のことをしないように実装されているため、type paramはindexで比較する必要があります。

clone-by-assign(non-pointerのみを含む型)の判別

clone-by-assign(non-pointerのみを含む型)である場合は、生成対象のパッケージ群で定義されている型でない時でも単純にassignすればよいので、これを判別できるようにしておきます。これの具体例はimage/color.RGBA64などですね。

https://github.com/ngicks/go-codegen/blob/6a0b75516f057f51967eb566eaf255890f975192/codegen/matcher/tester.go#L5-L31

わりかし単純です。ただし、stepNext func(*types.Named) boolを受けとってnamed typeに対してマッチするとき再帰しないでfalseを返す措置があります。
こういうシグネチャになっているのは、引数が生成対象のnamed typeであったり、implementorであったりして、methodを実装しているとき、それらをclone-by-assignとして取り扱わないようにしたいからです。その時にはそれらのmethodを呼び出すようにします。
とくにimplementorに対してはmethod内でどういうフックを行っているか明らかでないのでとりあえず呼び出さないと実装者の意図に反する可能性があります。

型情報をグラフ化する

生成されるClone/CloneFuncメソッドは、生成対象の型がさらにmethodを実装する型を含んでいる場合はそれらを呼び出します。

type A struct {
    B Implementor
}

func (a A) Clone() A {
    return A{
        B: a.B.Clone()
    }
}

type Implementor struct{
    // ...
}

func (i Implementor) Clone() Implementor {
    return Implementor{/*...*/}
}

上記で言うところのImplementorもさらに生成対象の型であるとき、これが実装するCloneもcode generatorによって生成されることになるので初回実行時にはまだ存在していません。
つまりimplementorの判別を行うだけではこのClone methodを発見することができません。

そのため、stableな出力結果を得るためには、型が参照する別のnamed typeが生成対象となっているのかを検知する必要があります。

型は別の型を含むことができ、さらにその型が別の方に依存していることがありまえます。これをここでtype dependency chainと呼びます。
このchainをたどったとき、どこかに生成対象の型が含まれている場合、chainの上流にいる型も生成対象の型となります。

source codeや、それの解析結果自体が型、呼び出しの依存関係の順向きグラフとなっています。
今回知りたいのは型がどこから参照されているかという逆向きの情報です。
おそらく、この逆向きの情報を手軽に得る方法は存在しないため、特別な実装を必要としています。

(goplsのFind All Referencesの実装を見たら一般的にどうやって逆向きの情報の得るのかを調べれるなあと思うだけ思って調べてないです)

以下のpackageでそれを実装します。

https://github.com/ngicks/go-codegen/blob/6a0b75516f057f51967eb566eaf255890f975192/codegen/typegraph/type_graph.go

このグラフは作成時に渡された[]*packages.Package内部のnamed typeをすべて列挙し、named type同士の依存関係を親から子、子から親に相互に参照できるようにedgeでつなぎます。

型は[]T, map[K]V, chan Tなど無名の型に含まれることがあります。
この型グラフはnamed typeからnamed typeへの依存関係のみを焦点としますが、場合によっては特定の無名な型を経由して依存される場合、逆向きに型をたどりたくないことがあります。
例えば、chan Tであるとき、Clone methodはこのTに何のアクションも起こせませんので、このedgeをたどる必要はありません。
そこで、edgeはこれらの無名の型をedge routeと呼び、[]T, map[K]Vそれぞれをedge route nodeのstackとして別途記録しておきます。

さらに、matcherを受けとり、すべての型を列挙するときにこれを使用することで、関心のある型にマーキングを行います。
今回で言うとclone-by-assign(non-pointerな型しか含まれない型)かimplementor(Clone/CloneFuncを実装するnamed type)を含む型がClone-ableとしてマッチします。

IterUpwardを以下のように実装し、matcherでmatchした型から依存関係を親側に向けてたどります。channelを含むedge routeに対してはClone/CloneFuncを呼び出すことはできないため、これらを含むedgeはフィルターして辿らないこととします。そのためedgeFilterを受けとるようになっています。

https://github.com/ngicks/go-codegen/blob/6a0b75516f057f51967eb566eaf255890f975192/codegen/typegraph/type_graph.go#L584-L614

MarkDependantを以下のように定義することで、IterUpwardで辿られた型をdependantとしてマークします。

https://github.com/ngicks/go-codegen/blob/6a0b75516f057f51967eb566eaf255890f975192/codegen/typegraph/type_graph.go#L572-L582

いずれかのマークがされた型にのみClone/CloneFuncを実装していきます。
逆に言うとマークされた型に対しては盲目的に(型情報によらずに)Clone/CloneFuncを呼び出してよいことになります。

field unwrapper

[]map[string]*[5]Tという型があるとき、[]map[string]*[5]の部分と、Tに分けて考え、前者側向けの共通処理を用意します。

以下のようにoutermost(初期化と返却)、mid(中間経路)、inner end(Tのclone expression)の三つに分けます。

func (v []map[string]*[5]T) []map[string]*[5]T {
/* outermost */ out := make([]map[string]*[5]T, len(v), cap(v))
/*       mid */ for k, v := range v {
/*       mid */     next := make(map[string]*[5]T)
/*       mid */     for k, v := range v {
/*       mid */         next := new([5]T)
// ...
/* inner end */         for k, vv := range v {
/* inner end */             v[k] = cloneExpr(vv)
/* inner end */         }
// ...
/*       mid */     }
/*       mid */ }
/* outermost */ return out
}

outermostは返すclonedの初期化と返却を行います。
midはfor(slice,array,map)かif v != nil { v := *v }(pointer)で引数のvを1つずつunwrapしていき、そのたび一つ内側の型のcloneされたオブジェクトを初期化します([]map[string]*[5]T -> map[string]*[5]T -> *[5]T -> [5]T)
inner endはTごとのclone expressionを記述します。implementorならClone/CloneFuncclone-by-assignなら単にvvを代入するのみ、という感じです。

Goは通常、型推論が行われるため変数の型を宣言しないことも多いです。実際上記のmidではfor k, v := range vとするときにiteration variable(kv)の型を明確に記述していません。
しかしmap, sliceなどの初期化にmake組み込み関数を使うときに型シグネチャを必要とします。これを用いないとslicelencapを指定できません。
ですのでmidを経由するたび1つunwrapした型をテキストとして出力できなければなりません。(e.g. [][]T -> []T)
そこでtypes.Typeast.Exprを受け取って順繰りにunwrapする機能が必要です。types.Typetypes.TypeString, ast.Exprprinter.Fprintでテキストとして出力可能だからです。

ここではtypes.Typeで行うこととします

https://github.com/ngicks/go-codegen/blob/6a0b75516f057f51967eb566eaf255890f975192/codegen/generator/cloner/method.go#L480-L494

typegraph.EdgeKindは前述のedge routeの種類を表現するenum-likeな値です。
これを使わなくてもunwrapは成立するんですが、こうするとtypegraph情報との連携がうまくいっていない場合にtype-assertionのところでpanicするので便利です

ast.Exprでなくtypes.Typeを使う理由はtype aliasをunaliasするのが型情報を必要とするからです。
例えばですが、下記のようにtype aliasが何度も重なっていたり、slicemapのような無名の型を含むことは構文ルール上許されています。

type A = B

type B = []C

type C struct {
    // ..
}

A = B, B = Cのような単純なaliasならば単にAの名前で型を参照すればよいのですが、B = []Cのようなことをされると、A, Bをunwrapしなければcloneが行えないことになります。
これは型情報を用いずastで追いきるのは少々困難です。外部Go moduleの型をaliasすることができるためです。

上記よりfield unwrapperをunwrapFieldAlongPathとして定義します。

https://github.com/ngicks/go-codegen/blob/6a0b75516f057f51967eb566eaf255890f975192/codegen/generator/cloner/method.go#L496-L621

(返された関数を2度以上呼び出すと(そうなることを意図していないにもかかわらず)結果が変わるよくない実装になっています。参考にする人がいるかはわかりませんが注意してください。)

fromTy, toTy types.Typeを引数に取ることでfromTy -> toTyな関数を出力します。のちの再利用を前提として変換先に別の型を指定できるようになっていますが、今回はclonerなのでこの二つは全く同じtypes.Typeが渡されることになります。
返り値の関数unwrapperで上記で言うclone exprをwrappee func(string) stringとして受け取とりinner endでそれを呼び出すようなfield unwrapperをテキストで出力します。

code generatorの実装

Configの定義

たとえ挙動を変えうる設定項目が一つもなくてもconfigを主体にAPIを設計しないとあとから設定項目を追加するのが破壊的変更なってしまいますので毎回何かを無理くりひねり出すんですが幸いにも今回はいくつかユーザーに取り扱いを決めてほしいものがあります。
そこでConfigを以下のように定義しています。

https://github.com/ngicks/go-codegen/blob/6a0b75516f057f51967eb566eaf255890f975192/codegen/generator/cloner/generator.go#L25-L28

今後項目が増えるかもしれませんが現在はこれだけです。

MatcherConfigは以下のように定義されます。

https://github.com/ngicks/go-codegen/blob/6a0b75516f057f51967eb566eaf255890f975192/codegen/generator/cloner/matcher.go#L22-L45

これも項目が増えるかもしれませんが現時点ではこれだけです。NoCopy, Channel, Func, Interfaceのグローバルオプションをそれぞれ用意しています。
CopyHandleIgnoreならフィールドはclone対象にならず、clone後にはzero valueになります。CopyHandleDisallowならこれを含む型は生成対象から除外されます。CopyHandleCopyPointerは、そのフィールドがpointerであるとき(=*T, interface, channelなど)の時のみコピーを行いそれ以外の時はIgnoreとして取り扱います。

CustomHandlersは後述します。

ConfigGenerate methodを実装します。

https://github.com/ngicks/go-codegen/blob/6a0b75516f057f51967eb566eaf255890f975192/codegen/generator/cloner/generator.go#L52-L56

これがこのcode generatorへのエントリポイントです。
こうすればConfigを無視して何かをすることはできなくなります。

in-place option: struct fieldにコメントをつけて挙動を変更できるようにする

挙動のfine tuningのためにstruct fieldにdirective commentをつけることでconfigをper-fieldレベルで上書きできるようにします。これをin-place optionと呼びます

typegraphのデータにstruct fieldのコメントを格納できるよう拡張する

型情報をグラフ化するのところで説明した通り、今回実装するcode generatorは事前に型情報グラフ化して、その時に渡されたmatcherの結果で生成対象の型を決定します。
per-fieldレベルの設定によってtype graphのマッチする、しないが左右されるためtypegraphの機能としてper-fieldレベルのデータを収めることとします。

そこで以下のようにoptionを定義し、

https://github.com/ngicks/go-codegen/blob/6a0b75516f057f51967eb566eaf255890f975192/codegen/typegraph/option.go#L3-L19

typegraphのNew関数でOptionを受けとれるようにします。(破壊的変更を加えました)

https://github.com/ngicks/go-codegen/blob/6a0b75516f057f51967eb566eaf255890f975192/codegen/typegraph/type_graph.go#L226-L232

typegraph.NodePriv(private)データを含めるようにします。

https://github.com/ngicks/go-codegen/blob/6a0b75516f057f51967eb566eaf255890f975192/codegen/typegraph/type_graph.go#L64-L78

こういうの(*void priv)はC言語だとよく見るパターンですね。

PrivParserはmatcher呼び出しの直前で呼び出します。

https://github.com/ngicks/go-codegen/blob/6a0b75516f057f51967eb566eaf255890f975192/codegen/typegraph/type_graph.go#L308-L315

このPrivデータ自体はtypegraphにとって関心のある所ではないためanyになっています。データは利用者ごとに別々のものを用意したほうがよいでしょう。

下記のようにPrivをtype paramにしてもよかったのですが、optionalなもののためにtype paramを追加するのはわかりにくくなるのでやめておきました。

type Node[T any] any {
    // ...
    Priv *T
}

コメントを解析する

前述通り*typegraph.Nodeを受けとって型情報ないしはast情報を用いてPrivに情報をセットできるようになっています。

Priv dataは以下のclonerPrivとして定義します。

https://github.com/ngicks/go-codegen/blob/6a0b75516f057f51967eb566eaf255890f975192/codegen/generator/cloner/priv.go#L26-L36

これは前述のConfigをoverrideできるようにロジックを集約しておきます。

https://github.com/ngicks/go-codegen/blob/6a0b75516f057f51967eb566eaf255890f975192/codegen/generator/cloner/priv.go#L38-L52

(Interfaceのオーバーライドが実装されていない!そのうち直ります。)

「struct fieldにdirective commentをつける」のstruct fieldについているコメントは下記で言うとAに付いているコメントは// 3, /* 4 */, // 7に当たる位置のみと定義します。

type A struct {
    _                 int
    // 1

    // 2

    // 3
    /* 4 */ A /* 5 */ string /* 6 */ `json:"a"` // 7
    /* 8 */
    // 9
    B                 string /* 10 */
    // 11
    C                 int
    // 12
    // 13
}

解析はgithub.com/dave/dstを用いて、dstにいったん変換してから行います。

dstdecorated syntax treeの略語であると述べられています。
dstastとほぼ同じ構造で、*ast.File*dst.Fileの相互変換が可能です。
ほぼ同じですが、astからコメントの取り扱いが変わっており、コメントが各nodeにアタッチされるようになっています。
astではコメントは単にファイル先頭からのオフセットとして位置が定義されているため、astの書き換え、特にnodeの追加を行うとオフセットが狂って正常なprintが行えないという問題がありました。dstはこれを解決することを意図しています。

dstではastと違い、コメントは直前もしくは直後のnodeにアタッチされているものとして取り扱われます。

上記の場合,A, B, Cについているコメントはdst上では

st := dts.Type.(*dst.StructType)
a := st.Fields.List[1]
b := st.Fields.List[2]
c := st.Fields.List[3]

//
// // 2
//
// // 3
// /* 4 */ A /* 5 */ string /* 6 */ `json:"a"` // 7
//

a.Decs.Start
// [0] = "// 2"
// [1] = "\n"
// [2] = "// 3"
// [3] = "/* 4 */"
a.Decs.End
// [0] = "// 7"

//
// /* 8 */
// // 9
// B               string /* 10 */
//

b.Decs.Start
// [0] = "/* 8 */"
// [1] = "\n"
// [2] = "// 9"
b.Decs.End
// [0] = "/* 10 */"

//
// // 11
// C                 int
// // 12
// // 13
//

c.Decs.Start
// [0] = "// 11"
c.Decs.End
// [0] = "\n"
// [1] = "// 12"
// [2] = "// 13"

という風になります。dst上でもコメントの取り扱いは微妙であとにフィールドが続くかによって何がどこに入ってくるか変わってしまいます。

fieldにアタッチされたコメントは以下のように定義できます。

  • Start: 最後の\n以後
  • End: 1つ

これらのコメントを列挙する関数をParseFieldDirectiveCommentDstとして定義すると、以下のように各フィールドのコメントを解析できます。
(これそのものはシンプルなテキスト解析なので特にいうことはありません)

https://github.com/ngicks/go-codegen/blob/6a0b75516f057f51967eb566eaf255890f975192/codegen/generator/cloner/priv.go#L54-L111

matcherの定義

code generatorは生成対象がstructunderlyingとする場合は各フィールドの型に対しての、map, slice, arrayunderlyingとする場合はその型に対しての、それぞれ向けのfield unwrapperとclone exprをテキストとして生成すれば所望の挙動を実現できます。
ここで、各型のclonerを生成する処理は使いまわすことができます。

各型のclonerの生成する部分は、型をどのようにハンドルすべきか(type handleKind int)を定義して、(1)「各型を判別してhandleKindを返す部分」と、(2)「実際にコードを生成する部分」を分けて実装します。
(1)をmatcherと呼びます。

この分割は以下の点で好都合です

  • 型グラフ生成時のmatcherとして同じ処理を使いまわせます
  • そもそもmatcher部分が巨大かつユーザーに内部状態を教えるためにloggerを受けとるため実際のコード生成はコード生成だけに集中しないとごちゃごちゃして読めたもんじゃないです
  • config項目の拡充などでmatcher部分は今後も変わり続けますがコード生成部分は多分あんまりもう変わらないので分離できておくと差分が見やすくていいです

ようするに(1)と(2)は別々の意図を持ったもので、別々の要因で変更が加わることがあり得る。SRPに従うと分けておくと後々楽ということです。

matcherは、edge route nodeのstackとbottom typeのTを分けて考えると簡単です。
あるnamed typeから別のnamed type、あるいは他の型を含むことができない型(intのようなbasic typeやfunc, interface, type paramなど)までをたどり、edge route nodeとその最終的な型を引数にしてコールバック関数を呼ぶTraverseTypesを定義し、これを活用します。

https://github.com/ngicks/go-codegen/blob/6a0b75516f057f51967eb566eaf255890f975192/codegen/typegraph/type_graph.go#L491-L542

TraverseTypesを使って型をとり、判別を行います。struct literalが含まれる場合は再帰処理で対応するのでstruct literalが出たらtraverseを中断したり、custom handler(後述)にマッチしたらマッチする直前までのfield unwrapperを生成したいのでそこで処理を中断したりといろいろ考慮を加えます。

そしてmatcher本体ロジックは下記のクソデカswitch-case

https://github.com/ngicks/go-codegen/blob/6a0b75516f057f51967eb566eaf255890f975192/codegen/generator/cloner/matcher.go#L181-L446

デカい!デカくてzennのpreviewだと最後まで表示できていないですね(200行までの制限がかかっているようです)。

こういったコアロジックは長大になる傾向がある気がします。どのタイミングで分割するか考えていないですしばらくはデカいまま放置されると思います。

各型のclone

前述のmatcherを呼び出して得られたhandleKindをswitch-caseで分岐することで各型のcloneを生成します。前述のfield unwrapperがあるため、実際には[][]Tのような型があるときにはbottom type Tのclone方法のみを生成します。

Tが型グラフの構築時にmatchedとなった型についてはClone/CloneFuncを呼び出す必要があるので、その考慮を加えるためにhandleFieldというラッパーを経由してmatcherを呼び出します。

https://github.com/ngicks/go-codegen/blob/6a0b75516f057f51967eb566eaf255890f975192/codegen/generator/cloner/matcher.go#L508-L550

各型向けに呼び出します。

https://github.com/ngicks/go-codegen/blob/6a0b75516f057f51967eb566eaf255890f975192/codegen/generator/cloner/method.go#L218-L224

switch-caseで分岐してそれぞれ向けのテキストを生成します。

https://github.com/ngicks/go-codegen/blob/6a0b75516f057f51967eb566eaf255890f975192/codegen/generator/cloner/method.go#L260-L374

struct literalとCloneFuncのtype argに対して再帰呼び出しが必要でややこしいですがmatcher部分と分離したため割合単純なコードになっています。

field unwrapperと組み合わせる

unwrapFieldAlongPathの呼び出しによって得られたfield unwrapperと組み合わせて最終的なcloner func(string) stringが得られます

https://github.com/ngicks/go-codegen/blob/6a0b75516f057f51967eb566eaf255890f975192/codegen/generator/cloner/method.go#L253-L258

https://github.com/ngicks/go-codegen/blob/6a0b75516f057f51967eb566eaf255890f975192/codegen/generator/cloner/method.go#L376-L386

source fileにsuffixを付けたファイルへ書き出し

あとはこれを書き出したら終わりです。

対象となった型が含まれていたファイル名+suffixなファイルに吐き出す方式をとるため以下でsuffixwriterを定義します

https://github.com/ngicks/go-codegen/blob/6a0b75516f057f51967eb566eaf255890f975192/codegen/suffixwriter/writer.go

token.FileSet.Positionで得られるtoken.Positionからファイル名が得られます。
これを引数にsuffixwriterを呼び出すことで所望の挙動を実現できます。

build constraintsのコピー

ファイルに書き出す際、生成対象の型を含むソースファイルのbuild constraintsを尊重しなければcompilation errorになってしまう可能性が高いため、これを考慮する実装が必要です。

go command documentationのBuild constraintsの項からわかる通り、//go:build directive commentや、ファイル名に_linux_amd64などのsuffixをつけることでbuild constraintsを指定できます。

build constraintsとはなんぞや

リンク先でも説明されていますがbuild constraintsとはどのような条件でこのファイルがpackageに含まれるかを決めるものです。

よくある使われ方はマルチプラットフォーム対応です。
プラットフォーム固有の機能やパラメータを同名の関数や定数をプラットフォームごとに定義しておき、linux向けとかmac向けとか、amd64向けとかarm64むけとか、そういったbuild constraintsでパッケージに含まれる定義を切り替えられるようにします。
そうすることで他のプラットフォーム非依存な部分とプラットフォーム依存部分でコードを切り分けることができます。

他で言えばwails(ElectronやTauriのGo版でおおむね間違っていない)のdevビルドとprodビルドをbuild constraintで切り替えて、dev版ではweb viewにdev toolsを表示しておくとかそういう使い方も考えられます。

goコマンドに組み込みのbuild constraintsにはGOOS(linux, windows, darwinなど)やGOARCH(amd64, arm64など)などがあります。
これらはgo env GOOSなどで確認できる値として勝手にbuild tagに設定されます。
そのほかの任意のbuild tagはCommand Documentation: goより、goコマンドのサブコマンドのうちビルド行うもの(build, test, etc)に-tags cliオプションを使って設定します。

C言語では#ifdefなどを活用して、ファイルの中でもconstraintに合わせて定数や関数の定義を切り替えることができます。ファイルの特定の行があるかどうかを制御するifのようなものですから、1つのファイル内で複数のconstraintによる分岐が行えます。
Goでは一方で、1ファイルに1つしか//go:buildが存在することが許さないようになっています。それぞれの環境向けのファイルを複数作っておき、それぞれで同名の関数/パラメータを定義することになります。

build constraintを尊重するには以下の二つを行います。

  • //go:buildコメントをコピーする
  • ファイルのsuffixがbuild constraintであるときはコピーする

//go:buildコメントのコピー

コピーするというよりは//go:buildコメント以外を消すというのが正しいです。

今回実装したcode generatorでは生成元の型が定義されたファイルのpackage clause、import declをそのまま再利用して生成されるファイルに書き出します。
この時書き出すpackage commentを//go:buildコメントのみになるようにフィルターします。

https://github.com/ngicks/go-codegen/blob/6a0b75516f057f51967eb566eaf255890f975192/codegen/codegen/comment.go#L14-L25

go/build/constraint.IsGoBuild, go/build/constraint.IsPlusBuildが定義されているのでこれをそのまま使います。

IsPlusBuild// +buildから始まる行に対してtrueを返します。これがGo1.16かそれ以前までに使われていた形式で、Go1.17でdeprecation、Go1.18から基本的に削除されているはずですが、一応簡単にサポートできるのでベストエフォートで対応してあります。後述するCustom Handlermaps.Cloneを使用するため、暗黙的にGo1.21以上がこのcode generatorの対象バージョンとなっています。そのためサポートする必要自体はないと思っています。

こうしてtrimされたpackage-commentをPrintFileHeader内でprintしていきます。
この関数は出力されるファイルすべてに対して呼ばれるprinterでpackage comment, package clauseとimport declをすべて出力するものです。

https://github.com/ngicks/go-codegen/blob/6a0b75516f057f51967eb566eaf255890f975192/codegen/codegen/print.go#L251-L290

printer.Fprintがコメントだけとかそういうレベルのprintに対応していないのでちょっと頑張って出力しています。

ファイルのsuffixがbuild constraintであるときはコピーする

Goはファイル名が_test suffixを除いたときに_os, _arch, _os_archでsuffixされている場合暗黙的なbuild constraintsとして取り扱います。
サポートされるos, archの一覧はgo tool dist list -jsonで得られます。

go tool dist list -jsonの出力
# go tool dist list -json
[
        {
                "GOOS": "aix",
                "GOARCH": "ppc64",
                "CgoSupported": true,
                "FirstClass": false
        },
        {
                "GOOS": "android",
                "GOARCH": "386",
                "CgoSupported": true,
                "FirstClass": false
        },
        {
                "GOOS": "android",
                "GOARCH": "amd64",
                "CgoSupported": true,
                "FirstClass": false
        },
        {
                "GOOS": "android",
                "GOARCH": "arm",
                "CgoSupported": true,
                "FirstClass": false
        },
        {
                "GOOS": "android",
                "GOARCH": "arm64",
                "CgoSupported": true,
                "FirstClass": false
        },
        {
                "GOOS": "darwin",
                "GOARCH": "amd64",
                "CgoSupported": true,
                "FirstClass": true
        },
        {
                "GOOS": "darwin",
                "GOARCH": "arm64",
                "CgoSupported": true,
                "FirstClass": true
        },
        {
                "GOOS": "dragonfly",
                "GOARCH": "amd64",
                "CgoSupported": true,
                "FirstClass": false
        },
        {
                "GOOS": "freebsd",
                "GOARCH": "386",
                "CgoSupported": true,
                "FirstClass": false
        },
        {
                "GOOS": "freebsd",
                "GOARCH": "amd64",
                "CgoSupported": true,
                "FirstClass": false
        },
        {
                "GOOS": "freebsd",
                "GOARCH": "arm",
                "CgoSupported": true,
                "FirstClass": false
        },
        {
                "GOOS": "freebsd",
                "GOARCH": "arm64",
                "CgoSupported": true,
                "FirstClass": false
        },
        {
                "GOOS": "freebsd",
                "GOARCH": "riscv64",
                "CgoSupported": true,
                "FirstClass": false
        },
        {
                "GOOS": "illumos",
                "GOARCH": "amd64",
                "CgoSupported": true,
                "FirstClass": false
        },
        {
                "GOOS": "ios",
                "GOARCH": "amd64",
                "CgoSupported": true,
                "FirstClass": false
        },
        {
                "GOOS": "ios",
                "GOARCH": "arm64",
                "CgoSupported": true,
                "FirstClass": false
        },
        {
                "GOOS": "js",
                "GOARCH": "wasm",
                "CgoSupported": false,
                "FirstClass": false
        },
        {
                "GOOS": "linux",
                "GOARCH": "386",
                "CgoSupported": true,
                "FirstClass": true
        },
        {
                "GOOS": "linux",
                "GOARCH": "amd64",
                "CgoSupported": true,
                "FirstClass": true
        },
        {
                "GOOS": "linux",
                "GOARCH": "arm",
                "CgoSupported": true,
                "FirstClass": true
        },
        {
                "GOOS": "linux",
                "GOARCH": "arm64",
                "CgoSupported": true,
                "FirstClass": true
        },
        {
                "GOOS": "linux",
                "GOARCH": "loong64",
                "CgoSupported": true,
                "FirstClass": false
        },
        {
                "GOOS": "linux",
                "GOARCH": "mips",
                "CgoSupported": true,
                "FirstClass": false
        },
        {
                "GOOS": "linux",
                "GOARCH": "mips64",
                "CgoSupported": true,
                "FirstClass": false
        },
        {
                "GOOS": "linux",
                "GOARCH": "mips64le",
                "CgoSupported": true,
                "FirstClass": false
        },
        {
                "GOOS": "linux",
                "GOARCH": "mipsle",
                "CgoSupported": true,
                "FirstClass": false
        },
        {
                "GOOS": "linux",
                "GOARCH": "ppc64",
                "CgoSupported": false,
                "FirstClass": false
        },
        {
                "GOOS": "linux",
                "GOARCH": "ppc64le",
                "CgoSupported": true,
                "FirstClass": false
        },
        {
                "GOOS": "linux",
                "GOARCH": "riscv64",
                "CgoSupported": true,
                "FirstClass": false
        },
        {
                "GOOS": "linux",
                "GOARCH": "s390x",
                "CgoSupported": true,
                "FirstClass": false
        },
        {
                "GOOS": "netbsd",
                "GOARCH": "386",
                "CgoSupported": true,
                "FirstClass": false
        },
        {
                "GOOS": "netbsd",
                "GOARCH": "amd64",
                "CgoSupported": true,
                "FirstClass": false
        },
        {
                "GOOS": "netbsd",
                "GOARCH": "arm",
                "CgoSupported": true,
                "FirstClass": false
        },
        {
                "GOOS": "netbsd",
                "GOARCH": "arm64",
                "CgoSupported": true,
                "FirstClass": false
        },
        {
                "GOOS": "openbsd",
                "GOARCH": "386",
                "CgoSupported": true,
                "FirstClass": false
        },
        {
                "GOOS": "openbsd",
                "GOARCH": "amd64",
                "CgoSupported": true,
                "FirstClass": false
        },
        {
                "GOOS": "openbsd",
                "GOARCH": "arm",
                "CgoSupported": true,
                "FirstClass": false
        },
        {
                "GOOS": "openbsd",
                "GOARCH": "arm64",
                "CgoSupported": true,
                "FirstClass": false
        },
        {
                "GOOS": "openbsd",
                "GOARCH": "ppc64",
                "CgoSupported": false,
                "FirstClass": false
        },
        {
                "GOOS": "openbsd",
                "GOARCH": "riscv64",
                "CgoSupported": true,
                "FirstClass": false
        },
        {
                "GOOS": "plan9",
                "GOARCH": "386",
                "CgoSupported": false,
                "FirstClass": false
        },
        {
                "GOOS": "plan9",
                "GOARCH": "amd64",
                "CgoSupported": false,
                "FirstClass": false
        },
        {
                "GOOS": "plan9",
                "GOARCH": "arm",
                "CgoSupported": false,
                "FirstClass": false
        },
        {
                "GOOS": "solaris",
                "GOARCH": "amd64",
                "CgoSupported": true,
                "FirstClass": false
        },
        {
                "GOOS": "wasip1",
                "GOARCH": "wasm",
                "CgoSupported": false,
                "FirstClass": false
        },
        {
                "GOOS": "windows",
                "GOARCH": "386",
                "CgoSupported": true,
                "FirstClass": true
        },
        {
                "GOOS": "windows",
                "GOARCH": "amd64",
                "CgoSupported": true,
                "FirstClass": true
        },
        {
                "GOOS": "windows",
                "GOARCH": "arm",
                "CgoSupported": false,
                "FirstClass": false
        },
        {
                "GOOS": "windows",
                "GOARCH": "arm64",
                "CgoSupported": true,
                "FirstClass": false
        }
]

JSONは内容から以下のように定義できるので

https://github.com/ngicks/go-codegen/blob/6a0b75516f057f51967eb566eaf255890f975192/codegen/suffixwriter/suffix.go#L17-L22

コマンドを呼び出してjson.Unmarshalします

https://github.com/ngicks/go-codegen/blob/6a0b75516f057f51967eb566eaf255890f975192/codegen/suffixwriter/suffix.go#L101-L111

json.Unmarshalの結果をmap[string]boolに集めます

https://github.com/ngicks/go-codegen/blob/6a0b75516f057f51967eb566eaf255890f975192/codegen/suffixwriter/suffix.go#L76-L99

goコマンドが入っていない環境は全く想定していませんが、一応ない場合はソースに埋めておいたものにfallbackするようにしてあります。(goコマンドがない環境ではgolang.org/x/tools/go/packages.Loadが動作しない)

前述通りファイルの出力の際にはsuffixwirter.clonerのようなsuffixを加えてファイル名に書き込みを行いますが、ここをbuild constraintsとなるsuffixが元のファイル名についている場合はsuffixの後ろに移動させるように考慮を加えます。

https://github.com/ngicks/go-codegen/blob/6a0b75516f057f51967eb566eaf255890f975192/codegen/suffixwriter/suffix.go#L113-L126

https://github.com/ngicks/go-codegen/blob/6a0b75516f057f51967eb566eaf255890f975192/codegen/suffixwriter/suffix.go#L135-L172

Custom handler

cloneの処理はユーザーごとに別のものを与えたかったりすることは十分に想定できます。
そこで、Custom handlerを渡せるようにします。

https://github.com/ngicks/go-codegen/blob/6a0b75516f057f51967eb566eaf255890f975192/codegen/generator/cloner/custom_handler.go#L38-L48

  • Matcherがtrueを返す時にこのcustom handlerを実行します
    • 例えば[]map[string]*[5]Tがstruct fieldの型であるとき、Matcher[]map[string]*[5]T, map[string]*[5]T, *[5]T, [5]T, Tを引数に何度も呼ばれます。
  • Importsでこのcustom handlerが使用する外部パッケージを指定します。
  • Exprでclone exprをfunc(s string) stringとして返します。引数sが、clone exprの引数となるべきidentのテキスト表現となります。各種データを引数で受けるため、これを使って処理を分岐させます。返したclone exprが呼びだし可能である場合isFuncをtrueとして返します。trueのときcode generatorはこれを呼び出すようなコードを生成します。

CustomHandlerExprDataImportMaptypes.Qualifierになれたりするようなものです。types.TypeStringとともに使ってもよいようにしてあります。

https://github.com/ngicks/go-codegen/blob/6a0b75516f057f51967eb566eaf255890f975192/codegen/imports/parser.go#L389-L399

typeExpr := types.TypeString(data.Ty, data.ImportMap.Qualifier(data.PkgPath))

で、マッチした型のテキスト表現が得られます(e.g. foo.Bar[string])。もはやCustomHandlerExprDataにあらかじめ評価済みのものを渡しておいてもいい気がしますが、custom handlerの実装によっては全くいらないことも多いのでやめておきました。

matcherの定義の項ですでに見せていますが、例えば[]map[string]*[5]Tがあるとき、[5]Tにマッチするcustom handlerを提供すると、[]map[string]*までのfield unwrapperが出力され、[5]Tに対してcustom handlerを呼び出します。

いくつかbuilt-inのcustom handlerを定義しておいています。

https://github.com/ngicks/go-codegen/blob/6a0b75516f057f51967eb566eaf255890f975192/codegen/generator/cloner/custom_handler.go#L50-L253

例えば、[n]Tを単なる代入に変換するcustom handlerが適用すると以下のコードを生成した部分が

var inner [5]string
for k, v := range /* [5]string */ v {
    inner[k] = v
}

以下になります。

inner = v

現時点で以下のbuilt-in custom handlerが定義されています

  • Tがclone-by-assignなとき、[]Tに対してcopyベースのcloneを実行する
    • forを回すより最適化された処理をするようなことを読んだのでやってますが特に確証はありません。
    • する意味なかったら泣きながら消します。
    • 45038#issuecomment-799795384よりslices.Cloneのほうがよりperformantですが、capの正確なコピーができません。
  • Vがclone-by-assignなとき、map[K]Vに対してmaps.Cloneを呼び出す
    • これもforを回すより最適化された処理を呼び出していたのでこうしています。
  • time.Timeに対してtime.Date(v.Year(), v.Month(), v.Day(), v.Hour(), v.Minute(), v.Second(), v.Nanosecond(), v.Location())を呼び出す
    • monotonic timer以外のコピーです。
    • 単なる代入だとmonotonic timerがコピーされて困ります。されたほうがいいかも?要今後の検討ですね
  • *big.Int, *big.Rat, *big.Floatに対してコピー処理を呼び出す。
    • math/bigをあまり使わないので詳しくないですが、big.New*してSetを呼び出さないとコピーされない作りになっています
    • これらは内部的に[]Wordを持つので暗黙的にpointerを持っています。
  • xml.Tokenに対してxml.CopyTokenを呼び出す。
  • basic type、もしくは既知のclone-by-assign、もしくはそれらのarrayに対して単なる代入を行う。
    • pointerを一切含まない型に関しては機械的に列挙可能なのでしておきました(これ)
    • unique.Handle*time.Locationなど、定義上、APIでの取り扱い上内部にpointerを含んでいてもそのまま代入すればいいものは目で確認しながらリスト化していってます。(ここ)

やる気がでたら拡充します。

cliとして呼び出せるようにする

github.com/spf13/cobraを使ってサブコマンドとして呼び出せるようにしてあります。

# go run github.com/ngicks/go-codegen/codegen@6a0b75516f057f51967eb566eaf255890f975192 cloner --help
cloner generates clone methods on target types.

cloner command generates 2 kinds of clone methods

1) Clone() for non-generic types
2) CloneFunc() for generic types.

CloneFunc requires clone function for each type parameters.

Example:

func (c C[T, U]) CloneFunc(cloneT func(T) T, cloneU func(U) U) C[T, U] {
        // ...
}

The cloner sub command, as other commands do, loads and parses Go source code files
by using "golang.org/x/tools/go/packages".Load
then it examines types defined in them whether if they are clone-able or not.
Multiple packages can be loaded and processed at once.
The type dependency chain is allowed to span across multiple packages and generated Clone method considers it.

The specified package path must be relative to the cwd, which can be changed by --dir option,
to limit the target packages to which the process can write generated code safely.

The clone-able is defines as
1) A struct type which has at least a field of
1-1) basic types or pointer of basic types
1-2) array, slice or map of 1-1).
1-3) channel, noCopy object (types with the Lock method, e.g. sync.Mutex or sync.Locker), func type when the configuration allows copying each of them.
1-4) a type that implements Clone or CloneFunc method
1-5) other 1) types.
2) A named type whose underlying type is array, slice or map of 1), basic types or pointer of basic type.

A field of deeply nested type, for example, []*[5]map[int]string is still considered as clone-able,
since bottom type, string, is a basic therefore clone-able type.
We call parts other than that ([]*[5]map[int]) _route_. And each element of them as _route node_
(i.e. for []*[5]map[int]string, route nodes are slice, pointer, map, in the order. The bottom type is string.)
Only disallowed _route node_ is interface literal. They are ignored silently.

The cloner sub command also allows per-field basis configuration by writing comments associated to it.
For example:

type Foo struct {
        //cloner:copyptr
        NoCopy *sync.Mutex
}

the cloner command generates

func (v Foo) Clone() Foo {
        return Foo{
                NoCopy: v.NoCopy,
        }
}

Without the comment, the cloner command ignores the type Foo since it has no clone-able fields other than that.

Usage:
  codegen cloner [flags] --pkg ./

Flags:
      --build-flags strings    a comma separated list of command-line flags to be passed through to the build system's query tool.
      --chan-copy             sets global option that copies channel fields
      --chan-disallow         sets global option that disallows channel fields
      --chan-ignore           sets global option that ignores channel fields.
      --chan-make             sets global option that makes new channel. Clone methods also copy the capacity of input channels.
  -d, --dir string            specifies the current working directory for the source code loader and other tools.
                              The path specified by --pkg flag is evaluated under this directory.
                              If empty cwd will be used.
      --dry                   enables dry run mode. any files will not be removed nor generated.
      --func-copy             sets global option that copies func fields
      --func-disallow         sets global option that disallow func fields.
      --func-ignore           sets global option that ignores func fields. func literal or named function type.
  -h, --help                  help for cloner
      --ignore-generated      You do not need this option.
                              If set, the type checker ignores ast nodes with comment //codegen:generated attached.
                              Useful for internal debugging.
      --interface-copy        sets global option that copies interface fields
      --interface-ignore      sets global option that ignores interface fields. func literal or named function type.
      --no-copy-copy          sets global option that copy pointer of no-copy object. Clone methods copy no-copy object if and only if field is pointer type.
      --no-copy-disallow      sets global option that disallow no-copy object. Types that contain no-copy type fields are not generation target.
      --no-copy-ignore        sets global option that ignores no-copy object. Clone methods just simply leave fields zero value.
  -p, --pkg ./...             [required] target package pattern. relative to dir. must start with "./". can be ./...
  -v, --verbose               verbose logs

matcherの定義で見せたMatcherConfigの各項目がcli flagとしてカスタマイズできるようになっています。説明がわかりにくい気がする・・・この辺は今後の改善事項ですね。

生成結果のexample

以下にexample typeとその生成結果が出力されます。

https://github.com/ngicks/go-codegen/tree/6a0b75516f057f51967eb566eaf255890f975192/codegen/generator/cloner/internal/testtargets

tree構造のcloneのexampleとか

https://github.com/ngicks/go-codegen/blob/6a0b75516f057f51967eb566eaf255890f975192/codegen/generator/cloner/internal/testtargets/tree/tree.go

https://github.com/ngicks/go-codegen/blob/6a0b75516f057f51967eb566eaf255890f975192/codegen/generator/cloner/internal/testtargets/tree/tree.clone.go

(exampleなのに本当に動作するbinary treeの実装を書いちゃった)

struct literalを含む型に対するexampleとか

https://github.com/ngicks/go-codegen/blob/6a0b75516f057f51967eb566eaf255890f975192/codegen/generator/cloner/internal/testtargets/structlit/lit.go

https://github.com/ngicks/go-codegen/blob/6a0b75516f057f51967eb566eaf255890f975192/codegen/generator/cloner/internal/testtargets/structlit/lit.clone.go

type aliasを含む場合のexampleとか

https://github.com/ngicks/go-codegen/blob/6a0b75516f057f51967eb566eaf255890f975192/codegen/generator/cloner/internal/testtargets/alias/alias.go

https://github.com/ngicks/go-codegen/blob/6a0b75516f057f51967eb566eaf255890f975192/codegen/generator/cloner/internal/testtargets/alias/alias.clone.go

build constraintsを含む場合のexampleとか

https://github.com/ngicks/go-codegen/tree/6a0b75516f057f51967eb566eaf255890f975192/codegen/generator/cloner/internal/testtargets/constraint

色々用意してあります。

今後

  • prefer-slices-cloneオプションの追加
    • 現在はTがclone-by-assignであるとき、[]Tに対してcopyベースの処理を行うのがデフォルトになっています
    • capの正確なコピーができない反面slices.Cloneのほうがzero valueでの初期化を挟まない分パフォーマンスがよいです
    • capの正確なコピーが必要ないケースのほうが多そうなのでこちらがデフォルトになるかもしれないです。
  • known clone by assignの拡充
    • まだ洗いきっていないstd libのpackageがあるので全部見る。
  • overlayオプション
    • in-placeオプション(型定義やstruct fieldにコメントをつけて行う設定)と同じことを外部データからもできるようにする。
      • フィールド単位の無視とか、コピー方法の指定とかです。
    • 他のcode generatorによって生成された型にコメントをつけて回るのは現実的にしたくない運用だからそこをカバーしに行くためです。
      • github.com/oapi-codegen/oapi-codegenの生成するコードにさらにCloneを生成してみて、server interfaceとかに不要なのにcloneを生成して困っています。
  • in-placeオプションの拡充
    • フィールドレベルでclonerの関数を指定したり
    • フィールドを無視させたり、単なるassignに変えたり
  • templateによるcustom handlerの受付
    • 現状custom handlerはgoのプログラムとして呼び出す場合にのみ渡せますが、これをcli経由でも渡せるように整備するということです。
    • text/templateで解釈できるテキストとしてcustom handlerを定義できればよいわけです。
    • text/templateはテキストから呼び出せる関数を任意に定義可能なのでなんでもできるんですが、
    • それはそれとして1度もそういうことをしたことがないのでノウハウがないため大変ですね。
  • 型がすでにCloneを実装していてそれがclonerが生成したものでないときは生成対象から外す。
  • method receiverをほかのmethod receiverと同じものにする
    • 現状問答無用でfunc(v Type) Clone() Typevをreceiverにしたmethodを生成しますが、このreceiverはgodoc上表示されるので見栄えが悪い
  • ドキュメントを整備する
    • 現状これらのサブコマンドの説明がgit repositoryのトップに全然なくて誰も把握できてないと思います。
    • 誰が読むのかもわからないドキュメントを読みやすく整備するのは精神との戦いだったりしますね
    • そもそもぱっと読んでわかるドキュメントを備えたOSSってのが珍しくて、ソースを読んでようやくなるほどなってなることが多いです。多分大変なことなんですよね。
  • 前回の記事の##おわりにで触れた別のcode generatorを作っていく。
    • そこに上げているwrapperの必要性を日々感じていて困っているのでいったん切り上げて別テーマに行くのもありですね。

おまけ: go/ast, go/typesお気をつけポインツ

ast

type declのunderlying typeは()で囲むことができる

The Go Programming Language SpecificationのTypesのところをよーく見たらわかるんですが、以下は合法です。

playground

type B (struct{})

これは見たことがない。ただ安易に*ast.TypeSpectypeSpec.Type.(*ast.StructType)として判定を行うと、合法なソースコードが処理できないというissueが上がってくることもありうる・・・かもしれないですね。

一応考慮に入れるなら以下のようにすればよいでしょう。

playground

package main

import (
    "fmt"
    "go/ast"
    "go/parser"
    "go/token"
)

var src = `package foo

type A (((struct{})))
`

func main() {
    fset := token.NewFileSet()
    file, err := parser.ParseFile(fset, "foo.go", src, parser.ParseComments|parser.AllErrors)
    if err != nil {
        panic(err)
    }

    var ts *ast.TypeSpec

SEARCH:
    for _, dec := range file.Decls {
        genDecl, ok := dec.(*ast.GenDecl)
        if !ok {
            continue
        }
        if genDecl.Tok != token.TYPE {
            continue
        }
        for _, spec := range genDecl.Specs {
            ts = spec.(*ast.TypeSpec)
            break SEARCH
        }

    }
    handleStructType(ts)
}

func handleStructType(ts *ast.TypeSpec) {
    unwrapped := ts.Type

    var loopCount int
    const maxDepth = 100
    for {
        if loopCount >= maxDepth {
            panic("too deep parenthesis")
        }
        paren, ok := unwrapped.(*ast.ParenExpr)
        if !ok {
            break
        }
        unwrapped = paren.X
        loopCount++
    }
    st, ok := unwrapped.(*ast.StructType)
    if !ok {
        // handle
        return
    }
    fmt.Printf("structType = %v\n", st)
    // structType = &{24 0xc0000a81e0 false}
}

構文ルール上、type A ((((struct {}))))みたいにいくつもparenthesisがネストするのは許されています。
筆者環境ではgofumptによるフォーマットをかけていますがこれだと冗長なParenthesis(()) は削除されて一つになります。

システム外部から入力を受け付けるケースではparenthesisの深さに上限をつけないとDoSを可能にしてしまいます。

*ast.StructTypeのFieldは0個もしくは複数個のNamesを持つ。

Goのstructは複数のFieldを一行に書くことができます。

*ast.Fieldの定義より、1つの*ast.Fieldは複数のNamesを持つことがあります。

*types.Structにはn番目のfieldを取得するAPIしかないため、astをベースに型情報との連携を行うとき、fieldの列挙が不正確だと不整合を起します。

type A struct {
    Foo, Bar, Baz string
}

上記のFoo,Bar, Bazは1つの*ast.Fieldで表現されます。

さらに、このNamesは0個の時もあります。以下のようにstruct embeddingが行われているときです。

type A struct {
    Embedded
}

この時、go/typesで定義される型情報上ではEmbeddedという名前の1個のfieldとして取り扱われます。

すべてのFieldの名前を列挙するには以下のように定義するとよいでしょう。

type FieldDescAst struct {
    Pos   int
    Name  string
    Field *ast.Field
}

func FieldAst(st *ast.StructType) iter.Seq[FieldDescAst] {
    return func(yield func(FieldDescAst) bool) {
        if st.Fields == nil || len(st.Fields.List) == 0 {
            return
        }
        pos := 0
        for i := 0; i < len(st.Fields.List); i++ {
            f := st.Fields.List[i]
            names := f.Names
            if len(names) == 0 {
                // embedded field
                unwrapped := f.Type
                var name string
            UNWRAP:
                for {
                    switch x := unwrapped.(type) {
                    default:
                        panic(fmt.Errorf("unknown type in an anonymous field: %T in %v", unwrapped, f.Type))
                    case *ast.Ident:
                        name = x.Name
                        break UNWRAP
                    case *ast.StarExpr:
                        unwrapped = x.X
                    case *ast.SelectorExpr:
                        unwrapped = x.Sel
                    case *ast.IndexExpr: // type param
                        unwrapped = x.X
                    case *ast.IndexListExpr:
                        unwrapped = x.X
                    }
                }
                if !yield(FieldDescAst{pos, name, f}) {
                    return
                }
                pos++
            } else {
                for _, name := range names {
                    if !yield(FieldDescAst{pos, name.Name, f}) {
                        return
                    }
                    pos++
                }
            }
        }
    }
}

EmbeddedFieldのspecよりembedできるのはnamed typeかそれへのpointerのみです。type paramとtype paramへのポインターはembedできません。

  • 型の名前となるidentifierは*ast.Ident
  • ポインターは*ast.StarExpr
  • X.Sel(=外部packageの型)は*ast.SelectorExpr
  • X[Index](=type paramが1つのgeneric type)は*ast.IndexExpr
  • X[Index1, Index2,...](=type paramが複数のgeneric type)は*ast.IndexListExpr

となります。

types

unaliasしよう

The Go Blog: What's in an (Alias) Name?より、Goでは型をaliasすることができます。
Go 1.24からaliasがtype paramを持てるようになります。

type Foo[T, U comparable] struct{}

type (
    Foo2 = Foo[int, string]
    // Go 1.23かそれ以前だと非合法
    Foo3[T interface{ ~int | ~uint }] = Foo[int, T]
)

厄介なことに、type aliasはsliceなどの無名の型を含むことができます。

type A = []B
type B = map[string]C
type C = chan D
type D struct {
    // ...
}
type E = struct{ Foo string }
type F = interface{ Look() }

struct literalやinterface literalにもaliasを行うことができます。

types.Unaliasを使うことで、aliasされていない型を取り出すことができます。

playground

package main

import (
    "fmt"
    "go/ast"
    "go/importer"
    "go/parser"
    "go/token"
    "go/types"
)

func parseStringSource(src string) (*token.FileSet, *ast.File, *types.Package) {
    fset := token.NewFileSet()
    f, err := parser.ParseFile(fset, "hello.go", src, parser.ParseComments|parser.AllErrors)
    if err != nil {
        panic(err)
    }
    conf := &types.Config{
        Importer: importer.Default(),
    }
    pkg := types.NewPackage("hello", "main")
    chk := types.NewChecker(conf, fset, pkg, nil)
    err = chk.Files([]*ast.File{f})
    if err != nil {
        panic(err)
    }
    return fset, f, pkg
}

func main() {
    _, _, pkg := parseStringSource(`package main

type A = B
type B = C
type C = D
type D struct {
    // ...
}

type AA = [][]B
`)

    a := pkg.Scope().Lookup("A")
    aa := pkg.Scope().Lookup("AA")
    fmt.Printf("type name = %s\n", types.TypeString(a.Type(), func(p *types.Package) string { return "" }))
    // type name = A
    fmt.Printf("unaliased A = %s\n", types.TypeString(types.Unalias(a.Type()), func(p *types.Package) string { return "" }))
    // unaliased A = D
    fmt.Printf("unaliased AA = %s\n", types.TypeString(types.Unalias(aa.Type()), func(p *types.Package) string { return "" }))
    // unaliased AA = [][]B
}

上記からわかる通り、最初のalias以外の型までunaliasしてくれます。

型を見たらとりあえずtypes.Unaliasするぐらいの勢いでよいと思います。

type asserterを定義しよう

例えば以下のようなstructがあるとします。

type A struct {
    F1 func()
    F2 Fn
}

type Fn func()

F1の型は*types.Signature, F2の方は*types.Namedです。
どちらも特定のsignatureを満たすfuncなわけですから、同じように取り扱いたいときはよくあると思います。

そこで以下のようなhelperを定義しておくと便利だと気づきました。

func asUnderlying[T types.Type](ty types.Type) T {
    ty = types.Unalias(ty)
    t, ok := ty.(T)
    if ok {
        return t
    }
    named, ok := ty.(*types.Named)
    if !ok {
        // should be nil
        return *new(T)
    }
    ty = types.Unalias(named.Underlying())
    t, _ = ty.(T)
    return t
}

aliasingを無視し、元となっている型がTであるか、tyがnamed typeならそのunderlying typeがTであるかをチェックします。

感想

そこそこ実用に耐えるレベルにはなってきたかな~?って感じですね。すぐできると思ったんですが、がっつりやってたのに1か月ぐらいかかっちゃいましたね。
あとは使ってみて荒を洗い出すところですね。とりあえず1回使ってみてtype aliasでtype A = []Bみたいな型があると動作しなかったり、fieldがembedされた型に対して正常に動作しないのに気づいたりしてから慌てて直してます。

他のcode generatorが吐いた型に対してさらにClone methodを生成するという前からやりたかったことができるようになったことで、生活がほんの少し楽になりました。
他のcode generatorというのはgithub.com/oapi-codegen/oapi-codegenなんですが、時と場合によってstruct literalを含む型が吐かれたり、type aliasであれこれやられたり*map[K]Vが吐かれたり、手で書いてたらめったに見ないことのオンパレードになってハードルが上がりまくるので大変でした。おかげでコーナーケースがある程度潰せていると思います。

前回の記事で作成したundgenに次いで、結構な要素を使いまわした別のcode generatorを作ったわけですが、こうして別のものを作ってみるとどこが使いまわせてどこが全然関係ないところなのかが見えてきて頭の中がまとまるので結構面白いです。特にtypegraph周りにはundgen固有の残骸が残ってて、全く使いまわせないのに不適切なところに不適切なものを定義してしまっていることに気付きました。ここから広げた枝を改めて刈り込む工程があります。

これまで型情報をいじっていろいろやってきましたが、function body内の型情報とそれを使ったrewriteに関してはなにもやってこなかったのでそちらにも進んでいきたいと思っています。
さらにvscode/vimと連携してエディターからそれらの機能を呼び出せるようにするとか、やりたいネタはまだまだあります。

GitHubで編集を提案

Discussion

ログインするとコメントできます