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

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

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

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

スポンサーリンク

背景

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

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

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

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

パッケージの初期化

パッケージ内では、パッケージレベルの変数の初期化は段階的に進みます。各段階では、宣言順で最も早く、かつ未初期化の変数に依存していない変数が選ばれます。

より正確に言うと、パッケージレベルの変数は、まだ初期化されておらず、かつ「初期化式を持たない」または「その初期化式が未初期化の変数に依存していない」のいずれかであるとき、初期化の準備が整っているとみなされます。初期化は、宣言順で最も早く、かつ準備の整った次のパッケージレベル変数を繰り返し初期化していくことで進み、準備の整った変数がなくなるまで続きます。

この処理が終わった時点でまだ未初期化の変数が残っている場合、それらの変数は1つ以上の初期化サイクル(循環)の一部であり、そのプログラムは不正です。

変数宣言の左辺に複数の変数があり、右辺の単一の(複数の値を返す)式によって初期化される場合、それらの変数はまとめて初期化されます。左辺のいずれかの変数が初期化されるなら、それらすべての変数が同じ段階で初期化されます。

var x = a
var a, b = f() // a と b はまとめて初期化され、x が初期化される前に初期化される

パッケージの初期化に関しては、ブランク変数も宣言中の他の変数と同様に扱われます。

複数のファイルにまたがって宣言された変数の宣言順は、それらのファイルがコンパイラに提示される順序によって決まります。すなわち、最初のファイルで宣言された変数は、2番目のファイルで宣言されたどの変数よりも先に宣言され、以降も同様です。再現性のある初期化動作を保証するため、ビルドシステムは、同じパッケージに属する複数のファイルを、ファイル名の辞書順でコンパイラに提示することが推奨されます。

依存関係の分析は、変数の実際の値には依存せず、ソース中のそれらへの字句上の参照のみに基づいて、推移的に分析されます。たとえば、変数 x の初期化式が、本体で変数 y を参照する関数を参照している場合、x は y に依存します。具体的には次のとおりです。

変数または関数への参照とは、その変数または関数を表す識別子のことです。

メソッド m への参照とは、t.m という形のメソッド値またはメソッド式のことであり、ここで t の(静的な)型はインターフェース型ではなく、メソッド m は t のメソッドセットに含まれます。結果として得られる関数値 t.m が実際に呼び出されるかどうかは問いません。

変数・関数・メソッド x が変数 y に依存するのは、x の初期化式または(関数とメソッドの場合は)本体が、y への参照、または y に依存する関数やメソッドへの参照を含むときです。

たとえば、次の宣言が与えられたとき、

var (
	a = c + b  // == 9
	b = f()    // == 4
	c = f()    // == 5
	d = 3      // == 5 after initialization has finished
)

func f() int {
	d++
	return d
}

初期化の順序は d, b, c, a となります。初期化式における部分式の順序は無関係であることに注意してください。すなわち、a = c + ba = b + c は、この例では同じ初期化順序になります。

依存関係の分析はパッケージ単位で行われます。考慮されるのは、現在のパッケージで宣言された変数・関数・(非インターフェースの)メソッドを参照する参照のみです。もし変数間に他の隠れたデータ依存関係が存在する場合、それらの変数間の初期化順序は規定されません。

たとえば、次の宣言が与えられたとき、

var x = I(T{}).ab()   // x has an undetected, hidden dependency on a and b
var _ = sideEffect()  // unrelated to x, a, or b
var a = b
var b = 42

type I interface      { ab() []int }
type T struct{}
func (T) ab() []int   { return []int{a, b} }

変数 a は b の後に初期化されますが、x が b より前に初期化されるのか、b と a の間なのか、a の後なのか、したがって sideEffect() が呼ばれる瞬間(x の初期化の前か後か)も規定されません。

変数は、パッケージブロック内で宣言された init という名前の関数を使って初期化することもできます。この関数は引数も結果パラメータも持ちません。

func init() { … }

このような関数は1つのパッケージに複数定義でき、1つのソースファイル内に複数あっても構いません。パッケージブロックでは、init 識別子は init 関数を宣言するためにのみ使えますが、その識別子自体は宣言されません。したがって、init 関数はプログラムのどこからも参照できません。

パッケージ全体は、すべてのパッケージレベル変数に初期値を割り当て、続いて、コンパイラに提示された順(複数のファイルにまたがる場合もある)でソース中に現れる順序で、すべての init 関数を呼び出すことによって初期化されます。

解説

結論:パッケージ内のグローバル変数は、「依存している変数が先、依存される変数が後」という順で自動的に初期化される。Goが依存関係を解析して順番を決めてくれる。循環依存があるとコンパイルエラーになる。最後に init 関数が実行される。

この節は、パッケージレベル変数(関数の外で宣言されたグローバルな変数)の初期化順序のルールを定めています。関数内のローカル変数の話ではない点に注意してください。

なぜ「順序」が問題になるのか

パッケージレベルの変数は、互いに参照し合うことがあります。

var x = a // x は a の値を使う
var a = 5

ここで x を先に初期化しようとすると、a がまだ決まっていないので困ります。だからGoは「a を先、x を後」という順序を自動的に判断して初期化します。書いた順番どおりではなく、依存関係に従って順番が決まるのがポイントです。

授業の履修に例えると、「数学II」を取るには先に「数学I」を取る必要がある、という前提条件の関係に似ています。Goが前提条件を解析して、正しい順序で履修(初期化)してくれます。

