Goジェネリックにおける[0]func(T)の活用

Goのジェネリックライブラリでは、見た目上「空」な構造体に奇妙なフィールドを埋め込むことがある。それは長さ0の配列で、要素型は関数かつ型パラメータを持つ。これは単なるトリックではなく、コンパイラを使って誤使用を防ぐための技法である。

1. 実際のニーズから:プラグイン可能な「比較戦略」

例えば、スライスに対して重複除去や検索、比較を行うジェネリックツールを作成しているとする。ユーザーが「2つの要素が等しいかの判断方法」をカスタマイズできるようにしたい。

package main

type Comparator[T any] interface {
    Compare(a, b T) bool
}

type Searcher[T any, C Comparator[T]] struct {
    comp C
}

func (s Searcher[T, C]) FindIndex(xs []T, target T) int {
    for i, v := range xs {
        if s.comp.Compare(v, target) {
            return i
        }
    }
    return -1
}

デフォルト実装として、Tが比較可能(comparable)な場合に==を使うようにしたい。

type BasicComparator[T comparable] struct{}

func (BasicComparator[T]) Compare(a, b T) bool {
    return a == b
}

ここまでで問題ないように見えるが、実際には2つの「型セーフティ」の落とし穴がある。

2. 落とし穴A:異なるTのデフォルト戦略が同じように見える

BasicComparator[int]BasicComparator[string]はメモリレイアウト上空構造体(struct{})である。 空構造体の最大の特徴はフィールドがまったくないこと。そのため、多くの場合「完全に同じように見える」。

大規模なジェネリックラッパーを作成する際、以下のような状況が発生する可能性がある:

  • デフォルト戦略をパラメータ、フィールド、戻り値として頻繁に渡す
  • ラッパー/型エイリアス/反射/unsafeや中間抽象層での処理
  • 「戦略タイプ」を「ただの空殻」と見なされた場合、誤って戦略を取得してしまうか、変換で回避してしまう可能性がある

直感的な理解:ジェネリック型がインスタンス化後も空である場合、型システムで制限できるものが少なくなり、誤使用の可能性が増える

我々が望むのは: BasicComparator[int]BasicComparator[string]が「構造的に異なる」ようにすることで、できるだけエラーをコンパイル時に防ぐ。

3. 落とし穴B:戦略オブジェクトが比較される・マップのキーとして使われる

戦略オブジェクト(比較器、ハッシュ器、ソートルールなど)は通常、データを持たず、動作を担当する。 もしそれが比較可能な空構造体であれば、以下のような「見た目は合理だが危険な」コードがコンパイル可能になる。

// 例:戦略をキーとして結果をキャッシュ
map[BasicComparator[int]]something

より一般的には、コンテナやキャッシュ構造を実装し、後日誰かが戦略オブジェクトを構造体に詰め込み、==で全体を比較しようとした場合、比較が成功するが意味が完全に異なるバグが隠れている。

我々が望むのは: 戦略オブジェクトが==比較をサポートしないことで、比較を試みた際にコンパイルエラーになるようにする。

4. 解決策:ゼロバイトながら型に強く束縛されたフィールドを追加

デフォルト戦略を次のように変更する。

type SaferComparator[T comparable] struct {
    _ [0]func(T)
}

func (SaferComparator[T]) Compare(a, b T) bool {
    return a == b
}

このフィールドは2つの目的を果たす。

4.1 [0]...:ゼロ長配列でメモリコストゼロ(実行時コストなし)

[0]Xのサイズは常に0で、Xが何であれ。 このフィールドは構造体のサイズを大きくせず、割り当てコストも逃逸分析にも影響しない——これはほぼ「型システム専用」の仕組み。

4.2 func(T):関数型は比較不能 → 構造体も比較不能

Goでは:

  • 関数値(func(...))は比較不能な型
  • 比較不能なフィールドを持つ構造体も比較不能

したがって:

var a, b SaferComparator[int]
// _ = (a == b) // コンパイルエラー:この型は比較不能

これにより、「戦略オブジェクトが比較される/キーとして使われる」という誤使用をコンパイル時に防ぐことができる。

4.3 func(T)Tを含める:型パラメータを構造に「烙印」

ポイントはfunc(T)というフィールド型が型パラメータTを含んでいること。 Tが異なるとフィールド型も異なる:

  • SaferComparator[int]のフィールドは[0]func(int)
  • SaferComparator[string]のフィールドは[0]func(string)

