沿著平滑的曲線學會 JavaScript 中的隱式強制型別轉換(實戰應用篇)

mynull發表於2019-03-08

這一部分內容是承接上一篇的, 建議先閱讀沿著平滑的曲線學會 JavaScript 中的隱式強制型別轉換(基礎篇)

前兩章討論了基本資料型別和基本包裝型別的關係, 以及兩個在型別轉換中十分重要的方法: valueOftoString 方法. 接下來的內容建立在前兩章之上, 給出判斷隱式型別轉換結果的方法, 文章最後部分給出了多個練習以及解析, 用以檢驗文中討論方法的正確性.

3 各種型別之間的強制型別轉換

此處談的強制型別轉換指的是除了符號型別(symbol)之外的基本資料型別以及物件之間的型別轉換, 對於符號型別(symbol)單獨討論.

3.1 ToPrimitive 將變數轉換為 基本資料型別

把一個變數轉換為 基本資料型別 的轉換過程可以被抽象成一種稱為 ToPrimitive 的操作, 主意它只是一個抽象出來的名稱, 而不是一個具體的方法, 各種資料型別對它的實現才是具體的.

ToPrimitive 把一個變數轉變成一個基本的型別, 會根據變數的型別不同而採取不同的操作:

  1. 如果這個變數已經是基本型別了: 那就不進行轉換了, 直接返回這個變數, 就直接用這個變數的值了.

  2. 當這個變數是一個物件時: 就呼叫這個物件的內部的方法 [[DefaultValue]] 來來把物件轉換成基本型別。

簡單來說, 對於基本資料型別直接返回本身. 對於物件就執行物件本身的 [[DefaultValue]] 方法來獲得結果. 那麼這個 [[DefaultValue]] 方法是怎麼工作的呢, 其實也並不難.

3.2 [[DefaultValue]] 操作 返回物件的基本資料型別(原始型別)的值

[[DefaultValue]] 方法利用物件內部的 valueOf 方法或 toString 方法返回運算元的基本資料型別(可以指定想得到的型別偏好)。此操作的過程可如此簡單理解:

在預設的情況下:

先呼叫 valueOf() 方法, 如果返回值是基本型別, 則使用這個值; 否則: 呼叫 toString() 方法, 得到返回值. 如果這兩個方法都無法得到基本資料型別的返回值,則會丟擲 TypeError 異常.

另: 對於 Date 物件, 會將這兩個方法的呼叫順序顛倒過來. 先呼叫 toString ,若得不到基本型別的值, 就再呼叫 valueOf. 若都不能得到基本型別的值, 同樣丟擲 TypeError 異常.

簡單總結一下 3.1 部分的內容: 在將一個值轉換為基本資料型別的時候, 如果這個值本身就是一個基本資料型別, 則直接使用它自己; 如果這個值是個物件, 就呼叫物件的兩個方法: valueOftoString , 這兩個函式得到的結果就是這個物件轉換成的基本型別的值.

3.2 基本資料型別之間的型別轉換

前一部分討論了物件如何強制轉換為基本資料型別, 夲節主要討論基本資料型別之間的相互轉換. 主要包含三個小部分:

  1. 其他型別資料轉換成 字串
  2. 其他型別轉換成 數值
  3. 其他型別轉換成 布林值

下面來一一具體討論.

3.2.1 其他型別資料轉換成 字串

其他基本資料型別的值轉換成字串型別其實非常簡單, 直接變成字串的形式就可以了. 例如:

null -> "null", undefined -> "undefined", true -> "true", false -> "false", 3.14159 -> "3.14159"

注: 對於非常大或者非常小的數字來說, 轉換成字串會是科學記數法的形式, 例如:

3140000000000000000000 -> "3.14e+21" // 很大的數轉換成字串

0.000000314 -> "3.14e-7"  // 很小的數轉換成字串
複製程式碼

3.2.2 其他型別資料轉換成 數值

其他基本資料型別轉換成數值型別也比較簡單, 只有字串需要做非常簡單的判斷. 具體為:

null -> 0, undefined -> NaN, true -> 1, false -> 0

對於字串來說, 可細分為一下的情況:

  • 空字串轉換為 0
  • 若字串中只含 數字, 加減號, 小數點符號, 則直接轉換, 而且忽略前導的 0, 例如:
"3.14" -> 3.14
"-0003.14" -> -3.14 // 前導有 0
複製程式碼
  • 如果字串中內容為十六進位制, 則轉換成的數值為十進位制的形式, 例如: "0xa -> 10"

  • 其他情況,則都轉換結果為 NaN, 例如:

"A10" -> NaN
"1A0" -> NaN
"10A" -> NaN
複製程式碼

3.2.3 其他型別資料轉換成 布林值

