The Go Blog
(エイリアスの)名前には何がある?
この記事は、ジェネリックエイリアス型、それが何であるか、そしてなぜそれが必要なのかについて書かれています。
背景
Goは大規模なプログラミングのために設計されました。大規模なプログラミングとは、大量のデータを扱うだけでなく、多くのエンジニアが長期間にわたって作業する大規模なコードベースを扱うことを意味します。
Goのコードをパッケージに整理する機能は、大規模なコードベースを、多くの場合異なる人々によって書かれ、公開APIを通じて接続された、より小さく管理しやすい部分に分割することで、大規模なプログラミングを可能にします。Goでは、これらのAPIは、パッケージによってエクスポートされる識別子、すなわちエクスポートされた定数、型、変数、関数から構成されます。これには、構造体のエクスポートされたフィールドや型のメソッドも含まれます。
ソフトウェアプロジェクトが時間とともに進化したり、要件が変更されたりするにつれて、コードの元のパッケージへの整理が不十分であることが判明し、リファクタリングが必要になる場合があります。リファクタリングには、エクスポートされた識別子とその宣言を古いパッケージから新しいパッケージに移動することが含まれる場合があります。これには、移動された宣言への参照をすべて更新して、新しい場所を参照するようにする必要もあります。大規模なコードベースでは、このような変更をアトミックに行う、つまり、移動とすべてのクライアントの更新を単一の変更で行うことは非現実的または実現不可能です。代わりに、変更は段階的に行う必要があります。たとえば、関数Fを「移動」するには、元のパッケージの宣言を削除せずに、新しいパッケージにその宣言を追加します。このようにして、クライアントは時間とともに段階的に更新できます。すべての呼び出し元が新しいパッケージのFを参照するようになったら、Fの元の宣言は安全に削除できます(後方互換性のために無期限に保持する必要がある場合を除く)。Russ Coxは、2016年の記事コードベースのリファクタリング(Goの助けを借りて)でリファクタリングについて詳しく説明しています。
関数Fをあるパッケージから別のパッケージに移動し、元のパッケージにも保持することは簡単です。必要なのはラッパー関数だけです。Fをpkg1からpkg2に移動するには、pkg2はpkg1.Fと同じシグネチャを持つ新しい関数F(ラッパー関数)を宣言し、pkg2.Fはpkg1.Fを呼び出します。新しい呼び出し元はpkg2.Fを呼び出し、古い呼び出し元はpkg1.Fを呼び出すことができますが、どちらの場合でも最終的に呼び出される関数は同じです。
定数の移動も同様に簡単です。変数はもう少し作業が必要です。新しいパッケージで元の変数へのポインタを導入するか、アクセサ関数を使用する必要がある場合があります。これは理想的ではありませんが、少なくとも実用的です。ここでのポイントは、定数、変数、関数については、上述のような段階的なリファクタリングを可能にする既存の言語機能が存在するということです。
しかし、型の移動についてはどうでしょうか?
Goでは、(限定)識別子、または単に名前が型の同一性を決定します。パッケージpkg1によって定義およびエクスポートされた型Tは、パッケージpkg2によってエクスポートされたそれ以外は同一の型定義の型Tとは異なります。この特性は、Tをあるパッケージから別のパッケージに移動し、元のパッケージにそのコピーを保持することを複雑にします。たとえば、型pkg2.Tの値は、型pkg1.Tの変数に代入できません。なぜなら、それらの型名、したがって型同一性が異なるからです。段階的な更新フェーズでは、クライアントは両方の型の値と変数を持つ可能性がありますが、プログラマの意図はそれらが同じ型を持つことです。
この問題を解決するために、Go 1.9は型エイリアスの概念を導入しました。型エイリアスは、異なる同一性を持つ新しい型を導入することなく、既存の型に新しい名前を提供します。
通常の型定義とは対照的に
type T T0
これは、宣言の右側の型と決して同一ではない新しい型を宣言しますが、エイリアス宣言は
type A = T // the "=" indicates an alias declaration
右側の型に新しい名前Aを宣言するだけです。ここで、AとTは同じ、したがって同一の型Tを表します。
エイリアス宣言により、型同一性を維持しながら、特定の型に新しい名前(新しいパッケージで!)を提供することが可能になります。
package pkg2
import "path/to/pkg1"
type T = pkg1.T
型名はpkg1.Tからpkg2.Tに変更されましたが、型pkg2.Tの値は型pkg1.Tの変数と同じ型を持ちます。
ジェネリックエイリアス型
Go 1.18はジェネリクスを導入しました。そのリリース以降、型定義と関数宣言は型パラメータによってカスタマイズできるようになりました。技術的な理由により、エイリアス型はその時点では同じ機能を得られませんでした。明らかに、ジェネリック型をエクスポートし、リファクタリングを必要とする大規模なコードベースもありませんでした。
今日、ジェネリクスは数年間存在し、大規模なコードベースでジェネリック機能が使用されています。やがて、これらのコードベースをリファクタリングする必要が生じ、それに伴い、ジェネリック型をあるパッケージから別のパッケージに移行する必要が生じるでしょう。
ジェネリック型を含む段階的なリファクタリングをサポートするために、2025年2月上旬にリリース予定の将来のGo 1.24では、プロポーザル#46477に従って、エイリアス型に対する型パラメータを完全にサポートします。新しい構文は、型定義や関数宣言と同じパターンに従い、識別子(エイリアス名)の左側にオプションの型パラメータリストが続きます。この変更前は、次のようにしか記述できませんでした。
type Alias = someType
しかし、今ではエイリアス宣言で型パラメータを宣言することもできます。
type Alias[P1 C1, P2 C2] = someType
以前の例を、今度はジェネリック型で考えてみましょう。元のパッケージpkg1は、適切に制約された型パラメータPを持つジェネリック型Gを宣言し、エクスポートしました。
package pkg1
type Constraint someConstraint
type G[P Constraint] someType
新しいパッケージpkg2から同じ型Gへのアクセスを提供する必要が生じた場合、ジェネリックエイリアス型が最適です(プレイグラウンド)
package pkg2
import "path/to/pkg1"
type Constraint = pkg1.Constraint // pkg1.Constraint could also be used directly in G
type G[P Constraint] = pkg1.G[P]
いくつかの理由から、単純に次のように書くことはできません。
type G = pkg1.G
いくつかの理由から
-
既存の仕様ルールによれば、ジェネリック型は使用されるときにインスタンス化される必要があります。エイリアス宣言の右側は型
pkg1.Gを使用するため、型引数を提供する必要があります。そうしないと、このケースに対する例外が必要になり、仕様がより複雑になります。わずかな利便性がこの複雑さに見合うかどうかは明らかではありません。 -
エイリアス宣言が独自の型パラメータを宣言する必要がなく、単にエイリアスされた型
pkg1.Gからそれらを「継承」する場合、Gの宣言はそれがジェネリック型であるという兆候を提供しません。その型パラメータと制約は、pkg1.Gの宣言(それ自体がエイリアスである可能性もあります)から取得する必要があるでしょう。可読性が損なわれますが、可読性の高いコードはGoプロジェクトの主要な目標の1つです。
明示的な型パラメータリストを記述することは、最初は不要な負担のように思えるかもしれませんが、追加の柔軟性も提供します。たとえば、エイリアス型によって宣言される型パラメータの数は、エイリアスされる型の型パラメータの数と一致する必要はありません。ジェネリックマップ型を考えてみましょう。
type Map[K comparable, V any] mapImplementation
Mapをセットとして使用することが一般的な場合、エイリアス
type Set[K comparable] = Map[K, bool]
は役立つかもしれません(プレイグラウンド)。エイリアスであるため、Set[int]やMap[int, bool]のような型は同一です。もしSetが定義された(非エイリアス)型であった場合、これは当てはまりません。
さらに、ジェネリックエイリアス型の型制約は、エイリアスされた型の制約と一致する必要はなく、それらを満たすだけでよいのです。たとえば、上記のセットの例を再利用して、次のようにIntSetを定義できます。
type integers interface{ ~int | ~int8 | ~int16 | ~int32 | ~int64 }
type IntSet[K integers] = Set[K]
このマップは、integers制約を満たす任意のキー型でインスタンス化できます(プレイグラウンド)。integersがcomparableを満たすため、通常のインスタンス化ルールに従って、型パラメータKはSetのKパラメータの型引数として使用できます。
最後に、エイリアスは型リテラルも表すことができるため、パラメータ化されたエイリアスはジェネリック型リテラルを作成することを可能にします(プレイグラウンド)
type Point3D[E any] = struct{ x, y, z E }
はっきりさせておくと、これらの例のどれも「特殊ケース」ではなく、何らかの形で仕様に新しいルールを必要とするものではありません。それらは、ジェネリクスのために導入された既存のルールの適用から直接導かれます。仕様で変更された唯一の点は、エイリアス宣言で型パラメータを宣言する機能です。
型名に関する余談
エイリアス型が導入される前、Goには型宣言は1つの形式しかありませんでした。
type TypeName existingType
この宣言は、既存の型から新しい異なる型を作成し、その新しい型に名前を付けます。struct{ x, y int }のような無名の型リテラルとは対照的に、型名を持つため、そのような型を名前付き型と呼ぶのは自然でした。
Go 1.9 でエイリアス型が導入されたことで、型リテラルにも名前(エイリアス)を付けることが可能になりました。例えば、次を考えてみましょう。
type Point2D = struct{ x, y int }
突然、型リテラルとは異なるものを記述する「名前付き型」という概念は、エイリアス名が明らかに型に対する名前であり、したがって、その型(型リテラルであり、型名ではないかもしれません!)が「名前付き型」と呼ばれるべきであると主張できたため、あまり意味をなさなくなりました。
(適切な)名前付き型には特別なプロパティ(メソッドをバインドできる、異なる代入ルールに従うなど)があるため、混乱を避けるために新しい用語を使用することが賢明であると考えられました。したがって、Go 1.9以降、仕様では以前は名前付き型と呼ばれていた型を定義済み型と呼んでいます。定義済み型のみが、その名前に結び付けられたプロパティ(メソッド、代入制限など)を持ちます。定義済み型は型定義によって導入され、エイリアス型はエイリアス宣言によって導入されます。どちらの場合も、型に名前が付けられます。
Go 1.18でジェネリクスが導入されたことで、事態はさらに複雑になりました。型パラメータも型であり、名前を持ち、定義済み型とルールを共有します。たとえば、定義済み型と同様に、名前の異なる2つの型パラメータは異なる型を表します。言い換えれば、型パラメータは名前付き型であり、さらに、いくつかの点でGoの元の名前付き型と類似した動作をします。
さらに、Go のプリデクレアされた型 (int、stringなど) は、その名前を通じてのみアクセスでき、定義済みの型や型パラメーターと同様に、名前が異なれば異なる型です (byteとruneのエイリアス型は一時的に無視します)。プリデクレアされた型は真に名前付き型です。
したがって、Go 1.18では、仕様は一周して、名前付き型の概念を正式に再導入しました。これは現在、「事前宣言された型、定義済み型、および型パラメータ」を包含しています。エイリアス型が型リテラルを示す場合の修正として、仕様は次のように述べています。「エイリアス宣言で与えられた型が名前付き型である場合、エイリアスは名前付き型を示します。」
Goの命名法の枠を一度離れて考えてみると、Goにおける名前付き型の正しい専門用語はおそらく名目型(nominal type)でしょう。名目型の同一性はその名前に明示的に結びついており、これこそがGoの名前付き型(現在は1.18の用語を使用)のすべてです。名目型の振る舞いは、その構造にのみ依存し、その名前(そもそも名前がある場合)には依存しない振る舞いを持つ構造型(structural type)とは対照的です。これらをまとめると、Goのプリデクレアされた型、定義済み型、および型パラメータ型はすべて名目型であり、Goの型リテラルと型リテラルを表すエイリアスは構造型です。名目型と構造型の両方が名前を持つことができますが、名前を持つからといってその型が名目型であるとは限りません。単に名前が付けられているというだけです。
これらはGoの日常的な使用には関係なく、実際には詳細を安全に無視できます。しかし、仕様では正確な用語が重要です。なぜなら、言語を支配するルールを記述しやすくなるからです。では、仕様はもう一度用語を変更すべきでしょうか?それはおそらく混乱するほどの価値はありません。更新が必要なのは仕様だけでなく、多くの補助ドキュメントもそうです。Goについて書かれたかなりの数の書籍が不正確になる可能性があります。さらに、「named」は不正確ではありますが、ほとんどの人にとっては「nominal」よりも直感的に分かりやすいでしょう。また、型リテラルを表すエイリアス型には例外が必要になったとしても、仕様で最初に使用されていた用語とも一致します。
可用性
ジェネリック型エイリアスの実装は予想以上に時間がかかりました。必要な変更には、go/typesに新しいエクスポートされたAlias型を追加し、その型に型パラメータを記録する機能を追加する必要がありました。コンパイラ側では、同様の変更により、パッケージのエクスポートを記述するファイル形式であるエクスポートデータ形式も変更する必要があり、エイリアスの型パラメータを記述できるようになりました。これらの変更の影響はコンパイラにとどまらず、go/typesのクライアント、ひいては多くのサードパーティパッケージにも影響します。これは大規模なコードベースに影響を与える変更であり、問題を防ぐために、いくつかのリリースにわたる段階的な展開が必要でした。
これらすべての作業を経て、ジェネリックエイリアス型はGo 1.24でデフォルトで利用可能になります。
サードパーティのクライアントがコードを準備できるように、Go 1.23から、goツールを呼び出すときにGOEXPERIMENT=aliastypeparamsを設定することで、ジェネリック型エイリアスのサポートを有効にできます。ただし、そのバージョンではエクスポートされたジェネリックエイリアスのサポートがまだ欠けていることに注意してください。
完全なサポート(エクスポートを含む)は最新版で実装されており、GOEXPERIMENTのデフォルト設定は間もなく変更され、ジェネリック型エイリアスがデフォルトで有効になります。したがって、もう一つの選択肢は、最新版のGoで試すことです。
いつものように、問題が発生した場合は、イシューを提出してご連絡ください。新しい機能をよりよくテストすればするほど、一般的な展開がよりスムーズになります。
ありがとうございます。ハッピーリファクタリング!
次の記事: Goは15周年を迎える
前の記事: GoでLLMを搭載したアプリケーションを構築する
ブログインデックス