Goのジェネリクスでnew(T)したら「type *T is pointer to type parameter, not type parameter」と怒られた
TL;DR
- Goのジェネリクスの制約はその型自身にのみかけることができ、派生するポインタに関して直接制約をかけることはできない
- ただし、型パラメータの推論をうまく使うことで擬似的に制約をかけることが可能
Go 1.18 で導入された generics だが、ポインタの絡む型を扱っている際に奇妙なエラーに悩まされることがある。
例えば、以下のように特定のインターフェースを実装した型を new して返すような関数を作ってみる。
|
|
CreateResourceは、「リソースの構造体を作る→その値の初期化」という一連の流れを共通化するための関数である。
Initializeメソッドに初期化処理を書くように定め、Initializableインターフェースでそのことを示している。
実際に初期化する際にはCreateResource[SomeResource]()のようにして呼び出す、という想定である。
中でポインタを作成しているのは、Initializeメソッドが自身の値を変更できるようにするためである。
(Initializeメソッドがポインタレシーバで定義されることを期待している)
しかし、このコードはコンパイル時に以下のエラーが発生する。
1./generics.go:11:4: r.Initialize undefined (type *T is pointer to type parameter, not type parameter)
一見するとなぜエラーとなるのかがわかりにくいのだが、Go の型システムについて正しく理解すると原因が分かるようになる。
なぜダメだったのか
エラーになったCreateResource[T]をもう一度よく見てみよう。
1func CreateResource[T Initializable]() *T {
2 r := new(T)
3 r.Initialize()
4 return r
5}
最初にnew(T)でTを作成している。newはポインタを返すので、rの型は*Tである。
一方、型パラメータにおいて、[T Initializable]という記述は、TについてはInitializableを実装していることを言っている一方、
*Tに関しては何も言っていない。 つまり、rに関して何の操作ができるのかコンパイラには何もわからないので、
その下の行でのr.Initialize()がエラーになるのである。
しかしこう思うかもしれない。「普通はTがメソッドを実装していたら*Tに対しても同じメソッド呼べますよね?」と。 実際、以下のようなコードは普通に機能する。
1type Fuga struct {}
2
3func (f Fuga) Hoge() {}
4
5func main() {
6 fp := new(Fuga)
7 fp.Hoge()
8}
このコードでは値型であるFugaに対してHogeメソッドを実装しているが、
Fugaのポインタfpに対し、fp.Hoge()というふうに値型で定義したメソッドを呼び出すことが出来ている。
これは一見するとHogeメソッドが値型とポインタ型の両方で自動的に共有されたかのように思えてしまうが、
実際にはそうはなっていない。
そもそもGoのメソッド呼び出し構文はただのシンタックスシュガーで、実際には以下のように展開される。
1type Fuga struct {}
2
3func (f Fuga) Hoge() {}
4
5func main() {
6 fp := new(Fuga)
7 Fuga.Hoge(*fp)
8}
このように、ポインタから値レシーバのメソッドを呼び出す際には、メソッドに自身を渡す際に内部的に逆参照を行っているのである。
つまり、結局のところ*Fugaを受け取るメソッドは存在せず、
呼び出し側でうまいことメソッドのレシーバ型に合わせて逆参照を行っているだけなのである。
つまり何が言いたいかと言うと、ある型についてinterfaceの実装は値型かポインタ型のいずれかに対してしか出来ないということである。 実装しなかった側の型はinterfaceの制約を満たすことはない。
より具体的にするため、以下の例を見てみよう。
1type Interface interface {
2 Func()
3}
4
5// 値型に対しInterfaceを実装
6type ValueImplemented struct{}
7func (v ValueImplemented) Func()
8
9// ポインタ型に対しInterfaceを実装
10type PointerImplemented struct{}
11func (p *PointerImplemented) Func()
12
13func UseInterface(i Interface) {}
14
15
16func main() {
17 valuev := ValueImplemented{}
18 valuep := PointerImplemented{}
19 pointerv := new(ValueImplemented)
20 pointerp := new(PointerImplemented)
21
22 UseInterface(valuev) // 値型でinterfaceを実装し、値を渡す→OK
23 UseInterface(valuep) // ポインタ型でinterfaceを実装し、値を渡す→NG
24 UseInterface(pointerv) // 値型でinterfaceを実装し、ポインタを渡す→本来はNGだがinterfaceへの変換時に値に直されるのでOK
25 UseInterface(pointerp) // ポインタ型でinterfaceを実装し、ポインタを渡す→OK
26}
今までの話を踏まえれば、上記の例では、ValueImplementedと*PointerImplementedがinterfaceInterfaceを実装し、
*ValueImplementedと*PointerImplementedはInterfaceを実装していないということになる。
※ 実際には*ValueImplementedをInterfaceとして取り扱おうとしても許される。(コード中のUseInterface(pointerv))
これは、ValueImplementedは値レシーバでInterfaceを実装しているため、
*ValueImplementedにおいても逆参照するだけでInterfaceを実装している型(ValueImplemented)に戻すことができるためである。
CreateResourceを修正する
インターフェースの実装ルールについて理解したところで、再度CreateResourceの例に戻ってみる。
1func CreateResource[T Initializable]() *T {
2 r := new(T)
3 r.Initialize()
4 return r
5}
6
7// Initializableを実装してみた型
8type SomeResource struct {
9 creationTime time.Time
10}
11func (r *SomeResource) Initialize() {
12 r.creationTime = time.Now()
13}
SomeResourceをよく見てみると、*SomeResourceに対してはInitializeが実装されているものの、
SomeResourceにはInitializeが実装されていない。
つまり、今回のシナリオ通りにTにSomeResourceを渡そうとしても、そもそもInitializableではないのだから制約を満たさずエラーになってしまうのである。
(実際にCreateResource[SomeResource]()の呼び出しを記述するとエラーになる)
つまり、そもそもTにかけるべき制約はInitializableではなかったのである。
実際にかけたい制約は「Tのポインタ型がInitializableを実装している」という制約である。
しかし、GoのGenericsにそのような制約を書く手段は存在しない。
よって、とりあえず動くようにする解決策としては「Tをanyにしてしまい、*TにInitializeが実装されていることを信じる」という方法がある。
これは以下のように書くことができる。
1func CreateResource[T any]() *T {
2 r := new(T)
3 interface{}(r).(Initializable).Initialize()
4 return r
5}
第二の型パラメータを利用したトリック
しかし、当然上記のような解決策では満足しないだろう。 これでは折角の型安全性が台無しである。
実は、以下のように2つめの型パラメータをうまく定義してやることで型安全性を保ったままこの問題を解決できる。
1func CreateResource[T any, PT interface { Initializable; *T }]() *T {
2 r := PT(new(T))
3 r.Initialize()
4 return (*T)(r)
5}
PTという型パラメータが増えている。interface { Initializable; *T }という記述は初見だと面食らうのだが、
これは型パラメータ特有の記法で、中に型名を並べて書くことで、記述した全ての型の積集合を表すことができる。
この場合、Initializableを実装しており、かつ*Tであるような型ということになる。
つまり、型パラメータPTを推論するには、*TかつInitializableな型が作成可能であること、
即ち*TがInitializableを実装していることが絶対条件となる。
(もしTにInitializableでない型を渡すと、積集合が空になってPTの推論に失敗する)
これにより、間接的に*Tに対して制約をかけることができているのである。
次に、関数内での動きに注目してみよう。
関数内で*Tの値を作り出したら、まずはPTにキャストする。
これにより、*TかつInitializableであることがわかった状態でrを触ることができ、本来の目的を達成できる。
また、PT自体は実質的には*Tと等価であるもののコンパイラ的には別の型なので、
返却する際には明示的に*Tに戻している。
このように、第二の型パラメータの推論時のチェックを活用することで、型安全性を保ったままポインタ型に制約をかけることができる。
参考文献
Go with Generics: type *T is pointer to type parameter, not type parameter - Stack Overflow
Go 1.18 Generics how to define a new-able type parameter with interface - Stack Overflow