這就更簡單了, 只有幾個特殊的值轉換後為 false , 除此之外的其他值轉換後都為 true, 這幾個特殊的值如下: NaN, undefined, null, 0, +0, -0, 空字串""

上述 3.2 部分的內容的記憶是比較簡單的, 在處理具體型別轉換的問題時只需要靈活運用就可以了, 但是有時候在同一個問題中的同一個變數涉及多個轉換過程, 比如從 物件 轉為字串, 然後再從字串轉為 數值.

下一節將會討論涉及到隱式型別轉換的實際應用, 會包含很多例子.

4 涉及到隱式型別轉換的情況

JavaScript 中很多常用的操作都會引起隱式的強制型別轉換, 下面的部分舉幾個常見的例子.

4.1 加減乘除號引起的隱式型別轉換 + - * /

常用的四則運算操作符在有些時候會引起隱式強制型別的轉換

4.1.1 加號 +

在 JavaScript 中, 加號 + 可以用來做加法運算, 也可以用來拼接字串, 那該怎麼判斷它執行的是哪個操作呢?

有人說只要加號連線的兩個變數其中有一個是字串時就執行的是拼接操作, 否則就執行加法操作. 這種說法是不完整的, 例如下面這幾個例子就無法按照這種說法得到結果, 因為加號兩邊的變數都不是字串型別:

// 例 1
console.log(true + true); // ?
console.log(1 + null); // ?


// 例 2
let array = [2, 3];
let a = 1 + array;

let obj = { name: 'doug', age: 4 };
let b = 1 + obj;

console.log(a, b); // ?
複製程式碼

那麼到底應該怎麼判斷呢? 個人認為可以這樣來做, 分成兩種簡單的情況 :

  1. 如果加號的左右兩邊都是除字串之外的基本型別值, 或者是可以通過 ToPrimitive(見 3.1 部分) 抽象操作轉換成這些型別的 物件, 那麼後臺會嘗試將這兩個變數都轉換成數字(具體機制見3.2.2節)進行加法操作.

看下面的實驗結果:

console.log( 1 + 1 );       // 2

// true -> 1; false -> 0
console.log( 1 + true );    // 2
console.log( 1 + false );   // 1
console.log( false + 1 );   // 1
console.log( false + true );    // 1

// null -> 0
console.log( 1 + null );    // 1
console.log( true + null ); // 1

// undefined -> NaN
console.log( 1 + undefined );   // NaN
console.log( true + undefined );    // NaN
console.log( null + undefined );    // NaN


// 通過 ToPrimitive 操作返回 number, boolean, null, undefined 基本型別值的物件
// 重寫了物件的 valueOf 和 toString 方法
let obj = {
    valueOf: function(){
        return true;
    },

    toString: function(){
        return 'call obj';
    }
}

console.log(1 + obj);   // 2
console.log(obj + obj); // 2, 這個例子更加典型
複製程式碼

前面的內容提到過在預設情況下 ToPrimitive 會首先呼叫 物件 objvalueOf 方法來獲取基本型別的值, 所以得到了 true , 然後輸出語句就變成了 console.log(1 + true)console.log(true + true) , 之後 true 被轉換成數值型別 1, 式子變成console.log(1+1). 實驗結果證實了剛才的設想, 適用於 例1 中的情況.

那麼 什麼情況下進行字串的拼接操作? 設想如下 :

  1. 如果加號的左右兩邊存在 字串 或者可以通過 ToPrimitive (見 3.1 部分)抽象操作轉換成字串的物件, 則執行的就是拼接操作.

在 例2 中:

let array = [2, 3];
let a = 1 + array;

let obj = { name: 'doug', age: 4 };
let b = 1 + obj;

console.log(a, b); // ?
複製程式碼

變數 a 等於 1 加上一個 陣列 array, 陣列是可以通過 ToPrimitive 轉化為字串的, 按照3.1 和 3.2 節的內容, 陣列先呼叫了自己的 valueOf 方法, 發現返回的是 陣列本身, 不是基本資料型別; 於是接著呼叫 toString 方法, 返回了一個各項用 "," 連線的字串 "2,3" . 於是現在就有了 let a = 1 + "2,3", 是數值和字串相加, 結果就是 "12,3".

物件 obj 呼叫 valueOf 返回它本身, 再呼叫 toString 方法返回字串 "[object Object]". 然後就變成了 let b = 1 + "[object Object]" , 就變成了數值和字串相加, 是拼接操作, 所以結果就出來了 "1[object Object]".

再看一個例子:

function fn(){ console.log('running fn'); }

console.log(fn + fn);
/*
function fn(){ console.log('running fn'); }function fn(){ console.log('running fn'); }
*/
複製程式碼

