Money Forward Developers Blog

株式会社マネーフォワード公式開発者向けブログです。技術や開発手法、イベント登壇などを発信します。サービスに関するご質問は、各サービス窓口までご連絡ください。

20230215130734

クイズでGo!panic(runtime error)が発生するのはどんな時?

こんにちは、マネーフォワードエックスカンパニー 個人サービス開発部 バンキングアプリ開発グループの仲川です。この記事は Money Forward Engineering 1 Advent Calendar 2023 の12日目の投稿です。

突然ですが、クイズです!

Q:Go言語ではどのような実装を行うとpanic(runtime error)が発生するでしょうか?また、それらの発生自体を防ぐにはどうすれば良いでしょうか?

少しだけ手を止めて考えてみてください...


さて、いくつ思いつきましたか?

熟練のGoエンジニアなら、5つ以上の実装パターンと対策がすぐに思い浮かんだでしょう。しかし、実務である程度Goを触っている方でも答えが出せないことも多いと思います。実際、panicの考慮が漏れてバグを発生させてしまった事例はよく見かけます。

これらはテストカバレッジを100%確保したり、linterやCopilotなどのツールを使っても見逃されることがあります。そのため、開発者は性質を理解し、適切にハンドリングする必要があります。

今回は、そんなpanic(runtime error)が発生する実装例を紹介します!

※以下のコードの実行結果は、バージョン1.21のものです。

panicが発生する実装例

1. nilポインタ参照(invalid memory address or nil pointer dereference)

多くの方が最初に思い浮かべるのがこれでしょう。

func main() {
    var ptr *int      // ポインタのゼロ値はnil
    fmt.Println(*ptr) // panic: runtime error: invalid memory address or nil pointer dereference
}

nilチェックを行うことが最も確実な対策です(もちろんnilが来ないことが明らかならば、必ずしもnilチェックは必要ではありません)

func main() {
    var ptr *int
    if ptr != nil {
        fmt.Println(*ptr)
    }
}

2. スライス or 配列 or 文字列で範囲外のインデックスを参照(index out of range / slice bounds out of range)

スライス(または配列)に存在しない要素にアクセスしようとすると、このエラーが発生します。

func main() {
    numbers := []int{1, 2, 3, 4, 5} // スライスの長さは5なので、有効なインデックスは0〜4
    fmt.Println(numbers[6])         // panic: runtime error: index out of range [6] with length 5
}

これは文字列でも同様の事象が発生します。

func main() {
    str := "12345"      // 文字列の長さは5なので、有効なインデックスは0〜4
    fmt.Println(str[6]) // panic: runtime error: index out of range [6] with length 5
}

エラーメッセージは上記とは異なりますが、範囲を指定した場合もpanicが発生します。

func main() {
    str := "12345"       // 文字列の長さは5なので、有効なインデックスは0〜4
    fmt.Println(str[:6]) // panic: runtime error: slice bounds out of range [:6] with length 5
}

対策としては、指定するインデックスに対して有効な要素数が存在するのかを事前にチェックする方法が挙げられます。

func main() {
    numbers := []int{1, 2, 3, 4, 5}
    if len(numbers) > 6 {
        fmt.Println(numbers[6])
    }
}

3. nilのmapへ要素を代入(assignment to entry in nil map)

varで変数を宣言しただけではmapはnilになります。読み取り時は空のマップのように動作しますが、書き込もうとするとpanicが発生します。

func main() {
    var data map[int]string
    fmt.Println(data)        // map[]
    fmt.Println(data == nil) // true
    fmt.Println(data[1])     // 読み取りではpanicは発生しない

    data[1] = "a" // panic: assignment to entry in nil map
    fmt.Println(data)
}

対策としては、make関数で作成するか、マップリテラルで作成する方法があります。make関数では任意のキャパシティを指定でき、マップリテラルの場合は自動で確保されます。

func main() {
    data1 := make(map[int]string, 3)
    fmt.Println(data1 == nil) // false
    data1[1] = "a"
    fmt.Println(data1) // map[1:a]

    data2 := map[int]string{}
    fmt.Println(data2 == nil) // false
    data2[2] = "b"
    fmt.Println(data2) // map[2:b]
}

4. インターフェースの型アサーションに失敗(interface conversion)

インターフェースを型アサーションした際に、インターフェースが満たしていない型への変換を行うとpanicが発生します。

