Go言語入門:言語仕様 -Vol.82-

スポンサーリンク
Go言語入門:言語仕様 -Vol.82- 用語解説
Go言語入門:言語仕様 -Vol.82-
この記事は約8分で読めます。
よっしー
よっしー

こんにちは。よっしーです(^^)

本日は、Go言語の言語仕様について解説しています。

スポンサーリンク

背景

Go言語を学び始めて、公式の「The Go Programming Language Specification(言語仕様書)」を開いてみたものの、「英語で書かれていて読むのが大変…」「専門用語ばかりで何を言っているのかわからない…」と感じたことはありませんか? 実は、多くのGo初心者が同じ壁にぶつかっています。

言語仕様書は、Go言語の「正式な取扱説明書」のような存在です。プログラミング言語がどのように動くのか、どんなルールで書くべきなのかが詳しく書かれていますが、その分、初めて読む人には難しく感じられるのも事実です。

そこでこの記事では、言語仕様書の導入部分を丁寧な日本語訳とともに、初心者の方でも理解しやすい補足説明を加えてお届けします。「強く型付けされている」「ガベージコレクション」「並行プログラミング」といった専門用語も、具体例を交えながらわかりやすく解説していきます。

言語仕様書は難しそうに見えますが、一つひとつの概念を丁寧に読み解いていけば、必ず理解できます。一緒に、Go言語の基礎をしっかり学んでいきましょう!

型単一化(Type unification)

型推論は型単一化を通じて型方程式を解く。型単一化は、方程式の左辺と右辺の型を再帰的に比較する。いずれかまたは両方の型が束縛型パラメータであるか、それを含む可能性があり、左辺と右辺が一致する(文脈に応じて、同一になるか代入互換になる)ような型パラメータの型引数を探す。この目的のため、型推論は束縛型パラメータから推論された型引数へのマップを保持する。このマップは型単一化の間に参照され更新される。最初は束縛型パラメータは既知であるがマップは空である。型単一化の間に新しい型引数 A が推論されると、型パラメータから引数への対応するマッピング P ➞ A がマップに追加される。逆に、型を比較する際、既知の型引数(マップのエントリが既に存在する型引数)はそれに対応する型パラメータの代わりに使用される。型推論が進むにつれて、すべての方程式が考慮されるか、または単一化が失敗するまで、マップはますます充填されていく。すべての単一化ステップが失敗せず、マップに各型パラメータのエントリがある場合、型推論は成功する。

たとえば、束縛型パラメータ P を持つ以下の型方程式が与えられたとき

[10]struct{ elem P, list []P } ≡A [10]struct{ elem string; list []string }

型推論は空のマップから開始する。単一化はまず左辺と右辺の型のトップレベル構造を比較する。両方とも同じ長さの配列であり、要素型が単一化されれば単一化される。両方の要素型は構造体であり、同じ数のフィールドが同じ名前を持ち、フィールド型が単一化されれば単一化される。P の型引数はまだ未知である(マップのエントリがない)ので、Pstring を単一化すると、マッピング P ➞ string がマップに追加される。list フィールドの型の単一化には []P[]string の、したがって Pstring の単一化が必要である。この時点で P の型引数は既知であるため(P のマップエントリが存在する)、型引数 stringP の代わりに使用される。そして stringstring と同一であるため、この単一化ステップも成功する。方程式の左辺と右辺の単一化はこれで終了する。型方程式が1つだけであり、単一化ステップが失敗せず、マップが完全に充填されているため、型推論は成功する。

単一化は、2つの型が同一でなければならないか、代入互換でなければならないか、または構造的に等しいだけでよいかに応じて、厳密な単一化と緩い単一化の組み合わせを使用する。それぞれの型単一化ルールは付録に詳細が記載されている。

X ≡A Y の形式の方程式で、XY が代入(パラメータ渡しおよび return 文を含む)に関与する型である場合、トップレベルの型構造は緩く単一化できるが、要素型は厳密に単一化されなければならず、代入のルールに一致する。

P ≡C C の形式の方程式で、P が型パラメータであり C がそれに対応する制約である場合、単一化ルールはもう少し複雑になる:

  • C の型集合のすべての型が同じ基底型 U を持ち、P に既知の型引数 A がある場合、UA は緩く単一化されなければならない。
  • 同様に、C の型集合のすべての型が同じ要素型で矛盾しないチャネル方向を持つチャネル型であり、P に既知の型引数 A がある場合、C の型集合の中で最も制限的なチャネル型と A は緩く単一化されなければならない。
  • P に既知の型引数がなく、C が基底型(チルダ)型でない型項 T をちょうど1つ含む場合、単一化はマッピング P ➞ T をマップに追加する。
  • C に上述の型 U がなく、P に既知の型引数 A がある場合、AC のすべてのメソッド(もしあれば)を持たなければならず、対応するメソッド型は厳密に単一化されなければならない。

