LoginSignup
239
143

More than 1 year has passed since last update.

JavaScriptの反意図性(クソ挙動)を指摘するバズツイ周辺解説

Last updated at Posted at 2022-01-22

先日このようなツイートが話題になりましたね

無論この件は例のコインハイブ事件で言われるような反意図性とはずれる話題ですが、直感的ではないという点でうなずくものがあります

今回はなぜこれらがこのような結果になるのか、回避方法等順に説明しようと思います

というのも、この中のいくつかはJavaScript固有のものではないので、他人事と思わないようにしていただきたく……
その上でJavaScriptの本当の意味でヤバイ箇所も説明できたらいいなと思います。

typeof NaN

> typeof NaN
< number

NaNとはNot a Numberの略、なのになんでnumberなんだ!!

この値は他のオブジェクトを数値に変換する関数parseInt(x)が失敗した場合、0*Infinity等の不確定な計算をした時に出力される値で、エラーを表現する値の一つです。
内部表現的にはただの浮動小数(IEEE754)なので、typeofnumberを返します。
IEEE754は浮動小数演算に関する標準なので、他の多くの浮動小数演算を利用する言語でも同様の表現になるはず

このエラー値の何が嬉しいかと言うと、NaNに演算した結果もNaNになるため、例外処理が最終結果を見るだけで済む等の利点があります。都度例外投げるのとどっちがいいんでしょうね。

NaN===NaN+0 === -0

> NaN===NaN
<- false
> +0===-0
<- true

ちなみに、NaN同士は同じ値と評価されないが、これもIEEE754仕様でそう返すことになっています。これもJavaScript固有のものではないです
一方+0===-0は直感的に正しいですが、正の0と負の0は内部表現値として違う値を取ります。(符号部と値表現部が独立しているため)
そのため、オブジェクトが同一であるかを返すObject.isでは以下のように===とは逆の値が返ってきます

> Object.is(NaN,NaN)
<- true
> Object.is(+0, -0)
<- false

Number.isNaN()isNaN()

ちなみに、NaNであるかの判定で===は使えないので、JavaScriptではisNaNを使いますが、Number.isNaN()isNaN()挙動が異なります
前者はNaN値であるかどうか、後者は数値変換してNaN値になるかどうかを見ます。
仕様上前者の方が気持ちいいですが、実際使うとしたら後者でしょうね…

> Number.isNaN('x')
<- false
> isNaN('x')
<- true

indexOf(NaN)includes(NaN)

値評価としては偽・オブジェクト評価で真という独特な挙動により、配列内の走査処理の挙動が異なります。
場所を返すindexOf===による値の評価、includesはオブジェクトとして含まれているかどうかを見るような挙動を取ります。

> let arr = [2,4,NaN,12]
<- undefined
> arr.findIndex(n => Number.isNaN(n))
<- 2
> arr.indexOf(NaN)
<- -1
> arr.includes(NaN)
<- true

② 9999999999999999

> 9999999999999999
<- 10000000000000000

なんで勝手に数字が変わるんだ!!

JavaScriptでは単純に扱うと浮動小数計算を行うため、浮動小数の精度で扱いきれない値では丸まります。
正確に表しきれない値は、以下のように取得できるので、これをオーバーしないように気をつけましょう。

> Number.MAX_SAFE_INTEGER
<- 9007199254740991

BigIntという多倍長型が用意されているので、これを利用することで正しく扱うことができます。

> 9999999999999999n
<- 9999999999999999n

0.5+0.1==0.60.1+0.2==0.3

> 0.5+0.1
<- 0.6
> 0.1+0.2
<- 0.30000000000000004

小数の計算すら正しくできないのか!

JavaScriptは浮動小数を利用するので、小数演算では誤差が生じることがありますがこれはJavaScriptに限らず発生します
例えば、Pythonは②のような多倍長整数は暗黙的に扱えますが、小数については同様の挙動をします。これを回避できるのは固定小数を採用してる言語だけでしょう。

整数の範囲で計算するとか、浮動小数の誤差を許容するなどの必要があります。

これをJavaScriptのせいだと思った人はJavaScriptにごめんなさいしましょう。ほら!

Math.max()Math.min()

> Math.max()
<- -Infinity
> Math.min()
<- Infinity

