【Go言語】int型やstring型にnilを含める方法
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)
}
その場合、上記で示したコードのi1
とi2
は違う状態であってほしいですが同じになってしまいます。
また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
メソッド、再設定用のSet
、Unset
メソッドなど必要な機能を持たせているように必要に応じて拡張すると良いでしょうう。
if i.Point.IsNull() {
fmt.Println("入力エラー")
}
if i.PaymentTotal < i.Point.Value() {
fmt.Println("使用ポイントが支払い金額を超過しているエラー")
}
どちらを採用するか
簡単なツールなど比較的コード量が少ない場合であれば、その1のパターンで事足りると思いますが、複数人で開発する規模であれば、その2のような仕組みを導入したほうが無難かなと思います。
Discussion