March 6, 2015 · JavaScript

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,總共有六種:

上面的六種 types 中,只有 Object 擁有 properties。JavaScript 中的 functions(Function)、arrays(Array)、regular expressions(RegExp)實際上都是 objects。其餘的五種 types 則被稱為 primitive type。其中 BooleanNumberString 這三種 primitive types 都有對應的 Object subtype,分別是 BooleanNumberString(注意兩者的字體差別)。

除此之外,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,代表「偏好」轉為 NumberString。在不給定 PreferredType 的情況下,除了 Date object 預設為 String 之外,其它值一律以 Number 作為預設值。

於是,假設 PreferredType 為 Number,轉換的規則是這樣的:

  1. 假如 input 為 primitive value,直接傳回作為結果。
  2. 否則,呼叫 input.valueOf()。假如結果是 primitive value,就傳回作為結果。
  3. 否則,呼叫 input.toString()。假如結果是 primitive value,就傳回作為結果。
  4. 若是 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 包含:

對這些值呼叫 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 轉成 NumberIEEE 754 double-precision binary floating-point format)。轉換規則如下:

其它的轉數字系列還有 ToInteger()(§9.4)、ToInt32()(§9.5)、ToUint32()(§9.6)跟 ToUint16()(§9.7)這四個 operations。這些 operators 都是先利用 ToNumber() 將值轉成 Number 之後再進行後續處理。對於幾個特殊的值,這幾個 operations 的回傳值如下:

對於其它的值,就是只取整數部分(譬如 3.14 會變成 3-2.82 會變成 -2),再把值切到適當的範圍中(譬如說,ToInt32() 的範圍是 -231 到 231 - 1、ToInteger() 則沒有範圍限制)。這個部分跟其它語言差不多,就不細講了。詳細的作法請參考 §9.4 - §9.7。

ToString(input)(§9.8)

接著,ToString() 會將 input 轉成 String。轉換規則如下:

ToObject(input)(§9.9)

最後,ToObject() 會將 input 轉成 Object

Type Coercion Rules

前面講了這麼多,終於要進主題了。這裡我採用與 ES 規格類似的條列法,希望不會太難懂。

Addition Operator(+)(§11.6.1)

  1. 先將 operands 透過 ToPrimitive() 轉為 primitive value。
  2. 如果任何一個 operand 是 String,則用 ToString() 將 operands 都轉為 String 做 string concatenation。
  3. 否則,都使用 ToNumber() 轉成 Number 做 numeric addition。

要注意的是,只有加法運算會套用這種轉型規則。減法、乘法、除法都是直接用 ToNumber() 把 operands 轉成 Number 做 arithmetic operation。這是因為 + 在 JavaScript 中可用來執行 numeric addition 或是 string concatenation 兩種用途的緣故。

Relational Operators(<><=>=)(§11.8.5)

  1. 先將 operands 透過 ToPrimitive(x, Number) 轉為 primitive value(x 代表 operand,因此此時必定是先試 valueOf() 再試 toString())。
  2. 假如 operands 都是 String,就做 string comparison。
  3. 否則,都使用 ToNumber() 轉成 Number 做 numeric comparison。

Equality Operators(==!====!==)(§11.9.3、§11.9.6)

==!====!== 的差別相信有寫過 JavaScript 的人應該都很熟悉了:===!== 不會對 operands 做任何轉型。因此只有在兩個 operands 型別相同時,x === y 的結果才有可能為 truex !== y 則相反。

至於 ==!= 的情況:

  1. 假如兩個 operands 的型別相同,則不做轉型。在這種情況下,equality 的結果與 ===!== 相同。
  2. 否則
    1. 若是 operand 為 nullundefined,那個 operand 不會被轉型。
    2. 若是某個 operand 為 Object,則使用 ToPrimitive() 將它轉為 primitive value。
    3. 若是某個 operand 為 Boolean,則使用 ToNumber() 將它轉為 Number
    4. 若是一個 operand 為 String、另一個為 Number,則使用 ToNumber() 把它們都轉為 Number

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.propx.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」嗎?但是對 nullundefined 以外的 primitive value 做 property access 好像也沒問題耶?

var x = "hello";  
console.log(x.length); // 5  
console.log(x.toUpperCase()); // "HELLO"  

這都是因為在做 property access 的時候,會自動幫你把 x 轉成 ObjectString object)的緣故。這種機制被稱為 auto-boxing。這個機制讓 primitive value 可以方便地使用定義在這些 prototype 上的功能。因此,在寫 JavaScript 的時候,其實很少主動建立 BooleanNumberString 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 的轉型規則相較起來比較直覺(?)一點,這裡就快速帶過去:

其它這裡沒提到的 operators,如:newdeletevoidtypeofinstanceofin(),,都不會令 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 and obj.toString() produces a string representation of obj.valueOf().

除非你的 object 真的是用來表示數值的,否則最好不要有 valueOf,且 obj.toString() 應產生 obj.valueOf() 的字串表示值。

當然啦,適時地隱式轉型其實是有用的。譬如說,能寫成 x && y 總是比 Boolean(x) && Boolean(y) 簡潔許多。但 JavaScript 在這方面似乎太過自由了(個人觀點XD)。我們只能盡可能採用 typeof 小心地檢查型別、以 ===!== 取代 ==!=、並且避免混合不同型別的運算(尤其是 +<><=>=),以免因為一時不注意,造成了難以察覺的 bug。

Appendix

這裡簡單解釋一下上個 section 例子中的結果是怎麼得來的。

Further Reading

  • LinkedIn
  • Tumblr
  • Reddit
  • Google+
  • Pinterest
  • Pocket
Comments powered by Disqus