お前誰だよ!

所謂番兵です。
配列の中の最大値を見るような関数を実装する時、答え用の変数と比較し、大きければ(小さければ)更新する、という動作をすると思います。

let x = -Infinity;
for(y of [-10,0,10]){
  if(x < y) x = y;
}
console.log(x) // 10

このときの初期値というのは、一番最大値になりにくい値、-Infinityが取られます。
何もしないのでこれが出てくる、というイメージです。

これの何が嬉しいかと言うと、複数の配列に対してmaxを行った結果に対して、さらにmaxを行う、などの時(例えば部分更新とか?)に、この出力が他の結果を阻害しません。

もう少し踏み込んで数学的な表現をすると、最大値・最小値を二項演算とみなした時の単位元です。

二項演算とは2つの値から1つの値を求める処理で、例えば四則演算とかがあります。

単位元とは、二項演算の一方に与えた時にもう片方の値が出てくるような値です。

例えば、加算・減算なら0(何を足しても0なので)、乗算なら1(何をかけても1なので)、最小公倍数なら1(どんな正の整数でも1の倍数なので)といった具合です。

これの類型で、最大値二項演算の単位元は-Infinity(どんな数字も-Infinityよりは大きい)、最小値二項演算の単位元はInfinity(どんな数字もInfinityよりは小さい)、ということです。

この性質のおかげで番兵として使え、複数の出力と合わせても他の結果を阻害しません。

おそらく、何も引数を持たない総和関数には0を返して欲しい、何も引数を持たない総乗関数には1を返して欲しい、というところでは共感していただけるのではないでしょうか。

というわけで、一番嬉しい戻り値、自然です

暗黙的型変換(型強制)

さてここまではJavaScript以外でもそうなるやい!というものが多かったですが、だんだん言い逃れできなくなります。

JavaScriptの大きな沼の一つである暗黙的型変換(型強制)(Type coercion)と呼ばれる挙動の説明を先にします。

JavaScriptは型を明記しない言語なので、異なる型同士の演算ができます。この時「ソレらしく変換する」という挙動が取られます。この挙動自体は他の言語でも見られますが、それが激しい、という感じです。

具体的な挙動は以下に解説がありますが、混乱するだけなので物好きだけ見ればいいと思います……

回避方法としては、比較に===を用いる、厳格モード("use strict"を使う)TypeScriptで型を見てそのような演算自体を回避する、というところです。

==の暗黙的型変換

挙動は以下のテーブルで見るのが早いです。良さげな評価なので、覚えようと思わないほうがいいです。

image.png

booleanの暗黙的型変換

上の表のifタブで見れますが、booleanに変換すると以下のようになります。大抵「何かオブジェクトがあればtrue・何もなければfalse」みたいな感じです。
image.png

numberの暗黙的型変換

色々ありますが、文字列が数値化できるなら、数値化、配列なら1引数までならその値、どうにもならなければNaNという感じです。

> +"100"
<- 100
> +[]
<- 0
> +["5"]
<- 5
> +[[2]]
<- 2

+の暗黙的型変換

二引数の足し算はざっくりと以下のような挙動を取るらしいです
- 引数
- →primitive化(boolean, number,BigInt, string, objectのいずれかに)
- →typeof x === 'object'になるものをvalueOftoStringobject以外の型に
- →文字列が含まれたら文字列として結合
- →数値化、成功したら数値として加算

そしてこれは左側優先で処理されていきます。
これらを頭に入れておかないと、以降の挙動は意味不明です

[]+[]

> []+[]
<- ''

えっなんで急に文字列が…?

[](配列)はtypeof []'object'を返す。
([]).valueOf()では配列がそのまま返ってきてしまう。
([]).toString()''を返す(.join()と同じ出力)
結果+の暗黙的型変換で、''''が返り、これを文字列結合して''が返ってくるという仕組み

[]+{}

> []+{}
<- '[object Object]'

えっなんで急に文字列が…?

{}(オブジェクト)はtypeof {}'object'を返す
({}).valueOf()では配列がそのまま返ってきてしまう。
({}).toString()'[object Object]'を返す。
結果+の暗黙的型変換で、'''[object Object]'が返り、これを文字列結合して'[object Object]'が返ってくるという仕組み

{}+[]

