すべての比較可能な型

All your comparable types by Robert Griesemer

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] // エラー: any は comparable を実装していません (Go 1.18 と 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 {  }

記述すること自体はできますが、genericLookupTable がキーの型として any を使えなかったのと同じ理由で、 lookupTable のようなマップに対しては使えませんでした。

この記事では、この背後にある言語の仕組みに光を当てたいと思います。そのために、まずは少し背景情報から始めます。

型パラメータと制約

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 を実装しているとは、TI の型集合の要素であることだと言います。もし T 自体が インターフェースであれば、T もまた型集合を記述します。その集合内のすべての型が I の型集合にも含まれて いなければなりません。そうでなければ、TI を実装していない型を含んでしまうことになるからです。つまり、 T がインターフェースである場合、T の型集合が I の型集合の部分集合であれば、T はインターフェース I を実装していることになります。

これで、制約の充足を理解するための材料がすべて揃いました。先ほど見たように、型制約は型パラメータに対して 受け入れ可能な引数型の集合を記述します。ある型引数が、制約インターフェースによって記述される集合に含まれて いれば、その型引数は対応する型パラメータの制約を 満たす と言います。これは、その型引数が制約を 実装する と言うのと同じことです。Go 1.18とGo 1.19では、制約の充足は制約の実装を意味していました。すぐ後で見るように、 Go 1.20では制約の充足はもはや制約の実装とまったく同じではなくなります。

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

型制約は、型パラメータに対してどの型引数が受け入れ可能かを指定するだけでなく、その型パラメータの値に対して どのような操作が可能かも決定します。予想通り、ある制約が Write のようなメソッドを定義していれば、対応する 型パラメータの値に対して Write メソッドを呼び出せます。より一般的に言えば、ある制約によって定義される型集合 内のすべての型でサポートされている +* のような操作は、対応する型パラメータの値に対しても使用が許可 されます。

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

比較可能な型

他の単項演算や二項演算とは対照的に、== は限られた数の事前宣言された型だけ でなく、配列や構造体、インターフェースを含む無限に多様な型に対して定義されています。これらすべての型を1つの 制約の中で列挙することは不可能です。事前宣言された型以外も気にかけるのであれば、型パラメータが == (そしてもちろん != も)をサポートしなければならないことを表現するための、別の仕組みが必要です。

私たちはこの問題を、Go 1.18で導入された事前宣言された型 comparable によって解決しています。comparable は インターフェース型であり、その型集合は比較可能な型からなる無限集合です。型引数に == のサポートを要求したい 場合には、いつでも制約として使えます。

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

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

lookupTable[[]int{}] = "slice"  // パニック: runtime error: hash of unhashable type []int

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

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

残念ながら、この comparable の定義と制約充足のルールが組み合わさることで、先ほど示した genericLookupTable 型のような、有用なジェネリックコードを書けなくなっていました。any が受け入れ 可能な引数型であるためには、anycomparable を満たし(したがって実装し)なければなりません。しかし any の型集合は comparable の型集合よりも大きく(部分集合ではなく)、そのため comparable を実装して いません。

var lookupTable GenericLookupTable[any, string] // エラー: any は comparable を実装していません (Go 1.18 と Go 1.19)

