The Go Blog

すべての比較可能な型

Robert Griesemer
2023年2月17日

2月1日、最新のGoバージョンである1.20をリリースしました。これにはいくつかの言語変更が含まれています。ここでは、それらの変更の1つである、事前宣言されたcomparable型制約がすべての比較可能な型で満たされるようになったことについて説明します。驚くべきことに、Go 1.20以前では、一部の比較可能な型はcomparableを満たしていませんでした!

もしあなたが混乱しているなら、あなたは正しい場所に来ました。有効なマップ宣言を考えてみましょう。

var lookupTable map[any]string

ここで、マップのキーの型はanyです(これは比較可能な型です)。これはGoで完全にうまく機能します。一方、Go 1.20以前では、一見同等に見えるジェネリックマップ型は、

type genericLookupTable[K comparable, V any] map[K]V

通常のマップ型と同じように使用できましたが、キー型としてanyを使用するとコンパイル時エラーが発生しました。

var lookupTable genericLookupTable[any, string] // ERROR: any does not implement comparable (Go 1.18 and Go 1.19)

Go 1.20から、このコードは問題なくコンパイルされます。

Go 1.20以前のcomparableの動作は、ジェネリクスで書きたかった種類のジェネリックライブラリを書くことを妨げていたため、特に厄介でした。提案されたmaps.Clone関数は、

func Clone[M ~map[K]V, K comparable, V any](m M) M { … }

書くことはできましたが、lookupTableのようなマップには使用できませんでした。これは、genericLookupTableがキー型としてanyを使用できなかったのと同じ理由です。

このブログ投稿では、これらすべての背後にある言語メカニズムに光を当てたいと考えています。そのためには、まず少し背景情報から始めます。

型パラメータと制約

Go 1.18ではジェネリクスが導入され、それによって型パラメータが新しい言語構造として追加されました。

通常の関数では、パラメータはその型によって制限される値のセットを範囲とします。同様に、ジェネリック関数(または型)では、型パラメータはその型制約によって制限される型のセットを範囲とします。したがって、型制約は型引数として許容される型のセットを定義します。

Go 1.18では、インターフェースの捉え方も変更されました。以前はインターフェースはメソッドのセットを定義していましたが、現在はインターフェースは型のセットを定義します。この新しい視点は完全に後方互換性があります。インターフェースによって定義された任意のメソッドのセットに対して、それらのメソッドを実装するすべての型の(無限の)セットを想像できます。たとえば、io.Writerインターフェースが与えられた場合、適切なシグネチャを持つWriteメソッドを持つすべての型の無限のセットを想像できます。これらの型はすべて必要なWriteメソッドを持っているため、インターフェースを実装します。

しかし、新しい型セットのビューは、古いメソッドセットのビューよりも強力です。メソッドを介して間接的にだけでなく、型のセットを明示的に記述できます。これにより、型セットを制御する新しい方法が得られます。Go 1.18から、インターフェースは他のインターフェースだけでなく、任意の型、型のユニオン、または同じ基底型を共有する無限の型のセットを埋め込むことができます。これらの型は型セットの計算に含まれます。ユニオン表記A|Bは「型Aまたは型B」を意味し、~T表記は「基底型がTであるすべての型」を表します。たとえば、インターフェースは、

interface {
    ~int | ~string
    io.Writer
}

基底型がintまたはstringであり、かつio.WriterWriteメソッドも実装するすべての型のセットを定義します。

このような汎化されたインターフェースは、変数型としては使用できません。しかし、それらは型のセットを記述するため、型のセットである型制約として使用されます。たとえば、ジェネリックなmin関数を次のように記述できます。

func min[P interface{ ~int64 | ~float64 }](x, y P) P

これは、任意のint64またはfloat64引数を受け入れます。(もちろん、より現実的な実装では、<演算子を持つすべての基本型を列挙する制約を使用するでしょう。)

余談ですが、メソッドなしで明示的な型を列挙することは一般的なため、少しの糖衣構文により、囲むinterface{}省略することができ、コンパクトでより慣用的な記述が可能です。

func min[P ~int64 | ~float64](x, y P) P { … }