> {}+[]
<- 0

えっなんで数字…?えっこの挙動知らない……

というのも、nodeコマンドで実行したプロンプトだと以下のようになります。

> {}+[]
'[object Object]'
> console.log({}+[])
[object Object]

⑧と全く同じ挙動が期待されるからです。これChromeやFirefoxのコンソール独特の挙動である。
実は、ブラウザのコンソールは値評価というよりも、入力されたものをソースコードとして処理しているところがあります。

これをソースコードとして評価すると、前の波括弧はオブジェクトではなくコードブロックと認識されます。これは無視され、残りは+[]となります。

これは単項演算子の+(所謂符号)であり、後者を数値評価し、結果0が返るという処理過程です。
なので、コード変換するevalなら0が返るし、console.logの引数にすれば期待通りの出力をします。

> eval('{}+[]')
<- 0
> console.log({}+[])
[object Object]
<- undefined

true+true+true === 3・⑪ true-true・⑫true == 1・⑬true === 1

> true+true+true
<- 3
> true - true
<- 0
> true == 1
<- true

論理和してくれないのか!?

+の型強制によりtrueは数値変換され、1になる。
1+1+1===3です。
また、-の型強制でも同様に数値変換される。
==の型強制でも同様に数値変換。
trueが1になるのはC言語等boolean型以前でもよく見られる挙動ですね。

> true === 1
<- false

一方===は型強制されないのでfalse、自然ですね

(!+[]+[]+![]).length === 9

> !+[]+[]+![]
<- 'truefalse'

まず入力が何だよ!!
まず、!演算子について確認すると、単項演算子で引数のboolean値を反転させます。所謂NOTです。

> !+[]
<- true

1つ目の!演算子が後者を見ると、単項の+です。[]を数値変換し0、これをboolean評価したらfalseなので、反転させてtrueです。

> ![]
<- false

一方2つ目の!演算子は後者を評価します。[]はtruthyなのでtrueと評価されます。これを反転させてfalseです。

> true+[]+false
<- 'truefalse'

これらを文字列結合させれば、+演算子の型強制で文字列結合です。

9+"1"

> 9+"1"
<- "91"

文字列結合で91です。流石に慣れてきましたかね

91-"1"

> 91-"1"
<- 90

+ではなく-だと、数値変換が優先されます。+stringの演算にも使われるため、特殊な挙動をとる、というような感じです。

> 91*"2"
<- 182
> 91/"2"
<- 45.5

[] == 0

> [] == 0
<- true

上の対応表から、ざっくり言えば、[]が数値変換すると0になるから等しい、という感じです。

おまけ

前にバズってたやつ

> 1+2+3+"4"+5+6
<- "6456"

加算が左優先のため、1+2+3までは数値、"4"が混入して以降は文字列として処理されている、という感じです。

リプ欄についてたやつ

(なんかリプのやつ表示できなくなったので)

> 018-017
<- 3

0から始まる017は8進数の17とみなされるため換算して15、一方018は0で始まりますがそのまま10進数と解釈され18となります。なのでこのような結果が出力されます。ちなみに01aみたいなのは普通にエラーになります。

8進数の冷遇は凄まじく、数値変換の際も、16進数はやってくれるのに8進数は許されません。まぁ勝手にやったらもっとゲキヤバ挙動扱いされたと思いますが…

> +"0x16"
<- 22
> +"016"
<- 16

変換するときは、parseInt()を利用し、8進数であることを明示しましょう。

> parseInt("016", 8)
<- 14

parseInt

> parseInt(0.000009)
< 0
> parseInt(0.0000009)
< 9

parseIntの引数は文字列に変換されます。すると前者は"0.000009"、後者は"9e-7"になります。
parseIntは前から取れるだけ取るような挙動をするので、前者は0に、後者は9になります。

参考

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/NaN NaN | MDN
https://exploringjs.com/deep-js/ch_type-coercion.html#converting-to-array-indices 2 Type coercion in JavaScript | DeepJavaScript
https://dev.to/sharadcodes/javascript-memes-and-jokes-keep-em-coming-4hf8|

絶対に怪しい記述あるから、変なところがあったら指摘していただけますと幸いです
あと面白いゲキヤバ挙動あったらそれはそれで教えて下さい

239
143
4

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
239
143