Go言語入門:よくある質問 -Type Parameters Vol.5-

スポンサーリンク
Go言語入門:よくある質問 -Type Parameters Vol.5- ノウハウ
Go言語入門:よくある質問 -Type Parameters Vol.5-
この記事は約7分で読めます。
よっしー
よっしー

こんにちは。よっしーです(^^)

本日は、Go言語のよくある質問 について解説しています。

スポンサーリンク

背景

Go言語を学んでいると「なんでこんな仕様になっているんだろう?」「他の言語と違うのはなぜ?」といった疑問が湧いてきませんか。Go言語の公式サイトにあるFAQページには、そんな疑問に対する開発チームからの丁寧な回答がたくさん載っているんです。ただ、英語で書かれているため読むのに少しハードルがあるのも事実で、今回はこのFAQを日本語に翻訳して、Go言語への理解を深めていけたらと思い、これを読んだ時の内容を備忘として残しました。

Type Parameters

なぜGoは型パラメータを持つメソッドをサポートしないのか?

Goはジェネリック型がメソッドを持つことを許可していますが、レシーバ以外では、それらのメソッドの引数にパラメータ化された型を使用することはできません。Goが将来ジェネリックメソッドを追加することはないと予想しています。

問題はそれらをどのように実装するかです。具体的には、インターフェース内の値が追加のメソッドを持つ別のインターフェースを実装しているかどうかをチェックすることを考えてみましょう。例えば、この型を考えてみます。これは、任意の型に対して引数を返すジェネリックなNopメソッドを持つ空の構造体です:

type Empty struct{}

func (Empty) Nop[T any](x T) T {
    return x
}

次に、Emptyの値がanyに格納され、それが何をできるかをチェックする他のコードに渡されるとします:

func TryNops(x any) {
    if x, ok := x.(interface{ Nop(string) string }); ok {
        fmt.Printf("string %s\n", x.Nop("hello"))
    }
    if x, ok := x.(interface{ Nop(int) int }); ok {
        fmt.Printf("int %d\n", x.Nop(42))
    }
    if x, ok := x.(interface{ Nop(io.Reader) io.Reader }); ok {
        data, err := io.ReadAll(x.Nop(strings.NewReader("hello world")))
        fmt.Printf("reader %q %v\n", data, err)
    }
}

xEmptyの場合、このコードはどのように動作するのでしょうか? xは3つのテストすべて、そして他の任意の型を持つ他の形式すべてを満たす必要があるように見えます。

これらのメソッドが呼び出されたとき、どのコードが実行されるのでしょうか? 非ジェネリックメソッドの場合、コンパイラはすべてのメソッド実装のコードを生成し、最終的なプログラムにリンクします。しかし、ジェネリックメソッドの場合、無限の数のメソッド実装が存在する可能性があるため、異なる戦略が必要です。

4つの選択肢があります:

  1. リンク時に、可能なすべての動的インターフェースチェックのリストを作成し、それらを満たすがコンパイル済みメソッドが欠けている型を探し、それらのメソッドを追加するためにコンパイラを再呼び出しする。 これにより、リンク後に停止して一部のコンパイルを繰り返す必要があるため、ビルドが大幅に遅くなります。特にインクリメンタルビルドが遅くなります。さらに悪いことに、新しくコンパイルされたメソッドコード自体が新しい動的インターフェースチェックを持つ可能性があり、プロセスを繰り返す必要があります。プロセスが決して終了しない例を構築することもできます。
  2. ある種のJITを実装し、実行時に必要なメソッドコードをコンパイルする。 Goは純粋な事前コンパイルのシンプルさと予測可能なパフォーマンスから大きな恩恵を受けています。1つの言語機能を実装するためだけにJITの複雑さを引き受けることには消極的です。
  3. 各ジェネリックメソッドに対して、型パラメータ上のすべての可能な言語操作のための関数テーブルを使用する遅いフォールバックを発行し、動的テストにそのフォールバック実装を使用する。 このアプローチでは、予期しない型でパラメータ化されたジェネリックメソッドが、コンパイル時に観察された型でパラメータ化された同じメソッドよりもはるかに遅くなります。これによりパフォーマンスがはるかに予測しにくくなります。
  4. ジェネリックメソッドがインターフェースを満たすことを全く使用できないと定義する。 インターフェースはGoプログラミングの本質的な部分です。ジェネリックメソッドがインターフェースを満たすことを禁止することは、設計の観点から受け入れられません。

これらの選択肢はどれも良いものではないため、私たちは「上記のどれでもない」を選択しました。

型パラメータを持つメソッドの代わりに、型パラメータを持つトップレベル関数を使用するか、レシーバ型に型パラメータを追加してください。

より詳細な情報と例については、提案書を参照してください。

解説

この問題は何について説明しているの?

Go言語ではジェネリクス(汎用的なコードを書く機能)が使えますが、メソッドの引数にはジェネリクスを使えません。この文章は「なぜそうなのか?」を説明しています。

基本的な用語

  • ジェネリクス(型パラメータ): 様々な型で動作する汎用的なコードを書く機能
  • メソッド: 構造体などに紐づいた関数
  • レシーバ: メソッドが属する型(構造体など)
  • インターフェース: 「このメソッドを持っていればOK」という契約のようなもの

何が問題なの?

例えば、次のようなコードを書きたいとします:

type Empty struct{}

// このような「メソッドの引数がジェネリック」なコードは書けない
func (Empty) Nop[T any](x T) T {
    return x
}

このメソッドは「どんな型でも受け取って、そのまま返す」というものです。一見便利そうですよね?

なぜ実装できないの?

問題は実行時にどのコードを呼び出すかです。

Goでは、変数が「どのインターフェースを満たしているか?」を実行時にチェックできます。しかし、ジェネリックメソッドの場合、理論上無限のパターンが存在します:

  • Nop(string) string
  • Nop(int) int
  • Nop(float64) float64
  • などなど、無限に…

コンパイラはこの無限のパターンすべてに対応するコードを事前に作ることができません。

解決策の候補と問題点

  1. リンク時に必要なコードを後から作る → ビルドが遅くなりすぎる、終わらない可能性も
  2. 実行時にコードを作る(JIT) → Goの「シンプルで速い」という哲学に反する
  3. 遅い汎用コードを使う → パフォーマンスが予測不可能
  4. ジェネリックメソッドはインターフェースに使えないとする → Goの設計哲学に反する

どれも良くないので、Goは最初から「メソッドの引数にジェネリクスは使えない」としました。

じゃあどうすればいいの?

代わりに次の方法を使います:

方法1: トップレベル関数を使う

// メソッドではなく、普通の関数として定義
func Nop[T any](x T) T {
    return x
}

方法2: 構造体自体をジェネリックにする

// 構造体にジェネリクスを付ける
type Container[T any] struct {
    value T
}

// メソッドは通常通り定義(引数はジェネリックではない)
func (c Container[T]) Get() T {
    return c.value
}

まとめ

  • Goではメソッドの引数にジェネリクスを使えない
  • 理由は実装が極めて困難で、どの解決策も大きな問題を抱えるから
  • 代わりに関数のジェネリクス型のジェネリクスを使う

この制限は不便に見えるかもしれませんが、Goの「シンプルで予測可能」という設計思想を守るための選択です!

おわりに 

本日は、Go言語のよくある質問について解説しました。

よっしー
よっしー

何か質問や相談があれば、コメントをお願いします。また、エンジニア案件の相談にも随時対応していますので、お気軽にお問い合わせください。

それでは、また明日お会いしましょう(^^)

コメント

タイトルとURLをコピーしました