👻

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

2023/02/23に公開

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

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

APIを実装しているとユーザからのリクエストを受け付けバリデーションをかける際、実際に0をリクエストしていたのか、そもそもリクエストされてないのかを判別したいケースがありました。

例えば、支払いに関するリクエスト内容を保持する構造体に「支払い金額」「使用ポイント」がある場合、「使用ポイント」はリクエストパラメータに含まれる場合は0でもOKとし、リクエストパラメータ自体がない場合はエラーにするケースを考えてみます。

APIサーバのコードをそのまま記載すると冗長的なので、リクエストを受け付けたテイで下記のように3パターンのリクエストがあると仮定します。

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は違う状態であってほしいですが同じになってしまいます。

またi3では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