🍛

NaNと和解できるだろうか?

に公開

免責事項

結論

できませんNaN をゆるすな。

要約

  • NaN はテスト目的以外で使わないようにしよう
  • 文字列が数値変換できるかは isNaN() で調べよう
  • number 型の値が NaN かどうかは Number.isNaN() で調べよう
  • 数値の検証には Number.isNaN() よりよい選択肢がないか考えよう
  • NaN が算術演算で発生しないよう保証しよう

NaN って?

NaN は浮動小数点数の非数not a number)を表す値の総称です。

JavaScriptにおける NaN は、number 型の特殊な値の一つで(NaN なのに!)、次の性質を持ちます:

  • NaN に対するすべての算術演算の結果は NaN となる
  • NaN に対する不等価演算子(!=, !==)の結果は true となる
  • NaN に対する等価演算子(==, ===)および大小比較(<, <=, >, >=)の結果は false となる
  • NaN に対するビット演算について、NaN0 に変換される
  • NaN に対する論理積演算子(&&)および論理和演算子(||)について、NaNfalse として振る舞う(falsy)
  • NaN に対する boolean への変換は false を与える

補足として、はじめに「NaN は非数の総称」と書きましたが、JavaScriptにおける NaN はただ一つの値に定まっています。したがって、NaN のバリエーションに思い悩むことはありません。

NaN はどこから来るの?

NaN は、グローバルプロパティとして、また組み込みの Number オブジェクトのプロパティ Number.NaN として、直接あつかうことができます。しかし、NaN が現れるのは通常、

  • 文字列の数値変換に失敗した場合
  • 算術演算で一貫性のある結果を与えられない場合

のいずれかです。
いずれの場合でも、NaN は「ある操作に失敗し、その一貫性が損なわれたことを伝える符丁」として現れます。

文字列の数値変換の失敗