這個例子的結果用剛才的設想是可以比較容易的得到結果的. 即: 函式 fn 可以通過 ToPrimitive 操作返回一個字串, 然後式子就變成了 字串+字串, 結果就是字串的拼接.

至此. 上面的論斷可能存在不嚴謹的地方, 歡迎批評指正.

4.1.2 減乘除 - * / 運算子產生的強制型別轉換

這三個運算子會將左右兩邊不是數值型別的變數強制轉換成簡單數值型別, 然後執行數學運算, 例如:

console.log(true - false);	// 1
console.log(true - null);	// 1
console.log(true * true);	// 1
console.log(2 - undefined);	// NaN

console.log([2] - 1);	// 1
// [2] -valueOf-> [2] -toString-> "2" -> 2

console.log('3' * 2);	//6

console.log('4' / '2');	// 2

let obj = {
	toString: function(){ // 重寫了 toString 方法, 返回一個字串
		return '4';
	}
};

console.log(obj * [2]);	// 8
複製程式碼

上述幾個例子中變數最終都被轉換成了數值型的基本資料型別. 其中陣列和物件通過 ToPrimitive (見 3.1 部分)先轉換成字串, 接著強制轉換成數值型別再進行數學運算.

4.2 邏輯運算子 ||&&

我們常用將邏輯運算子用在條件判斷中, 例如:

if(a || b){
    // codes
}
複製程式碼

這是很自然的操作.

然而邏輯運算子返回的並不是想象中的布林型別的 true or false , 而是它左右兩個運算元中的一個。例如:

let a = 50;
let b = 100;

console.log(a || b, a && b); // 50 100, 並沒有輸出 true 或者 false
複製程式碼

可以看到輸出結果並不是布林值, 而是兩個運算元中的一個. 同時還發現, 對於兩個相同的運算元, ||&& 操作符的輸出情況並不一樣. 下面來討論一下原因.

這兩個操作符會根據左邊(只判斷左邊, 不判斷右邊)的運算元轉換成布林型別之後的值決定返回哪個運算元, 會先檢驗左邊的運算元的真值, 再做出決定. 具體機制如下:

  1. 對於 || , 當左邊的真值為 true 時, 則返回左邊的; 否則返回右邊的運算元.
  2. 對於 && , 當左邊的真值為 false 時, 直接返回左邊的; 否則返回右邊的運算元.

可以這樣來簡單理解: || 意為 或, 只要兩個鐘有一個為真就可以了, 所以如果左邊為真整體就為真, 直接返回左邊就可以了. && 意為 且, 要求兩邊都為真, 如果左邊為真, 那麼就取決於右邊的真假情況了, 所以直接返回右邊.

回到開頭的例子:

if(a || b){
    // codes
}
複製程式碼

根據上面的討論, 可以知道 a || b 並不返回布林值, 然而 if 卻是根據布林值決定是否執行內部操作的, 那麼為什麼可以正常執行呢? 原因是 if 語句還要對 a || b 的返回值進行一次隱式強制型別轉換, 轉換成布林值, 然後再進行下一步的決定.

類似進行隱式強制型別轉換判斷的情況還有:

  • for迴圈
  • while 和 do while 迴圈
  • 三元運算子 xx? a:b

4.3 非嚴格相等符號 ==

4.3.1 與嚴格相等符號 === 的異同

非嚴格相等符號(==)是和嚴格相等符號(===)相關的概念. 它們的區別是: == 允許進行強制型別轉換, 再比較轉換後的左右運算元; 而 === 不進行強制型別轉換, 直接比較左右兩個運算元.

當左右兩個運算元的型別相同的時候, 這兩種比較符號的效果相同, 運用的原理也相同.

在比較物件的時候, 這兩個比較符號的原理也相同: 比較左右兩個變數指向的是不是同一個物件.

4.3.2 物件(包括陣列和函式)和基本資料型別之間的 == 比較

在物件與基本資料型別的比較的時候, 物件會通過 ToPrimitive (見 3.1 部分) 操作返回基本資料型別的值, 然後再進行比較.

4.3.3 布林值和其他型別的 == 比較

在布林值與其他型別比較時, 會先將布林型別的值轉換成數值, 即: true->1, false->0.

4.3.4 字串和數值的 == 比較

將字串轉換成數值型別, 然後進行比較

4.3.4 nullundefined 的比較

nullundefined 在用 == 比較時返回的是 true, 而且除了它們自身之外, 只有這二者相互比較時才返回 true.

換句話說, 除了其自身之外, null 只有和 undefined== 比較時才為 true, 與其他任何值比較時都是 false; 同樣的, 除了其自身之外, undefined 只有和 null== 比較時才為 true, 與其他任何值比較時都是 false.

即: 對於 nullundefined 來說, 只有這三種情況為真:

console.log( null == undefined ); 	// true
console.log( undefined == undefined ); 	// true
console.log( null == null ); 	// true
複製程式碼

其他特殊情況

  1. NaN 不與任何值等, 即使和自身相比也不相等
console.log(NaN == NaN); 	// false
複製程式碼
  1. +0 -0 0 三者相等
console.log(0 == +0);	// true
console.log(0 == -0);	// true
console.log(-0 == +0);	// true
複製程式碼

總結一下: 物件(包括陣列和函式)和其他型別比較時, 要進行型別轉換的是物件; 布林值和其他資料比較時, 要進行型別轉換的是布林值, 轉換成數值型別1或0; 在數值和字串比較時, 要轉換型別的是字串, 轉換成數值型別.

5 應用, 舉例分析

下面舉一些隱式強制型別轉換的例子, 用之前討論的內容判斷, 並給出解析:

console.log( "4" == 4 );		// true
// 字串和數值比較, 字串轉換為數值, 即 "4" -> 4

console.log( "4a" == 4 );		// false
/* 原理同上, 字串和數值比較, 字串轉換為數值, 
但是字串裡包含 "a", 所以轉換後是 NaN, 即 "4a" -> NaN; 
式子變成 `NaN == 4", 因為 NaN 與任何值都不等, 故為 false */

console.log( "5" == 4 );		// false
// 字串和數值比較, 字串轉換為數值, 即 "5" -> 4, 式子變成 `5 == 4`, false

console.log( "100" == true );		// false
/* 存在布林值, 首先布林值轉換為數值, 即: true -> 1,
式子變成: `"100" == 1`, 此時為字串和數值比較, 字串轉換為數值,
式子變成 `100 == 1`,  false
*/

console.log( null == undefined );		// true
console.log( undefined == undefined );		// true
console.log( null == null );		// true
console.log( null == [] );		// false
console.log( null == "" );		// false
console.log( null == {} );		// false
console.log( null == 0 );		// false
console.log( null == false);		// false
console.log( undefined == [] );		// false
console.log( undefined == "" );		// false
console.log( undefined == {} );		// false
console.log( undefined == 0 );		// false
console.log( undefined == false );		// false
console.log(null == Object(null) );		// false
console.log(undefined == Object(undefined) );		// false
// 以上的答案比較容易得出, 因為 null 和 undefined 除了自己之外只認識彼此, 文章 4.3.4 部分


console.log( "0" == false );		// true
/* 包含布林值, 首先布林值轉換為數值, 即: false -> 0,然後式子變成 ` "0" == 0 `;
此時變成了字串和數值比較, 字串轉換為數值, 即: "0" -> 0, 
然後式子變成 ` 0 == 0 `, true */

console.log( 0 == false);		// true
// false 轉換為 0, true

console.log( false == "" );		// true
/* false -> 0, 式子變成 ` 0 == "" `, 數字和字串比較;
字串轉換為數值, 即: "" -> 0, 式子變成 ` 0 == 0 `, true */

console.log( false == [] );		// true
// 包含物件和布林值, 布林值優先 轉換, 隨後物件通過 ToPrimitive 操作轉換為基本資料型別後比較:
// false -> 0, [] -> "" -> 0; ` 0 == 0 `, true

console.log( false == {} );		// false
// 包含物件和布林值, 布林值優先 轉換, 隨後物件通過 ToPrimitive 操作轉換為基本資料型別後比較:
// false -> 0, {} -> "Object Object" -> NaN; ` 0 == NaN ` false

console.log( 0 == "");		// true
// "" -> 0; ` 0 == 0 ` true

console.log( "" == [] );		// true
// 字串和物件比較, 物件通過 ToPrimitive 操作轉換為基本資料型別後比較:
// [] -toString- -> ""; 式子變成 ` "" == "" `, true 

console.log( 0 == []);		// true
// 數值和物件比較, 物件通過 ToPrimitive 操作轉換為基本資料型別後比較:
// [] -toString- -> ""; 式子變成 ` 0 == "" `, 此時是數值型別和字串比較, 字串轉換為數值
// "" -> 0 ; 式子變成了 ` 0 == 0 `,true

console.log( 0 == {});		// false
// 數值和物件比較, 物件通過 ToPrimitive 操作轉換為基本資料型別後比較:
// {} -> "Object Object" -> NaN; ` 0 == NaN ` false
複製程式碼

JavaScript 中設計的強制型別轉換的內容不止文中提到的這些, 仍存在沒有討論到的內容會在將來討論.同時文中可能存在錯誤, 請不吝指正, 謝謝.

參考資料:

  • 《JavaScript 高階程式設計》
  • 《你不知道的 JavaScript》
  • MDN

相關文章