Goブログ

(エイリアス)名に込められた意味

Robert Griesemer
2024年9月17日

この記事は、ジェネリックエイリアスタイプとは何か、そしてなぜそれらが必要なのかについてです。

背景

Goは大規模プログラミング向けに設計されました。大規模プログラミングとは、大量のデータを扱うだけでなく、大規模なコードベースを扱い、多くのエンジニアが長期間にわたってこれらのコードベースに取り組むことを意味します。

Goのコードをパッケージに編成する方法は、大規模なコードベースを、多くの場合異なる人々によって書かれ、公開APIを介して接続された、より小さく、より管理しやすい部分に分割することにより、大規模プログラミングを可能にします。Goでは、これらのAPIはパッケージによってエクスポートされた識別子、つまりエクスポートされた定数、型、変数、および関数で構成されます。これには、構造体のエクスポートされたフィールドと型のメソッドが含まれます。

ソフトウェアプロジェクトが時間とともに進化したり、要件が変更されたりすると、コードをパッケージに編成した元の方法が不適切になり、*リファクタリング*が必要になる場合があります。リファクタリングには、エクスポートされた識別子とそれぞれの宣言を古いパッケージから新しいパッケージに移動することが含まれる場合があります。これには、移動された宣言への参照もすべて更新して、新しい場所を参照するようにする必要があります。大規模なコードベースでは、このような変更をアトミックに行うこと、つまり、移動とすべてのクライアントの更新を1回の変更で行うことは、実際的でないか、不可能な場合があります。代わりに、変更は段階的に行う必要があります。たとえば、関数`F`を「移動」するには、古いパッケージの元の宣言を削除せずに、新しいパッケージに関数の宣言を追加します。こうすることで、クライアントは時間をかけて段階的に更新できます。すべての呼び出し元が新しいパッケージの`F`を参照するようになったら、`F`の元の宣言は安全に削除できます(後方互換性のために無期限に保持する必要がある場合を除く)。Russ Coxは、2016年の記事[*Codebase Refactoring (with help from Go)*](/talks/2016/refactor.article)でリファクタリングについて詳しく説明しています。

関数`F`をあるパッケージから別のパッケージに移動し、元の関数`F`を元のパッケージにも残すのは簡単です。ラッパー関数があればそれで十分です。`F`を`pkg1`から`pkg2`に移動するには、`pkg2`は`pkg1.F`と同じシグネチャを持つ新しい関数`F`(ラッパー関数)を宣言し、`pkg2.F`は`pkg1.F`を呼び出します。新しい呼び出し元は`pkg2.F`を呼び出すことができ、古い呼び出し元は`pkg1.F`を呼び出すことができますが、どちらの場合も最終的に呼び出される関数は同じです。

定数の移動も同様に簡単です。変数はもう少し作業が必要です。新しいパッケージに元の変数へのポインタを導入するか、アクセサ関数を使用する必要がある場合があります。これは理想的ではありませんが、少なくとも実行可能です。ここでのポイントは、定数、変数、および関数については、上記のような段階的なリファクタリングを可能にする既存の言語機能が存在することです。

しかし、型を移動する場合はどうでしょうか?

Goでは、*(修飾された)識別子*、または略して*名前*は、型の*同一性*を決定します。パッケージ`pkg1`によって定義されエクスポートされた型`T`は、パッケージ`pkg2`によってエクスポートされた型`T`の*それ以外は同一の*型定義とは*異なります*。このプロパティは、`T`のコピーを元のパッケージに残したまま、あるパッケージから別のパッケージに`T`を移動することを複雑にします。たとえば、型`pkg2.T`の値は、型名が異なり、したがって型IDが異なるため、型`pkg1.T`の変数に*代入できません*。段階的な更新フェーズでは、プログラマーの意図は同じ型を持つことですが、クライアントは両方の型の値と変数を持つ場合があります。

この問題を解決するために、*Go 1.9*は*型エイリアス*という概念を導入しました。型エイリアスは、異なるIDを持つ新しい型を導入することなく、既存の型に新しい名前を提供します。

通常の*型定義*とは対照的に、

type T T0

これは、宣言の右辺の型とは決して同一ではない*新しい型*を宣言しますが、*エイリアス宣言*

type A = T  // the "=" indicates an alias declaration

は、右辺の型に*新しい名前* `A`のみを宣言します。ここでは、`A`と`T`は同じ型`T`を示し、したがって同一です。

