Goの import cycle に立ち向かう

Goでは、パッケージの循環参照が禁止されている。 循環参照というのは、パッケージAとBがお互いに参照しあってるという状態だ。

f:id:castaneai:20200421154623p:plain

循環参照を持ったままビルドすると、次のエラーが出て失敗する。

import cycle not allowed

なぜ循環参照が禁止されているのか

気になったので調べてみると、Stackoverflowの回答が出てきた。

go - Import cycle not allowed - Stack Overflow

Allowing circular deps would significantly increase compile times since your entire circle of deps would need to get recompiled every time one of the deps changed. Having circular deps is also a heavy cognitive load since it makes it harder to reason about your program and tends towards complexity.

コンパイラの処理が複雑化する上に、コンパイル速度も遅くなるから、とのこと。

import cycleになる設計がそもそもおかしい?

まず、循環参照が起きている時点でそもそもの設計がおかしい可能性がある。

あるプログラムを構成する機能は、いくつかのレイヤー(層)に分けて設計すると見通しが良くて、テストなどもやりやすい。 これはレイヤードアーキテクチャとか呼ぶらしい。

f:id:castaneai:20200421145553p:plain

レイヤードアーキテクチャの視点 - Qiita より引用

この図の通り、依存関係は必ず上の層から下の層だけに作られる。決して逆方向(下から上)の依存関係はできない。 Goのパッケージにおいても、このレイヤードアーキテクチャの依存関係を守っていれば、異なるレイヤーをまたいだimport cycleは基本的に起きないはず。

ということで、自分はimport cycleを起こしてしまったら、まずはここを疑う。下の層から上の層に参照を持とうとしていないか、チェックする。

import cycleを回避する方法

どうしてもimport cycleが発生してしまう…どうすればいいんだ…ってときもあるだろう。 import cycleがあるとビルドすら通らないので「TODO: あとで直す」みたいに先送りにもできない。

いくつか、import cycleを避ける典型的な方法があるので紹介する。

Interfaceに切り出す

Goのinterfaceは、明示的に implements XXX みたいに書かなくても、メソッドを満たせば自動的に実装したことになる。 そしてInterfaceの自動実装にはパッケージ参照が伴わない。

よって、AとBが循環参照しているところを、片方の依存先を同じパッケージ内のInterfaceに差し替えてしまえばよい。

f:id:castaneai:20200421155433p:plain

このように、structBはBInterfaceを実装しておけば、ビルド時には BInterface のみを参照しているので循環参照は発生しない。

パッケージをまとめてしまう

潔く循環参照しているものをすべて同じパッケージに突っ込んでしまうという手もある。 これは思考を放棄した逃げの手にも見えるが、「そもそもこの2つの関係、別パッケージに分ける必要がなかった。同一にしたほうが設計的にも納得がいく」と気づいたりすることもあるので、この方法も常に選択肢として用意しておく。