Goで構造体を設計する場合、オブジェクト指向的な「型ごとの責務の分担」以外に、「どのように使われるものか」を考える必要があります。
- ポインタで扱うのか? 値として扱うのか? 両方許可するのか?
- 値として扱える場合にimmutable(変更不可能)なオブジェクトとするのか、mutable(変更可能)なオブジェクトとするのか
- 値として扱える場合にゼロ値での動作を補償するかどうか
他の言語で言うと、C#の構造体とクラスの違い、C++のデフォルトコンストラクタあたりに頭を悩ませたことがある人にはおなじみかもしれませんが、Goでもいくつか考慮が必要になります。
ポインタ型として扱う必要があるケース
まず最初に決断できる方針としては、ポインタ型でのみ扱うかどうかです。
内部にスライスやmap、ポインタなどの参照型な要素を持っていれば、基本的にポインタ型でのみ扱う構造体になります。これらの要素を持っていた場合、コピーしてしまうと、複数のインスタンスが、これらのフィールドのインスタンスを共有することになります。自分だけの要素だと思っていたのに変更が他のインスタンスにも影響を与えてしまう、となってしまいます。
標準ライブラリだと、ロック状態がコピーされて変なエラーになってしまうsync.Mutexや、内部に可変長なバッファを含むbig.Intなどが該当します。これらの構造体は利用コード内ではポインタ型で取扱います。 sync.Mutex
にはないですが、インスタンスを作成してポインタ型を返すファクトリー関数を用意すると良いでしょう。
Goの文法を使った構造体のコピーを防ぐ方法としてはgo vetに警告させる方法があるとissueにはあるのですが、手元でこの方法を試したところうまく動いておらず・・・
5/26追記
@orisanoさんより、型のベースが構造体で、Unlockメソッドも定義されていることが条件と教えていただきました。
実行時に防ぐ方法としては、このような実装を見かけたことはないのですが、作成したときのポインタ値を保持しておいて、メソッドを呼び出したときにエラーを出す方法があります。C言語などにあるassert機能がGoにはないので、実行時にコストがかかってしまう問題はありますが確実に発見できます。パッケージプライベートなフィールドに格納し、ファクトリー関数で設定することで確実にチェックできます。また、nil
と比較すればファクトリー関数を使わないで初期化したケースも拾えるでしょう。
5/26追記
@athos0220さんより、strings.Builderの中でポインタ値を保持してコピーを検知する手法が実際に使われていると教えていただきました。
// ポインタとしてのみ利用する構造体 |
ポインタ型として扱う場合は、明示的に値を取り出してコピーをするという組み込み文法では問題がおきるため、コピーが必要な場合は明示的なCopy()メソッドを用意すべきです。
// 明示的なコピー用メソッド |
ユーザーにどうしてもポインタとして扱わせたい場合は、実装をprivateにして、インタフェースだけを公開する方法もあります。
値として扱える場合
値として扱う場合は、インスタンス全体のコピーが行われることになります。代入したり、引数として渡すたびにコピーされます。ポインタの場合はコピーがされません。
ポインタ型として扱う必要があるケースの裏となりますが、値として扱う場合、ポインタ、mapやスライスなどをメンバーに持たせられません。持つこと自体は文法上エラーになりませんが、わかりにくいバグを誘発します。
値の方が実装の制約が強くかかりますが、メリットもあります。インスタンスを作ってその関数のライフサイクルの中でのみ消費される場合、スタックメモリ上にインスタンスが確保されます。スタックメモリは言語のランタイムやOSに問い合わせてメモリを確保するヒープメモリと異なり、メモリ割り当てのコストがほぼゼロです 1。
値で扱える構造体はポインタで扱っても問題ありません。ポインタにnilを入れることで無効な値であることが表現できます。値でも「IsZero()
」メソッドを用意することで同じことを表現することもあります。どちらで設計するかはチームごとに方向性を決めましょう。
mutableな構造体とimmutableな構造体
mutableな構造体は、フィールドの変更を行うメソッドは、フィールドを直接書き換えます。古典的なオブジェクト指向プログラミングなコードとなります。変更するメソッドはレシーバーをポインタ型にします。
type MutableMoney struct { |
近年、関数型言語からエッセンスを借りてきて普及しているのが「immutable」にする設計です。Goでもtime.Timeはimmutableです。immutableな構造体は、フィールドの変更を行うメソッドを呼び出すと、その変更を加えた新しいインスタンスを返します。レシーバーを変更することがないため、レシーバーは常に値型で問題ありません。
type ImmutableMoney struct { |
使われ方も異なります。immutableな型は変更を加えるメソッドではコピーを返すため、必ず返り値を変数に入れたり、他の関数に渡したり、返り値を利用するコードとなります。
mutableにするか、immutableにするかの指標は、ポインタ型にするか値にするかの指標と似ています。ポインタ型でなければならないケースは、内部にコピー不可能なもの(利用途中のチャネルなど)やコピーコストの大きな複合型を持っているため、immutableにすることは難しいでしょう。immutableは値との相性は良いです。ただ、値として扱うものをmutableにすることは可能です。
どちらで表現できるものをどちらにするかはアーキテクトの腕に見せ所ですが、Goの場合はエンティティと呼ばれるような構造体はmutableが良いでしょう。関数型にかぶれると全部immutableにしたくなりますが、time.Timeのようなほぼプリミティブみたいなデータでのみ利用した方がGoの標準ライブラリなど、Goのエコシステムと粒度が合わせやすいでしょう 2。
ゼロ値の動作を補償するかどうか
構造体の各フィールドは、初期化しないとゼロ値になります。構造体を未初期化で定義すればすべてのフィールドがゼロ値になります。
type Node struct { |
このときにも全部の機能が有効に動作することを補償するかどうかも決める必要があります。特に値として扱うケースではこれを考慮する必要性が高くなるでしょう。
ステータス的な属性があるなら、ゼロ値で未初期化状態、デフォルト状態にします。
type Status int |
ポインタや複合型などがフィールドにあれば、実行時にnilチェックをして初期化するコードを入れる方法も考えられます。しかし、おそらくゼロ値構造体インスタンスが頻繁に使われると思われる値で扱う構造体の場合、そもそもこれらの型のフィールドはあまり持っていないと思うので、考慮する必要はないでしょう。
ゼロ値での動作を補償しない、あるいはポインタとして扱う前提の構造体であれば、ファクトリー関数以外の動作を補償しない手もあります。
まとめ
オブジェクト指向設計的には「型を作る」の一言ですが、Goでは利用する場面や内部の状態に応じて実装方法にいくつかのバリエーションがあります。
- ポインタで扱うのを想定するか、値で扱うのを想定するか
- immutableなAPIセットを用意するか、mutableなAPIセットを用意するか
- ゼロ値の動作を補償するかどうか
Goにはどちらのスタイルであるか、スタイル違いで使ったときにエラーにする仕組みがない(あるいは弱い)ため、利用者に設計方針を伝えるためにExampleテストやREADMEなどで使い方を例示しましょう。
色々紹介しましたが、困ったらまずは「ポインタで扱う前提」「mutableなAPIセットを提供」「特定のファクトリー関数でのみ動作(ゼロ値動作を保証しない)」がいちばんお手軽なので問題ありません。値で扱う、プラスアルファでimmutableにする手法がミートするのはケースとしてはやや狭くなります。上手くハマればメモリ確保が軽くなったり、不具合が減るでしょう。ゼロ値での動作は値で扱うケースでは補償してあげる方が便利なことが多いでしょう。
ここで説明しなかった観点にはパフォーマンスもあります。ポインタを使った方が確かにパフォーマンスは良かったりしますが、かなり巨大な構造体にならないかぎりはそれが問題になることはほとんどありません。早すぎる最適化よりは、最適な使われ方を模索した方が良いでしょう。
この手の「自由度がある」ことで利用側で考慮が必要なケースは、制約を加える言語機能がないから必要になっていると言えます。とはいえ「制約を与える(≒引き算をする)」ことがうまくやり切れる人は、単純に機能を追加する人よりは少なく、制約が緩ければ誤った利用方法を抑制できず、制約が厳し過ぎれば、元のコードをそのまま使うのをあきらめ、vendoringの上カスタマイズされて実装が枝分かれしてしまうことになります。個人的には制約のための言語機能がない言語設計も1つの合理的な帰結だと思っています。