Go “未設定”を表す値をnilにするかゼロ値にするか

AvatarPosted by

この記事は GRIPHONE Advent Calendar 2022 10日目の記事です。

こんにちは、SREの笹です。

Goでは、変数の初期化時に値を設定しない場合、ゼロ値と呼ばれるデフォルト値が自動的に挿入されます。intstring といった型の場合、それぞれ 0""(空文字) が割り当てられます。任意に nil を代入することはできません。

package main

func main() {
    var str string // => ""
    var num int // => 0
    var ok bool // => false

    str = nil // => Cannot use 'nil'  as the type string
}

この仕組みにより、ブール型、数値型、文字列型といった基本の型を利用する場合、開発者はその変数が有効な状態であるか確認する必要がなくなり、よりコードを書きやすくなります。

しかしながら、この仕様で頭を悩ませることがあります。その一つとして、未設定を表す値をどう扱うかという問題が挙げられます。

未設定を表す値

未設定を表す値は、具体的に言えば変数が代入されているか代入されていないかを知ることができる値です。

Null を変数に代入できる言語の場合、下記のパターンから判定を行うことができます。

  • 変数に値が設定されていない時: Null
  • 変数に 0 が設定されている時: 0
  • 変数に 100 が設定されている時: 100
let num = null;

// 何らかの処理

if (num === null) {
    // numは未設定の状態
}

// 何らかの処理

if (num === 0) {
    // numは設定されており0の状態
}

では、Goではどうでしょうか。先ほど紹介した通り、Goでは初期値を定義せずに変数を初期化した場合、ゼロ値が利用されます。これにより、下記のようなパターンとなります。

  • 変数に値が設定されていない時: 0 (ゼロ値により)
  • 変数に 0 が設定されている時: 0
  • 変数に 100 が設定されている時: 100
package main

func main() {
    var num int

    // 何らかの処理

    // if num == nil {
    //     この比較はできない
    // }

    if num == 0 {
        // numは設定されていないか、0が設定された状態
    }
}

見てわかる通り、設定されていない場合と設定された値がゼロ値だった場合の判別を行うことができません。

どう判別するか

判別する必要がない場合はシンプルにゼロ値を利用すれば良いことですが、判別したい場合もあります。例えばあるユーザが値を入力しなかったか、それともたまたまゼロ値と同等の値を入力したのかを判別したい時には困ります。

そういう場合に使えるパターンを2つ紹介します。

パターンA: ポインタ型に変更しnilを使用する

前述の通り、基本の型では nil を代入することはできません。しかし、ポインタ型に変更することで nil を利用することができるようになります。

package main

func main() {
    var num *int = nil

    // 何らかの処理

    if num == nil {
        // numは未設定の状態
    }

    // 何らかの処理

    if num != nil && *num == 0 {
        // numは0が設定された状態
    }
}

このパターンのメリットは、使い慣れた言語のようにコードを書くことができることです。未設定の場合は nil を代入することで、ゼロ値が代入された場合でも判定に困ることはありません。

このパターンのデメリットは、前述したコードの書きやすさがなくなることです。ポインタ型の変数を利用する場合、必要に応じて変数が nil でないかを確認する必要があります。確認を忘れた場合、ランタイムエラーの原因となってしまいます。

package main

func main() {
    var num int

    if *num == 0 {
        // => invalid memory address or nil pointer dereference
    }
}

また、Goの nil は型を持っており、違う型の nil と単純に比較することができないということも頭の片隅に入れておいた方が良いでしょう。

package main

func newIntNilPointer() *int {
    return nil
}

func newStrNilPointer() *string {
    return nil
}

func main() {
    i := newIntNilPointer()
    s := newStrNilPointer()

    if i != s {
        // => Invalid operation: i != s (mismatched types *int and *string)
    }
}

このパターンは使い所によっては便利に動作します。例えば、 json.Unmarshal を利用する場合、ポインタ型を持つ構造体であればJSONに存在しないキーを nil として初期化してくれます。

パターンB: 設定されたかを表すboolを持つ

nil の扱いを避けたい場合は、変数と同じレイヤに値が設定されたかを表す bool を持つこともできます。構造体にまとめても良いでしょう。

package main

type NullableInt struct {
    Int int
    isNotNull bool
}

func main() {
    num := NullableInt{}

    num.Int = 0
    num.isNotNull = true

    if num.isNotNull {
        // 0が代入された場合でも判別可能
    }
}

このパターンのメリットは、Goのゼロ値の仕組みの範囲内で目的を実現できることです。 nil をチェックする必要はなく、ランタイムエラーの原因にもなりません。

このパターンのデメリットは、変数が増えることです。特にインターフェイス等で実装を隠蔽しない場合、代入したのに判定変数を変更し忘れ不具合の原因になります。

他言語の利用者から見れば冗長に感じるこのパターンですが、Go標準のパッケージでも採用されています。例えば、database/sql パッケージの構造体 NullString では、データベースのレコードの内容が NULL だった場合と空文字だった場合の区別のために Valid という変数を String とともに提供しています。

Validtrue だった時 Stringnot NULLである

どちらのパターンを採用すべきか?

双方のパターンどちらにもメリット・デメリットがあり、どちらか一方を採用すればOKという指標はありません。

サクッとJSONから未設定を判定したいという場合は前者を、基盤部分で仕組みとして未設定を判定したいという場合は後者を選ぶ等、柔軟に判断するのが良いと思われます。

Goはシンプルな言語仕様で学習コストが低いのが特徴ですが、シンプルな言語使用だからこそはまる問題もあります。TPOに応じて適切な方法を選んでいけると良いですね。