`significant: true` で非有限数を渡すと `FloatDomainError` が発生する問題を修正
RoundingHelper#digit_count に1行のガードを追加することで、significant: true オプション使用時に Infinity / NaN を渡すと発生していた FloatDomainError を修正しました。非 significant パスがすでに "Inf" / "-Inf" / "NaN" を返していた一方、significant: true パスのみクラッシュしていたという一貫性の欠如が解消されます。
背景
significant オプションの有無によって、同じ入力に対する挙動が矛盾していました。number_to_rounded は非有限数を "Inf" / "NaN" として整形するパスを既に持っており、significant: true なしでは正常に動作します。しかし significant: true を加えた途端にクラッシュします。
ActiveSupport::NumberHelper.number_to_rounded(Float::INFINITY, precision: 3)
# => "Inf" ← 正常
ActiveSupport::NumberHelper.number_to_rounded(Float::INFINITY, precision: 3, significant: true)
# => FloatDomainError: Infinity ← クラッシュ
この問題は Float 除算のゼロ割りや ** のオーバーフロー、外部から取り込んだ "Infinity" 文字列のパースなど、計算結果が Infinity になり得る場面でフォーマッタに渡されたときに顕在化します。また RoundingHelper を経由するすべてのヘルパー — number_to_percentage、number_to_currency、number_to_delimited — にも同じクラッシュが波及します。
2016年にも同様の問題を修正しようとした #25946 が存在しますが、number_to_human での "Inf" 出力が「人間に読みやすいとは言えない」という指摘を受けて2019年にクローズされました。今回のPRは RoundingHelper パスのみを対象とし、同一ヘルパー内で両パスの挙動を一致させることに絞っています。
技術的な変更
クラッシュの根本原因は RoundingHelper#digit_count 内の Math.log10 に非有限数が渡されることです。significant: true かつ precision > 0 のとき、absolute_precision が digit_count を呼び出し、Math.log10(Float::INFINITY) は Infinity を返し、その .floor が FloatDomainError を投げます。
変更前:
def digit_count(number)
return 1 if number.zero?
(Math.log10(number.abs) + 1).floor
end
変更後:
def digit_count(number)
return 1 if number.zero?
return 1 unless number.respond_to?(:finite?) && number.finite?
(Math.log10(number.abs) + 1).floor
end
追加された1行で非有限数の場合に 1 を早期リターンします。戻り値が 1 であることには意味があります。absolute_precision は precision - digit_count + 1 を計算するため digit_count が 1 を返すと precision - 1 になり、その後の BigDecimal("Infinity").round(n) はどんな n でも Infinity をそのまま返します。最終的に NumberToRoundedConverter#convert の rounded_number.finite? 分岐が "%f" % rounded_number 経由で "Inf" 等にフォーマットします。
テストでは3つのヘルパーインターフェース(インスタンスメソッド、クラスメソッド、ActiveSupport::NumberHelper モジュール直接呼び出し)すべてに対して Inf、-Inf、NaN の各ケースを検証する test_to_rounded_with_significant_true_and_non_finite_value が追加されました。
設計判断
非有限数を digit_count の入口でガードするという最小限の修正が選ばれました。修正箇所を digit_count の1箇所に限定することで、number_to_rounded、number_to_percentage、number_to_currency、number_to_delimited の4つのヘルパーをまとめて修正できます。
respond_to?(:finite?) を用いているのは、Float だけでなく BigDecimal のような finite? を持つ数値型にも対応するためです。finite? を持たない数値型(Integer など)は事実上有限であるためガードをスキップし、既存の Math.log10 パスに進みます。
number_to_human と number_to_human_size が今回の修正スコープから外れている点も注目に値します。これらは RoundingHelper とは別の calculate_exponent / exponent メソッド内で独自に Math.log10 / Math.log を呼び出しており、修正には別途対応が必要です。また number_to_human での "Inf" 出力の妥当性については過去のPRで議論があることから、意図的に分離されています。
まとめ
RoundingHelper#digit_count への1行追加により、significant: true パスと非 significant パスの挙動が統一されました。変更は既存の true / false 動作に影響を与えず、非有限数が渡された場合に限って従来クラッシュしていたパスが "Inf" / "-Inf" / "NaN" を返すようになります。設計意図として rounded_number.finite? 分岐がコンバーター内に既にあったことを踏まえると、digit_count にガードが欠けていたのはオリジナル実装時の見落としであり、今回の修正でその完成を見たといえます。