你懂 JavaScript 嗎?#8 強制轉型(Coercion)

你所不知道的 JS

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

很難做決定

本文主要會談到

前言

強制轉型(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 引擎在背後偷偷幫我們做的工作。在這個部份會來探討 ToStringToNumberToBooleanToPrimitive 這幾個抽象的值運算,來看看到底在強制轉型時背地裡做了什麼好事。

偷偷地在幹什麼?

ToString

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

規則簡單說明如下

ToString Conversions

圖片來源:ToString Conversions

JSON 的字串化(JSON Stringification)

順道一提 JSON 的字串化。

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

規則簡單說明如下

JSON.stringify(42) // "42"
JSON.stringify(true) // "true"
JSON.stringify(null) // "null"
JSON.stringify('Hello World') // ""Hello World"",字串會在外面再包一層引號!

範例如下。

若陣列中某個元素的值為非法值則會自動以 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,可為陣列或函式)來自訂過濾機制,決定序列化過程中應該包含哪些屬性。

const someObj = {
  a: 2,
  b: function() {},
}

JSON.stringify(someObj, ['a']); // "{"a":2}"
const someObj = {
  a: 2,
  b: function() {},
}

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

// "{"a":2}"

ToNumber

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

規則簡單說明如下

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 的值有

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

而除了以上的值之外,都會被轉為 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 規格

規則簡單說明如下

明確的強制轉型(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

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

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

這裡有兩件事情要注意…

Math.floor(-29.8) // -30
~~-29.8 // -29
-29.8 | 0 // -29

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

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

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

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

說明如下

注意,前面提到的 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

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

轉換規則可參考前面提到的 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)會將第一個運算元做布林測試或強制轉型為布林以便測試。

因此可應用於

範例如下。

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 的強制轉型規則如下

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

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

規則

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

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

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

可參考規格

範例 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

有幾個例外需要注意…

範例如下。

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'

答案揭曉。

說明

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

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

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

也許世界上大多數的開發者都詬病 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 都不是字串且陣列沒有 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

這裡要注意的是…

回顧

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

References


同步發表於2019 鐵人賽


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

乾杯


comments powered by Disqus