「準備が整っている」変数から初期化する

原文のアルゴリズムを噛み砕くと、Goは次を繰り返します。

まだ初期化されていない変数のうち、「初期化式を持たない」か「その式が依存する変数がすべて初期化済み」のもの(=準備が整ったもの)を探し、その中で宣言順が最も早いものを初期化する。これを準備の整った変数がなくなるまで繰り返す、という流れです。

循環依存はエラー

もし変数同士がぐるぐると依存し合っていると、どれも「準備が整わない」まま処理が終わってしまいます。

var a = b // a は b に依存
var b = a // b は a に依存 → 堂々巡り

これを**初期化サイクル(循環)**と呼び、Goはコンパイルエラーにします。鶏が先か卵が先か決められない状態を、Goは許しません。

複数の戻り値はまとめて初期化される

原文の最初のコード例です。

var x = a
var a, b = f() // a と b はまとめて初期化され、x が初期化される前に初期化される

f() が2つの値を返し、それを ab が同時に受け取ります。このとき ab1つのセットとして同じ段階で初期化されます。xa に依存するので、a(と b)が初期化された後に初期化されます。

依存関係は「値」ではなく「ソース上の参照」で判断される

ここが少し高度なポイントです。Goは実際の値を計算して順序を決めるのではなく、**ソースコード上で何を参照しているか(字句上の参照)**を見て、それを芋づる式(推移的)にたどって判断します。

「推移的」とは、x が関数 f を呼び、その f が変数 y を使っているなら、xy に依存している、とみなすことです。直接でなく、間接的なつながりも追いかけます。

原文の例で確認します。

var (
	a = c + b  // == 9
	b = f()    // == 4
	c = f()    // == 5
	d = 3      // == 5 after initialization has finished
)

func f() int {
	d++
	return d
}

依存関係を整理します。acb に依存します。bc は関数 f() を呼び、その f()d を使う(d++)ので、bcd に依存します。d は何にも依存しません。

したがって初期化順序は d → b → c → a になります。

  • まず d を初期化(d = 3)。
  • 次に b = f()fd を1増やして返すので d が 4 になり、b = 4
  • 次に c = f()。再び d が 5 になり、c = 5
  • 最後に a = c + b = 5 + 4 = 9

bc はどちらも宣言順で a より早く、依存関係も同じなので、宣言順(b が先)どおりに b → c となります。

なお原文の注記どおり、a = c + b と書いても a = b + c と書いても、abc の両方に依存することは変わらないので、初期化順序は同じです。式の中での足す順番は関係ありません。

「隠れた依存」は順序が保証されない

原文の2つ目のコード例は、Goの依存解析の限界を示しています。

var x = I(T{}).ab()   // x has an undetected, hidden dependency on a and b
var _ = sideEffect()  // unrelated to x, a, or b
var a = b
var b = 42

type I interface      { ab() []int }
type T struct{}
func (T) ab() []int   { return []int{a, b} }

xI(T{}).ab() を呼びますが、Iインターフェース型です。原文のルールにあるとおり、依存解析が追いかけるのは「非インターフェースのメソッド」への参照だけなので、インターフェース越しの呼び出しの先で ab が使われていても、Goはそれを依存だと検出できません(”undetected, hidden dependency”)。

その結果、ab の後に初期化されることは確定するものの、x(および sideEffect())がどのタイミングで初期化されるかは**規定されない(不定)**になります。

教訓としては、パッケージ変数の初期化に副作用のある複雑な処理を絡めると、順序が予測できなくなり危険だということです。グローバル変数の初期化はできるだけ単純に保つのが安全です。

init 関数

最後に登場するのが init 関数です。これはパッケージの初期化時に自動で呼ばれる特別な関数です。

func init() {
	// パッケージが読み込まれるときに自動実行される
}

init には次の特徴があります。

  • 引数も戻り値も持てません(func init() の形に固定)。
  • 1つのパッケージに複数定義できます(同じファイル内でも、複数ファイルにまたがってもよい)。
  • 自分のコードからは呼び出せませんinit という名前は宣言として使えるだけで、識別子として参照できないからです。あくまでGoが自動で呼ぶ専用の関数です。

主な用途は、変数宣言だけでは書ききれない複雑な初期化処理です。

var config map[string]string

func init() {
	config = make(map[string]string)
	config["host"] = "localhost" // 変数宣言だけでは書きにくい準備処理
}

パッケージ初期化の全体の流れ

原文の最後の段落がまとめになっています。1つのパッケージは次の順で初期化されます。

  1. すべてのパッケージレベル変数に、依存関係に従った順序で初期値を割り当てる。
  2. その後、すべての init 関数を、ソース中に現れる順序(複数ファイルの場合はコンパイラに提示された順)で呼び出す。

つまり**「変数の初期化がすべて終わってから、init 関数が走る」**という二段構えです。これにより、init 関数の中ではパッケージ変数がすべて初期化済みである、と安心して使えます。


補足:実務では、依存解析の細かいアルゴリズムを暗記する必要はありません。重要なのは次の3点です。第一に、グローバル変数は依存関係に従って自動で正しい順に初期化される。第二に、循環依存はエラーになる。第三に、複雑な初期化は init 関数に書け、それは自動で1回だけ呼ばれる。この3点を押さえておけば十分です。

おわりに 

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

よっしー
よっしー

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

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

コメント

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