初めに
JavaScriptを書くとやっぱり完全に理解していないなあ、このエラーをどう解釈していくかとか自分にはよくある話です。今回はJavaScriptのちょっとおかしなところと、エンジンがどう処理しているか一緒に考えてみたい、そして何よりもどう対処していくかについてまとめたいと思います。
What the... JavaScript?
(Kyle Simpsons氏の映像です。一部の問題がすでに修正されている。)
Equality (==) & Strict equality (===)
Equality (==)
Strict equality (===)
JS Comparison Table
console.log([] == ![]) // true
// console.log(![] == false) // true
// console.log([] == false) // true
// console.log({} == !{}) // false
console.log([] === ![]) // false
Kyle Simpsons氏は映像で解釈したのですが、やはりエンジンがどうしてこのように解釈しているのかわかりません...!
Equality (==)ではなくStrict equality (===)を使えってよく言われると思います。上のように![]
はEquality (==)でエンジンが変な判断をしてLogical NOT (!)
がついてるのに無視されてtrue
を出した。
では同じロジックなら!{}
も同じ判断をするべきだと思うが、!{}
がfalse
を出した...。Kyle Simpsons氏曰くオブジェクトは数値に変換されるようで、つまり!{}
は!0
と同じのように扱われ、自然とfalse
になった。
別の例なら、
console.log([] + {}) // [object Object]
console.log([] + {} == [{}]) // true
console.log({} + []) // [object Object]
console.log({} + [] == [{}]) // true
console.log([{}] == {} + []) // true
console.log([{}] == [] + {}) // true
console.log([{}] == [{}] + []) // true
console.log([{}] == [{}] + [] + []) // true
console.log([{}] == [{}] + [] + {}) // false
(頭を抱えて)何これ、全然わからない!
やはりEquality (==)やめましょうね。
(前の文章で時々The Modern JavaScript Tutorialの例がEquality (==)が出てくるけど、自分はテストで書き換えると直す癖があるので修正しましたが、実際に値を許容するために敢えてEquality (==)を使うプログラマがいるらしいです。自分はできるだけEquality (==)やInequality (!=)避けたいです。)
自分の書き方としては![]
と!{}
とは書きません。判断を任せたいというのは値ではなく演算子です。NOT value
じゃなくNOT equal
ですから。
コードが思うように動かせるために、段階的に自分の書き方をテストし確認してから次のコードを書く、デバッグにも作業の効率にもいいと思います。(console
が面倒ならJest
など使ってもいい。)
coercion
型強制
Number constructor
String constructor
console.log(Number('')) // 0
console.log(Number(' ')) // 0
console.log(Number('\r\n\t')) // 0 // new line Regex
console.log(Number('-0')) // -0
console.log(JSON.parse('-0')) // -0
console.log(Number('- 0')) // NaN
console.log(JSON.stringify(-0)) // 0 // string
console.log(String(-0)) // 0 // string
console.log(-0 + '') // 0 // string
Number()
は文字列を数値に変換する動きで、emptyやspace、改行の正規表現を0
にします。いざ-
はspaceに遭うと、こんなの数字にはならない、非数です!ってNaN
に変換したのはまだ理解できる。
console.log(Number('0.')) // 0
console.log(Number('.0')) // 0
console.log(Number('.')) // NaN
console.log(Number(undefined)) // NaN
console.log(Number(null)) // 0
console.log(Number({})) // NaN
console.log(Number([])) // 0
console.log(Number('0O0')) // 0
console.log(Number('0X0')) // 0
// console.log(Number('0a0')) // NaN
// console.log(Number('0b0')) // 0
undefined
が値が未定義のままで非数、null
が元から無しで0
。
{}
はオブジェクトが数値化にされるのでNaN
(NaN
は数値型)に、[]
はempty value
で0
、ふむふむ。
あれ、Number('0O0')
、Number('0X0')
は0
だと?
さき-
とspaceの組み合わせが非数なのに、0
と文字のO
、X
が非数にはならないのはなぜ?もちろん考えても答えが出ませんので、どう処理していくかは自分なりに考えるしかありません。
// only for string converting to number
function checkNaN(str) {
if (Number(str) === NaN) return true
if (typeof str !== 'string') return 'Invalid value'
if (str[0] === '0' && str[1] === '.') return 'Decimals'
let check = str.split('').filter(x => Number.isNaN(Number(x)))
return check.length > 0
}
console.log(checkNaN('0O0')) // true
console.log(checkNaN('0X0')) // true
console.log(checkNaN('{}')) // true
console.log(checkNaN('[]')) // true
console.log(checkNaN({})) // Invalid value
console.log(checkNaN([])) // Invalid value
console.log(checkNaN('0.0314E+2')) // Decimals
console.log(checkNaN('9007199254740991n')) // true
// check with other function
これはデモのために書いてみたものです。小数や巨大数など正しく処理できません。もっといい判定方法や処理があります。
次は数値から文字列です。
console.log(JSON.stringify(-0)) // 0 // string
console.log(String(-0)) // 0 // string
console.log(-0 + '') // 0 // string
文字列だから正数と負数の概念がないかな、でもnegative-
時々文字列として保管しておいて必要なとき数値に扱われることもあるので、勝手に-
が消されたら困りますね。
さらに、
console.log(String({})) // [object Object]
console.log(String([])) // ''
console.log(String(null)) // 'null'
console.log(String([null])) // ''
console.log(String(undefined)) // 'undefined'
console.log(String([undefined])) // ''
console.log(String([null, null,])) // ','
console.log(String([undefined, undefined,])) // ','
console.log(String([, ,])) // ','
うん、わかりません。undefined
がundefined
のままでいてほしいときもあって、null
だってempty slot
とは違う概念だと思う。
少し時間をかけて書いてみましたが、巨大数と小数はとりあえず処理から外しています。
// for array or number (not including Decimals and BigInt)
function NumToStr(item) {
function isSafe(value) {
// if (value === undefined) return undefined // optional
// if (value === null) return null // optional
if (Number.isSafeInteger(value)) {
if (value === 0 && (1 / value) === -Infinity) return '-0'
if (value === 0 && (1 / value) === Infinity) return '0'
return String(value)
}
return NaN
}
if (typeof item === 'object') {
if (Array.isArray(item)) {
if (item.length === 0) return 'Invalid value'
return item.map(x => isSafe(x))
}
return 'Invalid value'
}
if (typeof item === 'number') {
return isSafe(item)
}
return 'Invalid value'
}
(inputが配列や数値、outputが数値と非数の分別つける状況を想定している。)
数値から文字列への処理が手順がかかると思います。これもただのデモコードで、配列と数値だけ受け入れるパターンです。一番に改善してほしいのは-0
と+0
の判断なので、巨大数や小数など緻密な処理が必要なので省けました。undefined
とnull
もNaN
として扱います。
console.log(NumToStr({})) // Invalid value
console.log(NumToStr([])) // Invalid value
console.log(NumToStr(null)) // Invalid value
console.log(NumToStr([null])) // [ NaN ]
console.log(NumToStr(undefined)) // Invalid value
console.log(NumToStr([undefined])) // [ NaN ]
console.log(NumToStr([null, null,])) // [ NaN, NaN ]
console.log(NumToStr([undefined, undefined,])) // [ NaN, NaN ]
console.log(NumToStr([, ,])) //[ <2 empty items> ]
console.log(NumToStr([1, -1, +0, -0, 0.5, -0.5, undefined, null, ,]))
// [ '1', '-1', '0', '-0', NaN, NaN, NaN, NaN, <1 empty item> ]
console.log(NumToStr(0.0314E+2)) // NaN
console.log(NumToStr(9007199254740991n)) // Invalid value
また、下のようにオブジェクトの生成にはnull
を使うと変な動きが発生してしまいます。
let o1 = {
hello: 'world'
}
let o2 = Object.create(null)
o2.hello = 'world'
console.log(o1 + '') // [object Object]
console.log(o1.toString()) // [object Object]
// console.log(o2 + '') // TypeError: Cannot convert object to primitive value
console.log(o2) // [Object: null prototype] { hello: 'world' }
console.log(null.prototype) // TypeError: Cannot read properties of null (reading 'prototype')
console.log(null.__proto__) // TypeError: Cannot read properties of null (reading '__proto__')
// note: null has no prototype, and it is the end of .__proto__
null
を{}
に変えたら上手く動いてくれるようになるが、それはnull
と{}
根本的に違うもので、null
自身がプリミティブとして、プロトタイプチェーンの終点として存在している時点、JavaScriptでは違う概念で扱われていると思います。
勉強すればするほどJavaScriptの型変換が不安定で、ありきたりな特殊の存在が自分のルールで動いている感じになっている。もっと統合的で予想外のないのができていたらいいなあとよく思うが、これもJavaScriptの魅力だと思います。
empty slot
配列のempty slotがメソッドによってどう処理されるか、ここにまとめてみました。
(この文章では[]
、{}
、''
がempty value
で、コンソールが<1 empty item>
表示するところがempty slot
と称しています。)
empty slot
が難しい。どうやって扱うのか難しい。
私の知っているempty slot
の特徴を少しまとめたいと思う。
empty value
がlengthがないのに対し、empty slot
はlengthにカウントされる。undefined
とnull
と違い、型がありません。for
ループで走査することができる。
型がないのでtypeof
で排除できない。lengthがあるので普通にempty value
のように扱うことができない。うまく排除しても配列内のほかの要素のindexが変えられてしまいます。データ処理でとても厄介な存在だと思います。
// empty slot
console.log(Array(3)) // [ <3 empty items> ]
let arr1 = []
arr1.length = 3
console.log(arr1) // [ <3 empty items> ]
console.log(arr1[0]) // undefined
let arr2 = [undefined, undefined, undefined]
console.log(arr2) // [ undefined, undefined, undefined ]
console.log(arr2[0]) // undefined
empty item
はね、後で値を入れるから先にそこで穴を開けといて!って感覚だと私は思います。しかしその穴に入ってる値は未定義だからアクセスしてもundefined
になるわけです。
でも、それはつまりエンジンはempty item
とundefined
と一緒に認識しているってことですか?それはないと思います。結果としては確かにundefined
ですが、元と言えば穴と未定義で見つからないこととは根本的に違うと思っています。
Kyle Simpsons氏が映像で提示したjoin()
とmap()
の例です。
console.log([undefined, undefined, undefined].join('+')) // ++
console.log([undefined, undefined, undefined].map(x => '+')) // [ '+', '+', '+' ]
console.log([null, null, null].join('+')) // ++
console.log([null, null, null].map(x => '+')) // [ '+', '+', '+' ]
console.log([, , ,].join('+')) // ++
console.log([, , ,].map(x => '+')) // [ <3 empty items> ]
join()
とmap()
処理の違いは値の扱い方にあるとKyle Simpsons氏が言いました。あくまでも自分の考えですが、join()
は結果としての値を処理することに対し、map()
は値そのまま扱うのではないかと。
join()
:empty slot
もundefied
も値としてundefined
、つまり存在しないというロジックで処理を行う。
map()
:undefined
はundefined
、null
はnull
、値そのままですが、empty slot
もそのままです。
map()
ではなくfilter()
でやると楽だと思います。
console.log([undefined, undefined, undefined].filter(x => !x === false)) // []
console.log([null, null, null].filter(x => !x === false)) // []
console.log([, , ,].filter(x => !x === false)) // []
(map()
、filter()
、reduce()
はES6からのメソッドです。)
Array.prototype.map()
Array.prototype.filter()
Array.prototype.reduce()
finally{}
& return
ここからは構文の行いの解釈の部分が多く、あまり対処方法がないのですが。
// finally
function foo() {
try {
return 2
} finally {
return 3
}
}
console.log(foo()) // 3
try...catch...finally
構文では確かにfinally
が必ず実行するのですが、try
ですでに実行したreturn 2
がfinally
のreturn 3
に上書きされました。
function foo() {
try {
return 2
}
finally {
// return
}
}
console.log(foo()) // 2
return
は外側に戻る手段の一つとして使われます。普通の関数はデフォルトの返り値がundefined
(指定の値がなければreturn
書かなくともundefined
)。
ここfinally
をreturn
使わない場合は正常にtry
のreturn
が実行します。
function foo() {
bar: {
try {
console.log('I try!')
return 2
}
finally {
break bar
}
}
console.log('oops')
}
console.log(foo())
// I try!
// oops
// undefined
しかしfinally
がreturn
の後に実行することでbreak
はreturn
の実行をキャンセルして、foo()
がreturn
できる値がなくundefined
になった。
返し値を上書きしたり、上の例のようにreturn
を止めることもできるfinally
の動き、映像を見て初めて知りました。しかしこれは構文自体の行いなので気を付けないといけないと思います。
generator & finally{}
// generator
function* foo() {
console.log('a: ', yield 1)
console.log('b: ', yield 2)
}
let generator = foo()
console.log(generator.next())
// { value: 1, done: false }
console.log(generator.next(5))
// a: 5
// { value: 2, done: false }
console.log(generator.next(10))
// b: 10
// { value: undefined, done: true }
最初yield
双方向の特性にびっくりしました。(内部からだけでなく外側の値、関数もアクセスできるなんて便利すぎる...。)
about yield yield
の動きなら少し前ジェネレータについて書いた文章ですが、ご参考になれば幸いです。
yield
の動きを分かればその特性を利用していろいんなことができると思いますが、問題なのはfinally
との相互作用です。
function* foo() {
try {
console.log('a: ', yield 1) // 1
console.log('b: ', yield 2)
}
finally {
console.log('finally!') // 2
console.log('c: ', yield 3) // 2 // 3, yield 3 => 25
// return // 2, undefined => 7
}
console.log('d: ', yield 4)
}
let generator = foo()
console.log(generator.next())
// { value: 1, done: false }
console.log(generator.return(7))
// finally! // *
// { value: 3, done: false }
console.log(generator.next(25))
// c: 25 // *
// { value: 7, done: true }
console.log(generator.next(42))
// { value: undefined, done: true }
全体の動きは、
console.log(generator.next())
yield 1
// { value: 1, done: false }
↓
console.log(generator.return(7))
(return undefined => 7)
console.log('finally!')
// finally!
yield 3
// { value: 3, done: false }
↓
console.log(generator.next(25))
(yield
3 => 25)
console.log('c: ', 25)
// c: 25
return 7
// { value: 7, done: true } (state changed!)
↓
console.log(generator.next(42))
// { value: undefined, done: true }
finally
とreturn
の相互作用でジェネレータのイタレーションの順序がぐちゃぐちゃになっていて、本来なら.return(7)
で終わるべきなのに、finally
なかのすべてのyield
を終わらせなければreturn
は実行しません。
object destructuring & default param values
function foo() {
let x = 2
let y = 3
return {
x: x,
y: y
}
}
// let o = foo()
// let x = o.x
// let y = o.y
// console.log(x, y) // 2 3
let { x, y } = foo() // referencing the same property name
console.log(x, y) // 2 3
自分にはまだ慣れてない書き方です。
自分の理解では、destructuringはpropertyが単体のパズルとして扱われて、右手(Right-hand side)は値のコンテキストで、アクセスの仕方はproperty name
です。
function foo1() {
let XX = 2
let YY = 3
return {
x: XX,
y: YY,
z: 4
}
}
let { x, y } = foo1()
console.log(x, y) // 2 3
function foo2() {
let XX = 2
let YY = 3
return {
x: XX,
y: YY
}
}
let { x: BAM, y: BAZ } = foo2()
console.log(BAM, BAZ) // 2 3
// BAM -> x -> foo2() {x: XX} -> 2
// BAZ -> y -> foo2() {x: YY} -> 3
// test
BAM = 10
console.log(BAM) // 10
console.log(foo2()) // { x: 2, y: 3 }
ここはちょっと不思議なことが起きました。
console.log(BAM, BAZ)
で値をアクセスできるというのは、BAM
とBAZ
もlet
で宣言されたんですね。
実際BAM
にreassignしてみたらBAM
の値が変わりましたが、foo2()
内の値はそのままです。つまり元は、
let { x, y } = foo2()
let BAM = x
let BAZ = y
console.log(x, y) // 2 3
こんな感じですね。
下からはデフォルト引数と関わる部分です。
function foo3({ x, y }) {
console.log(x, y)
}
foo3({ y: 10, x: 25 }) // 25 10
foo3({ y: 5 }) // undefined 5
function foo4({ x: XX, y: YY }, z) {
console.log(XX, YY, z)
}
foo4({ y: 10, x: 25 }, 50) // 25 10 50
関数ならまず引数の位置、そしてオブジェクトからproperty name
に沿って値をアクセス。
しかしデフォルト引数と関連したらちょっと理解しにくいところがあって、
function foo5(x = { y: 10 }, { y = 20 } = {}) {
console.log(x.y, y)
}
foo5() // 10 20
foo5({ y: 30 }, { y: 40 }) // 30 40
foo5({}, {}) // undefined 20
まずfoo5()
のデフォルト引数はこう解釈できると思います。
let x = {
y: 10
}
let { y = 20 } = {}
console.log(x.y, y) // 10 20
foo5()
のようにデフォルト値がちゃんとアクセスできて、
foo5({ y: 30 }, { y: 40 })
もy
というproperty name
があるから間違わない。
最初は何も思わなかったけど、foo5({}, {})
がだめになったのは両方引数の解釈が違うことに直結していると気づきました。
つまりx = { y: 10 }
は右手(Right-hand side)から左手(Left-hand side)、{ y = 20 } = {}
は左手から右手。
destructuringはアサインの逆行とも言えるでしょう。
そしたらfoo5({}, {})
はx = {}
、{y = 20} = {}
になり、
x.y
が存在しないのでundefined
、
y
は同じproperty name
で上書きされてなければ元のままです。
y
は単なるオブジェクトのプロパティではなかったの?
私も同じ疑問を持ったが、destructuringはそれだけではなかったと思います。
let { y = 20 } = {}
let y = 10 // SyntaxError: Identifier 'y' has already been declared
destructuringでは{}
中身も宣言されていますね。さきのBAM
と同じです。
{y = 20} = {}
をRight-hand sideのルールで書き換えてみたら、
let y = 20
let obj = {}
obj.y = y
console.log(obj.y, y) // 20 20
完全ではないけれど感覚としては近いと思います。
classes
// classes
class Px {
another() { // Px.prototype
console.log('Px: another')
}
}
class Cx extends Px {
something() { // Cx.prototype
console.log('Cx: something')
}
another() { // Cx.prototype
this.something()
super.another()
}
}
let x = new Cx()
class Py {
another() { // Py.prototype
console.log('Py: another')
}
}
class Cy extends Py {
something() { // Cy.prototype
console.log('Cy: something')
}
another() { // Cy.prototype
this.something()
super.another()
}
}
let y = new Cy()
x.another()
// Cx: something
// Px: another
y.another()
// Cy: something
// Py: another
y.another.call(x)
// Cx: something // this = x
// Py: another // super => [[HomeObject]]: [class Py]
なぜclass Cx
とclass Cy
のanother()
が各自の拡張先のanother()
を上書きしないというのは、そもそも各自のanother()
は自分の.prototype
に保存されてるんです。
this
指定がなければ、プロトタイプチェーンでのアクセス順は、
親constructor()
↓
親フィールド
↓
自分のprototype
↓
親のprototype。
(前はちょうど関連文章overriding class fieldを書きました。ご参考になればと。)
this
で今いるコンテキストを指定すると、まず同じコンテキストから探し、なければプロトタイプチェーンへ。先着順みたいに最初に見つかった同じ名前のメソッドを実行する。x.another()
とy.another()
は問題なく自分の.prototype
からsomething()
を見つけ、そしてsuper
で親のanother()
へアクセス。
y.another.call(x)
では確かにcall()
で強制的this
の参照先がclass Cx
に変えたけれど、super
は自フィールドとextends
拡張先と結びつけるために[[HomeObject]]を参照するので、call()
に影響されないんです。(逆にもしcall()
でsuper
が変えられるのならextends
の意味がなくなると思う。)
JavaScriptのthis
は動的で、メソッド、書き方(アロー関数)、キーワード(new
)、環境(グローバルかローカルか)に依存して、何かあっちもこっちも手伝いたくて時に自分の原点が見失うみたいな、非常に厄介だけどいとおしい存在だと思います。
まとめ
(初投稿でなぜかこの部分がなくなった。)
最後まで読んでいただきありがとうございます。
JavaScriptの動きが確かにおかしなところがたくさんあるので扱いにくいですね。
今回の文章について感想として少し書きたいと思います。
構文上の問題ならちゃんとチェックしてから使うとか、概念の問題なら慣れれば?そつなくこなせると思いますが、自分にとって一番困るのはやはり型変換で起こすエラーと、そのたびに対処メソッドを考えることです。(デモで書いたコードは本当に雑で穴だらけなのでそのまま使っちゃだめですよ!)
それに対してTypeScriptの良さと魅力これまで別の方の文章で感じたんですが、まだもうちょっとJavaScriptと付き合いたいです。もっといいコードが書けるように頑張りたいと思います!