iT邦幫忙

2019 iT 邦幫忙鐵人賽

DAY 8
2

你所不知道的 JS

強制轉型(coercion)到底是一個有用的功能,還是設計上的缺陷呢?

很難做決定

...

...

本文主要會談到

  • 強制轉型(coercion)分為兩種,分別是「明確的」強制轉型(explicit coercion)和「隱含的」強制轉型(implicit coercion),只要是程式碼中刻意寫出來的型別轉換的動作,就是明確的強制轉型;反之,在程式碼中沒有明確指出要轉換型別卻轉型的,就是隱含的強制轉型。
  • 明確的強制轉型規則與範例說明。
  • 隱含的強制轉型規則與範例說明。
  • Symbol 的強制轉型的規則與範例說明。
  • 隱含的強制轉型的心酸血淚?各種令人崩潰的範例。
  • 抽象的關係式比較。

前言

強制轉型(coercion)分為兩種,分別是「明確的」強制轉型(explicit coercion)和「隱含的」強制轉型(implicit coercion),只要是程式碼中刻意寫出來的型別轉換的動作,就是明確的強制轉型;反之,在程式碼中沒有明確指出要轉換型別卻轉型的,就是隱含的強制轉型。

範例如下,b 的值由運算式 String(a) 而來,這裡表明會將 a 強制轉為字串,因此是明確的強制轉型;而 c 的值由運算式 a + '' 而來,當兩值的型別不同且其中一方是字串時,+ 所代表的是字串運算子,要將兩字串做串接,而會將數字強制轉型為字串,並連接兩個字串,因此是隱含的強制轉型,稍後會再詳述。

var a = 42;
var b = String(a); // 明確的強制轉型
var c = a + ''; // 隱含的強制轉型

b // '42'
c // '42'

注意,無論是明確或隱含,強制轉型的結果會是基本型別值,例如:數字、布林或字串。

抽象的值運算

「抽象的值運算」指的是「內部限定的運算」,意即這是 JavaScript 引擎在背後偷偷幫我們做的工作。在這個部份會來探討 ToString、ToNumber、ToBoolean 和 ToPrimitive 這幾個抽象的值運算,來看看到底在強制轉型時背地裡做了什麼好事。

偷偷地在幹什麼?

ToString

任何非字串的值被強制轉型為字串時,會遵循 ES5 的規格中的 ToString 來運作。

規則簡單說明如下

  • undefined -> 'undefined'
  • null -> 'null'
  • boolean 的 true -> 'true',false -> 'false'
  • 在數字方面,非常大或非常小的數字以指數呈現,例如:'1.23e21'
  • 物件
    • 若有定義其 toString 方法,則會以它自己的 toString 方法所產生的結果為優先,例如,陣列有自己定義的 toString 方法,因此 [1,2,3].toString() 會得到 "1,2,3"
    • 若沒有定義 toString 方法,則回傳內部的屬性 [[Class]] ,這是一個用來標記這個值是屬於物件的哪個子分類的標籤,例如:({}).toString() 會得到 [object Object]

ToString Conversions

圖片來源:ToString Conversions

JSON 的字串化(JSON Stringification)

順道一提 JSON 的字串化。

JSON 的字串化 JSON.stringify 將值序列化(serialize)為 JSON 字串,這個轉為 JSON 字串的過程與 ToString 規則有關,但並不等於強制轉型。

規則簡單說明如下

  • 若為簡單值,即字串、數字、布林、null,則規則與 ToString 相同。這些能轉為 JSON 字串的值稱為是「JSON-safe」的值,意即只要對 JSON 來說是安全的(safe),就都能轉為 JSON 字串。
