你懂 JavaScript 嗎?#8 強制轉型(Coercion)
15 Oct 2018強制轉型(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
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
圖片來源: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 來運作。
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"
String(..)
vs .toString()
若討論到 String(..)
與 .toString()
要用誰好呢?
若無法肯定是否會收到 null 或 undefined 或數字,那麼使用 String(..)
是比較保險的,不會讓程式報錯而壞掉。
String(null); // "null"
String(undefined); // "undefined"
String(12345); // "12345"
null.toString(); // Uncaught TypeError: Cannot read property 'toString' of null
undefined.toString(); // Uncaught TypeError: Cannot read property 'toString' of undefined
12345.toString(); // Uncaught SyntaxError: Invalid or unexpected token
方法三:使用一元正/負運算子 +
、-
+'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 是簡單的基本型別,則就會轉為 undefined
、null
、true
、false
或數字(非常大或非常小的數字以指數呈現)的字串格式。
如下範例,數字 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)[9] - // 1
[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
當中,陣列 b 由於沒有 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 得到字串 ‘2’ 再轉數字"" == [null]
,true,陣列取 toString 得到空字串,轉數字後得到 00 == '\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
抽象的關係式比較
這裡要來談比較運算子(comparison)的部份,意即 <
(小於)、 >
(大於)、<=
(小於等於)、>=
(大於等於),例如:a > b
表示比較 a 是否大於 b。
其比較規則為
- 若兩個運算元皆為字串時,就直接依照字典字母順序做比較。
- 除了 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
同步發表於2019 鐵人賽。
恭喜讀完「導讀,型別與文法」最困難的部份「強制轉型」!明天見 (*´∀`)~♥
補充
(2019/04/29)
在 Jest 中,toBeTruthy 和 toBeFalsy 會做強制轉型,可看成是使用 ==
做比較;因此若希望能精準測試,則會改用 toBe(value)
,即是使用 ===
做比較,不會經過強制轉型,比較的是原本設定的值。
在 toBeTruthy 中判斷的是強制轉型後的結果,因此只要是經過強制轉型以後可以是 true 的,都會通過測試。
// coerce to true
expect(42).toBeTruthy();
expect([]).toBeTruthy();
expect('false').toBeTruthy();
同樣的,在 toBeFalsy 中判斷的是強制轉型後的結果,因此只要是經過強制轉型以後可以是 false 的,都會通過測試。
// coerce to false
expect(0).toBeFalsy();
expect('').toBeFalsy();
expect(null).toBeFalsy();
expect(undefined).toBeFalsy();
expect(NaN).toBeFalsy();
expect(false).toBeFalsy();
若希望能精準測試,則會改用 toBe(value)
,就不經過強制轉型,即是使用 ===
做比較。
以下 會 通過測試。
expect(true).toBe(true);
expect(false).toBe(false);
以下 不會 通過測試。
// Expected: true, received: 42. Comparing two different types of values. Expected boolean but received number.
expect(42).toBe(true);
// Expected: true, received: []. Comparing two different types of values. Expected boolean but received array.
expect([]).toBe(true);
// Expected: true, received: "false". Comparing two different types of values. Expected boolean but received string.
expect('false').toBe(true);
// Expected: false, received: 0. Comparing two different types of values. Expected boolean but received number.
expect(0).toBe(false);
// Expected: false, received: "". Comparing two different types of values. Expected boolean but received string.
expect('').toBe(false);
// Expected: false, received: null. Comparing two different types of values. Expected boolean but received null.
expect(null).toBe(false);
// Expected: false, received: undefined. Comparing two different types of values. Expected boolean but received undefined.
expect(undefined).toBe(false);
// Expected: false, received: NaN. Comparing two different types of values. Expected boolean but received number.
expect(NaN).toBe(false);