型制約から型方程式を解く際、1つの方程式を解くことで追加の型引数が推論され、それによってその型引数に依存する他の方程式の解決が可能になることがある。型推論は新しい型引数が推論される限り型単一化を繰り返す。


解説

型単一化ってなに?

前回、型推論は「型方程式を解くこと」だと学びましたね。型単一化は、その方程式を実際に解くアルゴリズムです。

やっていることのイメージはパズルです。左辺と右辺を見比べて、「この穴(型パラメータ)にはこの型が入るはず」と1つずつ埋めていきます。

マップ(対応表)で管理する

コンパイラは「型パラメータ → 型引数」の対応表(マップ)を持っています。最初は空で、推論が進むにつれて埋まっていきます。

原文の例を、ステップごとに追ってみましょう。

左辺: [10]struct{ elem P,      list []P }
右辺: [10]struct{ elem string, list []string }

ステップ1:トップレベルの比較

左辺も右辺も「長さ10の配列」→ 一致。次は要素型(構造体)を比較。

ステップ2:構造体のフィールドを比較

フィールドの数と名前が同じ → 一致。次は各フィールドの型を比較。

ステップ3:elem フィールドの型を比較

P ≡ string

P はまだマップにない → マッピング P ➞ string を追加。

マップ: { P: string }

ステップ4:list フィールドの型を比較

[]P ≡ []string

両方スライス → 要素型を比較 → P ≡ string。マップを見ると P はすでに string とわかっている。string ≡ string → 一致!

結果: すべてのステップが成功し、マップが完全に埋まったので、型推論は成功です。

厳密な単一化と緩い単一化

型を比較するとき、どこまで「一致」と見なすかに2つのレベルがあります。

厳密な単一化:型がまったく同一でなければならない

int ≡ int        // 一致 ✅
int ≡ MyInt      // 不一致 ❌(別の型)

緩い単一化:代入互換であればOK

int ≡ int        // 一致 ✅
MyInt ≡ ~int     // 一致 ✅(基底型が同じ)

使い分けのルールは:

  • トップレベルの型構造 → 緩い単一化
  • 要素型(スライスの中身、構造体のフィールドなど) → 厳密な単一化
func f[S ~[]E, E any](s S) {}

type MySlice []int
f(MySlice{1, 2, 3})

この場合、MySlice ≡ S はトップレベルなので緩く比較されます(MySlice~[]E を満たす)。でも要素型の int ≡ E は厳密に比較されます。

制約からの推論(P ≡C C)

型パラメータと制約の間の方程式は、少し特殊なルールで処理されます。4つのケースがありますが、実用上よく出会うのは以下の2つです。

ケース1:制約の型集合の基底型が全部同じ場合

func f[T ~int](x T) {}
f(MyInt(42))  // T の型引数は MyInt → ~int の基底型 int と MyInt を緩く比較

ケース3:制約に具体的な型が1つだけある場合

func f[T int](x T) {}
// T に既知の型引数がなく、制約に int だけがある
// → 自動的に P ➞ int がマップに追加される

チルダなしの具体型が1つだけなら、「これしかない」と確定できるわけですね。

連鎖的な推論

1つの方程式を解くと、その結果が別の方程式を解く手がかりになることがあります。コンパイラは新しい型引数が見つかる限り繰り返し処理を行います。

func transform[S ~[]E, E any, R any](s S, f func(E) R) []R { ... }

ints := []int{1, 2, 3}
result := transform(ints, strconv.Itoa)

この例では:

  1. intsS を比較 → S ➞ []int
  2. S の制約 ~[]E から → E ➞ int
  3. strconv.Itoafunc(int) stringEint と一致(確認)→ R ➞ string

最初は R がわからなかったのに、E が決まったことで R も決まりました。こうやって芋づる式に推論が進んでいきます。

失敗するケース

単一化が失敗するのは、矛盾が見つかったときです。

// P を string としたいが、同時に int でもなければならない
// → 矛盾! 単一化失敗

マップに P ➞ string が登録された後で P ≡ int という比較が出てくると、string ≡ int は成立しないので失敗します。

まとめ:型単一化の全体像

型単一化の流れをざっくりまとめます。

  1. 空のマップ(型パラメータ → 型引数の対応表)を用意する
  2. 各型方程式について、左辺と右辺を再帰的に比較する
  3. 型パラメータが出てきたら:
    • まだマップにない → 対応する型をマップに登録
    • すでにマップにある → 登録済みの型で置き換えて比較を続ける
  4. 矛盾が見つかったら失敗
  5. すべての方程式を処理し終わり、マップが完全に埋まっていれば成功

普段の開発でこのアルゴリズムを意識することはほとんどありませんが、「コンパイラが型推論に失敗した」というエラーに遭遇したときに、「コンパイラは方程式を解こうとしたけど矛盾が見つかったんだな」と理解できると、エラーの原因を特定しやすくなります。

おわりに 

本日は、Go言語の言語仕様について解説しました。

よっしー
よっしー

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

それでは、また明日お会いしましょう(^^)

コメント

タイトルとURLをコピーしました