Goでjson.Marshalを行う際、nil sliceを渡すと結果がnullになる

ZOZO Advent Calendar 2024 18日目の記事になります。
今回はTips的な話になるのですが、Go言語のMarshal関数を利用してjsonのencodeを行う際、nil sliceを渡すとnullになる挙動があり、注意が必要だと思ったので紹介したいと思います。

nil sliceについて

nil sliceについてはA Tour of Goで紹介されています。 go.dev

上記説明に書かれているとおりなのですが、sliceの初期値はnilなので初期化されていないsliceはnilになります。 また、nil sliceは基底配列(underlying array)が存在しません。 よく比較されるnil sliceと空のslice(empty slice)に対して挙動の違いを確認します。

package main

import (
    "fmt"
    "reflect"
)

func main() {
    // nil slice
    var nc []int
    // empty slice
    s := []int{}

    fmt.Println("nil slice:", nc, len(nc), cap(nc), nc == nil, reflect.TypeOf(nc))
    fmt.Println("empty slice:", s, len(s), cap(s), s == nil, reflect.TypeOf(s))
}

上記コードを実行すると下記結果が得られます。nil slice, empty sliceの長さ、キャパシティはどちらも同じ結果になりました。しかしnilの比較に関しては違う結果になりました。 sliceの長さ、キャパシティだけで比較するとnil slice, empty sliceの区別がつかず、nilの比較を行わずに後述のencoding/jsonパッケージ等を利用すると意図しない挙動になることもあるので注意が必要です。

$ go run main.go
nil slice: [] 0 0 true []int
empty slice: [] 0 0 false []int

nil sliceを渡した際のMarshal関数の挙動

本題に入る前に、今回確認する挙動はMarshal関数の説明に記載されている挙動になります。

Array and slice values encode as JSON arrays, except that []byte encodes as a base64-encoded string, and a nil slice encodes as the null JSON value.

上記を踏まえた上で、encoding/jsonパッケージのMarshal関数を利用した際の挙動を確認したいと思います。

まず構造体をjsonに変換する例が下記コードになります。

package main

import (
    "encoding/json"
    "fmt"
    "log"
)

type PrintMessage struct {
    Message string `json:"message"`
}

func main() {
    messsage := PrintMessage{Message: "Hello World!"}
    marshaledMessage, err := json.Marshal(messsage)
    if err != nil {
        log.Fatal("Error converting to json")
    }
    fmt.Println(string(marshaledMessage))
}

出力される結果は下記になります。

$ go run main.go
{"message":"Hello World!"}

上記コードで利用するjson.Marshal関数に対してnil slice, empty sliceをそれぞれ渡します。

package main

import (
    "encoding/json"
    "fmt"
    "log"
)

type PrintMessage struct {
    Message string `json:"message"`
}

func main() {
    // nil slice
    var nilSliceMessage []PrintMessage
    // empty slice
    emptySliceMessage := []PrintMessage{}

    marshaledEmptySliceMessage, err := json.Marshal(emptySliceMessage)
    if err != nil {
        log.Fatal("Error converting to json")
    }
    marshaledNilSliceMessage, nil := json.Marshal(nilSliceMessage)
    if nil != nil {
        log.Fatal("Error converting to json")
    }
    fmt.Println("empty slice:", string(marshaledEmptySliceMessage))
    fmt.Println("nil slice:", string(marshaledNilSliceMessage))
}

出力される結果は下記になります。empty sliceは空sliceが返され、nil sliceはnullが返されていることがわかります。

$ go run main.go
empty slice: []
nil slice: null

nil sliceとempty sliceの宣言について

最後にnil sliceとempty sliceの宣言について触れたいと思います。sliceの宣言を行うとき下記3パターンの宣言方法があると思います。

var hoge []int
hoge := []int{}
hoge := make([]int, 0)

上記3パターンの宣言についてnil slice, empty sliceどちらになるか確認したいと思います。

package main

import (
    "fmt"
    "reflect"
)

func main() {
    var s1 []int
    s2 := []int{}
    s3 := make([]int, 0)

    fmt.Println("s1 slice:", s1, len(s1), cap(s1), s1 == nil, reflect.TypeOf(s1))
    fmt.Println("s2 slice:", s2, len(s2), cap(s2), s2 == nil, reflect.TypeOf(s2))
    fmt.Println("s3 slice:", s3, len(s3), cap(s3), s3 == nil, reflect.TypeOf(s3))
}

上記コードの結果は下記になります。

$ go run main.go
s1 slice: [] 0 0 true []int # nil slice
s2 slice: [] 0 0 false []int # empty slice
s3 slice: [] 0 0 false []int # empty slice

まとめ

nil sliceとempty sliceの挙動を踏まえてjson.Marshal関数の挙動を確認できました。若干浅い内容になってしまったのですが、sliceとjson.Marshal関数の内部的な挙動についてもう少し調べて理解を深めたいなと思いました。