新しい型セットのビューでは、インターフェースを実装するとはどういう意味かを説明するための新しい方法も必要です。(非インターフェース)型TがインターフェースIを実装するとは、Tがインターフェースの型セットの要素である場合を指します。もしT自体がインターフェースである場合、それは型セットを記述します。そのセット内のすべての型もIの型セットに含まれている必要があります。そうでなければ、TIを実装しない型を含むことになります。したがって、Tがインターフェースである場合、Tの型セットがIの型セットのサブセットである場合に、TはインターフェースIを実装します。

これで、制約の充足を理解するためのすべての材料が揃いました。前述のとおり、型制約は、型パラメータに対して許容される引数型のセットを記述します。型引数が対応する型パラメータ制約を満たすのは、型引数が制約インターフェースによって記述されたセット内にある場合です。これは、型引数が制約を実装すると言う別の方法です。Go 1.18およびGo 1.19では、制約の充足は制約の実装を意味しました。しかし、Go 1.20では、制約の充足は制約の実装とはまったく同じではなくなります。

型パラメータ値に対する操作

型制約は、型パラメータに許容される型引数を指定するだけでなく、型パラメータの値に対して可能な操作も決定します。期待どおりに、制約がWriteのようなメソッドを定義する場合、対応する型パラメータの値に対してWriteメソッドを呼び出すことができます。より一般的には、制約によって定義された型セット内のすべての型によってサポートされる+*のような操作は、対応する型パラメータの値に対して許可されます。

たとえば、minの例では、関数本体内でint64float64の型でサポートされるすべての操作が型パラメータPの値に対して許可されます。これにはすべての基本的な算術演算だけでなく、<のような比較も含まれます。しかし、float64の値では定義されていないため、&|のようなビット演算は含まれません。

比較可能な型

他の単項および二項演算子とは対照的に、==は限られた事前宣言された型だけでなく、配列、構造体、インターフェースを含む無限の多様な型に定義されています。これらのすべての型を制約で列挙することは不可能です。事前宣言された型以上のものにこだわる場合、型パラメータが==(そしてもちろん!=)をサポートしなければならないことを表現するには、別のメカニズムが必要です。

この問題は、Go 1.18で導入された事前宣言された型comparableによって解決されます。comparableはインターフェース型であり、その型セットは比較可能な型の無限セットであり、型引数が==をサポートする必要がある場合に制約として使用できます。

しかしながら、comparableが含む型の集合は、Goの仕様で定義されているすべての比較可能な型の集合と同じではありません。設計上、インターフェース(comparableを含む)によって指定された型集合は、インターフェース自体(または他のインターフェース)を含みません。したがって、anyのようなインターフェースは、すべてのインターフェースが==をサポートしているにもかかわらず、comparableには含まれません。なぜでしょうか?

インターフェース(およびそれらを含む複合型)の比較は、実行時にパニックを引き起こす可能性があります。これは、動的型、つまりインターフェース変数に格納されている実際の値の型が比較可能でない場合に発生します。元のlookupTableの例を考えてみましょう。任意の値をキーとして受け入れます。しかし、==をサポートしない値(例えばスライス値)をキーとして入力しようとすると、実行時パニックが発生します。

lookupTable[[]int{}] = "slice"  // PANIC: runtime error: hash of unhashable type []int

対照的に、comparableは、コンパイラが==でパニックを起こさないことを保証する型のみを含みます。これらの型を厳密に比較可能な型と呼びます。

ほとんどの場合、これはまさに私たちが望んでいることです。ジェネリック関数で==が、オペランドがcomparableによって制約されている場合にパニックを起こさないと知ることは安心できますし、直感的に期待するところです。

残念ながら、このcomparableの定義と制約の充足規則は、先に示したgenericLookupTable型のような有用なジェネリックコードを書くことを妨げていました。anyが許容される引数型であるためには、anycomparableを満たす(したがって実装する)必要があります。しかし、anyの型セットはcomparableの型セットよりも大きく(サブセットではないため)、したがってcomparableを実装しません。

var lookupTable GenericLookupTable[any, string] // ERROR: any does not implement comparable (Go 1.18 and Go 1.19)