JSON.stringify(42) // "42"
JSON.stringify(true) // "true"
JSON.stringify(null) // "null"
JSON.stringify('Hello World') // ""Hello World"",字串會在外面再包一層引號!
  • 無法轉為 JSON 字串的非法值有 undefined、function、symbol、具有循環參考(circular reference)的物件,由於它們無法轉為 JSON 字串,因此 JSON.stringify 會自動忽略這些非法值或丟出錯誤。又,若陣列中某個元素的值為非法值則會自動以 null 取代;若物件中的其中一個屬性為非法值,則會排除這個屬性。
  • 若為物件且有定義 toJSON 方法則會優先呼叫此方法,並依此方法之回傳值作為序列化的結果。因此,若試圖 JSON 字串化一個含有非法值的物件,應定義其 toJSON 方法以回傳適當的 JSON-safe 的值。

範例如下。

若陣列中某個元素的值為非法值則會自動以 null 取代;若物件中的其中一個屬性為非法值,則會排除這個屬性。

JSON.stringify(undefined) // undefined,忽略非法值
JSON.stringify(function() {}) // undefined,忽略非法值
JSON.stringify(Symbol()) // undefined,忽略非法值
JSON.stringify([1, 2, 3, undefined]) // "[1,2,3,null]",非法值以 null 取代
JSON.stringify({ a: 2, b: function() {}}) // "{"a":2}",忽略非法屬性

具有循環參考的物件,丟出錯誤。

const a = { someProperty: 'Jack' };
const b = { anotherProperty: a };
a.b = b;

JSON.stringify(a) // Uncaught TypeError: Converting circular structure to JSON
JSON.stringify(b) // Uncaught TypeError: Converting circular structure to JSON

針對含有非法值的物件或具有循環參考的物件,解法是定義其 toJSON 方法以回傳 JSON-safe 的值。

範例如下,物件 someObj 含有非法的屬性 b 會導致轉 JSON 字串時被忽略,因此定義其 toJSON 方法只要序列化合法的 a 屬性即可。

const someObj = {
  a: 2,
  b: function() {}, // 非法!
  toJSON: function() {
    return {
      a: 2, // 序列化過程只包含 a 屬性
    }
  },
}

JSON.stringify(someObj); // "{"a":2}"

再看一個範例,對於「具有循環參考的物件」該怎麼處理呢?如下,a 和 b 是具有循環參考的物件,在先前的例子中 JSON.stringify(a)JSON.stringify(b) 會丟出錯誤「Uncaught TypeError: Converting circular structure to JSON」,因此分別定義其 toJSON 方法,這裡的序列化過程只包含 prompt 屬性且其值為字串 'Hello World'

const a = {
  someProperty: 'Jack',
  toJSON: function() {
    return {
      prompt: 'Hello World'
    }
  },
};

const b = {
  anotherProperty: a ,
  toJSON: function() {
    return {
      prompt: 'Hello World'
    }
  },
};

a.b = b;

// 序列化成功!不會被報錯了!
JSON.stringify(a) // "{"prompt":"Hello World"}"
JSON.stringify(b) // "{"prompt":"Hello World"}"

除了 toJSON 外,JSON.stringify 也可傳入第二個選擇性參數「取代器」(replacer,可為陣列或函式)來自訂過濾機制,決定序列化過程中應該包含哪些屬性。

  • 取代器為陣列時,陣列內的元素為指定要包含的屬性名稱。如下,指定序列化過程中只需要包含 a 屬性。
const someObj = {
  a: 2,
  b: function() {},
}

JSON.stringify(someObj, ['a']); // "{"a":2}"
  • 取代器為函數時,函式是用來運算要回傳以做序列化的屬性的值。如下,指定除了 b 以外的屬性都要做序列化。
const someObj = {
  a: 2,
  b: function() {},
}

JSON.stringify(someObj, function(key, value) {
  if (key !== 'b') {
    return value
  }
});

// "{"a":2}"

ToNumber

若需要將非數字值當成數字來操作,像是做數學運算,就會遵循 ES5 的規格中的 ToNumber 來運作。

