1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

JavaScriptのおかしなところについて

Last updated at Posted at 2022-08-24

初めに

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
{}はオブジェクトが数値化にされるのでNaNNaNは数値型)に、[]empty value0、ふむふむ。

あれ、Number('0O0')Number('0X0')0だと?
さき-とspaceの組み合わせが非数なのに、0と文字のOXが非数にはならないのはなぜ?もちろん考えても答えが出ませんので、どう処理していくかは自分なりに考えるしかありません。

// 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([, ,])) // ','

うん、わかりません。undefinedundefinedのままでいてほしいときもあって、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の判断なので、巨大数や小数など緻密な処理が必要なので省けました。undefinednullNaNとして扱います。

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にカウントされる。
  • undefinednullと違い、型がありません。
  • 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 itemundefinedと一緒に認識しているってことですか?それはないと思います。結果としては確かに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 slotundefiedも値としてundefined、つまり存在しないというロジックで処理を行う。
map()undefinedundefinednullnull、値そのままですが、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 2finallyreturn 3に上書きされました。

function foo() {
  try {
    return 2
  }
  finally {
    // return
  }
}

console.log(foo()) // 2

returnは外側に戻る手段の一つとして使われます。普通の関数はデフォルトの返り値がundefined(指定の値がなければreturn書かなくともundefined)。
ここfinallyreturn使わない場合は正常にtryreturnが実行します。

function foo() {
  bar: {
    try {
      console.log('I try!')
      return 2
    }
    finally {
      break bar
    }
  }
  console.log('oops')
}

console.log(foo())
// I try!
// oops
// undefined

しかしfinallyreturnの後に実行することでbreakreturnの実行をキャンセルして、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 }

finallyreturnの相互作用でジェネレータのイタレーションの順序がぐちゃぐちゃになっていて、本来なら.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)で値をアクセスできるというのは、BAMBAZletで宣言されたんですね。
実際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 Cxclass Cyanother()が各自の拡張先の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と付き合いたいです。もっといいコードが書けるように頑張りたいと思います!

1
0
2

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
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?