ユーザーはこの問題を早期に認識し、多数の問題と提案を短期間で提出しました(#51338#52474#52531#52614#52624#53734など)。明らかに、これは対処する必要のある問題でした。

「明らかな」解決策は、単に厳密に比較可能ではない型もcomparable型セットに含めることでした。しかし、これは型セットモデルとの矛盾につながります。次の例を考えてみましょう。

func f[Q comparable]() { … }

func g[P any]() {
        _ = f[int] // (1) ok: int implements comparable
        _ = f[P]   // (2) error: type parameter P does not implement comparable
        _ = f[any] // (3) error: any does not implement comparable (Go 1.18, Go.19)
}

関数fは、厳密に比較可能な型引数を必要とします。明らかに、fintでインスタンス化することは問題ありません。intの値は==でパニックを起こすことはなく、したがってintcomparableを実装します(ケース1)。一方、fPでインスタンス化することは許可されていません。Pの型セットはその制約anyによって定義され、anyは可能なすべての型のセットを表します。このセットには、まったく比較可能ではない型が含まれています。したがって、Pcomparableを実装しないため、fをインスタンス化するために使用することはできません(ケース2)。そして最後に、anyanyによって制約される型パラメータではなく)型を使用することも機能しません。まったく同じ問題があるためです(ケース3)。

しかし、この場合、型引数としてany型を使用したいのです。このジレンマから抜け出す唯一の方法は、言語を何らかの方法で変更することでした。しかし、どうすればよいのでしょうか?

インターフェースの実装 vs 制約の充足

前述の通り、制約の充足はインターフェースの実装です。型引数Tが制約Cを満たすのは、TCを実装している場合です。これは理にかなっています。TCが期待する型セットに含まれている必要があり、これこそがインターフェース実装の定義です。

しかし、これはまた問題でもあります。なぜなら、厳密に比較できない型をcomparableの型引数として使用できないからです。

そのため、Go 1.20では、約1年間もの間、数多くの代替案を公開で議論した結果(上記の課題を参照)、このケースに限り例外を導入することにしました。矛盾を避けるため、comparableの意味を変更するのではなく、変数を値に渡すことに関連するインターフェース実装と、型引数を型パラメータに渡すことに関連する制約充足を区別しました。一度分離することで、これらの概念それぞれに(わずかに)異なる規則を与えることができ、まさにそれが提案#56548で行われたことです。

良い知らせは、この例外が仕様の中でかなり局所的であることです。制約の充足はインターフェースの実装とほとんど同じですが、注意点があります。

Tが制約Cを満たすのは、

  • TCを実装している場合、または
  • Cinterface{ comparable; E }の形式で記述でき、ここでEが基本インターフェースであり、T比較可能であり、Eを実装している場合です。

2番目の箇条書きが例外です。仕様の形式主義に深入りすることなく説明すると、この例外が言っているのは次のことです。厳密に比較可能な型を期待する制約C(メソッドEなどの他の要件も持つ可能性がある)は、==をサポートする任意の型引数T(および、もしあればEのメソッドも実装する型引数T)によって満たされます。さらに短く言えば、==をサポートする型はcomparableも満たします(たとえそれを実装していなくても)。

この変更が後方互換性があることはすぐにわかります。Go 1.20以前では、制約の充足はインターフェースの実装と同じであり、そのルールは今でも存在します(1番目の箇条書き)。そのルールに依存していたすべてのコードは以前と同様に動作し続けます。そのルールが失敗した場合にのみ、例外を考慮する必要があります。

以前の例をもう一度見てみましょう。

func f[Q comparable]() { … }

func g[P any]() {
        _ = f[int] // (1) ok: int satisfies comparable
        _ = f[P]   // (2) error: type parameter P does not satisfy comparable
        _ = f[any] // (3) ok: satisfies comparable (Go 1.20)
}

今では、anycomparableを満たします(しかし、実装はしません!)。なぜでしょうか?Goは型anyの値に==を使用することを許可しているため(これは仕様規則の型Tに対応します)、そして制約comparable(規則の制約Cに対応します)がinterface{ comparable; E }と記述でき、この例ではEが単に空のインターフェースであるためです(ケース3)。

興味深いことに、Pは依然としてcomparableを満たしません(ケース2)。その理由は、Panyによって制約された型パラメータであるため(anyではありません)、==演算子はPの型セット内のすべての型で使用できるわけではなく、したがってPでは使用できません。それは比較可能な型ではありません。したがって、例外は適用されません。しかし、これは問題ありません。comparableという厳密な比較可能性要件がほとんどの場合に適用されることを知りたいのです。私たちは、==をサポートするGoの型に対してのみ例外が必要なだけで、これは本質的に歴史的な理由によるものです。非厳密に比較可能な型を比較する能力は常に持っていました。

