JavaScriptなどのべき乗演算はNaNが戻ることがある
べき乗演算にハマる
あるバグを調査する中でハマってしまった記録です。
そのバグとは「アプリ画面のある数値がNaN
として表示されてしまう」というものです。
表示部分の処理からJavaScriptのコードを追いかけていくと、次のような関数(イメージ)に遭遇しました。
function calc (value) {
return (1.23 + 0.123 * value ** 0.123 + 0.123 * value ** 1.23) * 1.23
}
ある物理量を別の物理量に変換して戻す関数です。1.23
などはフィッティングパラメータなので適当な数値をイメージしてください。非常に単純な演算ですが、今回のバグの原因はこの部分でした。
直前までの処理を追うと、引数value
は有限な実数であることが確定しています。
その上で、この関数がNaN
を戻すことがあると瞬時にわかった方はそれほど多くないと思います[1]。
原因
先に原因をまとめます。
- 以下の条件を満たす指数関数
は複素数を戻すa^b a<0 -
が非整数b
- JavaScriptでは複素数が戻った場合、
NaN
として扱われる
その結果として、value
が負だと value ** 0.123
などの部分で関数は NaN
を戻してしまうのですね。
// node.js v18.15.0
console.log(calc(1)) // 1.81548
console.log(calc(0)) // 1.5129
console.log(calc(-1)) // NaN
たとえば NaN
扱いになります。実際に試してみましょう。
console.log((1.2) ** 1.2) // 1.2445647472039776
console.log((-1) ** 2) // 1
console.log((-1) ** 1.2) // NaN
このような振る舞いはMDN Web Docsには記載がありません。しかし、言語仕様としてはECMAScriptにちゃんと定義されています。こんなことがないとなかなか読みに行かないですけど…。
If
and base < \bold{-0}_{\mathbb{𝔽}} is not an integral Number, return NaN. exponent
他の言語ではどうなる?
このような浮動小数点数の演算は実数から実数、つまり写像
少し調べた限りでは、その他の主要な言語でも似た挙動になるようでした。たとえばJavaでもNaN
が戻ります。
// openjdk v17.0.5
public class Sample {
public static void main(String... args) {
System.out.println(Math.pow(-1, 1.2)); // NaN
}
}
一方、Pythonで次のコードをテストすると、そのまま複素数が戻りました。Python優秀だなあと少し思いましたが、別のバグを生みそうでちょっと怖いですね。
普段使わないので調べていないのですが、MATLABやR言語のような科学計算に利用される言語だと似たような形でサポートされていたりするのでしょうか?🤔
# python v3.11.0
print((1.2) ** 1.2) # 1.2445647472039776
print((-1) ** 2) # 1
print((-1) ** 1.23) # (-0.7501110696304597-0.6613118653236517j)
数学的な話
「こんな条件のときに指数関数は複素数になるよ」というだけだと忘れてしまうので[2]、数学的な話を簡単にまとめます。あくまで要点だけまとめるので、数学的に正確でない箇所があります。ご了承ください。
事前準備としての複素対数関数
複素数
と定義します。これを用いれば
と書けます。高校数学で学ぶ極形式ですね。以降はこのような複素数
このような
という集合になります[3]。
これ前提に複素数の対数関数、すなわち指数関数
として定義できます。
では、複素数
です。この絶対値部分と偏角部分を比較すれば
という形で複素対数関数が与えられると理解できます。
指数関数が複素数を戻す条件
指数関数
になります。実数の対数関数は正の実数に対して定義されるものなので、
そのため、以降は
となりますね。
したがって、この虚部が
です。ここでもともと
です。この式は
では、どうすればバグに気づけたか
これはひとつの経験とするのは当然として、どうすれば事前に気づけたでしょうか?
今回のバグそもそも「指数関数が複素数を戻すことがある」という前提が欠落していたために起こったと言えます。たとえば割り算を行うとき、多くの方はゼロ除算に気をつけるはずです。今回のJavaScriptを例に取れば
// node.js v18.15.0
console.log(1 / 0) // Infinity
console.log(-1 / 0) // -Infinity
console.log(0 / 0) // NaN
となりますが、多くのフロントエンドエンジニアは、JavaScriptが澄まし顔でこの処理を実行することを知っているはずです。
このような言語仕様を把握することは非常に大切です(今回は数学的な背景もありますが)。しかし重箱の隅をつつきだしたら切りがありません。
正直、私もこのようなバグを未然に防ぐ方法を思いついていないのですが、その答えがあるとするならユニットテストの実装なのかなと思っています。
ユニットテストには境界値テストという概念があります。たとえば
function isValid(num) {
return num >= 0 && num <= 100
}
なら
ユニットテストでは
- 意図した演算が実行されることを確かめる
ということだけではなく
- 少なくともテストを書いた範囲なら正常に動作することを保証する
という視点も大切なのかなと反省として感じました。
Discussion