規則簡單說明如下

  • undefined -> NaN。
  • null -> +0 即是 0。
  • boolean 的 true -> 1,false -> +0 即是 0。
  • string -> 數字或 NaN。
  • object
    • 若有定義其 valueOf 方法,則會優先使用 valueOf 取得其基本型別值。
    • 若沒有定義 valueOf 方法,則會改用 toString 方法取得其基本型別值,再用 ToNumber 轉為數字。在這裡先簡化為 Number(..) 會來處理這一連串的流程即可。
    • 注意,以 Object.create(null) 建立的 null 沒有 valueOftoString 方法,因此在試圖轉為基本型別值的時候會出錯,丟出 TypeError。

ToNumber Conversions

圖片來源:ToNumber Conversions

範例如下。

Number(undefined) // NaN
Number(null) // 0
Number(true) // 1
Number(false) // 0
Number('12345') // 12345
Number('Hello World') // NaN
Number({ name: 'Jack' }}) // NaN

const a = {
  name: 'Apple',
  valueOf: function() {
    return '999'
  }
}

Number(a) // 999

ToBoolean

讓我們複習一下 Truthy 與 Falsy 的概念,這會遵循 ES5 的規格中的 ToBoolean 來運作。

ToBoolean Conversions

圖片來源:ToBoolean Conversions

Falsy 值

在 JavaScript 中會被轉為 false 的值有

  • "" 空字串
  • 0, -0, NaN
  • null
  • undefined
  • false

我們只要熟記這幾個值就可以了! d(d'∀')

而除了以上的值之外,都會被轉為 true,舉例如下

  • 'Hello World' 非空字串
  • 42 非零的有效數字
  • [], [1, 2, 3] 陣列,不管是不是空的
  • {}, { name: 'Jack' } 物件,不管是不是空的
  • function foo() {} 函式
  • true

Falsy 物件

當使用包裹器物件來建立字串、數字或布林值時,由於包了一層物件,因此就算其底層的基型值是會被轉為 false 的值,它根本上都還是個物件,而只要是物件(即使是空物件),就會被轉為 true。

const a = new String('');
const b = new Number(0);
const c = new Boolean(false);

!!a // true
!!b // true
!!c // true

Truthy 值

再次強調,只要不是前面列舉為會轉為 false 的值,都會被轉為 true。

ToPrimitive

詳細狀況可見 ES5 規格

規則簡單說明如下

  • undefined -> undefined(基本型別值,不轉換)。
  • null -> null(基本型別值,不轉換)。
  • boolean -> boolean(基本型別值,不轉換)。
  • number -> number(基本型別值,不轉換)。
  • string -> string(基本型別值,不轉換)。
  • object:使用 [[DefaultValue]] 內部方法,依照傳入的參數來決定要使用 toString 或 valueOf 取得基本型別值,看參考規格

明確的強制轉型(Explicit Coercion)

「明確的強制轉型」是指程式碼中刻意寫出來的明顯的型別轉換的動作。

明確的 Strings <--> Numbers

字串與數字間的明確的強制轉換。

方法一:使用內建函式 String(..)Number(..)

String(123) // "123"
Number('123') // 123

注意,這裡的 String(..) 是直接調用 .toString 來轉字串,與 + 字串運算子經過 ToPrimitive 的運作-由於傳入 [[DefaultValue]] 演算法的參數是 number,因此先使用 valueOf 取得基型值,然後再用 toString 轉為字串,兩種方法是完全不同的。

const a = {
  toString: function() { return 54321 },
};

const b = {
  valueOf: function() { return 12345 },
};

String(a) // "54321"
b + '' // "12345"

方法二:使用物件原型的方法 .toString()

(123).toString() // "123"

方法三:使用一元正/負運算子 +-

+('123') // 123
-('-123') // 123

這個方法有個缺點,就是很容易造成各種語意上的誤會,像是與遞增(++)和遞減(--)或與二元運算子的數學運算「加」(+)和「減」(-)混淆。

較常使用一元正和負運算子 +- 的時機是將 日期轉為數字,也就是取得 1970 年 1 月 1 日 00:00:00 UTC 到目前為止的毫秒數,或稱 UNIX 時間戳記、時戳值 timestamp。