結果と対策

私たちGophersは、言語固有の振る舞いが、言語仕様に明記されたかなりコンパクトな一連の規則に説明され、還元できるという事実に誇りを持っています。長年にわたり、これらの規則を洗練させ、可能な場合はより単純に、そしてより一般的にしてきました。また、規則を直交させ、意図しない不幸な結果がないか常に注意を払ってきました。紛争は、布告ではなく、仕様を参照して解決されます。これは、Goの誕生以来私たちが目指してきたことです。

慎重に作られた型システムに、例外を安易に追加することはできません。

では、問題は何でしょうか?明らかな(軽度ではあるが)欠点と、それほど明らかではない(より深刻な)欠点があります。明らかに、制約の充足に関する規則が以前よりも複雑になり、議論の余地はありますが、以前ほどエレガントではなくなりました。これは私たちの日常業務に大きな影響を与える可能性は低いでしょう。

しかし、私たちは例外の代償を払っています。Go 1.20では、comparableに依存するジェネリック関数は、静的に型安全ではなくなりました。==および!=演算子は、宣言では厳密に比較可能であるとされていても、comparable型パラメータのオペランドに適用されるとパニックを起こす可能性があります。単一の非比較可能な値は、単一の非厳密に比較可能な型引数を介して複数のジェネリック関数または型をすり抜け、パニックを引き起こす可能性があります。Go 1.20では、次のように宣言できるようになりました。

var lookupTable genericLookupTable[any, string]

コンパイル時エラーなしで、組み込みのmap型と同様に、この場合、厳密に比較可能ではないキー型を使用すると、実行時パニックが発生します。私たちは静的な型安全性を実行時チェックのために諦めました。

これは十分ではない状況があり、厳密な比較可能性を強制したい場合もあります。以下の観察により、少なくとも限定的な形でそれを正確に行うことができます。型パラメータは、制約充足ルールに追加した例外の恩恵を受けません。たとえば、前の例では、関数gの型パラメータPanyによって制約されており(それ自体は比較可能ですが、厳密に比較可能ではありません)、そのためPcomparableを満たしません。この知識を利用して、特定の型Tに対して一種のコンパイル時アサーションを作成できます。

type T struct { … }

Tが厳密に比較可能であることをアサートしたい。次のように書きたくなります。

// isComparable may be instantiated with any type that supports ==
// including types that are not strictly comparable because of the
// exception for constraint satisfaction.
func isComparable[_ comparable]() {}

// Tempting but not quite what we want: this declaration is also
// valid for types T that are not strictly comparable.
var _ = isComparable[T] // compile-time error if T does not support ==

ダミーの(ブランク)変数宣言が私たちの「アサーション」として機能します。しかし、制約充足ルールにおける例外のため、isComparable[T]Tがまったく比較可能でない場合にのみ失敗します。T==をサポートしていれば成功します。この問題は、Tを型引数としてではなく、型制約として使用することで回避できます。

func _[P T]() {
    _ = isComparable[P] // P supports == only if T is strictly comparable
}

このメカニズムを示す合格失敗のプレイグラウンドの例を次に示します。

最終的な考察

興味深いことに、Go 1.18リリースから2ヶ月前までは、コンパイラは現在Go 1.20で行っているのとまったく同じように制約の充足を実装していました。しかし、当時、制約の充足はインターフェースの実装を意味していたため、言語仕様と矛盾する実装になっていました。issue #50646によってこの事実に気づかされました。リリースに極めて近く、迅速な決断を下す必要がありました。説得力のある解決策がない中、実装を仕様と一致させることが最も安全であると考えられました。1年後、そしてさまざまなアプローチを検討する十分な時間を経て、当初私たちが望んでいた実装が、私たちが持っていた実装であったようです。私たちは一周回って戻ってきました。

いつものように、期待どおりに動作しない点がありましたら、https://go.dokyumento.jp/issue/newに問題を提出してお知らせください。

ありがとうございます!

次の記事: Go統合テストのコードカバレッジ
前の記事: プロファイルガイド最適化プレビュー
ブログインデックス