Goが循環インポートをエラーにする理由
Goのインポート方針について
Goのインポート機能では以下のような方針を採用しています。
- 未使用インポートをエラーにする
- 循環インポートをエラーにする
前者についてはよく叩かれるポイントでその解説は以下に書きました。
後者についてはPascalやJava系、Swift、Pythonも採用していて特にGoが特別叩かれることもない方針だと思っていたんですが、叩かれを観測したのでちょっとなぜそうなっているのかを解説してみようと思います。
循環インポートの例
コード
package main
import (
"fmt"
"play.ground/bar"
)
func main() {
fmt.Print(bar.Bar{})
}
module play.ground
package foo
import "play.ground/bar"
type Foo struct{}
func (f *Foo) String() string {
return bar.BarID
}
package bar
import "play.ground/foo"
const BarID = "deadbeef"
type Bar struct {
*foo.Foo
}
実行結果
package play.ground
imports play.ground/bar
imports play.ground/foo
imports play.ground/bar: import cycle not allowed
つまり依存ツリーを組み立てた時、無限の枝が伸びる状態が「循環インポート」状態という事です。
play.ground
└ play.ground/bar
└ play.ground/foo
└ play.ground/bar
└ play.ground/foo
└ ...
循環インポートの問題点
現代のプログラミング言語の多くは1パスでプログラムコードを解釈します。インタプリタ型は当然としてC/C++も例外ではありません。つまり「コンパイル・実行」されるまでにソースコードを2度パースすることはありません。
さらにプログラム言語の多くは多重定義はバグの元なのでエラー扱いになります。なので対策の無いヘッダーファイルをincludeした時、再度同じヘッダーファイルが参照された場合に「多重定義」になってしまいます。
C/C++ではそのような「多重定義」を回避するために「インクルードガード」という対策をヘッダーファイルに施します。C/C++ではプリプロセッサという仕掛けに依存していてコンパイラは重複する定義がそれぞれどこのファイルを読み込んだ結果かを判別できません。なので「インクルードガード」という対策がヘッダーに必要なのです。
しかし、「インクルードガード」は方針が実装者によってまちまちであったり、巨大なプロジェクトになるとまれに「インクルードガード」用のシンボルが衝突するレアケースもあったりします。インクルード参照単位をコンパイラが把握しないことによる落とし穴は多くあります。例えば、プリプロセッサ上のエラーを追跡しにくいことや、参照順依存を作ってしまったり。
Goではインポートした結果は一度だけ解釈されインポート単位でユニークな名前空間に閉じて読み込まれ「多重定義」を回避、さらに循環があることを発見してエラー扱いにします。この仕掛けはC/C++のインクルード機構の持つ多くの問題を回避します。PascalやJava(Kotlin,Scala)、Swift、Pythonもこちらを採用しています。実用向けの固い処理系の多くがこちらの方針を採用しています。
C#はファイル単位でシングルトン扱いで読み込むのは同じですが、循環インポートはコンパイル時にチェックしません。このC#のような仕掛けであれば、ちゃんとエラー追跡ができて最もコードの配置に自由が与えられるわけですが、この自由にこそ問題があります。多重定義は自動回避されても循環インポートがあるとインポートに順番依存が発生したりします。1パスしか解釈しないという都合上、ある定義Aに依存した別定義を解釈する時点である定義Aが順番によって見つかったり見つからなかったりという状況が発生しうるためです。
また、自由だと、気軽に依存関係を作ってしまい、依存スパゲッティな構造を作りこみやすい。そうしてできたとある依存末端の「インポート単位のコード」は責務があいまいで独立性が低く再利用しにくい実装になっている場合が多いのです。
独立性や再利用性の高いコードというのは依存関係がシンプルなものです。シンプルさを保つのに「循環インポートをエラー」とするのはかなり有効な方針なのです。
例えばA->B->C->Aという循環依存関係があったとします。インクルードガードのような仕掛けがあればこれもコンパイル可能なわけですが、A,B,Cいずれも単体で利用することはできません。A,B,Cという各々のコンポーネントがあるのではなく、「A+B+C」という巨大なコンポーネントが生まれただけなのです。例えばAだけに用事があるのに、全体を取り込む必要があるとなると必要以上にコンパイルを遅くしてしまいます。また、テストはコンポーネント単位で実行することになりますので、テストの作り込み場所も難しくなります。
C/C++の「インクルードガード」方式も大量の必要のない「ファイルを開いて読み飛ばす処理」が走るわけでこれもまたコンパイルを遅くしてしまいます。
また、複雑な依存関係はコードの追加場所の判断を鈍らせます。本当はAに追加すべきコードをBに書いたとしても循環インポートOKな環境の場合、動いてしまうがゆえに修正される機会が乏しい。追加場所のミスはあとあと負債になります。問題が起こったときに問題の原因にたどり着くことを遅くします。
なので一部のプロジェクトではC/C++であっても循環インポートを禁止するチーム内ルールを採用しているところもあります。その方が問題の追跡がしやすい、コンパイルが遅くなりにくい、コードのカタマリごとに責務が明確化されるなどメリットがあるからです。
「循環インポート禁止」の効能
コードの追加場所をミスっていると、循環インポートエラーになりやすいです。なので正しいコードの追加場所というものの意識が書き手に育ちやすいです。
「循環インポート禁止」という縛りプレイをしていると、複数のパッケージから参照したい定義は別パッケージに分離するだけで循環インポートを回避できることに気づきますが、それは自然と依存関係がシンプルに保たれ、独立性が高く、再利用もしやすい実装に近づきます。そして、「インクルードガード」のような方針の差がコードに載ってきませんし、インポート順依存も生まれにくいのです。また、コードの役割というか責務も明確化しやすいですね。
- 必要なものを必要な定義だけインポートしやすい
- コードの役割がはっきりするので実装を追加する場所の判断もしやすい
- コンパイルに無駄な処理が入り込みにくい
- インクルードガードのような(チームごとに方針がばらつく)定型処理が不要
実用モダン処理系はこれらのような利点を得るために循環インポートを禁止しているのです。
循環インポートの解消
例えば以上のような依存関係になったら、「パッケージA」のある部分定義が「パッケージA」や「パッケージC」にとって必要という事。こういう時はその定義を別の「パッケージD」に分離して以下のようにします。
図の上で「輪の形状」依存は残りますが、ループ(循環)は解消されます。
まとめ
- 循環インポート出来ちゃう系のコード品質は書き手のスキルに大きく左右される
- 循環インポート禁止ルールは循環インポート出来ちゃう環境でも採用されている
- 実用系言語処理系の多くは循環インポートをエラー扱いにする
- 循環インポート禁止だとコードの品質(独立性、再利用性)が向上する(逆に循環インポート可能だとコード品質が低下しやすい)
- C#は循環参照できてしまうが、BADではあるという認識が広まっておりほとんどの開発者は回避する
- C/C++はプリプロセッサ任せのため、現状のインクルードガード手法が仕方なく生み出された経緯がある
- 循環インポートはパッケージを分離していくことで解消できる
- 考え方はPascalやJava系、Swift、Pythonなども同じで循環インポートが発生したら細分化で解消できる
- ただし、Goのインポート単位はファイル単位じゃなくフォルダ単位なので注意
Discussion