💊

ゼロ値がカプセル化の抜け道にならないように: Goで奇数を表す型を作ってみる

2023/06/08に公開

カプセル化の狙い

データと挙動からなるオブジェクトに対するカプセル化とは、

  • 挙動のみを外部に提供し、内部状態を表すデータの存在を外部から隠す(隠蔽の要件)
  • 初期化を行う入り口で、正しい内部状態を持つオブジェクトを生成する(初期化の要件)
  • 状態遷移を引き起こす挙動において、内部状態の正しさが崩れないように処理を行う(状態維持の要件)

を徹底することで、

  • オブジェクトの内部の事情を知らない外部の利用者が勝手にデータを書き換えるのを防ぎ、
  • オブジェクトのライフサイクルのいかなる段階においても、内部状態が正しくあることを保証する

という考え方を指しています。

Goの文脈でカプセル化を考えてみる

Goでstructを定義するとき、すべてのフィールドを小文字から始まるnon-exportedの形にし、
利用者に提供したいメソッドを大文字から始まるexportedの形にすれば、隠蔽の要件は達成できます。状態維持の要件についても、実装で意識すれば達成できます。

では初期化の要件どうか。これを説明するために、例として奇数を表す型を作ってみましょう。

奇数を表す型を作ってみる

package odd_number

type OddNumber struct {
	value int
}

func New(value int) OddNumber {
	if value%2 == 0 {
		panic("not an odd number")
	}
	return OddNumber{value: value}
}

func (o OddNumber) Value() int {
	return o.value
}

初期化関数としてNewが用意されており、

  • 奇数を渡せば odd_number.New(13).Value() で元通りの値を取得することができ、
  • 偶数を渡せば odd_number.New(42) がそもそも失敗するので、

初期化の要件は達成できていそう。

果たしてそうだろうか。初期化の要件の記述を振り返ってみましょう。

初期化を行う入り口で、正しい内部状態を持つオブジェクトを生成する

Newは初期化を行う入り口の一つではありますが、Goにはもう一つ、必ず存在する入り口がありました。

var zero odd_number.OddNumber
zero.Value() // 0

カプセル化を破るゼロ値

ゼロ値の存在により、OddNumberは「基本的に奇数を表しているが、例外的に偶数である0も許容している」というはっきりしない型になってしまいました。New関数を用意しただけでは初期化の要件を達成することはできない、と言うことができます。

ゼロ値が作られることは防ぎようがないので、ここからはゼロ値が使われることを防ぐ手立てを考えていきます。

対処法その1: Valid Instance

ゼロ値であることが分かるように、bool型のvalidフラグをフィールドに追加します。

package odd_number

type OddNumber struct {
	value int
	valid bool
}

func New(value int) OddNumber {
	if value%2 == 0 {
		panic("not an odd number")
	}
	// validをtrueに設定できる唯一の入り口がNewである。
	return OddNumber{value: value, valid: true}
}

func (o OddNumber) Value() int {
	o.panicIfInvalid()
	return o.value
}

// すべてのメソッドで呼び出す必要がある。
func (o OddNumber) panicIfInvalid() {
	if !o.valid {
		panic("not a valid OddNumber")
	}
}

これにより、Newから作られたOddNumberは正常に動作することができ、ゼロ値のOddNumberはメソッドの呼び出しで必ず失敗するようになります。

デメリット: 記述の煩雑さ

Valid Instance は書き方が煩雑で、すべてのメソッドでpanicIfInvalid()を呼び出す必要があります。メソッドを追加する時も付け忘れないように気をつける必要があります。

メリット: 値比較の継続

フィールドを1つ追加しただけなので、追加前の構造が概ね保持されており、

odd_number.New(13) == odd_number.New(13) // true

のような値比較は引き続き成立するので、値比較が重要視される場面では、そこを妥協せずにカプセル化を達成することができます。

対処法その2: Public Pointer

このやり方では、ポインタの性質をうまく利用しようとしています。

ポインタの状態は、

  • 指す先がない(ゼロ値である、nilである)
  • 指す先がある(意図したtypeのvalueを指している)

という二通りしかないので、指す先のtypeを隠蔽し、ポインタのtypeだけを外部に公開するようにすれば、

  • ゼロ値のOddNumbernilなので、メソッドの呼び出しで必ず失敗する
  • Newから作られたOddNumberだけが、有効な指す先を持っている

という状況を作り出すことができるようになります。

package odd_number

type OddNumber = *oddNumber

func New(value int) OddNumber {
	if value%2 == 0 {
		panic("not an odd number")
	}
	return &oddNumber{value: value}
}

type oddNumber struct {
	value int
}

func (o *oddNumber) Value() int {
	return o.value
}

メリット: 記述の簡潔さ

少ないコードを追加するだけでカプセル化を達成できるようになります。

デメリット: 値比較の中断

Public Pointer はポインタにしなければならないので、

odd_number.New(13) == odd_number.New(13) // false

のような値比較は使えなくなります。元から値比較の利用が想定されなかったオブジェクトには向いていると言うことができます。

補足

隠蔽の要件、初期化の要件、状態維持の要件、Valid Instance、Public Pointerは、筆者が執筆時に思いついた名称であり、既存の文献から引用したものではないので、インターネットで検索してもヒットするものはないと思われます。

Voicyテックブログ

Discussion