ぷろぐらみんぐ帳

C#とかJavaScriptとか

NaNのみ除外する数値判定

JavaScriptの闇が深い真偽値の話。NaNを含む数値列に対して一括計算するときにどうする?という問題。

isNaN()の場合

NaNのみ判定したければisNaN()という関数がある。ECMAScript 1stから対応。とりあえず評価用に次のようなtest関数を作ってみる。 developer.mozilla.org

function test(func) {
    function testunit(val) {
        return ('' + func).replace(/^\s*function\s*([^\(]*)[\S\s]+$/im, '$1') + "(" + val + ") is " + func(val) + "<br/>";
    }
    var str = "";
    str += testunit(true);
    str += testunit(false);
    str += testunit(null);
    str += testunit(undefined);
    str += testunit(0);
    str += testunit(1);
    str += testunit(-3.1);
    str += testunit(-3.0);
    str += testunit(012);//8進数で10
    str += testunit(0xA);//16進数で10
    str += testunit(Math.PI);
    str += testunit(Infinity);
    str += testunit(NaN);
    str += testunit("");
    str += testunit(" ");
    str += testunit("abc");
    str += testunit("123");
    str += testunit(console);

    document.write(str);
}

デバッグ環境はIE11。Function.nameプロパティが使えないため、関数名の取得は正規表現で取っている。test(isNaN)を実行すると、

//isNaNの実行結果
isNaN(true) is false
isNaN(false) is false
isNaN(null) is false
isNaN(undefined) is true
isNaN(0) is false
isNaN(1) is false
isNaN(-3.1) is false
isNaN(-3) is false
isNaN(10) is false
isNaN(10) is false
isNaN(3.141592653589793) is false
isNaN(Infinity) is false
isNaN(NaN) is true
isNaN() is false
isNaN( ) is false
isNaN(abc) is true
isNaN(123) is false
isNaN([object Console]) is true

NaNのほかundefinedや空白以外の文字列、オブジェクトもtrueとなっているのが注意。この理由は書いてあった。

true を返す場合、x を使用すると全ての算術式で NaN を返すことになります。これはつまり、JavaScriptにおいて isNaN(x) == true という式は、x - 0 という式が NaN を返すかどうか、というケースと同等である(JavaScript では x - 0 == NaN は常に false を返すため、このことを確認できませんが)ということです。

なるほど、算術式で計算したときにNaNとなるのがisNaN()であると。

ちなみに、Number.isNaN()という関数もあり、型変換の問題の影響がないらしい。ただし、Number.isNaNはECMAScript 2015以降なので、IEは非対応Google Chromeが対応しているのでそれでデバッグをする。test(Number.isNaN)の実行結果

//Number.isNaN()の場合(IE非対応)
isNaN(true) is false
isNaN(false) is false
isNaN(null) is false
isNaN(undefined) is false
isNaN(0) is false
isNaN(1) is false
isNaN(-3.1) is false
isNaN(-3) is false
isNaN(10) is false
isNaN(10) is false
isNaN(3.141592653589793) is false
isNaN(Infinity) is false
isNaN(NaN) is true
isNaN() is false
isNaN( ) is false
isNaN(abc) is false
isNaN(123) is false
isNaN([object Object]) is false

isNaN()とNumber.isNaN()の結果が違うだと…。感覚的にはNumber.isNaN()のほうがマッチしやすくて、こちらは本当にNaNだけtrueを返す

isFinite()の場合

似たような関数に、有限数かどうかを判定するisFinite()がある。ECMAScript 3rdから。 developer.mozilla.org test(isFinite)を実行すると、

//isFinite()の場合
isFinite(true) is true
isFinite(false) is true
isFinite(null) is true
isFinite(undefined) is false
isFinite(0) is true
isFinite(1) is true
isFinite(-3.1) is true
isFinite(-3) is true
isFinite(10) is true
isFinite(10) is true
isFinite(3.141592653589793) is true
isFinite(Infinity) is false
isFinite(NaN) is false
isFinite() is true
isFinite( ) is true
isFinite(abc) is false
isFinite(123) is true
isFinite([object Console]) is false

trueやfalse、null、空白文字列や"123"のような文字列の数値がtrueとなっている。nullではtrueなのに、undefinedだとfalseなのが非常にいやらしい。Mozillaのページを読んでいくと「より堅牢性の高いNumber.isFiniteでは~」という記述がある。Number.isNaNと同様に、Number.isFiniteはECMAScript 2015以降なので、IEは非対応Google Chromeでのtest(Number.isFinite)の実行結果

//Number.isFinite()の場合(IE非対応)
isFinite(true) is false
isFinite(false) is false
isFinite(null) is false
isFinite(undefined) is false
isFinite(0) is true
isFinite(1) is true
isFinite(-3.1) is true
isFinite(-3) is true
isFinite(10) is true
isFinite(10) is true
isFinite(3.141592653589793) is true
isFinite(Infinity) is false
isFinite(NaN) is false
isFinite() is false
isFinite( ) is false
isFinite(abc) is false
isFinite(123) is false
isFinite([object Object]) is false

やはりisFinite()とNumber.isFinite()の結果が違う。Number.isFinite()のほうが感覚的にマッチしやすい。IEのことをばっさり切り捨ててしまうなら、数値判定はNumber.isFinite()で十分だろう。もしInfinityを入れたいのなら、条件でプラスしてやればよさそうだ(もう少しいいやり方あるかもしれない)。

typeofを使う

数値判定一般なら、Number.isFinite()を使えばいいことがわかったが、悲しいことにIE非対応なのでどうにかする方法を考える。先程のNumber.isNaNのMozillaのページを見ると「ポリフィル」のところに面白いコードがあった。

Number.isNaN = Number.isNaN || function(value) {
    return typeof value === "number" && value !== value;
}

これ初めてみたときは目からうろこで、なるほどtypeofを使うのか!確かにtypeofならECMAScript 1stからなので、IEでも十分いけそう。「value !== value」でなぜNaNが除外できるかというと、 developer.mozilla.org

NaN は別の NaN 値を含むあらゆる数と同じではないと比較されます(==、!=、===、!== によって)。ある値が NaN かどうかを的確に判定するには Number.isNaN() か isNaN() を使用してください。あるいは自己比較を実行しましょう。NaNだけが、自身と同等ではないと比較評価されます

ということは、IEでもこんな関数を定義してやればNaNは弾けることになる。

function isValidNumber(val) {
    return typeof val === "number" && val === val;
}

test(isValidNumber);
isValidNumber(true) is false
isValidNumber(false) is false
isValidNumber(null) is false
isValidNumber(undefined) is false
isValidNumber(0) is true
isValidNumber(1) is true
isValidNumber(-3.1) is true
isValidNumber(-3) is true
isValidNumber(10) is true
isValidNumber(10) is true
isValidNumber(3.141592653589793) is true
isValidNumber(Infinity) is true
isValidNumber(NaN) is false
isValidNumber() is false
isValidNumber( ) is false
isValidNumber(abc) is false
isValidNumber(123) is false
isValidNumber([object Console]) is false

ようやく望む結果が出てきた。ちなみに&&以降を!isNaN(val)で置き換えても同じ結果になる。処理時間計測してみたらほとんど変わらなかったんでお好みで。Infinityを除外したければ&&以降をisFinite(val)でやればOK。

linq.jsで使う

なんでこんなことを長々と調べてたかというと、linq.jsでNaNが入っているデータに対して平均を求める問題を考えたかったため。このライブラリは計算途中をトレースする機能があるが、LINQ自体計算がブラックボックス化しがちなので、JavaScriptのように独特の型仕様が入ると思わぬバグでハマりそうだった。

やっちゃダメな例だけど、if(x)で変な数値除外できればいいやとか適当なことやってると、平均でやばいことになる。(この例の正しい平均は1.5)

<script type="text/javascript" src="Scripts/linq.js"></script>
<script type="text/javascript">
    var array = [1, 2, 3, NaN, 0];
    var sum = Enumerable.From(array).Where("$").Sum();
    document.write("sum = " + sum + "<br/>");
    var average = Enumerable.From(array).Where("$").Average();
    document.write("average = " + average + "<br/>");
</script>
//sum = 6 ← 合計は何も問題ない
//average = 2 ← 平均で0が除外されてしまっている!!

簡単な例だからすぐ気づくが、データ数が多くなったり複雑な計算させると多分気づかないと思う(自分はこれバグだって気付ける自信ないです…)。

なのでこうしよう。

<script type="text/javascript">
    function isValidNumber(val) {
        return typeof val === "number" && val === val;
    }

    var array = [1, 2, 3, NaN, 0];
    var sum = Enumerable.From(array).Where("isValidNumber($)").Sum();
    document.write("sum = " + sum + "<br/>");
    var average = Enumerable.From(array).Where("isValidNumber($)").Average();
    document.write("average = " + average + "<br/>");
</script>
//sum = 6
//average = 1.5←正しい値に

JavaScriptの動的型付けは飛び道具的なことできて面白いこともあるが、すごく独特の挙動を示すので気をつけないとほんとしねる。