Type Coercion Rules in JavaScript
由於最近有一些前端開發的需求,不得已(?)只好來好好學一下 JavaScript。其中,JavaScript 略顯隱晦的隱式轉型(type coercion)規則容易使得程式算出一些難以預期的結果,因此特地寫下這篇筆記將這些規則整理出來。
在繼續看下去之前,可以先玩玩看這個 Type Coercion Challenge。假如你對其中的結果感到懷疑,並且對它的原理感興趣的話,這篇筆記或許適合你繼續看下去(:P)。
這篇的內容基本上是參考 ECMAScript® Language Specification 整理出來的。為了方便起見,後面都以 §a.b.c 表示在規格中對應的 section number。想要仔細求證的人可以自己去翻翻規格書。
Standard Types
先來看看 JavaScript 實際上到底有哪些 types 吧。根據 ES(ECMAScript)5.1 的標準 §8,總共有六種:
- Undefined
- Null
- Boolean
- Number
- String
- Object
上面的六種 types 中,只有 Object 擁有 properties。JavaScript 中的 functions(Function
)、arrays(Array
)、regular expressions(RegExp
)實際上都是 objects。其餘的五種 types 則被稱為 primitive type。其中 Boolean、Number 與 String 這三種 primitive types 都有對應的 Object subtype,分別是 Boolean
、Number
與 String
(注意兩者的字體差別)。
除此之外,ES6 定義了第七種 type:Symbol(也是 primitive type)。不過這篇我們暫且不提它。
Conversion Abstract Operations
另外,ES 規格 §9 定義了一組 conversion abstract operations 來描述「將某個值轉為特定型別」的行為。這些 operations 並不是語言的一部分,只是用來輔助定義型別轉換規則的。
ToPrimitive(input[, PreferredType])
(§9.1)
ToPrimitive()
會試圖把 input
轉換成 primitive value。其中的 optional parameter PreferredType
可以是 Number 或 String,代表「偏好」轉為 Number 或 String。在不給定 PreferredType
的情況下,除了 Date
object 預設為 String 之外,其它值一律以 Number 作為預設值。
於是,假設 PreferredType
為 Number,轉換的規則是這樣的:
- 假如
input
為 primitive value,直接傳回作為結果。 - 否則,呼叫
input.valueOf()
。假如結果是 primitive value,就傳回作為結果。 - 否則,呼叫
input.toString()
。假如結果是 primitive value,就傳回作為結果。 - 若是
input.toString()
跟input.valueOf()
的回傳結果都不是 primitive value,就丟出TypeError
。
PreferredType
為 String 的情況,就是把上面的 2 跟 3 對調。也就是,先試 input.toString()
,再試 input.valueOf()
。
可以看到在上面的操作中,轉型實際上是靠 valueOf()
與 toString()
來達成的。由於在 ES 規格中,Object.prototype
上明確定義了這兩個 methods(§15.2.4.2、§15.2.4.4),因此 Object「幾乎」都擁有這兩個 properties(為什麼說「幾乎」呢?因為像 Object.create(null)
〔§15.2.3.5〕這種 prototype 為 null
的 object 就沒有繼承到這兩個 properties)。
其中,Object
「預設」的 valueOf()
實作(也就是 Object.prototype.valueOf()
)實際上是回傳自己:
var obj = {};
console.log(obj === obj.valueOf()); // true
由於這個實作回傳的結果並非 primitive value,因此若是沒有覆寫掉 valueOf()
,對 object 執行 ToPrimitive()
operation 實際上得到的都會是 toString()
的結果。
ToBoolean(input)
(§9.2)
ToBoolean(input)
會將 input
轉成 Boolean。ES 標準中的 falsy values 包含:
false
undefined
null
NaN
+0
-0
""
對這些值呼叫 ToBoolean()
的結果都會得到 false
,除此之外的值得到的結果都會是 true
。
要注意的是,所有的 objects 都是 truthy values。因此
function true_or_false(val) {
if (val) { console.log(true); }
else { console.log(false); }
}
true_or_false(false); // false
true_or_false({}); // true
true_or_false([]); // true
true_or_false(new Boolean(false)); // true
empty object、empty array 跟「值為 false
」的 Boolean
object 實際上都是 truthy value。
ToNumber(input)
(§9.3)
ToNumber(input)
則是將 input
轉成 Number(IEEE 754 double-precision binary floating-point format)。轉換規則如下:
- Undefined:轉成
NaN
。 - Null:轉成
+0
。 - Boolean:
true
轉為1
,false
轉為+0
。 - Number:不需轉換。
- String:將字串 parse 成數字(像是把
"123.45"
轉成123.45
,詳細的規則請參考 §9.3.1)。 - Object:先透過
ToPrimitive()
轉成 primitive value,再根據上面的規則轉成 Number。
其它的轉數字系列還有 ToInteger()
(§9.4)、ToInt32()
(§9.5)、ToUint32()
(§9.6)跟 ToUint16()
(§9.7)這四個 operations。這些 operators 都是先利用 ToNumber()
將值轉成 Number 之後再進行後續處理。對於幾個特殊的值,這幾個 operations 的回傳值如下:
NaN
:四個 operations 都轉成+0
。+0
、-0
、Infinity
、-Infinity
:ToInteger()
維持原值不做轉換。其它三個 operations 一律轉成+0
。
對於其它的值,就是只取整數部分(譬如 3.14
會變成 3
、-2.82
會變成 -2
),再把值切到適當的範圍中(譬如說,ToInt32()
的範圍是 -231 到 231 - 1、ToInteger()
則沒有範圍限制)。這個部分跟其它語言差不多,就不細講了。詳細的作法請參考 §9.4 - §9.7。
ToString(input)
(§9.8)
接著,ToString()
會將 input
轉成 String。轉換規則如下:
- Undefined:轉成
"undefined"
。 - Null:轉成
"null"
。 - Boolean:
true
轉為"true"
,false
轉為"false"
。 - Number:將數字轉成字串(像是把
1.313
轉成"1.313"
,詳細的規則請參考 §9.8.1)。 - String:不需轉換。
- Object:先透過
ToPrimitive()
轉成 primitive value,再根據上面的規則轉成 String。
ToObject(input)
(§9.9)
最後,ToObject()
會將 input
轉成 Object:
- 如果
input
是null
或undefined
會直接丟出TypeError
。 - 如果
input
是其它 primitive types(Boolean、Number、String)會轉成對應的 Object subtypes(Boolean
、Number
、String
)。 - 如果
input
本來就是 Object,則不做任何轉換。
Type Coercion Rules
前面講了這麼多,終於要進主題了。這裡我採用與 ES 規格類似的條列法,希望不會太難懂。
Addition Operator(+
)(§11.6.1)
- 先將 operands 透過
ToPrimitive()
轉為 primitive value。 - 如果任何一個 operand 是 String,則用
ToString()
將 operands 都轉為 String 做 string concatenation。 - 否則,都使用
ToNumber()
轉成 Number 做 numeric addition。
要注意的是,只有加法運算會套用這種轉型規則。減法、乘法、除法都是直接用 ToNumber()
把 operands 轉成 Number 做 arithmetic operation。這是因為 +
在 JavaScript 中可用來執行 numeric addition 或是 string concatenation 兩種用途的緣故。
Relational Operators(<
、>
、<=
、>=
)(§11.8.5)
- 先將 operands 透過
ToPrimitive(x, Number)
轉為 primitive value(x
代表 operand,因此此時必定是先試valueOf()
再試toString()
)。 - 假如 operands 都是 String,就做 string comparison。
- 否則,都使用
ToNumber()
轉成 Number 做 numeric comparison。
Equality Operators(==
、!=
、===
、!==
)(§11.9.3、§11.9.6)
==
/!=
與 ===
/!==
的差別相信有寫過 JavaScript 的人應該都很熟悉了:===
與 !==
不會對 operands 做任何轉型。因此只有在兩個 operands 型別相同時,x === y
的結果才有可能為 true
,x !== y
則相反。
至於 ==
與 !=
的情況:
- 假如兩個 operands 的型別相同,則不做轉型。在這種情況下,equality 的結果與
===
/!==
相同。 - 否則
- 若是 operand 為
null
或undefined
,那個 operand 不會被轉型。 - 若是某個 operand 為 Object,則使用
ToPrimitive()
將它轉為 primitive value。 - 若是某個 operand 為 Boolean,則使用
ToNumber()
將它轉為 Number。 - 若是一個 operand 為 String、另一個為 Number,則使用
ToNumber()
把它們都轉為 Number。
- 若是 operand 為
Increment/Decrement Operators(++
、--
)(§11.3.1、§11.3.2、§11.4.4、§11.4.5)
對於 increment/decrement operators,prefix 的情況比較簡單一些:operand 會使用 ToNumber()
轉成 Number。但由於這種運算也會改變自身的緣故,所以經過運算之後,operand 的型別也會變成 Number。
var x = "123";
console.log(++x); // 124
console.log(x); // 124
這應該不難理解。
那麼 postfix 的情況呢?到底 x++
(或 x--
)回傳的是 x
原本的值,還是轉成 Number 的值?
var y = "123";
console.log(x++); // 123
console.log(x); // 124
可以看到回傳的值是使用 ToNumber()
轉成 Number 的結果。
Property Accessors(.
、[]
)(§11.2.1)
做 property access 的情況比較特殊一點。ES 規格規定,當 JavaScript 執行像是 x.prop
這樣的 expression 時,需要將它包成一個叫做 Reference(§8.7)的內部型別。當對 Reference 取值(如 y = x.prop
、x.prop()
、x.prop + y
)或賦值(x.prop = y
)時,分別會對它執行 GetValue()
(§8.7.1) 與 SetValue()
(§8.7.2) operation(當然,這兩個 operations 都是 abstract operations,僅供內部使用)。而這兩個 operations 此時都會利用 ToObject(x)
將 x
轉為 Object。
聽起來好像不太直覺?沒關係,讓我們先回想一下。還記得一開始有說過「只有 Object 擁有 properties」嗎?但是對 null
跟 undefined
以外的 primitive value 做 property access 好像也沒問題耶?
var x = "hello";
console.log(x.length); // 5
console.log(x.toUpperCase()); // "HELLO"
這都是因為在做 property access 的時候,會自動幫你把 x
轉成 Object(String
object)的緣故。這種機制被稱為 auto-boxing。這個機制讓 primitive value 可以方便地使用定義在這些 prototype 上的功能。因此,在寫 JavaScript 的時候,其實很少主動建立 Boolean
、Number
或 String
objects。
不過有個要注意的小地方是,若是想要將 property 存在 primitive value 上,雖然執行時並不會出錯,但結果可能不是你想的那樣:
var x = 123;
x.lalala = "hahaha";
console.log(x.lalala); // undefined
這是因為轉換成的 Object 只是暫時存在的,把 property 存在上面當然馬上就不見了。
此外,如果是執行 obj[prop]
這種 expression,prop
會先被 ToString()
轉型成 String:
var obj = {};
obj[obj] = 123;
console.log(obj["[object Object]"]); // 123
Other Operators
其它 operators 的轉型規則相較起來比較直覺(?)一點,這裡就快速帶過去:
- unary
+
、unary-
的 operand 使用ToNumber()
轉成 Number(§11.4.6、§11.4.7)。 - bitwise NOT operator(
~
)的 operand 透過ToInt32()
轉成 Number(§11.4.8)。 - logical NOT operator(
!
)的 operand 透過ToBoolean()
轉成 Boolean(§11.4.9)。 - subtraction operator(
-
)與 multiplicative operators(*
、/
、%
)會使用ToNumber()
將 operands 都轉成 Number(§11.5、§11.6.2)。 - left shift(
<<
)與 right shift(>>
)的 left operand 透過ToInt32()
轉成 Number,right operand 透過ToUint32()
轉成 Number(§11.7.1、§11.7.2)。 - unsigned right shift(
>>>
)的 operands 都透過ToUint32()
轉成 Number(§11.7.3)。 - bitwise operators(
&
、^
、|
)的 operands 都透過ToInt32()
轉成 Number(§11.10)。 - logical operators(
&&
、||
)的 left operand 透過ToBoolean()
轉成 Boolean,right operand 不做轉型(§11.11)。 - conditional operator(
?:
)的第一個 operand 透過ToBoolean()
轉成 Boolean,其餘 operands 不做轉型(§11.12)。
其它這裡沒提到的 operators,如:new
、delete
、void
、typeof
、instanceof
、in
、()
、,
,都不會令 operands 轉型。
Discussion
從上面的規則應該可以感覺到,就算你在做一些亂七八糟的運算,JavaScript 都會試著進行(有時候甚至不是那麼直覺的)轉型以讓運算能順利完成。像是:
console.log(" " == 0); // true
console.log(2 == true); // false
console.log("3" + 5); // "35"
console.log("3" - 5); // -2
console.log([] + []); // ""
console.log([] + {}); // "[object Object]"
console.log(+[]); // 0
console.log(+{}); // NaN
得到這些結果的原因寫在最後面的 Appendix 裡。
雖然你可能會說:「我沒事寫這種奇怪的 expression 幹嘛?」但問題在於,出現這類運算可能是非刻意的。譬如說,打錯變數名稱、意外傳錯參數、或者是將未預料的回傳值拿去做計算。但又由於 JavaScript「貼心地」幫你做轉型,使得計算過程沒發生任何錯誤(甚至連警告都沒有),以至於這種錯誤通常難以查明......。
這個轉型規則還有個不太符合直覺的地方:那就是 Object 轉成 primitive value 時,並不會參考另一個 operand 的型別,而是始終堅持先試 obj.valueOf()
、再試 obj.toString()
(Date
object 作為 +
operand 的情況則是相反)。這有什麼問題呢?來看看這個《Effective JavaScript》裡頭的例子:
var obj = {
toString: function() {
return "[object MyObject]";
},
valueOf: function() {
return 17;
}
};
"object: " + obj; // "object: 17"
對此,作者的建議是,如果真的需要 valueOf()
,請讓 valueOf()
與 toString()
的結果一致:
It’s best to avoid
valueOf
unless your object really is a numeric abstraction andobj.toString()
produces a string representation ofobj.valueOf()
.除非你的 object 真的是用來表示數值的,否則最好不要有
valueOf
,且obj.toString()
應產生obj.valueOf()
的字串表示值。
當然啦,適時地隱式轉型其實是有用的。譬如說,能寫成 x && y
總是比 Boolean(x) && Boolean(y)
簡潔許多。但 JavaScript 在這方面似乎太過自由了(個人觀點XD)。我們只能盡可能採用 typeof
小心地檢查型別、以 ===
/!==
取代 ==
/!=
、並且避免混合不同型別的運算(尤其是 +
、<
、>
、<=
與 >=
),以免因為一時不注意,造成了難以察覺的 bug。
Appendix
這裡簡單解釋一下上個 section 例子中的結果是怎麼得來的。
" " == 0
:由於0
不是 String,因此兩個 operands 都會被轉成 Number。ToNumber(" ")
會得到0
,因此結果為true
。2 == true
:由於兩個 operands 都不是 String,因此都轉成 Number。ToNumber(true)
的結果為1
,因此答案是false
。"3" + 5
:由於"3"
是 String,因此5
也透過ToString()
轉成"5"
。兩個字串串起來得到"35"
。"3" - 5
:減法運算中,兩個 operands 都會被轉成 Number。ToNumber("3")
得到3
,因此結果為-2
。[] + []
:Object(且不是Date
object)會先試valueOf()
、再試toString()
以得到 primitive value。但[].valueOf()
會得到它自己(繼承自Object.prototype.valueOf()
),不是 primitive value。再試[].toString()
(§15.4.4.2),得到""
。兩個空字串串在一起,得到的還是""
。[] + {}
:[]
的轉換過程同上,會得到""
。({}).valueOf()
同樣不是 primitive value,因此呼叫({}).toString()
得到"[object Object]"
(§15.2.4.2)。"" + "[object Object]"
結果為"[object Object]"
。+[]
:unary+
會將 operand 轉成 Number。因此先將[]
轉成 primitive value(""
),再轉成 Number。結果為0
。+{}
:跟上面相同,先將{}
轉成 primitive value("[object Object]"
),再轉成 Number,得到NaN
。
Further Reading
- Object.prototype.valueOf() - JavaScript | MDN
- Object.prototype.toString() - JavaScript | MDN
- Chapter 4: Coercion - You Don't Know JS: Types & Grammar
- Chapter 9. Operators - Speaking JavaScript
- Item 3: Beware of Implicit Coercions - Effective JavaScript
- What is {} + {} in JavaScript?
- Coercing objects to primitives
- JavaScript coercion rules