func main() {
    var data interface{}
    data = "abc"
    num := data.(int) // panic: interface conversion: interface {} is string, not int
    fmt.Println(num)
}

型アサーションでは第2の変数を用意すると、成否のboolが取得でき、この記述を行った時にはpanicが発生しません。なおGoの慣習として、成否のboolはokという変数に値を代入する記述方法が一般的です。

func main() {
    var data interface{}
    data = "abc"
    num, ok := data.(int)
    fmt.Println(num) // 0 (失敗時は指定した型のゼロ値になります)
    fmt.Println(ok)  // false
}

5. 閉じているチャネルに対する操作(send on closed channel / close of closed channel)

閉じているchannelにデータを送信するとpanicが発生します。

func main() {
    ch := make(chan bool)
    close(ch)
    ch <- true // panic: send on closed channel
}

閉じているchannelを再度閉じることでもpanicは発生します。

func main() {
    ch := make(chan bool)
    close(ch)
    close(ch) // panic: close of closed channel
}

これらの対策はケースバイケースかもしれませんが、defer文を使ってcloseを関数内の処理が終了した後に実行することも一つの方法です。

func main() {
    ch := make(chan bool)
    defer close(ch)

    go func() { ch <- true }()
    fmt.Println(<-ch) // true
}

なお、nil channelをクローズしようとした際もpanicが発生します。

func main() {
    var ch chan bool
    close(ch) // panic: close of nil channel
}

6. panic関数の呼び出し

これは正確にはruntime errorではありませんが、panic関数で意図的に呼び出すことも可能です。

func main() {
    text := "dummy-error"
    panic(text) // panic: dummy-error
}

なお、panic関数にnilのインターフェース値を渡してランタイムエラーを起こすことは可能です。

func main() {
    var err any
    panic(err) // panic: panic called with nil argument
}

7. その他のpanic

ここからは普段ほとんど遭遇する機会がない内容になるので、コード例の紹介だけとします。他にもパターンはあると思いますが、キリがないので今回はここで終わりにします。

  • makechan: size out of range
func main() {
    c := make(chan int, 111111111111111) // panic: makechan: size out of range
    fmt.Println(c)
}
  • growslice: len out of range
func main() {
    length := 1<<63 - 1
    fmt.Println(length) // 9223372036854775807

    a := make([]struct{}, length)
    b := make([]struct{}, length)
    a = append(a, b...) // panic: runtime error: growslice: len out of range
    fmt.Println(len(a))
}
  • cannot convert slice
func main() {
    s := make([]byte, 2, 4)
    a := [4]byte(s) // panic: runtime error: cannot convert slice with length 2 to array or pointer to array with length 4
    fmt.Println(a)
}

番外編

番外1. ゼロによる除算はどうなるか?

以下のようなゼロ除算は以前まではランタイムエラーが起きていましたが、現在はコンパイルエラーになります。

func main() {
    fmt.Println(100 / 0) // invalid operation: division by zero
}

以下のコミット(2020/01/19)で修正されています。 github.com

番外2. panicのリカバリーと並行処理での注意点

panicはdefer/rescueを利用してリカバリーする手段が提供されています。

func main() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("Recovered in f", r) // Recovered in f dummy-error
        }
    }()

    panic("dummy-error")
}

このリカバリーを行う際に注意が必要な事として、新たにgoroutineを作成した場合、その内部で発生したpanicは内部でハンドリングしないと回復できないことです。

func main() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("Recovered in f", r) // ここに到達しない
        }
    }()

    ch := make(chan bool)
    defer close(ch)

    go func() {
        ch <- true
        panic("dummy-error") // panic: dummy-error
    }()

    fmt.Println(<-ch)
}

冗長な記述になりますが、panicの回復が必要な場合は、goroutine内でもリカバリー用の記述を実装する必要があります。

func main() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("Recovered in f", r)
        }
    }()

    ch := make(chan bool)
    defer close(ch)

    go func() {
        defer func() {
            if r := recover(); r != nil {
                fmt.Println("Recovered in f", r) // Recovered in f dummy-error
            }
        }()

        ch <- true
        panic("dummy-error")
    }()

    fmt.Println(<-ch) // true
}

まとめ

今回は、panicの発生事例について解説しました。Goはシンプルな言語ですが、それゆえこういった落とし穴が多く存在すると思っています。全部AIが判断してくれるまでまだ時間が掛かりそうなので、それまでは普段からこの辺りの意識を高めて、ゆったり年を越せるように心がけたいです!!