const timestamp = +new Date();
timestamp // 1539236301262

經由強制轉型取得時戳值並不是很好的方法,建議改用 Date.now().getTime() 會是更理想的作法,可讀性更高。

方法四:使用一元位元否定運算子 ~

位元否定運算子(bitwise not)的功能是進行二進位的補數(公式為 ~x 得到 -(x+1),例如:~42 得到 -43),它會先將值經由 ToNumber 轉為數字,再經由 ToInt32 轉為 32 位元有號整數,最後再逐位元的否定,很類似 ! 強制將值轉為布林並反轉其真偽的運作方式。

範例如下,~ 接受 indexOf 的回傳值並作轉換,對於「找不到」的 -1 會轉為 0,做條件判斷時會再轉為 false,其他因找而回傳的索引值(例如:0、1、2...)經否定再轉布林時都會是 true,這樣的寫法有助於提高可讀性。

const str = 'Hello World';

function find(target) {
  const result = str.indexOf(target);

  if (~result) {
    console.log(`找到了,索引值原本是 ${result},被轉為 ${~result}`);
  } else {
    console.log(`找不到,回傳結果原本是 ${result},被轉為 ${~result}`);
  }
}

find('llo'); // 找到了,索引值原本是 2,被轉為 -3
find('abc') // 找不到,回傳結果原本是 -1,被轉為 0

同場加映:浮點數轉為整數

使用 ~~ 將浮點數轉為整數,其運作方式為反轉兩次而得到截斷小數的結果,類似 !! 的真偽值雙次否定。

這裡有兩件事情要注意...

  • 使用 x | 0 也可以得到同樣的效果,差別只在於 ~~ 運算子優先權較高,遇到四則運算時不用包小括號。
  • Math.floor(..) 的結果不同。如下,Math.floor(-29.8) 得到 -30,而 ~~-29.8 得到 --29。
Math.floor(-29.8) // -30
~~-29.8 // -29
-29.8 | 0 // -29

明確的剖析數值字串(Numeric String)

除了使用 Numer(..) 將值強制轉型為數字外,還可用 parseInt(..) 剖析而得到數字。parseInt(..) 的用途是將字串剖析為數字,它接受一個字串作為輸入,若輸入非字串的值則會使用 ToString 強制轉為字串。

Numer(..)parseInt(..) 的差異在於

  • parseInt(..) 可容忍(或想像成忽略)非數值的字元,在由左至右掃描值的過程中,遇到非數值字元就停下來(忽略後續部份),只轉換到停下來之前所得到的數值。除非整個字串都是非數值,否則不會得到 NaN。而 Numer(..) 則是只要傳入的字串不是可轉成數值的,就會得到 NaN。
  • 「指定基底」是個必要的好習慣,parseInt(..) 若沒有輸入第二個參數來指定基數,就會以第一個參數的頭幾個字元決定基數為何,例如:開頭若為 0X 就會轉為十六進位的數字。因此,使用 parseInt(..) 最好要傳入基底以維持結果的正確性,例如:parseInt('12345', 10)
var a = '123';
var b = '123px';

Number(a) // 123
parseInt(a) // 123

Number(b) // NaN
parseInt(b) // 123
parseInt('HelloWorld') // NaN

明確的 * --> Boolean

探討任何值強制轉為布林的情況。

方法一:使用內建函式 Boolean(..)

使用 Boolean(..) 來執行 ToBoolean 的轉換工作。

Boolean('Hello World') // true
Boolean([]) // true
Boolean({}) // true
Boolean(null) // false
Boolean(undefined) // false
Boolean(NaN) // false
Boolean(0) // false
Boolean('') // false

方法二:否定運算子 !

雙次否定即可強制將值轉為布林。

!!'Hello World' // true
!![] // true
!!{} // true
!!null // false
!!undefined // false
!!NaN // false
!!0 // false
!!'' // false

隱含的強制轉型(Implicit Coercion)