ユーザーはこの問題に早くから気づき、短期間のうちに数多くのイシューや提案を提出しました (#51338#52474#52531#52614#52624#53734 など)。これは明らかに、 私たちが対処すべき問題でした。

「明白な」解決策は、単純に厳密に比較可能でない型も comparable の型集合に含めてしまうことでした。しかし、 これは型集合モデルとの間に矛盾を引き起こします。次の例を考えてみましょう。

func f[Q comparable]() {  }

func g[P any]() {
        _ = f[int] // (1) OK: int は comparable を実装している
        _ = f[P]   // (2) エラー: 型パラメータ P は comparable を実装していない
        _ = f[any] // (3) エラー: any は comparable を実装していない (Go 1.18、Go 1.19)
}

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

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

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

先ほど述べたように、制約の充足とはインターフェースの実装のことです。型引数 T が制約 C を満たすのは、 TC を実装している場合です。これは理にかなっています。TC が期待する型集合に含まれていなければ ならず、それはまさにインターフェースの実装の定義そのものだからです。

しかし、これこそが問題でもあります。なぜなら、これによって厳密に比較可能でない型を comparable の型引数 として使えなくなっているからです。

そこでGo 1.20に向けて、数多くの代替案について1年近くにわたり公開の場で議論した末に(上に挙げたイシューを 参照)、私たちはこのケースに限った例外を導入することにしました。矛盾を避けるために、comparable の意味その ものを変えるのではなく、変数に値を渡す際に関係する インターフェースの実装 と、型パラメータに型引数を渡す 際に関係する 制約の充足 とを区別することにしました。いったん分離してしまえば、それぞれの概念に(わずかに) 異なるルールを与えられます。それこそが、まさに提案#56548で私たちが行った ことです。

良いニュースは、この例外は仕様書の中でかなり局所的 なものだということです。制約の充足は、ただし書きが1つ付くものの、インターフェースの実装とほぼ同じままです。

T が制約 C を満たすのは、次のいずれかの場合である。

  • TC を実装している。または
  • Cinterface{ comparable; E } という形式で書けて(E は基本インターフェース)、T比較可能であり、かつ E を実装している。

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

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

先ほどの例をもう一度見てみましょう。

func f[Q comparable]() {  }

func g[P any]() {
        _ = f[int] // (1) OK: int は comparable を満たす
        _ = f[P]   // (2) エラー: 型パラメータ P は comparable を満たさない
        _ = f[any] // (3) OK: comparable を満たす (Go 1.20)
}

これで、anycomparable を(実装はしていませんが!)満たすようになりました。なぜでしょうか。Goでは型 any の値に対して == を使うことが許されており(これは仕様のルールにおける型 T に対応します)、また制約 comparable(これはルールにおける制約 C に対応します)は、この例では E を単に空インターフェースとした interface{ comparable; E } として書けるからです(ケース3)。

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

結果と対処法

私たちGopherは、Go言語特有の振る舞いが説明可能であり、言語仕様に明記された、かなりコンパクトなルールの集合 に還元できるという事実に誇りを持っています。長年にわたり、私たちはこれらのルールを洗練させ、可能な場合には、 よりシンプルに、そしてしばしばより一般的にしてきました。また、意図しない不都合な結果を常に警戒しながら、 ルール同士の直交性を保つよう注意も払ってきました。論争は、命令によってではなく、仕様書を参照することで解決 されます。それこそが、Go誕生以来私たちが目指してきたことです。

注意深く作り込まれた型システムに、何の代償もなく例外を追加することなど、そう簡単にはできません!

では、どこに落とし穴があるのでしょうか。明白な(とはいえ軽微な)欠点が1つと、それほど明白ではない(そして より深刻な)欠点が1つあります。明らかなのは、制約の充足に関するルールがより複雑になり、以前よりも間違いなく エレガントさに欠けるようになったということです。これが日々の作業に大きな影響を与えることはまずないでしょう。

しかし、この例外には代償も伴います。Go 1.20では、comparable に依存するジェネリックな関数は、もはや静的に 型安全ではなくなります。宣言上は厳密に比較可能であるとされているにもかかわらず、comparable で制約された 型パラメータのオペランドに対して ==!= の操作を適用すると、パニックが発生することがあります。たった 1つの厳密に比較可能でない型引数によって、たった1つの比較不能な値が複数のジェネリックな関数や型をすり抜けて しまい、パニックを引き起こすことがあり得ます。Go 1.20では、次のように宣言できるようになりました。

var lookupTable genericLookupTable[any, string]

これはコンパイル時エラーにはなりませんが、このマップに厳密に比較可能でないキーの型を使うと、組み込みの map 型を使った場合とまったく同じように、実行時パニックが発生します。私たちは静的な型安全性を、実行時の チェックと引き換えにしたのです。

これでは不十分で、厳密な比較可能性を強制したい状況もあるかもしれません。次の観察によって、少なくとも限定的な 形でそれが可能になります。型パラメータは、私たちが制約充足のルールに追加した例外の恩恵を受けません。たとえば、 先ほどの例では、関数 g の型パラメータ Pany(それ自体は比較可能ですが厳密には比較可能ではありません) によって制約されているため、Pcomparable を満たしません。この知識を使えば、与えられた型 T に対して、 ある種のコンパイル時のアサーションを作り出せます。

type T struct {  }

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

// isComparable は、制約充足の例外により厳密に比較可能でない型を含め、
// == をサポートする任意の型でインスタンス化できる。
func isComparable[_ comparable]() {}

// 魅力的だが、これは私たちが本当に欲しいものではない。この宣言は
// 厳密に比較可能でない型 T に対しても有効になってしまう。
var _ = isComparable[T] // T が == をサポートしていなければコンパイル時エラー

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

func _[P T]() {
    _ = isComparable[P] // T が厳密に比較可能である場合に限り、P は == をサポートする
}

この仕組みを示す、成功する例失敗する例のPlaygroundを用意しました。

最後に

興味深いことに、Go 1.18のリリースの2か月前まで、コンパイラは今のGo 1.20とまったく同じように制約の充足を 実装していました。しかし、当時は制約の充足がインターフェースの実装を意味していたため、実際には言語仕様と 矛盾する実装になっていました。私たちはこの事実に、イシュー#50646によって気づ かされました。リリースまで非常に時間が迫っており、素早く決断を下さなければなりませんでした。説得力のある 解決策がない中で、実装を仕様に合わせるのが最も安全に思えました。それから1年が経ち、さまざまなアプローチを 検討する時間もたっぷりあった今、当初持っていた実装こそが、私たちが最初から欲しかった実装だったように思えます。 私たちは一周して元の場所に戻ってきたのです。

いつものように、期待通りに動作しないことがあれば、https://go.dev/issue/new からイシューを立てて私たちに知らせてください。

ありがとうございました!

By Robert Griesemer