
こんにちは。よっしーです(^^)
本日は、Go言語の言語仕様について解説しています。
背景
Go言語を学び始めて、公式の「The Go Programming Language Specification(言語仕様書)」を開いてみたものの、「英語で書かれていて読むのが大変…」「専門用語ばかりで何を言っているのかわからない…」と感じたことはありませんか? 実は、多くのGo初心者が同じ壁にぶつかっています。
言語仕様書は、Go言語の「正式な取扱説明書」のような存在です。プログラミング言語がどのように動くのか、どんなルールで書くべきなのかが詳しく書かれていますが、その分、初めて読む人には難しく感じられるのも事実です。
そこでこの記事では、言語仕様書の導入部分を丁寧な日本語訳とともに、初心者の方でも理解しやすい補足説明を加えてお届けします。「強く型付けされている」「ガベージコレクション」「並行プログラミング」といった専門用語も、具体例を交えながらわかりやすく解説していきます。
言語仕様書は難しそうに見えますが、一つひとつの概念を丁寧に読み解いていけば、必ず理解できます。一緒に、Go言語の基礎をしっかり学んでいきましょう!
型推論(Type inference)
ジェネリック関数の使用において、関数が使用される文脈(関数の型パラメータの制約を含む)から推論可能であれば、一部またはすべての型引数を省略することができる。型推論は、欠けている型引数を推論でき、かつ推論された型引数でインスタンス化が成功する場合に成功する。そうでなければ、型推論は失敗し、プログラムは無効となる。
型推論は、推論のために型のペア間の型関係を使用する。たとえば、関数の引数はそれに対応する関数パラメータに代入可能でなければならない。これにより、引数の型とパラメータの型の間に関係が確立される。これら2つの型のいずれかが型パラメータを含む場合、型推論は代入可能性の関係が満たされるよう型パラメータを置換する型引数を探す。同様に、型推論は型引数がそれに対応する型パラメータの制約を満足しなければならないという事実を利用する。
このような一致する型の各ペアは、1つまたは複数のジェネリック関数に由来する、1つまたは複数の型パラメータを含む型方程式に対応する。欠けている型引数を推論することは、対応する型パラメータに対する型方程式の集合を解くことを意味する。
たとえば、以下が与えられたとき
// dedup は引数のスライスから重複エントリを除去したコピーを返す。
func dedup[S ~[]E, E comparable](S) S { … }
type Slice []int
var s Slice
s = dedup(s) // s = dedup[Slice, int](s) と同じ
プログラムが有効であるためには、型 Slice の変数 s は関数パラメータ型 S に代入可能でなければならない。複雑さを軽減するため、型推論は代入の方向性を無視するので、Slice と S の型関係は(対称的な)型方程式 Slice ≡A S(あるいは S ≡A Slice)で表現できる。ここで ≡A の A は、左辺と右辺の型が代入可能性の規則に従って一致しなければならないことを示す(詳細は型単一化の節を参照)。同様に、型パラメータ S はその制約 ~[]E を満足しなければならない。これは S ≡C ~[]E と表現でき、X ≡C Y は「X は制約 Y を満足する」を意味する。これらの観察から、2つの方程式の集合が得られる
Slice ≡A S (1)
S ≡C ~[]E (2)
これで型パラメータ S と E について解くことができる。(1) からコンパイラは S の型引数が Slice であると推論できる。同様に、Slice の基底型は []int であり、[]int は制約の []E と一致しなければならないので、コンパイラは E が int でなければならないと推論できる。したがって、これら2つの方程式に対して、型推論は以下を推論する
S ➞ Slice
E ➞ int
型方程式の集合が与えられたとき、解くべき型パラメータは、インスタンス化が必要であり明示的な型引数が提供されていない関数の型パラメータである。これらの型パラメータは束縛型パラメータと呼ばれる。たとえば、上記の dedup の例では、型パラメータ S と E は dedup に束縛されている。ジェネリック関数呼び出しの引数は、それ自体がジェネリック関数であることがある。その関数の型パラメータは束縛型パラメータの集合に含まれる。関数引数の型は、他の関数(関数呼び出しを囲むジェネリック関数など)の型パラメータを含む場合がある。それらの型パラメータも型方程式に現れることがあるが、その文脈では束縛されていない。型方程式は常に束縛型パラメータに対してのみ解かれる。
型推論は、ジェネリック関数の呼び出しと、(明示的に関数型が指定された)変数へのジェネリック関数の代入をサポートする。これには、ジェネリック関数を他の(場合によってはジェネリックな)関数の引数として渡すこと、およびジェネリック関数を結果として返すことが含まれる。型推論はこれらの各ケースに固有の方程式の集合に対して動作する。方程式は以下のとおりである(明確さのため型引数リストは省略):
f(a0, a1, …) という関数呼び出しで、f または関数引数 ai がジェネリック関数である場合: 対応する関数引数とパラメータの各ペア (ai, pi) で、ai が型なし定数でない場合、方程式 typeof(pi) ≡A typeof(ai) が得られる。 ai が型なし定数 cj であり、typeof(pi) が束縛型パラメータ Pk である場合、ペア (cj, Pk) は型方程式とは別に収集される。
ジェネリック関数 f を関数型の(非ジェネリックな)変数 v に代入する v = f の場合: typeof(v) ≡A typeof(f)。
return …, f, … という return 文で、f が関数型の(非ジェネリックな)結果変数 r への結果として返されるジェネリック関数である場合: typeof(r) ≡A typeof(f)。
さらに、各型パラメータ Pk と対応する型制約 Ck は型方程式 Pk ≡C Ck を生成する。
型推論は、型なし定数を考慮する前に、型付きオペランドから得られる型情報を優先する。したがって、推論は2つのフェーズで進行する:
- 型方程式が型単一化を使用して束縛型パラメータについて解かれる。単一化が失敗した場合、型推論は失敗する。
- まだ型引数が推論されていない各束縛型パラメータ
Pkについて、同じ型パラメータとの1つ以上のペア(cj, Pk)が収集されている場合、それらすべてのペアの定数cjの定数種別を、定数式と同じ方法で決定する。Pkの型引数は、決定された定数種別のデフォルト型である。矛盾する定数種別のために定数種別を決定できない場合、型推論は失敗する。
これら2つのフェーズの後にすべての型引数が見つかっていない場合、型推論は失敗する。
2つのフェーズが成功した場合、型推論は各束縛型パラメータに対して型引数を決定している:
Pk ➞ Ak
型引数 Ak は、要素型として他の束縛型パラメータ Pk を含む複合型である場合がある(あるいは単に別の束縛型パラメータであることもある)。繰り返しの単純化プロセスにおいて、各型引数内の束縛型パラメータは、それらの型パラメータに対する対応する型引数で置換され、各型引数が束縛型パラメータを含まなくなるまで続けられる。
型引数が束縛型パラメータを通じて自分自身への循環参照を含む場合、単純化ひいては型推論は失敗する。そうでなければ、型推論は成功する。
解説
型推論ってなに?
型推論とは、ジェネリック関数を使うとき、型引数を書かなくてもコンパイラが自動的に正しい型を見つけてくれる仕組みのことです。
func min[T ~int|~float64](x, y T) T {
if x < y { return x }
return y
}
// 型引数を明示
min[int](3, 5)
// 型推論に任せる(引数が int なので T=int と推論される)
min(3, 5)
毎回 min[int] と書かなくていいので、コードがすっきりしますよね。この裏側でコンパイラがどうやって型を見つけているのかを、この節では詳しく説明しています。
型方程式という考え方
型推論の核心は、型の関係を「方程式」として捉え、それを解くというアプローチです。中学校の連立方程式と似た発想です。
原文の dedup の例で見てみましょう。
func dedup[S ~[]E, E comparable](S) S { … }
type Slice []int
var s Slice
s = dedup(s)
コンパイラはここで2つの手がかりを得ます。
手がかり1:引数 s はパラメータ S に代入可能でなければならない
Slice ≡ S ... (1)
「Slice と S は一致するはず」という方程式ですね。
手がかり2:S は制約 ~[]E を満たさなければならない
S ≡ ~[]E ... (2)
この2つの方程式を解きます。
- (1) から:
S = Slice - (2) に代入:
Slice ≡ ~[]E→Sliceの基底型は[]int→[]intと[]Eを照合 →E = int
結果:
S ➞ Slice
E ➞ int
連立方程式が解けました! コンパイラはまさにこれと同じことをやっています。
型推論の2つのフェーズ
型推論は2段階で行われます。
フェーズ1:型付きの値から推論する
まず、型がわかっている引数を使って方程式を解きます。上の dedup の例はこのフェーズで完結しています。
フェーズ2:型なし定数から推論する
フェーズ1で決まらなかった型パラメータがあれば、型なし定数のデフォルト型を使います。
func sum[T ~int | ~float64 | ~string](x... T) T { ... }
c := sum(1, 2, 3) // 1, 2, 3 は型なし定数
1, 2, 3 は型なし定数なので、フェーズ1では T が決まりません。フェーズ2で「整数定数のデフォルト型は int」というルールが適用され、T = int と決定されます。
型付きの値が優先されるのは、プログラマが明示的に指定した型情報のほうが信頼性が高いからです。
b := sum[float64](2.0, 3) // T=float64 と明示
c := sum(b, -1) // b が float64 なので T=float64 と推論(フェーズ1で決定)
// -1 は型なし定数だが、フェーズ1の結果が優先される
束縛型パラメータとは
「束縛型パラメータ」とは、今まさに推論しようとしている型パラメータのことです。
func outer[A any](x A) {
inner(x) // inner の型パラメータを推論する
}
func inner[B any](y B) { ... }
inner(x) を推論するとき、B は束縛型パラメータ(推論対象)ですが、A はすでに outer で決まっているので束縛されていません。方程式 B ≡ A を解くとき、A は既知の値として扱い、B についてのみ解きます。
どんな場面で型推論が使えるか
型推論は以下の4つの場面で動作します。
1. 関数呼び出し
min(3, 5) // 引数の型から推論
2. 変数への代入
type intMin func(int, int) int
var f intMin = min // 変数の型から推論
3. 別の関数への引数渡し
func apply(f func(int, int) int) { ... }
apply(min) // パラメータの型から推論
4. return文
func getMin() func(int, int) int {
return min // 戻り値の型から推論
}
部分的な型引数と「右から左の省略」
型引数は右側から省略できます。
func apply[S ~[]E, E any](s S, f func(E) E) S { ... }
apply[[]int](mySlice, myFunc)
// S=[]int を明示、E は S から推論 → E=int
左側だけ指定して、右側の推論を任せるイメージです。
単純化プロセス
推論結果の中に、まだ他の型パラメータが残っていることがあります。その場合は繰り返し置換して解消します。
// 推論の途中結果が
// S ➞ []E
// E ➞ int
// だったとすると...
// S の中の E を int に置換
// S ➞ []int
すべての型引数から束縛型パラメータがなくなれば完了です。もし循環参照(A が B を含み、B が A を含む)が発生したら、解けないのでエラーになります。
失敗するケース
型推論が失敗するのは以下の場合です。
// 1. 手がかりがない
x := sum // 型も引数もないので推論できない
// 2. 矛盾する情報
func f[T any](a, b T) {}
f(1, "hello") // T は int? string? 矛盾!
// 3. 型なし定数の種別が矛盾する
// 整数定数と文字列定数が同じ型パラメータに対応する場合など
失敗した場合はコンパイルエラーになるので、型引数を明示的に書いて解決します。実際の開発では、型推論が失敗するケースはそれほど多くありません。ほとんどの場合は自然に推論が効くように Go のジェネリクスは設計されています。
おわりに
本日は、Go言語の言語仕様について解説しました。

何か質問や相談があれば、コメントをお願いします。また、エンジニア案件の相談にも随時対応していますので、お気軽にお問い合わせください。
それでは、また明日お会いしましょう(^^)

コメント