Goブログ

比較可能なすべての型

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以降、このコードは正常にコンパイルされます。

comparableのGo 1.20より前の動作は、特に、最初にジェネリックで書きたいと思っていた種類のジェネリックライブラリの作成を妨げたため、非常に煩わしいものでした。提案されたmaps.Clone関数

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

は記述できますが、キー型としてanyを使用した場合に、genericLookupTableを使用できなかったのと同じ理由で、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を実装するのは、Tがインターフェースの型セットの要素である場合です。T自体がインターフェースである場合、それは型セットを表します。そのセット内のすべての型は、Iの型セットにも存在する必要があります。そうでない場合、TにはIを実装しない型が含まれます。したがって、Tがインターフェースである場合、Tの型セットがIの型セットのサブセットである場合、インターフェースIを実装します。

これで、制約の充足を理解するためのすべての要素が揃いました。前に見たように、型制約は、型パラメータに許容される引数型のセットを記述します。型引数は、型引数が制約インターフェースによって記述されたセットにある場合に、対応する型パラメータ制約を満たします。これは、型引数が制約を実装すると言う別の方法です。Go 1.18およびGo 1.19では、制約の充足は制約の実装を意味しました。少し後で説明するように、Go 1.20では、制約の充足は必ずしも制約の実装ではありません。

型パラメータ値の操作

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

たとえば、minの例の場合、関数本体では、int64型とfloat64型でサポートされている任意の操作が、型パラメータ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)。そして最後に、(anyで制約された型パラメータではなく)型anyを使用することも、まったく同じ問題のため機能しません(ケース3)。

しかし、この場合、型引数として型anyを使用できるようにしたいと考えています。このジレンマを解決する唯一の方法は、何らかの方法で言語を変更することでした。しかし、どのように?

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

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

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

そこでGo 1.20では、数多くの代替案を公に議論した約1年後(上記のissueを参照)、この場合に限って例外を導入することにしました。矛盾を避けるため、comparableの意味を変更するのではなく、変数に値を渡す際に関連するインターフェースの実装と、型パラメータに型引数を渡す際に関連する制約の充足を区別しました。一旦分離すれば、これらの概念に(わずかに)異なるルールを与えることができ、それが提案#56548で行ったことです。

良い知らせは、例外が仕様の中で非常に局所化されていることです。制約の充足は、ある注意点を除いて、ほとんどインターフェースの実装と同じままです。

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

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

2つ目の箇条書きが例外です。仕様の形式主義に深入りせずに言うと、例外が述べているのは次のとおりです。厳密に比較可能な型を期待する制約C(およびメソッドEのような他の要件がある場合もあります)は、==をサポートする任意の型引数T(およびEのメソッドも実装する場合)によって満たされます。あるいはさらに短く言うと、==をサポートする型は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に対応します)は、この例ではEが単に空のインターフェースであるinterface{ comparable; E }として記述できるからです(ケース3)。

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

帰結と対策

Goのプログラマは、言語固有の動作が、言語仕様に明記された、かなり簡潔な一連のルールに説明され、還元できることを誇りに思っています。長年にわたり、これらのルールを洗練させ、可能な限りシンプルにし、より一般的にしてきました。また、意図しない不幸な結果がないか常に警戒し、ルールを直交的に保つように注意してきました。論争は、布告ではなく、仕様を参照することによって解決されます。それがGoの開始以来目指してきたことです。

注意深く作成された型システムに例外を追加すれば、当然、結果が生じます!

では、落とし穴はどこにあるのでしょうか?明白な(ただし穏やかな)欠点と、あまり明白ではない(より深刻な)欠点があります。明らかに、制約の充足に関するルールが以前より複雑になり、以前のものほど優雅ではないと言えるでしょう。これが日々の作業に大きな影響を与える可能性は低いでしょう。

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

var lookupTable genericLookupTable[any, string]

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

これが十分ではない場合や、厳密な比較可能性を強制したい状況もあるでしょう。以下の観察により、少なくとも限定的な形式で、まさにそれを行うことができます。型パラメータは、制約充足ルールに追加した例外の恩恵を受けません。たとえば、以前の例では、関数gの型パラメータPは、any(それ自体は比較可能ですが厳密には比較可能ではない)によって制約されているため、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にissueを報告してください。

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

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