👻

【Go言語】int型やstring型にnilを含める方法

2023/02/23に公開

Go言語では、変数の初期化時に値を設定しない場合、ゼロ値と呼ばれるデフォルト値が自動的に設定される。(int型:0、string型:空文字 etc)

ただ、場合によっては未設定(nil) なのか0もしくは空文字を設定したのか明確に区別したいときがある。

ユースケースを1つ挙げると、ユーザからのリクエストを受け付けた値を保持する際、実際に0をリクエストしていたのか、そもそもリクエストされてないのか、というケースがあった。

例えば、支払いに関するリクエスト内容を保持する構造体に「支払い金額」「使用ポイント」がある場合、「使用ポイント」は0がリクエストされる場合はOKとし、リクエストされない場合はエラーとしたい。

package main

import "fmt"

type PaymentInput struct {
	PaymentTotal int
	Point        int
}

func main() {
	i1 := &PaymentInput{PaymentTotal: 1000}
	fmt.Printf("i1 : %+v\n", i1) // i1 : &{PaymentTotal:1000 Point:0}

	i2 := &PaymentInput{PaymentTotal: 1000, Point: 0}
	fmt.Printf("i2 : %+v\n", i2) // i2 : &{PaymentTotal:1000 Point:0}

	i3 := &PaymentInput{PaymentTotal: 1000, Point: nil} // cannot use nil as int value in struct literal
	fmt.Printf("i3 : %+v\n", i3)
}

その場合、上記で示したコードのi1i2は違う状態であってほしいが同じになってしまい、nilを持たせることもできない。

そういったケースでnilを扱う方法を2つ紹介する。

その1 : ポインタ型にする

1つ目の方法はポイント型に変更する。

package main

import "fmt"

type PaymentInput struct {
	PaymentTotal int
	Point        *int // ポイント型に変更
}

func main() {
	i1 := &PaymentInput{PaymentTotal: 1000}
	fmt.Printf("i1 : %+v\n", i1) // i1 : &{PaymentTotal:1000 Point:<nil>}

	p2 := 0
	i2 := &PaymentInput{PaymentTotal: 1000, Point: &p2}
	fmt.Printf("i2 : %+v\n", i2) // i2 : &{PaymentTotal:1000 Point:0x14000096028}

	i3 := &PaymentInput{PaymentTotal: 1000, Point: nil}
	fmt.Printf("i3 : %+v\n", i3) // i3 : &{PaymentTotal:1000 Point:<nil>}
}

この方法のメリットは言語仕様そのままで、特別な仕組みを追加してない点が1つ挙げられる。

ただ、「ポイント型の場合は未設定とゼロ値を分けたい項目である」という暗黙的なルールを含み、コードからその意図が読み取りづらい。

また、値を参照するにnilチェックが必要となり、書き忘れると実行時にエラーとなる可能性を残してしまう。

i := &PaymentInput{PaymentTotal: 1000}

if i.PaymentTotal < *i.Point { // panic: runtime error: invalid memory address or nil pointer dereference
	fmt.Println("使用ポイントが支払い金額を超過しているエラー")
}

その2 : 型を拡張し設定の有無を示すbool型を持たせる

2つ目の方法が型を拡張し設定の有無を示すbool型を持たせる。

database/sqlパッケージでNullXxxという構造体が用意されており、DBから取得した値がnullである場合に使用する型である。

type NullInt32 struct {
	Int32 int32
	Valid bool // Valid is true if Int32 is not NULL
}
// https://pkg.go.dev/database/sql#NullInt32

これと同じ仕組みを導入する。

package main

import "fmt"

type PaymentInput struct {
	PaymentTotal int
	Point        NullableInt
}

type NullableInt struct {
	value *int
	isSet bool
}

func (v *NullableInt) Value() int {
	if !v.isSet {
		return 0
	}
	return *v.value
}

func (v *NullableInt) Set(val int) {
	v.value = &val
	v.isSet = true
}

func (v *NullableInt) Unset() {
	v.value = nil
	v.isSet = false
}

func (v *NullableInt) IsNull() bool {
	return !v.isSet
}

func NewNullableInt(val int) NullableInt {
	return NullableInt{value: &val, isSet: true}
}

func main() {
	i1 := &PaymentInput{PaymentTotal: 1000}
	fmt.Printf("i1 : %+v\n", i1) // i1 : &{PaymentTotal:1000 Point:{value:<nil> isSet:false}}

	i2 := &PaymentInput{PaymentTotal: 1000, Point: NewNullableInt(0)}
	fmt.Printf("i2 : %+v\n", i2) // i2 : &{PaymentTotal:1000 Point:{value:0x1400012c038 isSet:true}}
}

NullableIntという型を新たに作り、nilの有無をisSetに持たせるようにしている。

基本的にdatabase/sqlパッケージのNullXxxと同じだが、nilチェックを行うIsNullメソッド、再設定用のSetUnsetメソッドなど必要な機能を持たせている。

これにより、コードの見通しが良くなり、安全にポインタを扱うことができる。

if i.Point.IsNull() {
	fmt.Println("入力エラー")
}

if i.PaymentTotal < i.Point.Value() {
	fmt.Println("使用ポイントが支払い金額を超過しているエラー")
}

どちらを採用するか

簡単なツールなど比較的コード量が少ない場合であれば、その1のパターンで事足りると思うが、複数人で開発する規模であれば、その2のような仕組みを導入したほうが良いだろう。

Discussion