「隱含的強制轉型」是指在程式碼中沒有明確指出要轉換型別但卻轉型的動作。

隱含的 Strings <--> Numbers

Case 1 Strings --> Numbers:+ 運算子是數字的相加,還是字串的串接?

若兩運算元的型別不同,當其中一方是字串時,+ 所代表的就是字串運算子,而會將另外一個運算元強制轉型為字串,並連接兩個字串。這裡提到的「另外一個運算元」就先稱它為 b 好了,若 b 是物件則會呼叫 ToPrimitive 做處理-由於傳入 [[DefaultValue]] 演算法的參數是 number,因此先使用 valueOf 取得基型值,然後再用 toString 轉為字串;若 b 是簡單的基本型別,則就會轉為 undefinednulltruefalse或數字(非常大或非常小的數字以指數呈現)的字串格式。

如下範例,數字 1 會轉為字串 '1',而陣列 c 和 d 分別會使用 toString 轉為 '1, 2''3, 4'

const a = '1';
const b = 1;
const c = [1, 2];
const d = [3, 4];

a + 1 // "11"
b + 1 // 2
b + '' // "1"
c + d // "1,23,4"

再看兩個著名的例子:[] + {}{} + []

先猜猜看結果是什麼?

皆為 [object Object]

...

...

...

公佈答案摟!

[] + {} // "[object Object]"
{} + [] // 0

說明如下

  • [] + {} 中,[] 會轉為空字串,而 {} 會轉為字串 "[object Object]"
  • {} + [] 中,{} 被當成空區塊而無作用,+[] 被當成強制轉型為數字 Number([]) (由於陣列是物件,中間會先使用 toString 轉成字空串,導致變成 Number(''))而得到 0。

注意,前面提到的 String(..) 是直接調用 .toString 來轉字串,與 + 字串運算子經過 ToPrimitive 的運作-由於傳入 [[DefaultValue]] 演算法的參數是 number,因此先使用 valueOf 取得基型值,然後再用 toString 轉為字串,兩種方法是完全不同的。

const a = {
  toString: function() { return 54321 },
};

const b = {
  valueOf: function() { return 12345 },
};

String(a) // "54321"
b + '' // "12345"

Case 2:使用數學運算子將字串轉為數字

const a = '1';

a + 1 // "11"
a - 0 // 1
a * 1 // 1
a / 1 // 1
[9] - [7] // 2

轉換規則可參考前面提到的 ToNumber。

隱含的 * --> Boolean

在什麼狀況下會隱含地將值強制轉為布林呢?

  • if 述句中的條件判斷(或稱測試運算式 test expression)
  • for 述句中的條件判斷,意即測試運算式的第二個子句
  • while 與 do...while 中檢測條件是否成立的測試運算式
  • 三元運算式 條件 ? 值1 : 值2 中的條件運算,意即測試運算式的第一個子句
  • 邏輯運算子的 ||(or) 和 &&(and)左手邊的運算元會被當成測試運算式

轉換規則可參考前面提到的 ToBoolean。

範例如下。

var a = 12345;
var b = 'Hello World';
var c; // undefined
var d = null;

if (a) { // true
  console.log('a 是真的'); // a 是真的
}

while (c) { // false
  console.log('從來沒跑過');
}

c = d ? a : b;
c // "Hello World"

if ((a && d) || c) { // true
  console.log('結果是真的'); // 結果是真的
}

運算子 ||&&

邏輯運算子的 ||(or) 和 &&(and) 其實應該要稱呼為「(運算元的)選擇器運算子」(operand selector operator),這是因為它們並不是產生邏輯運算值 true 或 false,而是在兩個運算元當中「選擇」其中一個運算元的值作為結果。

規則為,||(or) 和 &&(and)會將第一個運算元做布林測試或強制轉型為布林以便測試。

  • ||(or)來說,若結果為 true,則取第一個運算元為結果;若結果為 false,則取第二個運算元為結果。
  • &&(and)來說,若結果為 true,則取第二個運算元為結果;若結果為 false,則取第一個運算元為結果。

