The Go Blog
汎用インターフェース
初めて聞くまで自明ではないアイデアがあります。インターフェースはそれ自体が型であるため、型パラメーターを持つこともできます。このアイデアは、汎用関数や型の制約を表現する上で驚くほど強力であることが判明しました。この投稿では、いくつかの一般的なシナリオで型パラメーターを持つインターフェースの使用について議論することで、それを実証します。
シンプルなツリーセット
動機付けの例として、二分探索木の汎用バージョンが必要だと仮定します。そのようなツリーに格納される要素は順序付けされている必要があるため、型パラメーターは使用する順序付けを決定する制約を必要とします。簡単なオプションは、Go 1.21 で導入された cmp.Ordered 制約を使用することです。これは、型パラメーターを順序付けられた型 (文字列と数値) に制限し、型のメソッドが組み込みの順序付け演算子を使用できるようにします。
// The zero value of a Tree is a ready-to-use empty tree.
type Tree[E cmp.Ordered] struct {
root *node[E]
}
func (t *Tree[E]) Insert(element E) {
t.root = t.root.insert(element)
}
type node[E cmp.Ordered] struct {
value E
left *node[E]
right *node[E]
}
func (n *node[E]) insert(element E) *node[E] {
if n == nil {
return &node[E]{value: element}
}
switch {
case element < n.value:
n.left = n.left.insert(element)
case element > n.value:
n.right = n.right.insert(element)
}
return n
}
(プレイグラウンド)
ただし、このアプローチには、< が定義されている基本型でのみ機能するという欠点があります。たとえば、time.Time のような構造体型を挿入することはできません。
ユーザーに比較関数を提供するよう要求することで、これを改善できます。
// A FuncTree must be created with NewTreeFunc.
type FuncTree[E any] struct {
root *funcNode[E]
cmp func(E, E) int
}
func NewFuncTree[E any](cmp func(E, E) int) *FuncTree[E] {
return &FuncTree[E]{cmp: cmp}
}
func (t *FuncTree[E]) Insert(element E) {
t.root = t.root.insert(t.cmp, element)
}
type funcNode[E any] struct {
value E
left *funcNode[E]
right *funcNode[E]
}
func (n *funcNode[E]) insert(cmp func(E, E) int, element E) *funcNode[E] {
if n == nil {
return &funcNode[E]{value: element}
}
sign := cmp(element, n.value)
switch {
case sign < 0:
n.left = n.left.insert(cmp, element)
case sign > 0:
n.right = n.right.insert(cmp, element)
}
return n
}
(プレイグラウンド)
これは機能しますが、欠点もあります。明示的に初期化された比較関数が必要になるため、コンテナ型のゼロ値を使用できなくなります。また、関数フィールドを使用すると、コンパイラが比較呼び出しをインライン化しにくくなり、実行時のオーバーヘッドが大幅に増加する可能性があります。
要素型にメソッドを使用することで、これらの問題を解決できます。メソッドは型に直接関連付けられているためです。メソッドを明示的に渡す必要はなく、コンパイラは呼び出しのターゲットを確認でき、インライン化できる可能性があります。しかし、要素型が必要なメソッドを提供することを要求する制約をどのように表現できるでしょうか?
制約でのレシーバーの使用
最初に試すアプローチは、Compare メソッドを持つ通常のインターフェースを定義することです。
type Comparer interface {
Compare(Comparer) int
}
しかし、これはうまく機能しないことにすぐに気づきます。このインターフェースを実装するには、メソッドのパラメーター自体が Comparer でなければなりません。これは、このメソッドの実装がパラメーターを独自の型に型アサートする必要があるだけでなく、すべての型が Comparer 型を持つ私たちのパッケージを名前で明示的に参照する必要があることも意味します(そうでなければメソッドシグネチャが同一になりません)。これはあまり直交的ではありません。
より良いアプローチは、Comparer インターフェース自体を汎用化することです。
type Comparer[T any] interface {
Compare(T) int
}
この Comparer は、Comparer がインスタンス化されうる各型に対応する、インターフェースのファミリー全体を記述します。Comparer[T] を実装する型は、「私は自分自身を T と比較できる」と宣言します。たとえば、time.Time は、一致する Compare メソッドを持っているため、自然に Comparer[time.Time] を実装します。
// Implements Comparer[Time]
func (t Time) Compare(u Time) int
これは改善されましたが、十分ではありません。私たちが本当に望んでいるのは、型パラメーターが**それ自体**と比較できることを示す制約です。つまり、制約が自己参照的であることを望んでいます。微妙な洞察は、自己参照的な側面がインターフェース定義自体の一部である必要はないということです。具体的には、Comparer 型の T の制約は単に any です。代わりに、それは Comparer を MethodTree の型パラメーターの制約として使用する方法の結果です。
// The zero value of a MethodTree is a ready-to-use empty tree.
type MethodTree[E Comparer[E]] struct {
root *methodNode[E]
}
func (t *MethodTree[E]) Insert(element E) {
t.root = t.root.insert(element)
}
type methodNode[E Comparer[E]] struct {
value E
left *methodNode[E]
right *methodNode[E]
}
func (n *methodNode[E]) insert(element E) *methodNode[E] {
if n == nil {
return &methodNode[E]{value: element}
}
sign := element.Compare(n.value)
switch {
case sign < 0:
n.left = n.left.insert(element)
case sign > 0:
n.right = n.right.insert(element)
}
return n
}
(プレイグラウンド)
time.Time は Comparer[time.Time] を実装しているので、このコンテナの有効な型引数となり、ゼロ値を空のコンテナとして使用することもできます。
var t MethodTree[time.Time]
t.Insert(time.Now())
最大限の柔軟性のために、ライブラリは3つのAPIバージョンすべてを提供できます。繰り返しを最小限に抑えたい場合は、すべてのバージョンで共通の実装を使用できます。最も汎用的な関数バージョンを使用できます。
type node[E any] struct {
value E
left *node[E]
right *node[E]
}
func (n *node[E]) insert(cmp func(E, E) int, element E) *node[E] {
if n == nil {
return &node[E]{value: element}
}
sign := cmp(element, n.value)
switch {
case sign < 0:
n.left = n.left.insert(cmp, element)
case sign > 0:
n.right = n.right.insert(cmp, element)
}
return n
}
// Insert inserts element into the tree, if E implements cmp.Ordered.
func (t *Tree[E]) Insert(element E) {
t.root = t.root.insert(cmp.Compare[E], element)
}
// Insert inserts element into the tree, using the provided comparison function.
func (t *FuncTree[E]) Insert(element E) {
t.root = t.root.insert(t.cmp, element)
}
// Insert inserts element into the tree, if E implements Comparer[E].
func (t *MethodTree[E]) Insert(element E) {
t.root = t.root.insert(E.Compare, element)
}
(プレイグラウンド)
ここで重要な観察は、共通の実装(関数ベースのバリアント)はいかなる方法でも制約されないということです。共通のコアとして機能するために、最大限の柔軟性を維持する必要があります。また、比較関数を構造体フィールドに格納することもありません。代わりに、関数引数はコンパイラが構造体フィールドよりも分析しやすいため、パラメーターとして渡します。
もちろん、ある程度のボイラープレートはまだ必要です。エクスポートされたすべての実装は、わずかに異なる呼び出しパターンで完全なAPIを複製する必要があります。しかし、この部分は記述も読み取りも簡単です。
メソッドと型セットの組み合わせ
新しいツリーデータ構造を使用して、順序付きセットを実装し、要素の検索を対数時間で提供できます。今、検索を定数時間で実行する必要があると想像してみましょう。ツリーと並行して通常の Go マップを維持することで、これを試みることができます。
type OrderedSet[E Comparer[E]] struct {
tree MethodTree[E] // for efficient iteration in order
elements map[E]bool // for (near) constant time lookup
}
func (s *OrderedSet[E]) Has(e E) bool {
return s.elements[e]
}
func (s *OrderedSet[E]) Insert(e E) {
if s.elements == nil {
s.elements = make(map[E]bool)
}
if s.elements[e] {
return
}
s.elements[e] = true
s.tree.Insert(e)
}
func (s *OrderedSet[E]) All() iter.Seq[E] {
return func(yield func(E) bool) {
s.tree.root.all(yield)
}
}
func (n *node[E]) all(yield func(E) bool) bool {
return n == nil || (n.left.all(yield) && yield(n.value) && n.right.all(yield))
}
(プレイグラウンド)
しかし、このコードをコンパイルするとエラーが発生します。
無効なマップキー型 E (比較可能な制約がありません)
エラーメッセージは、型パラメーターをマップキーとして使用できるようにするために、さらに制約する必要があることを示しています。`comparable` 制約は、等価演算子 `==` と `!=` が定義されているすべての型で満たされる特殊な事前宣言された制約です。Go では、これは組み込みの `map` 型のキーとして使用できる型のセットでもあります。
この制約を型パラメーターに追加するには3つのオプションがあり、それぞれ異なるトレードオフがあります。
-
元の
Comparer定義にcomparableを埋め込むことができます (プレイグラウンド)。type Comparer[E any] interface { comparable Compare(E) int }これは、
Tree型がcomparableな型でのみ使用可能になるという欠点があります。一般的に、汎用型を不必要に制限したくはありません。 -
新しい制約定義を追加できます (プレイグラウンド)。
type Comparer[E any] interface { Compare(E) int } type ComparableComparer[E any] interface { comparable Comparer[E] }これはきれですが、API に新しい識別子 (
ComparableComparer) を導入し、命名は難しいです。 -
より制約の厳しい型にインラインで制約を追加できます (プレイグラウンド)。
type OrderedSet[E interface { comparable Comparer[E] }] struct { tree Tree[E] elements map[E]struct{} }これは、特に頻繁に発生する必要がある場合、読みにくくなる可能性があります。また、他の場所で制約を再利用することも難しくなります。
これらの中からどれを使用するかは、スタイルの選択であり、最終的には個人の好みに委ねられます。
汎用インターフェースの制約 (しない)
この時点で、汎用インターフェースの制約について議論する価値があります。汎用コンテナ型用のインターフェースを定義したいと思うかもしれません。たとえば、セットデータ構造を必要とするアルゴリズムがあるとします。さまざまなトレードオフを持つ多くの異なる種類のセット実装があります。必要なセット操作のインターフェースを定義することで、パッケージに柔軟性を加え、特定のアプリケーションにとってどのトレードオフが適切であるかという決定をユーザーに任せることができます。
type Set[E any] interface {
Insert(E)
Delete(E)
Has(E) bool
All() iter.Seq[E]
}
ここで自然な疑問は、このインターフェースの制約は何であるべきかということです。可能であれば、汎用インターフェースの型パラメーターは any を制約として使用し、任意の型を許可する必要があります。
上記の議論から、その理由は明らかであるはずです。異なる具体的な実装は、異なる制約を必要とする場合があります。上で検討したすべての Tree 型と OrderedSet 型は、これらの型が異なる制約を持っているにもかかわらず、その要素型に対して Set を実装できます。
インターフェースを定義する目的は、実装をユーザーに任せることです。ユーザーが実装にどのような制約を課したいかを予測できないため、制約(`any`よりも強いもの)はインターフェースではなく、具体的な実装に残すようにしてください。
ポインタレシーバ
Set インターフェースを例で使ってみましょう。シーケンス内の重複要素を削除する関数を考えます。
// Unique removes duplicate elements from the input sequence, yielding only
// the first instance of any element.
func Unique[E comparable](input iter.Seq[E]) iter.Seq[E] {
return func(yield func(E) bool) {
seen := make(map[E]bool)
for v := range input {
if seen[v] {
continue
}
if !yield(v) {
return
}
seen[v] = true
}
}
}
(プレイグラウンド)
これは map[E]bool を E 要素の単純なセットとして使用します。したがって、comparable であり、それゆえに組み込みの等価演算子を定義する型でのみ機能します。これを任意の型に一般化したい場合、それを汎用セットに置き換える必要があります。
// Unique removes duplicate elements from the input sequence, yielding only
// the first instance of any element.
func Unique[E any](input iter.Seq[E]) iter.Seq[E] {
return func(yield func(E) bool) {
var seen Set[E]
for v := range input {
if seen.Has(v) {
continue
}
if !yield(v) {
return
}
seen.Insert(v)
}
}
}
(プレイグラウンド)
しかし、これは機能しません。Set[E] はインターフェース型であり、seen 変数は nil に初期化されます。Set[E] インターフェースの具体的な実装を使用する必要があります。しかし、この投稿で見てきたように、any 要素型で機能するセットの一般的な実装はありません。
追加の型パラメータとして、使用できる具体的な実装をユーザーに提供してもらう必要があります。
// Unique removes duplicate elements from the input sequence, yielding only
// the first instance of any element.
func Unique[E any, S Set[E]](input iter.Seq[E]) iter.Seq[E] {
return func(yield func(E) bool) {
var seen S
for v := range input {
if seen.Has(v) {
continue
}
if !yield(v) {
return
}
seen.Insert(v)
}
}
}
(プレイグラウンド)
しかし、これを私たちのセット実装でインスタンス化すると、別の問題に直面します。
// OrderedSet[E] does not satisfy Set[E] (method All has pointer receiver)
Unique[E, OrderedSet[E]](slices.Values(s))
// panic: invalid memory address or nil pointer dereference
Unique[E, *OrderedSet[E]](slices.Values(s))
最初の問題は、エラーメッセージから明らかです。私たちの型制約は、S の型引数が Set[E] インターフェースを実装する必要があると言っています。そして、OrderedSet のメソッドがポインタレシーバを使用しているため、型引数もポインタ型でなければなりません。
それを実行しようとすると、2番目の問題に直面します。これは、実装で変数を宣言しているという事実から生じます。
var seen S
S が *OrderedSet[E] の場合、変数は以前と同様に nil で初期化されます。seen.Insert を呼び出すとパニックが発生します。
ポインタ型しか持っていない場合、値型の有効な変数を取得できません。そして、値型しか持っていない場合、それに対してポインタメソッドを呼び出すことはできません。結果として、値型**と**ポインタ型の両方が必要になります。したがって、新しい制約 PtrToSet を持つ追加の型パラメータ PS を導入する必要があります。
// PtrToSet is implemented by a pointer type implementing the Set[E] interface.
type PtrToSet[S, E any] interface {
*S
Set[E]
}
// Unique removes duplicate elements from the input sequence, yielding only
// the first instance of any element.
func Unique[E, S any, PS PtrToSet[S, E]](input iter.Seq[E]) iter.Seq[E] {
return func(yield func(E) bool) {
// We convert to PS, as only that is constrained to have the methods.
// The conversion is allowed, because the type set of PS only contains *S.
seen := PS(new(S))
for v := range input {
if seen.Has(v) {
continue
}
if !yield(v) {
return
}
seen.Insert(v)
}
}
}
(プレイグラウンド)
ここでの秘訣は、PtrToSet インターフェースの追加の型パラメーターを介して、関数シグネチャ内の2つの型パラメーターを接続することです。S 自体は制約されていませんが、PS は型 *S を持ち、必要なメソッドを持っている必要があります。したがって、事実上、S がいくつかのメソッドを持つように制限していますが、それらのメソッドはポインターレシーバーを使用する必要があります。
この種の制約を持つ関数の定義には追加の型パラメーターが必要ですが、重要なのは、これを使用するコードを複雑にしないことです。この追加の型パラメーターが型パラメーターリストの最後にある限り、推論できます。
// The third type argument is inferred to be *OrderedSet[int]
Unique[int, OrderedSet[int]](slices.Values(s))
これは一般的なパターンであり、覚えておく価値があります。他人の作品でこれに遭遇したとき、または自分の作品でこれを使用したいときに役立ちます。
func SomeFunction[T any, PT interface{ *T; SomeMethods }]()
2つの型パラメーターがあり、一方がもう一方のポインターに制約されている場合、その制約は関連するメソッドがポインターレシーバーを使用することを保証します。
ポインタレシーバに制約すべきか?
この時点で、かなり圧倒されているかもしれません。これはかなり複雑で、すべての Go プログラマーがこの関数シグネチャで何が起こっているのかを理解することを期待するのは不合理に思えます。また、API にさらに多くの名前を導入する必要がありました。そもそも Go にジェネリクスを追加することに警告した人々は、このことを懸念していました。
もしあなたがこれらの問題に巻き込まれていると感じたら、一歩下がって考えてみる価値があります。多くの場合、問題を別の方法で考えることで、この複雑さを回避できます。この例では、iter.Seq[E] を受け取り、一意の要素を持つ iter.Seq[E] を返す関数を作成しました。しかし、重複排除を行うには、一意の要素をセットに収集する必要がありました。そして、これには結果全体のための領域を割り当てる必要があるため、結果をストリームとして表現することから実際に恩恵を受けることはありません。
この問題を再考すれば、Set[E] を通常のインターフェース値として使用することで、余分な型パラメーターを完全に回避できます。
// InsertAll adds all unique elements from seq into set.
func InsertAll[E any](set Set[E], seq iter.Seq[E]) {
for v := range seq {
set.Insert(v)
}
}
(プレイグラウンド)
Set をプレーンなインターフェース型として使用することで、呼び出し元が具体的な実装の有効な値を提供する必要があることが明確になります。これは非常に一般的なパターンです。そして、iter.Seq[E] が必要な場合は、set 上で All() を呼び出すだけで取得できます。
これは呼び出し元にとって少し複雑になりますが、ポインタレシーバーへの制約とは別の利点があります。単純なセット型として map[E]bool から始めたことを思い出してください。その基礎の上で Set[E] インターフェースを実装するのは簡単です。
type HashSet[E comparable] map[E]bool
func (s HashSet[E]) Insert(v E) { s[v] = true }
func (s HashSet[E]) Delete(v E) { delete(s, v) }
func (s HashSet[E]) Has(v E) bool { return s[v] }
func (s HashSet[E]) All() iter.Seq[E] { return maps.Keys(s) }
(プレイグラウンド)
この実装はポインタレシーバーを使用しません。したがって、これは完全に有効ですが、ポインタレシーバーへの複雑な制約では使用できません。しかし、私たちの InsertAll バージョンでは問題なく機能します。多くの制約と同様に、メソッドがポインタレシーバーを使用することを強制することは、多くの実用的なユースケースにとって実際には過度に制限的である可能性があります。
まとめ
インターフェース上の型パラメーターが有効にするパターンとトレードオフのいくつかを示すことができたことを願っています。これは強力なツールですが、コストも伴います。主なポイントは次のとおりです。
- 汎用インターフェースを自己参照的に使用することで、レシーバーに対する制約を表現します。
- それらを使用して、異なる型パラメーター間に制約された関係を作成します。
- それらを使用して、さまざまな種類の制約を持つ異なる実装を抽象化します。
- ポインタレシーバーに制約する必要がある状況に陥った場合は、余分な複雑さを避けるためにコードをリファクタリングできないかどうか検討してください。「ポインタレシーバーに制約すべきか?」を参照してください。
常にそうですが、オーバーエンジニアリングは避けてください。柔軟性は劣るものの、よりシンプルで読みやすいソリューションが最終的には賢明な選択となるかもしれません。