文字列の数値変換が行われるケースとして以下が挙げられます:

  • 明示的な文字列・数値変換を行う関数を適用した際
    • Number()
    • parseInt()
    • parseFloat()
  • 暗黙の数値変換を行う演算子を適用した際
    • 単項マイナスおよび減算(-
    • 乗算(*
    • 除算(/)および剰余算(%
    • 単項プラス(+
    • 文字列以外との大小比較(<, <=, >, >=
  • 数値を期待する組み込み関数に対して文字列を渡した際
    • Math.sin() などの数学関数
    • Array.prototype.splice() などのインデックスや個数を受け取る関数
    • ...and more!

多すぎ🤮

文字列が数値変換される場合のパターンは数多くありますが、組み込み関数または演算子による暗黙的な変換は Number() 相当の処理を行うため、Number() に対する挙動を知っていれば十分です

Number()NaN を返すのは、引数の文字列が空でなくかつ数値リテラルとして解釈できない場合です。

NaNだこれは
//  数値リテラルに含まれない文字がある
//  - 10進数値リテラルではない
console.log(Number("7f"));
//  - 2進数値リテラルではない
console.log(Number("0b2"));
//  - 8進数値リテラルではない
console.log(Number("0o8"));
//  - 16進数値リテラルではない
console.log(Number("0xg"));
//  ==> すべて NaN

//  10進数値リテラル以外に符号(+/-)がついている
console.log(Number("-0b1"));
console.log(Number("-0o7"));
console.log(Number("-0xf"));
//  ==> すべて NaN(正符号も同様)

//  符号の後に数字・小数点以外の文字がある
console.log(Number("+ 42"));
console.log(Number("- 42"));
//  ==> すべて NaN

//  下線が含まれる
console.log(Number("123_456"));
//  ==> NaN

注意点として、文字列変換で扱われる数値とソースコード上の数値リテラルとでは解釈に差異があります

  • 先頭および末尾の空白と改行は無視され、空文字列は 0 と解釈される
    • 例:Number("\n +42\n ")42 に変換される
    • 例:Number("")NaN ではなく 0 に変換される
  • 符号の後に空白を挿入できない
    • 例:Number("+ 42")42 でなく NaN に変換される
  • 10進法以外では符号をつけられない
    • 例:Number("-127")-127 に変換される一方、Number("-0x7f")-127 でなく NaN に変換される
  • 下線を含められない
    • 例:123_456123456 と等価だが、Number("123_456")123456 でなく NaN に変換される
  • 古い8進法の記法が使えない
    • 先頭の 0 は無視され10進法として解釈される
    • 例:Number("010")7 でなく 10 に変換される
  • "Infinity"Infinity に変換される

ぱっとこれらの性質を理解できますか? 私はできません。

実践的な教訓として、数値変換には過度な期待をしないようにしましょう

算術演算の失敗

算術演算において、首尾一貫した値にならない計算があります。最も代表的な例は、∞ − ∞ や 0/0 でしょう(はい、これらは定義できます――コンピュータがなんか突然ばくはつしたり宇宙の法則が乱れたりはしません!――しかしそれは「ふつう」の算術ではなくなっちまいます)。JavaScript ではこれらの算術演算に対して例外を送る(throw する)のではなく、number 型の値の範疇に収めます。つまり NaN です。

NaNということでしょう
//  無限ひく無限の結果は不定
console.log(Infinity - Infinity);

// ゼロ割るゼロの商は不定
console.log(0 / 0);

//  ゼロ割るゼロの余りも不定
console.log(0 % 0);

//  無限かけるゼロは不定
console.log(Infinity * 0);

つまり算術演算の結果が NaN になるのは、結果が不定であるという意味合いがあります。

加えて NaN との算術演算はまた NaN を与えます。NaN は色移りするのです

一難去ってまた一難
//  NaN との加減乗除は NaN
console.log(NaN + 42);
console.log(NaN - 42);
console.log(NaN * 42);
console.log(NaN / 42);
console.log(NaN % 42);
//  ==> すべて NaN

したがって、NaN によってすべての計算をご破算にしないために、入力値が NaN でないことを検証し、また、出力値が NaN でないことを保証しなければなりません

NaN かどうかはどう調べる?

NaN かどうかを調べるには、組み込み関数の isNaN() または Number.isNaN() を使うか、NaN 自身と不等価であることを利用できます。

汝はNaNなりや?
//  組み込み関数を使って判定する
console.log(isNaN(NaN));
console.log(Number.isNaN(NaN));
//  ==> いずれも true

//  NaNは自身を含むすべての値と等価ではない
console.log(NaN !== NaN);
//  ==> true

不等価演算子による検証は、NaN の性質を知るよい題材にはなるかもしれませんが、組み込み関数が用意されている以上、わざわざ使う意味はないでしょう。

isNaN()Number.isNaN()、なにが違う?

isNaN()Number.isNaN() の違いは何でしょうか? それは、引数を number 型へ変換するかどうかです

本物の NaN を見せてあげますよ
//  isNaN() は文字列を数値変換する
console.log(isNaN("foo"));
//  ==> true

//  Number.isNaN() は型変換をしない
console.log(Number.isNaN("foo"));
//  ==> false

//  Error
//  型変換の実装を持たないオブジェクトは数値変換できない
console.log(isNaN(Object.create(null)));

//  OK
//  オブジェクトは NaN ではない
console.log(Number.isNaN(Object.create(null)));
//  ==> false

//  Error
//  symbol は数値変換できない
console.log(isNaN(Symbol()));

//  OK
//  symbol は NaN ではない
console.log(Number.isNaN(Symbol()));
//  ==> false

isNaN() の利点は、文字列が数値変換できるかを検査できることです。一方で、暗黙の型変換のために、型変換の実装(valueOf(), toString(), [Symbol.toPrimitive]() のいずれか)を持たないオブジェクトや symbol 型の値を与えると TypeError を引き起こす欠点があります。したがって実用上は、文字列か数値に対してのみ適用するようにするとよいでしょう。

一方で Number.isNaN() の利点は、引数の値が NaN であることを知れるということです。暗黙の型変換が行われないため、NaN 以外の値を与えてもエラーにならず、false を返します。したがって実用上は、文字列の数値変換が必要ない機能で、NaN を除外したい場合にのみ使うとよいでしょう。

isNaN() に頼らない

世界が単純であればあるほど、あなたは明晰です。しかし本当にそうでしょうか? 残念ながら世界は複雑で、あなたは風にたなびく煙のような定命の存在です。

isNaN() らはこれ以上なく単純かつ明快な関数たちですが、数値に対して期待したい様々な性質たち、整数かどうか、有限値かどうかなどを調べるにはすこし力足らずです。
また、数値変換や算術演算を扱うあらゆる個所で NaN かどうかを検証するのは、労のわりに得るものが少な過ぎます。

NaN かどうかより詳細な条件が必要なら、それを検証する Number.isInteger()Number.isFinite() のような関数を Number.isNaN() の代わりに使いましょうNumber.isInteger() は整数かどうかを、Number.isFinite() は有限の数値かどうかを判定します。

isNaN() は数値検査関数の中でも最弱
//  NaN は有限数ではない
console.log(Number.isFinite(NaN));
//  ==> false

//  Infinity は有限数ではない
console.log(Number.isFinite(Infinity));
console.log(Number.isFinite(-Infinity));
//  ==> いずれも false

//  円周率(Math.PI)は有限数
console.log(Number.isFinite(Math.PI));
//  ==> true

//  NaN は整数ではない
console.log(Number.isInteger(NaN));
//  ==> false

//  Infinity は整数ではない
console.log(Number.isInteger(Infinity));
console.log(Number.isInteger(-Infinity));
//  ==> いずれも false

//  円周率(Math.PI)は整数ではない
console.log(Number.isInteger(Math.PI));
//  ==> false

//  0.5 は整数ではない
console.log(Number.isInteger(0.5));
//  ==> false

//  42 も -42 も整数
console.log(Number.isInteger(42));
console.log(Number.isInteger(-42));
//  ==> いずれも true

関数の実装において、NaN を与えないようにするのは呼び出し(顧客)側の責任であって関数(店)側の責任ではないため、無視してデフォルトの値に置き換えるかエラーを起こすようにしましょう。加えて、呼び出し個所の限定される関数については、検査済みの値を渡し、内部での検証を行わないようにしましょう。

型注釈をつける

NaNnumber 型の値であるため、オブジェクトのようにクラスを与えることはできませんが、型注釈を与えることによって後続の処理に意図を伝えることができます。

プログラムに「型」なんかありませんよ… ファンタジーやメルヘンじゃあないんですから
//  型を定義する
/** @typedef {number & { __brand: "NaNType" }} NanType */

//  isNaN の結果で x の型を推論できるようにする
/**
 * @param {unknown} x
 * @returns {x is NanType}
 */
const isNan = x => Number.isNaN(x);

コメントとして型定義を記述し、判定結果と型定義を対応づけることで、NaN かどうかを型レベルで判別できます。

もちろん、コメントの内容は実行時には無視されますし、そこで定義されている型も、実態のない「まやかし」です。エディタは型推論の結果を伝えてくれますが、それだけです。
しかし、それでも型は真実へ向かおうとする意志を示してくれ、プログラムを正しさへ導いてくれるはずです。

そうは言えども型注釈をつけるのはあなたですし、その注釈の正しさを保証するのはあなた自身です

大人はウソつきではないのです。ただまちがいをするだけなのです……
//  型定義(詳細は省略)
/** @typedef {...} NotNaN */

/**
 * @param {NotNaN} x
 * @param {NotNaN} y
 * @returns {NotNaN}
 */
const divide = (x, y) => (x / y);

//  型推論は NotNaN になっているのだから、
//  当然! quotient は NaN ではない!
const quotient = divide(0, 0);

//  🤔🤔🤔
console.log(quotient);

NaN は利用できないの?

NaN は算術演算における無効値であり、論理演算の中では false として扱われます。算術演算の項として使わない限りは、nullundefined と同じように使えるかも知れません。

ただし、null 扱いではないため、ヌル値合体演算子(nullable ?? fallback)やオプショナルチェーン演算子(nullable?.property)を使って条件分岐を軽量にするような工夫はできませんし、nullundefined に与えられているような、属性やプロパティなどの不存在を示す意味を NaN は持っていません
では、nullundefined のような友好的な値たちを差し置いて NaN を使う理由は何でしょうか? ありません。

NaN をゆるすな

参考文献

Discussion