因此可應用於

  • ||(or) 可用來設定變數的初始值。
  • &&(and)可用來執行「若特定條件成立,才做某件事情」,功能近似 if 述句。

範例如下。

const a = 'Hello World!'
const b = 777;
const c = null;

a && c // 測試 a 為 true,選 c,結果是 null
a && b // 測試 a 為 true,選 b,結果是 777
undefined && b // 測試 undefined 為 false,選 undefined,結果是 undefined
a || b // 測試 a 為 true,選 a,結果是 "Hello World!"
c || 'foo' // 測試 c 為 false,選 'foo',結果是 "foo"

若 flag 條件成立(true),就執行函式 foo,之後會再提到短路的議題。

const flag = true;

function foo() {
  console.log('try me');
}

flag && foo(); // try me

Symbol 的強制轉型

symbol 的強制轉型規則如下

  • 在轉為字串方面,將 symbol 明確的強制轉型是允許的,但隱含的強制轉型是被禁止的,並且會丟出錯誤訊息。
var s1 = Symbol('Hello World');
String(s1); // "Symbol(Hello World)"

var s2 = Symbol(' World Hello');
s2 + ''; // TypeError: Cannot convert a Symbol value to a string
  • 在轉為數字方面,無論是明確或隱含都是禁止的,並且會丟出錯誤訊息。
const n1 = Symbol(777);
Number(s1); // TypeError: Cannot convert a Symbol value to a number

const n2 = Symbol(999);
+n2; // TypeError: Cannot convert a Symbol value to a number
  • 在轉為布林方面,無論是明確或隱含都是可以的,並且結果都是 true。
const b1 = Symbol(true);
const b2 = Symbol(false);
Boolean(b1); // true
Boolean(b2); // true

const b3 = Symbol(true);
const b4 = Symbol(false);

if (b3) {
  console.log('b3 是真的');
}

if (b4) {
  console.log('b4 是真的');
}

// b3 是真的
// b4 是真的

寬鬆相等(Loose Equals) vs 嚴格相等(Strict Equals)

關於相等性的運算子有四個「==」(寬鬆相等性 loose equality)、「===」(嚴格相等性 strict equality)、「!=」(寬鬆不相等 loose not-equality)和「!==」(嚴格不相等 strict not-equality)。寬鬆與嚴格的差異在於檢查值相等時,是否會做 強制轉型== 會做強制轉型,而 === 不會

const a = '100';
const b = 100;

a == b // true,強制轉型,將字串 '100' 轉為數字 100
a === b // false

這裡要說明一下,===== 其實都會做型別的檢查,只是當面對型別不同時的反應是不一樣的而已。

規則

如果型別相同,就會以同一性做比較,但要注意

  • NaN 不等於自己(其實,NaN 不大於、不小於也不等於任何數字,所以當然也不等於它自己)。
  • +0、-0 彼此相等。
  • 物件(含 function 和 array)的相等是比較參考(reference),若參考相等才是相等。

如果型別不同,則會先將其中一個或兩個值先做強制轉型(可遞迴),再用型別相同的同一性做比較。

  • 字串轉為數字。
  • 布林轉為數字。
  • null 與 undefined 在寬鬆相等下會強制轉型為彼此,因此是相等的,但不等於其他值。
  • 若比較的對象是物件,使用 valueOf()(優先)或 toString() 將物件取得基本型別的值,再做比較。

!=!== 就是先分別做 ===== 再取否定(!)即可。

可參考規格

範例 1

const a = '123';
const b = 123;

a === b // 答案是?
a == b // 答案是?

...

...

...

答案揭曉。

a === b // false
a == b // true

a == b 當中,字串 a 優先轉為數字後,此時就可比較 123 == 123,因此是相等的(true)。

範例 2

const a = true;
const b = 123;

a === b // 答案是?
a == b // 答案是?

...

...

