Go言語の strconv.ParseFloat のとある挙動変更を見つけた話

弊社のとあるGoプロダクトでGo 1.14から1.16へアップデートしたところ、プログラムの挙動が変わる問題が発生しました *1。 ドキュメントに書かれていない strconv.ParseFloat の挙動の変更を踏んでしまったのです。

package main

import (
    "fmt"
    "strconv"
)

func main() {
    fmt.Println(strconv.ParseFloat("1e100x", 64))
    fmt.Println(strconv.ParseFloat("1e1000x", 64))
}

このコードをGo 1.14で実行すると

0 strconv.ParseFloat: parsing "1e100x": invalid syntax
0 strconv.ParseFloat: parsing "1e1000x": invalid syntax

となりますが、Go 1.15や1.16.4では

0 strconv.ParseFloat: parsing "1e100x": invalid syntax
+Inf strconv.ParseFloat: parsing "1e1000x": value out of range

のようになります。 strconv.ParseFloat は引数の文字列全体が浮動小数点数として解釈できなければエラーを返してくれる関数です。 しかし、prefixが大きすぎる浮動小数点数である場合は、別のエラーになっています。

これはバグでしょうか、意図した変更でしょうか。 この挙動の変更を追いかけたときの私の脳内をトレースしてみます。

まずはリリースノートを確認しましょう。 当初、私は1.14と1.16の間ということしか分かっておらず、1.151.16の両方のリリースノートを開きました。 しかし strconv.ParseFloat のエラーの内容を変更するという記述はどこにもありません。 1.16のParseFloatが Eisel-Lemire アルゴリズムを採用したという変更がいかにも怪しくて最初に疑いましたが、正解は1.15の ParseComplex の追加の方でした。

誰かissue報告はしていないでしょうか。意図した変更なので閉じられているか、gotipでは直っている可能性があります。 strconv ParseFloat value out of rangeのようなキーワードで探してみましたが、ヒットしませんでした。

それでは該当ファイルのHistoryやBlameを確認しましょう。 src/strconv/atof.goの変更を追っていると、複素数 (例: 3+4i) をパースする ParseComplex を実装する直前のコミットである 1d31f9b が目に留まると思います。複素数の実部虚部は浮動小数点数も許されており (例: 1e20+3e40i)、 ParseFloat の処理をうまく使うために parseFloatPrefix を導入したようです。

このコミットよく見ていると、パースが最後まで到達していないエラー処理を遅延させたために、 1e1000x のように全体で浮動小数点数として解釈できないものにはprefixをパースしたときのエラーをそのまま返してしまっていることがわかります。コミットの意図を汲み取ると、複素数のパーサーを書くためにリファクタリングしたコミットであり、エラー内容を変更するという意図はなさそうです。この段階で、意図しない挙動変更だったという確信を持ちます。

最後にドキュメントとの矛盾を探します。ParseFloat のエラーについて次のように記述されています。

The errors that ParseFloat returns have concrete type *NumError and include err.Num = s.

If s is not syntactically well-formed, ParseFloat returns err.Err = ErrSyntax.

If s is syntactically well-formed but is more than 1/2 ULP away from the largest floating point number of the given size, ParseFloat returns f = ±Inf, err.Err = ErrRange. https://golang.org/pkg/strconv/#ParseFloat

1e1000x1e100x と同様に syntactically well-formed ではありませんから、ドキュメントからも err.Err = ErrSyntax を返すのが正しいことがわかります。 もしエラーの変更が正しいならば、ドキュメントを修正する必要がありそうです。

該当するコミットにエラー内容までを変更する意図はなさそうなことや、ドキュメントと挙動が異なることから自信を持ったので、issueをたてました。

github.com

数時間後にはCLが作られて、Mergeされていました。こういう素早さはさすがですね。 やはり意図しない変更ということで合っていたようです。次のリリースでは直っていることでしょう。

strconv.ParseFloat の挙動の変更を見つけて報告したお話でした。おしまい。