エイリアス宣言により、型のIDを保持したまま、特定の型に新しい名前(新しいパッケージで!)を付けることができます。

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](/issue/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`へのアクセスを提供する必要がある場合、ジェネリックエイリアスタイプがまさにうってつけです[(playground)](/play/p/wKOf6NbVtdw?v=gotip)

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

いくつかの理由があります。

  1. *既存の仕様ルール*に従って、ジェネリック型は*使用*されるときに*インスタンス化*する必要があります。エイリアス宣言の右辺は型`pkg1.G`を使用するため、型引数を指定する必要があります。そうしないと、この場合の例外が必要になり、仕様がより複雑になります。わずかな利便性が複雑さを正当化することは明らかではありません。

  2. エイリアス宣言が独自の型パラメータを宣言する必要がなく、代わりにエイリアスされた型`pkg1.G`から単に「継承」する場合、`G`の宣言はそれがジェネリック型であることを示すものがありません。その型パラメータと制約は、`pkg1.G`の宣言(それ自体がエイリアスである可能性があります)から取得する必要があります。可読性が低下しますが、読みやすいコードはGoプロジェクトの主要な目的の1つです。

明示的な型パラメータリストを書き留めることは、最初は不要な負担のように思えるかもしれませんが、追加の柔軟性も提供します。1つは、エイリアスタイプによって宣言された型パラメータの数が、エイリアスされた型の型パラメータの数と一致する必要がないことです。ジェネリックマップ型を考えてみましょう。

type Map[K comparable, V any] mapImplementation

集合として`Map`を使用することが一般的な場合、エイリアス

type Set[K comparable] = Map[K, bool]

が役立つ場合があります[(playground)](/play/p/IxeUPGCztqf?v=gotip)。エイリアスであるため、`Set[int]`や`Map[int, bool]`などの型は同一です。`Set`が(エイリアスではない)*定義された*型の場合、これは当てはまりません。

さらに、ジェネリックエイリアスタイプの型制約は、エイリアスされた型の制約と一致する必要はなく、それらを*満たす*だけで済みます。たとえば、上記の集合の例を再利用すると、`IntSet`を次のように定義できます。

type integers interface{ ~int | ~int8 | ~int16 | ~int32 | ~int64 }
type IntSet[K integers] = Set[K]

このマップは、`integers`制約を満たす任意のキー型でインスタンス化できます[(playground)](/play/p/0f7hOAALaFb?v=gotip)。`integers`は`comparable`を満たすため、通常のインスタンス化ルールに従って、型パラメータ`K`は`Set`の`K`パラメータの型引数として使用できます。

最後に、エイリアスは型リテラルも表すことができるため、パラメータ化されたエイリアスを使用すると、ジェネリック型リテラルを作成できます[(playground)](/play/p/wql3NJaUs0o?v=gotip)

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の定義済み型(intstringなど)は、その名前を通してのみアクセスでき、定義型や型パラメータと同様に、名前が異なれば異なる型になります(ここでは、byteruneのエイリアス型は無視します)。定義済み型は真に名前付き型です。

したがって、Go 1.18では、仕様は一周して、現在は「定義済み型、定義型、および型パラメータ」を含む名前付き型の概念を正式に再導入しました。型リテラルを示すエイリアス型を修正するために、仕様では「エイリアス宣言で指定された型が名前付き型である場合、エイリアスは名前付き型を示します」と述べています。

少しの間、Goの命名法の枠にとらわれずに考えると、Goにおける名前付き型の正しい技術用語はおそらくノミナル型でしょう。ノミナル型のアイデンティティはその名前に明示的に結び付けられており、これはまさにGoの名前付き型(現在は1.18の用語を使用)のすべてです。ノミナル型の動作は、構造と名前(そもそも名前がある場合)のみに依存する動作を持つ構造体型とは対照的です。まとめると、Goの定義済み型、定義型、および型パラメータ型はすべてノミナル型であり、Goの型リテラルおよび型リテラルを示すエイリアスは構造体型です。ノミナル型と構造体型の両方に名前を付けることができますが、名前があるからといって、その型がノミナル型であるとは限りません。単に名前が付けられていることを意味します。

これらのことは、Goの日常的な使用には関係なく、実際には詳細は無視しても問題ありません。しかし、正確な用語は、言語を支配する規則を記述しやすくするため、仕様では重要です。では、仕様はその用語をもう一度変更する必要があるでしょうか?おそらく、混乱を起こすほどの価値はありません。更新が必要なのは仕様だけでなく、多くのサポートドキュメントも同様です。Goについて書かれたかなりの数の書籍が不正確になる可能性があります。さらに、「名前付き」は、正確さには欠けますが、ほとんどの人にとって「ノミナル」よりも直感的にわかりやすいでしょう。また、型リテラルを示すエイリアス型には例外が必要ですが、仕様で使用されていた元の用語とも一致します。

利用可能性

ジェネリック型エイリアスの実装には、予想以上に時間がかかりました。必要な変更には、go/typesに新しいエクスポートされたAlias型を追加し、その型で型パラメータを記録する機能を追加する必要がありました。コンパイラ側では、同様の変更により、エクスポートデータ形式(パッケージのエクスポートを記述するファイル形式)も変更する必要があり、現在はエイリアスの型パラメータを記述できる必要があります。これらの変更の影響はコンパイラだけにとどまらず、go/typesのクライアント、ひいては多くのサードパーティパッケージにも影響します。これは、大規模なコードベースに影響を与える変更でした。問題を回避するために、いくつかのリリースにわたる段階的なロールアウトが必要でした。

これらすべての作業の後、ジェネリックエイリアス型は、Go 1.24でデフォルトでついに利用可能になります。

サードパーティのクライアントがコードを準備できるように、Go 1.23以降、goツールを呼び出すときにGOEXPERIMENT=aliastypeparamsを設定することで、ジェネリック型エイリアスのサポートを有効にすることができます。ただし、そのバージョンでは、エクスポートされたジェネリックエイリアスのサポートがまだ不足していることに注意してください。

完全なサポート(エクスポートを含む)はtipに実装されており、GOEXPERIMENTのデフォルト設定はまもなく切り替えられ、ジェネリック型エイリアスがデフォルトで有効になります。そのため、もう1つの選択肢は、tipの最新バージョンのGoを試してみることです。

いつものように、問題が発生した場合は、issueを提出してお知らせください。新しい機能をより適切にテストすればするほど、一般的なロールアウトはスムーズになります。

ありがとうございました。リファクタリングをお楽しみください!

次の記事:Go 15周年
前の記事:GoでLLMを活用したアプリケーションを構築する
ブログインデックス