「エイリアス」という名前の意味

What’s in an (Alias) Name? by Robert Griesemer

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

背景

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

Goがコードをパッケージへと組織化する仕組みは、大規模なコードベースを、しばしば異なる人々によって書かれ、公開APIを通じて接続される、より小さく管理しやすい単位に分割することで、大規模な環境でのプログラミングを可能にしています。Goにおいて、これらのAPIはパッケージによってエクスポートされる識別子(エクスポートされた定数、型、変数、関数)から構成されます。これには構造体のエクスポートされたフィールドや型のメソッドも含まれます。

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

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

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

では、型の移動についてはどうでしょうか?

Goでは、(修飾)識別子、つまり短く言えば 名前 が型の 同一性 を決定します。パッケージ pkg1 によって定義され、エクスポートされた型 T は、パッケージ pkg2 によってエクスポートされた、それ以外はまったく同一の型 T の定義とは別の型として扱われます。この性質のために、T を元のパッケージにコピーとして残したまま別のパッケージへ移動することが複雑になります。たとえば、型 pkg2.T の値は型名、ひいては型の同一性が異なるため、型 pkg1.T の変数に代入できません。段階的な更新の途中では、プログラマの意図としては同じ型にしたいはずなのに、クライアント側は両方の型の値や変数を抱えることになりかねません。

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

通常の型定義

type T T0

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

type A = T  // "="はエイリアス宣言であることを示す

右辺の型に対する 新しい名前 A だけを宣言します。ここでは AT は同じ、つまり同一の型 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

同じ型 G への参照を新しいパッケージ pkg2 から提供する必要が生じた場合、ジェネリックなエイリアス型がまさにうってつけです(プレイグラウンド)。

package pkg2

import "path/to/pkg1"

type Constraint      = pkg1.Constraint  // pkg1.Constraintは`G`の中で直接使うこともできる
type G[P Constraint] = pkg1.G[P]

ここで、単純に次のように書くことは できない 点に注意してください。

type G = pkg1.G

理由はいくつかあります。

  1. 既存の仕様のルールにより、ジェネリックな型は 使用 される際にインスタンス化されなければなりません。エイリアス宣言の右辺は型 pkg1.G を使用しているため、型引数を与える必要があります。そうしなければこのケースのために例外を設ける必要が生じ、仕様がより複雑になってしまいます。このわずかな便利さが、その複雑さに見合うかどうかは自明ではありません。

  2. もしエイリアス宣言が自分自身の型パラメータを宣言する必要がなく、代わりにエイリアス先の型 pkg1.G から単純にそれらを「継承」するのだとしたら、G の宣言はそれがジェネリックな型であることを何ら示さなくなってしまいます。その型パラメータと制約は pkg1.G の宣言(それ自体もエイリアスかもしれません)から取得しなければならなくなります。これでは可読性が損なわれてしまいますが、可読性の高いコードはGoプロジェクトの主要な目標の1つです。

明示的な型パラメータリストを書き下すことは、最初は不要な負担のように思えるかもしれませんが、それによって追加の柔軟性も得られます。たとえば、エイリアス型が宣言する型パラメータの数は、エイリアス先の型パラメータの数と一致している必要はありません。ジェネリックなmap型を考えてみましょう。

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]

このmapは、integers 制約を満たす任意のキー型でインスタンス化できます(プレイグラウンド)。integerscomparable を満たすので、通常のインスタンス化のルールに従って、型パラメータ KSetK パラメータに対する型引数として使用できます。

最後に、エイリアスは型リテラルを表すこともできるため、パラメータ化されたエイリアスによってジェネリックな型リテラルを作ることも可能です(プレイグラウンド)。

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における名前付き型に対する正確な専門用語は、おそらく公称型(nominal type)でしょう。公称型の同一性はその名前に明示的に結びついており、これはまさにGoの名前付き型(1.18の用語で言えば)がどういうものかを表しています。公称型の振る舞いは、名前ではなく構造だけに依存する振る舞いを持つ 構造的型 (そもそも名前を持つ場合の話ですが)とは対照的です。まとめると、Goの定義済みの型、定義された型、型パラメータの型はすべて公称型であり、一方でGoの型リテラルと、型リテラルを表すエイリアスは構造的型です。公称型も構造的型も名前を持つことはできますが、名前を持つからといってその型が公称型になるわけではなく、単に名前が付いているだけということになります。

こういったことは日々のGoの利用においてはどれも重要ではなく、実際にはこれらの細部は気にしなくても構いません。しかし仕様の中では、正確な用語は重要です。それによって言語を支配するルールを記述しやすくなるからです。では、仕様は用語をもう一度変更すべきでしょうか。おそらくその手間に見合わないでしょう。更新が必要になるのは仕様だけでなく、多くの補助的なドキュメントも同様だからです。Goについて書かれたかなりの数の書籍も不正確になってしまうかもしれません。さらに、「名前付き(named)」は「公称(nominal)」ほど正確ではないものの、多くの人にとってはおそらく直感的にわかりやすいでしょう。これは、型リテラルを表すエイリアス型に対して例外が必要になったとはいえ、仕様で使われてきた元々の用語とも合致しています。

提供状況

ジェネリックなエイリアス型の実装には、予想していたよりも長い時間がかかりました。必要な変更として、go/typesに新しくエクスポートされる Alias 型を追加し、さらにその型に型パラメータを記録できるようにする必要がありました。コンパイラ側でも同様の変更が必要で、パッケージのエクスポート内容を記述するファイル形式であるエクスポートデータの形式を、エイリアスの型パラメータを記述できるように修正しなければなりませんでした。これらの変更の影響はコンパイラだけにとどまらず、go/types のクライアント、つまり多数のサードパーティ製パッケージにも及びます。これはまさに大規模なコードベースに影響する変更であり、何かを壊さないようにするために、複数のリリースにわたる段階的な展開が必要でした。

こうしたすべての作業を経て、ジェネリックなエイリアス型はついにGo 1.24でデフォルトで利用できるようになります。

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

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

いつものように、問題に遭遇した場合はIssueを立てて知らせてください。新機能をよくテストすればするほど、全体的な展開もスムーズになります。

ありがとうございました。良きリファクタリングを!

By Robert Griesemer