👨‍🔧

JavaScriptなどのべき乗演算はNaNが戻ることがある

2023/10/28に公開

べき乗演算にハマる

あるバグを調査する中でハマってしまった記録です。

そのバグとは「アプリ画面のある数値が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

たとえば (-1)^{1.2} \simeq -0.75 - 0.66i であり、JavaScriptでは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 base < \bold{-0}_{\mathbb{𝔽}} and exponent is not an integral Number, return NaN.

他の言語ではどうなる?

このような浮動小数点数の演算は実数から実数、つまり写像 f:\mathbb{R}\rightarrow\mathbb{R} を前提にしているはずなので、突然複素数が戻ると対応が難しいことが想像できます。これは処理というより言語仕様の範疇ですね。

少し調べた限りでは、その他の主要な言語でも似た挙動になるようでした。たとえば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]、数学的な話を簡単にまとめます。あくまで要点だけまとめるので、数学的に正確でない箇所があります。ご了承ください。

事前準備としての複素対数関数

複素数 z=x+iy と原点 O との距離 r

r = |z| = \sqrt{x^2+y^2}

と定義します。これを用いれば \displaystyle z = r\left(\frac{x}{r}+i\frac{y}{r}\right)です。原点 O から z を結ぶ直線と x 軸がなす角を偏角 \theta と定義することで、x/r, y/r はそれぞれ \cos\theta, \sin\theta だから

z = r (\cos\theta + i\sin\theta)

と書けます。高校数学で学ぶ極形式ですね。以降はこのような複素数 z の偏角を \arg z と書きます。

このような \arg z\mod 2\pi, すなわち 2\pi n (n=0,\,\pm 1,\,\pm 2,\,\cdots) だけの不定性があるので

\arg z = \theta_{0} + 2\pi n\ (n=0,\,\pm 1,\,\pm 2,\,\cdots)

という集合になります[3]n=0 のときの偏角を特別に \theta_0 = \mathrm{Arg}\,z と書きます。

これ前提に複素数の対数関数、すなわち指数関数 e^z の逆関数を考えます。これはある複素数 w=e^z の演算によって z 平面から w 平面に移った点をもとに戻す関数であり、これを複素数の対数関数

z = \log w

として定義できます。

では、複素数 w の関数としての z(w) を具体的に求めてみましょう。定義より w = e^{x+iy} であり、一方で極形式で w を書き直せば w=|w|e^{i\arg w} です。これらは等しいので

e^{x}\cdot e^{iy} = |w|\cdot e^{i\arg w}

です。この絶対値部分と偏角部分を比較すれば x = \log |w|,\ y = \arg w なので、z=x+iy より

\log w = \log |w| + i\arg w

という形で複素対数関数が与えられると理解できます。\arg w は集合なので \log w は多価関数である点に注意してください。

指数関数が複素数を戻す条件

指数関数 a^ba, b は実数)を考えます。e の指数関数に直せば

a^{b}= e^{b\log a}

になります。実数の対数関数は正の実数に対して定義されるものなので、a>0 では問題なく実数が戻ります。いわゆる真数条件ですね。

そのため、以降は a < 0 をイメージした上で、前述の複素対数関数を考えましょう。\log a に定義を代入すれば \displaystyle a^{b}= \exp\left\{b\left( \log |a| + i\arg a \right)\right\} だから

a^{b}= \exp\left( b \log |a| \right) \cdot \exp\left( ib\arg a \right)

となりますね。\exp(b\log |a|) は明らかに実数 (a^{|b|}) なので、虚部が 0 になるかは後半部分で決まります。\arg a の定義から \exp\left( ib\arg a \right) = \exp\left\{ ib(\theta_{0} + 2\pi n) \right\} なので、ここでEulerの公式を用いれば

\exp\left( i b\arg a \right) = \cos\left\{b(\theta_{0} + 2\pi n)\right\} + i\sin\left\{b(\theta_{0} + 2\pi n)\right\} \quad (n=0,\,\pm 1,\,\pm 2,\,\cdots).

したがって、この虚部が 0 になる条件は \sin\{b(\theta_{0} + 2\pi n)\} = 0, すなわち

b(\theta_{0} + 2\pi n) = \pi m \quad (n,\,m=0,\,\pm 1,\,\pm 2,\,\cdots)

です。ここでもともと a<0 の議論をしていることを思い出せば \theta_{0} = \mathrm{Arg}\,a = \pi です[4]\pi を代入して整理すれば

(2n + 1)b = m \quad (n,\,m=0,\,\pm 1,\,\pm 2,\,\cdots)

です。この式は b が整数であれば、その左辺に対応する m が取れることを意味します。つまり、b が整数なら \sin 項は 0 であり、その結果として a^b の虚部は 0 になります。

では、どうすればバグに気づけたか

これはひとつの経験とするのは当然として、どうすれば事前に気づけたでしょうか?

今回のバグそもそも「指数関数が複素数を戻すことがある」という前提が欠落していたために起こったと言えます。たとえば割り算を行うとき、多くの方はゼロ除算に気をつけるはずです。今回のJavaScriptを例に取れば

// node.js v18.15.0
console.log(1 / 0)  // Infinity
console.log(-1 / 0) // -Infinity
console.log(0 / 0)  // NaN

となりますが、多くのフロントエンドエンジニアは、JavaScriptが澄まし顔でこの処理を実行することを知っているはずです。

このような言語仕様を把握することは非常に大切です(今回は数学的な背景もありますが)。しかし重箱の隅をつつきだしたら切りがありません。
正直、私もこのようなバグを未然に防ぐ方法を思いついていないのですが、その答えがあるとするならユニットテストの実装なのかなと思っています。

ユニットテストには境界値テストという概念があります。たとえば 0\leq \mathrm{num} \leq 100 を満たす数かを戻す関数

function isValid(num) {
    return num >= 0 && num <= 100
}

なら 0100 の付近を調査します。今回の関数では 0 付近が怪しいことは検討がついたはずなので、その前後を調べるテストを書けば気づけたかもしれません。
ユニットテストでは

  • 意図した演算が実行されることを確かめる

ということだけではなく

  • 少なくともテストを書いた範囲なら正常に動作することを保証する

という視点も大切なのかなと反省として感じました。

脚注
  1. 数学科出身の知人に何気なく話したら一瞬で喝破されたので私が実力不足なだけです🥲 ↩︎

  2. このバグに遭遇したのは数ヶ月前ですが、記事を書き始める頃にはもう忘れていました。 ↩︎

  3. 単位円をぐるっと1周回したら重なる、ということです。 ↩︎

  4. a>0 では \mathrm{Arg}\,a = 0 なので 2bn = m が求める条件です。思い出せば、n は複素対数関数が多価関数であることに由来していました。そのため、あくまで実数の範囲で考えるなら n=0 であり、任意の ba^b は実数になります。n=0 の場合(主値といいいます)以外を考える場合は複素数になりえます😲 ↩︎

Discussion