...

答案揭曉。

a === b // false
a == b // false

a == b 當中,布林 a 優先轉為數字(Numer(true) 得到 1)後,此時就可比較 1 == 123,因此是不相等的(false)。

範例 3

const a = null;
const b = 123;

a === b // 答案是?
a == b // 答案是?

...

...

...

答案揭曉。

a === b // false
a == b // false

a == b 當中其實比較的是 null == 123,因此是不相等的(false)。

範例 4

const a = '1,2,3';
const b = [1,2,3];

a === b // 答案是?
a == b // 答案是?

...

...

...

答案揭曉。

a === b // false
a == b // true

a == b 當中,陣列 a 由於沒有 valueOf(),只好使用 toString() 取得其基型值而得到字串 '1,2,3',此時就可比較 '1,2,3' == '1,2,3',因此是相等的(true)。

範例 5

有幾個例外需要注意...

  • null 與 undefined 沒有其物件包裹形式,因此 Object(null)Object(undefiend) 等同於 Object(),也就是空物件 {}
  • Number(NaN) 得到 NaN,且 NaN 不等於自己。

範例如下。

var a = null;
var b = Object(a); // 等同於 Object()
a == b; // false

var c = undefined;
var d = Object(c); // 等同於 Object()
c == d; // false

var e = NaN;
var f = Object(e); // 等同於 new Number(e)
e == f;

邊緣情況

這部份來提一些邊緣(少見但驚人)的狀況。

避免修改原型的 valueOf(..)

經由原生的內建函式所建立的值,由於是物件型態,在強制轉型時會經過 ToPrimitive
的過程,也就是使用 valueOf(..)(優先)或 toString(..) 將物件取得基本型別的值,才會做後續比較。因此,若修改了原型中的 toValue(..) 方法,則可能會導致比較時出現「不可思議」的結果。

Number.prototype.valueOf = function() {
  return 3;
};

new Number(2) == 3; // true

一些瘋狂的範例

以下會得到什麼結果呢?請小心服用。

"0" == false;
false == 0;
false == "";
false == [];
false == {};

"" == 0;
"" == [];
"" == {};

0 == [];
0 == {};

[] == ![]

2 == [2]
"" == [null]
0 == '\n'

...

...

...

答案揭曉。

...

...

...

說明

  • "0" == false;,true,字串轉數字、布林再轉數字
  • false == 0;,true,布林轉數字
  • false == "";,true,字串轉數字、布林再轉數字
  • false == [];,true,布林轉數字、陣列取 toString 得到空字串再轉數字
  • false == {};, false,布林轉數字、物件取 valueOf 得到空物件
  • "" == 0;,true,字串轉數字
  • "" == [];,true,字串轉數字、陣列取 toString 得到空字串再轉數字
  • "" == {};,false,字串轉數字、物件取 valueOf 得到空物件
  • 0 == [];,true,陣列取 toString 得到空字串再轉數字
  • 0 == {};,false,物件取 valueOf 得到空物件
  • [] == ![],true,左手邊取 valueOf 得到空字串再轉數字得到 0,右手邊被 ! 強制轉為布林得到 false 再轉為數字
  • 2 == [2],true,陣列取 toString 得到空字串再轉數字
  • "" == [null],true,陣列取 toString 得到空字串,轉數字後得到 0
  • 0 == '\n',true,'\n' 意即 ' '(空白),轉數字後得到 0

總結:如何安全地使用隱含的強制轉型?

若允許強制轉型,但又希望能避免「難以預料」的強制轉型(上例),這裡有一些建議...

  • 若有一邊可能會出現 true 或 false,就不要用 ==,改用 ===
  • 若有一邊可能會出現 []、空字串 "" 或 0 ,就不要用 ==,改用 ===

以下是一定很安全的強制轉型,使用 == 即可,不需要用 ===...

  • 比較 null 與 undefined 的強制轉型是安全的,因為它們互轉為彼此,一定相等。
  • typeof x 得到的是固定的七種字串值(例如:'string''number''boolean''undefined''function''object''symbol'),因此做 typeof x == '指定值' 一定是安全的。