構造レベルで「同じように見える」ことがなくなり、中間層で互換性のあるものとして扱われにくくなる(特に多くのラッパー、型エイリアス、ジェネリックアダプターを持つ場合、この「強い区別」は非常に価値がある)。

5. イメージしやすい対照実験

5.1 対照:空構造体の戦略は比較可能(通常望まない)

type PlainComparator[T comparable] struct{}

func (PlainComparator[T]) Compare(a, b T) bool {
    return a == b
}

func compareStrategy() {
    var x, y PlainComparator[int]
    _ = (x == y) // コンパイル可能:しかし比較することは通常意味がない
}

5.2 _ [0]func(T)を追加後:比較が直接禁止される

type StrictComparator[T comparable] struct {
    _ [0]func(T)
}

func (StrictComparator[T]) Compare(a, b T) bool {
    return a == b
}

func compareStrategy2() {
    var x, y StrictComparator[int]
    // _ = (x == y) // コンパイルエラー:比較不能(より安全)
}

5.3 依然としてゼロコスト:オブジェクトサイズは変わらない(0のまま)

空構造体のサイズは0;[0]func(T)を追加しても0のまま。 (unsafe.Sizeofで確認可能:どちらも0;他の構造体に入れてもスペースを消費しない——ゼロ長配列はレイアウトに影響しない)

6. なぜ他の「Tを束縛する方法」を使わないのか?

あなたは以下の方法も考えているかもしれない:

6.1 _ T —— 不可能、スペースを消費し、値が必要

type Tag[T any] struct {
    _ T
}
// Tのサイズを消費し、完全にゼロコストではない

6.2 _ *T —— ポインタサイズを消費

type Tag[T any] struct {
    _ *T
}
// 通常8バイト(64ビット)

6.3 struct{} —— ゼロコストだがTへの束縛が弱い

type Tag[T any] struct {
    _ struct{}
}
// 0コストだが、Tをフィールド型に「烙印」していない

[0]func(T)は以下の3点を同時に満たす:

  • ゼロコスト(0バイト)
  • 強いTへの束縛(フィールド型がTに依存)
  • 構造体を比較不能に(関数が比較不能だから)

これは「1つのフィールドで3つの利益」を得る技法。

7. この技法をいつ使うべきか?

適用可能なシナリオ(非常に典型的):

  1. 戦略/設定/アダプタオブジェクト:Equal/Hash/Compare/Encode/Decode戦略など
  2. ==比較を望まない場合:比較は通常意味がなく、バグを隠す可能性がある
  3. 異なる型パラメータのインスタンスが型レベルで強く区別されるようにしたい場合:多重ラッパーで「空殻として扱われるのを防ぐ」

不適切なシナリオ:

  • 型の値を比較する必要がある(なら比較不能にしない)
  • 型が実際のデータを保持する必要がある(ならフィールドを直接追加し、ラベルは不要)

8. 実際に使えるコード例(すべて新規)

package main

type Comparator[T any] interface {
    Compare(a, b T) bool
}

type SaferComparator[T comparable] struct {
    _ [0]func(T)
}

func (SaferComparator[T]) Compare(a, b T) bool {
    return a == b
}

type Searcher[T any, C Comparator[T]] struct {
    comp C
}

func (s Searcher[T, C]) Contains(xs []T, target T) bool {
    for _, v := range xs {
        if s.comp.Compare(v, target) {
            return true
        }
    }
    return false
}

func main() {
    s := Searcher[int, SaferComparator[int]]{
        comp: SaferComparator[int]{},
    }
    _ = s.Contains([]int{1, 2, 3}, 2)
}

この例では:

  • 戦略タイプは0サイズ(ほぼゼロコスト)
  • 戦略オブジェクトは比較不能(誤使用防止)
  • SaferComparator[int]SaferComparator[string]は型レベルで強く区別される

9. 一言でまとめると

_ [0]func(T)は**「ゼロバイトフィールド + 強い型束縛 + 比較禁止」**の組み合わせテクニック。 非常に低コストで強力なコンパイル時制約を実現し、ジェネリックライブラリの「デフォルト戦略/型ラベル/動作アダプタ」に最適である。

タグ: Go ジェネリックプログラミング 型セーフティ コンパイラ制約 ゼロコストアーキテクチャ

5月24日 14:35 投稿