...

...

也許世界上大多數的開發者都詬病 JavaScript 中「隱含的強制轉型」的這部份,覺得這是個壞東西,但也許它其實是減少冗贅、反覆套用和非必要實作細節的好方法,而前提是,必須要能清楚了解強制轉型的規則。

...

...

JavaScript Equality Table

下圖為 JavaScript 中的相等性,此圖視覺化了所有的比較項目。

JavaScript Equality Table

圖片來源:JavaScript Equality Table

抽象的關係式比較

這裡要來談比較運算子(comparison)的部份,意即 <(小於)、 >(大於)、<=(小於等於)、>=(大於等於),例如:a > b 表示比較 a 是否大於 b。

其比較規則為

  1. 若兩個運算元皆為字串時,就直接依照字典字母順序做比較。
  2. 除了 1 之外的狀況都適用
  • 先使用 ToPrimitive 做強制轉型-先使用 valueOf 取得基型值,然後再用 toString 方法轉為字串。
  • 承上,若有任一值轉型後的結果不是字串,就使用 ToNumber 的規則轉為數字,來做數字上的比較。

注意

  • 由於規格只定義了 a < b 的演算法,因此 a > b 會以 b < a 的方式做比較。
  • 由於沒有「嚴格關係比較」,所以一定會遇到強制轉型的狀況。

範例如下,由於 a 和 b 都不是字串且陣列沒有 valueOf,因此先用 toString 取得基型值,得到 a 為 '12'、b 為 '13',型別都是字串,接著做字母順序的比較。

const a = [12];
const b = ['13'];

a < b // true,'12' < '13'
a > b // false,其實是比較 b < a,即 '13' < '12'

範例如下,由於 a 和 b 都不是字串,因此先用 valueOf 取得基型值(只取到原來的物件),再用 toString 而得到兩個字串 [object Object],因此比較 [object Object][object Object]。又,a == b 比較的是兩物件存值的所在的記憶體位置,也就是參考(reference)。

const a = { b: 12 };
const b = { b: 13 };

a < b // false,'[object Object]' < '[object Object]'
a > b // false,其實是比較 b < a,即 '[object Object]' < '[object Object]'
a == b // false,其實是比較兩物件的 reference

a >= b // true
a <= b // true

這裡要注意的是...

  • a <= b 其實是 !(b > a),因此 !false 得到 true。
  • a >= b 其實是 b <= a 也就是 !(a > b) 等同於 !false 得到 true。

回顧

看完這篇文章,我們到底有什麼收穫呢?藉由本文可以理解到...

  • 強制轉型(coercion)分為兩種,分別是「明確的」強制轉型(explicit coercion)和「隱含的」強制轉型(implicit coercion),只要是程式碼中刻意寫出來的明顯的型別轉換的動作,就是明確的強制轉型;反之,在程式碼中沒有明確指出要轉換型別但卻轉型的,就是隱含的強制轉型。
  • 明確的強制轉型規則與範例說明。
  • 隱含的強制轉型規則與範例說明。
  • Symbol 的強制轉型的規則與範例說明。
  • 隱含的強制轉型的心酸血淚?各種令人崩潰的範例。
  • 抽象的關係式比較。

References


同步發表於部落格


恭喜讀完「導讀,型別與文法」最困難的部份「強制轉型」!明天見 (*´∀`)~♥

乾杯


上一篇
你懂 JavaScript 嗎?#7 原生功能(Natives)
下一篇
你懂 JavaScript 嗎?#9 文法(Grammar)
系列文
你懂 JavaScript 嗎?30

1 則留言

0
SunAllen
iT邦高手 1 級 ‧ 2018-11-01 19:09:56

神作再加一!

感謝大大!

/images/emoticon/emoticon33.gif今天眼花了,明天繼續看...

感謝!

我要留言

立即登入留言