[JS] 深入了解型別與轉型

JavaScript 不論遇到什麼奇形怪狀的資料運算,都會盡可能讓程式執行下去,這麼貼心的設計也是兩面刃,可能在不知不覺中程式正常運作,也可能突然就報錯了卻毫無頭緒,馬上來認識 JavaScript 的型別系統與轉型吧!


💎 型別(type)

  • JavaScript 的型別可以分為『原始型別』和『物件型別』兩類,差異在於:

    • 原始型別沒有屬性和方法可以使用
    • 物件型別可以任意存取屬性和方法
  • 原始型別(Primitive types)

    • string
    • number
    • boolean
    • null
    • undefined
    • symbol (ES 6 新定義)
  • 物件型別(Object types)

    • 原生物件(Native)
      • Object, Array, Function, Date, Math, RegExp
      • 原始型別包裹物件(Primitive Wrapper):Number, String, Boolean
    • 寄宿物件(Host)
      • window
      • DOM
  • 型別檢查(typeof):
    除了 null 有原生的 bug 會得到 object 的結果(此 bug 不會修正),其他型別都可以使用 typeof 來檢查。

    1
    2
    3
    4
    5
    6
    7
    8
    console.log(typeof 'a'); // string
    console.log(typeof 1); // number
    console.log(typeof true); // boolean
    console.log(typeof null); // object <== 這是語言本身的bug
    console.log(typeof undefined); // undefined
    console.log(typeof Symbol()) // symbol
    console.log(typeof {}); // object
    console.log(typeof []); // object

💎 裝箱(boxing)和 拆箱(unboxing)

  • 前面提到原始型別是沒有屬性和方法可以使用的,但我們卻可以利用字串方法來修改原始型別的字串(如下方的程式碼),這是因為 JavaScript 有 裝箱(boxing)和 拆箱(unboxing)機制來處理這些操作。

    1
    2
    console.log('123.45'.split('.'));
    // ['123', '45']
  • 上個段落的物件型別中有一項 原始型別包裹物件(Primitive Wrapper),使用這個方式來建立的字串(數字或布林值)會是物件的形式,並且有許多原生方法可以使用,JavaScript 便是利用這個物件型別來實作裝箱與拆箱。

    1
    2
    3
    4
    5
    let str = '123';
    let strObj = new String('123');

    console.log(typeof str); // string
    console.log(typeof strObj); // object
  • 裝箱(boxing):當 JS 執行方法發現資料是原始型別時,便會使用 new 把原始型別資料建立成物件型別的資料,就可以順利執行物件內的方法。

  • 拆箱(unboxing):當 JS 完成被裝箱的物件需要執行的方法後,就會把這個物件資料還原成原始型別的資料,這時會用到兩個重要的方法,這會在接下來介紹的『轉型』再次談到:

    • valueOf():回傳 Number 原始型別。
    • toString():回傳 String 原始型別。

💎 強制轉型(coercion)

  • 強制轉型分為兩種:顯性轉型(explicit coercion)、隱性轉型(implicit coercion)

🔸 顯性轉型(explicit coercion):

  • 顯性轉型指的是在程式碼中直接撰寫的型別轉換。

  • 轉數字

    • parseInt()

      • 轉整數。
      • 小數點以後無條件捨去。
      • 轉換方式:由左至右,遇到無法轉成數字的時候停止,回傳前面的數字。
        1
        2
        3
        console.log(parseInt('1.23')); // 1
        console.log(parseInt('1.23a')); // 1
        console.log(parseInt('a1.23')); // NaN
    • parseFloat()

      • 轉浮點數(包含小數點以後)。
      • 轉換方式與 parseInt() 相同。
        1
        console.log(parseFloat('1.23')); // 1.23
    • Number()

      • 可以轉整數和浮點數。
      • 數字範圍在正負 2的53次方 -1 間。
      • 轉換方式:整筆字串都可以轉成數字才轉換,否則得到 NaN。
        1
        2
        console.log(Number('-1.23')); // -1.23
        console.log(Number('-1.23a')); // NaN
    • BigInt()

      • 轉整數
      • 範圍可以超過2的53次方(表示方式會在數字後面加上 n
      • 轉換方式:非整數時直接報錯,不會得到 NaN。
      • 需注意瀏覽器支援度。
        1
        2
        3
        console.log(BigInt("9007199254740991")); // 9007199254740991n
        console.log(typeof 1n); // bigint
        console.log(BigInt("900a")); // Cannot convert 900a to a BigInt
    • 算數運算子

      • 前方加上+:可能會和 ++ 混淆。
      • 前方加上-:內容若是負數,會被轉成正數。
      • 後方加上- 0:較穩定寫法。
        1
        2
        3
        4
        console.log(+'-1'); // -1
        console.log(++'-1'); // Invalid left-hand side expression in prefix operation
        console.log(-'-1'); // 1
        console.log('-1' - 0); // -1
  • 轉字串

    • toString():不能轉換 null 和 undefined,可以設定進位制。

      1
      2
      3
      console.log((3).toString()); // 3
      console.log(undefined.toString()); // Cannot read properties of undefined
      console.log((3).toString(2)); // 11
    • String():可以轉換 null 和 undefined,不能設定進位制。

      1
      2
      console.log(String(3)); // 3
      console.log(String(undefined)); // undefined
    • 算數運算子:加上空字串 + ''

      1
      console.log(typeof (3 + '')); // string
  • 轉布林值

    • 布林值只有兩種值(true 或 false),轉型後會得到 true 的值稱為 truthy,得到 false 的則是 falsy,除了下列項目是 falsy,其他的都是 truthy

      • false
      • 0
      • -0
      • 0n (BigInt)
      • “” (空字串,包含 ``, ‘’)
      • null
      • undefined
      • NaN
      • document.all (正常情況下不會用到)
    • 轉換方式:

      • Boolean()

        1
        console.log(Boolean([])); // true
      • 邏輯運算子:前方加上雙驚嘆號 !!,單驚嘆號 ! 會是相反結果。

        1
        2
        console.log(!true); // false
        console.log(!!{}); // true

🔸 隱性轉型(implicit coercion)

  • 隱性轉型出現在使用運算子時,這在其他程式語言通常是不允許的(需要相同型別才可以運算),JavaScript 則會自動轉型讓程式繼續執行,也因為規則繁雜,容易疏忽出錯。

  • 核心方法 ToPrimitive
    JavaScript 在執行運算時會使用 ToPrimitive 方法將運算元轉換成原始型別後就能繼續運算,ToPrimitive 帶有一個 hint(提示),用來決定要轉成什麼型別,如果要了解詳細的運作順序可以參考 深入理解Javascript中Object类型的转换 這篇文章,裡面引用了 ECMA 的規範詳細解說,非常值得一讀!

  • 原始型別的隱性轉型

    • + 算數運算子:加法運算中只要有一個值屬於字串型別,就會全部轉為 string,最高優先。

      1
      2
      3
      4
      // 有字串
      console.log('' + 1 + null + true); // 1nulltrue
      // 無字串
      console.log(1 + null + true); // 2
    • 其他算數運算子:-*/% 運算都會轉成 number。

      1
      2
      3
      4
      console.log('6' - undefined); // NaN
      console.log(9 * [9]); // 81
      console.log('3' / {}); // NaN
      console.log(6 % true); // 0
  • 物件型別的隱性轉型

    • toPrimitive 執行物件型別的轉換時會使用到物件內的 valueOftoString 方法來取得原始型別的回傳值,因為這是物件內的方法,我們可以透過覆蓋方法來改變回傳值:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      // 刻意改成 valueOf 回傳字串、toString 回傳數字
      let a = {
      valueOf: function() {
      return '1';
      },
      toString: function() {
      return 2;
      }
      };

      console.log(a + 0); // "10"
      console.log(a.toString()); // 2
    • 陣列也是物件,ToPrimitive 會回傳陣列本身內容執行 toString() 方法後的字串。

      1
      2
      3
      4
      5
      // 空陣列轉字串 "",空物件轉字串 "[object Object]"
      console.log([] + {}); // "[object Object]"

      // 陣列轉字串 "1,2,3",後方數字配合轉字串
      console.log([1,2,3] + 2 + 1); // "1,2,321"
    • {} + 任意值

      1. {} 在運算元前方時會被視為區塊語句,而不是物件,所以實際執行的只有後面的 + x
      2. + 任意值是前一段落提到的顯性轉型轉數字的方法,所以後方的運算元轉為 number 型別。
        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        // 強制轉型
        console.log(+{}); // NaN

        // 看起來是物件加陣列,實際上是後方的陣列強制轉型為數字
        console.log({} + []); // 0

        // 上方程式實際執行時等同下面兩行:
        {}
        +[];

        // 宣告一個物件變數 x 來存放空物件,才能被當成物件來計算
        let x = {};
        console.log(x + []); // "[object Object]"
    • {} + {}:這是一個特別狀況所以額外說明,不同的瀏覽器會有不同的執行結果:

      1. NaN - 第一個 {} 被視為區塊,
      2. “[object Object][object Object]” - 第一個 {} 被視為物件
  • 邏輯運算
    邏輯運算中的運算元都會被轉為 boolean,差別在於其運算後回傳的結果。

    • || (or) 會回傳第一個結果為 true 的運算元,若無,則是最後一個。

      1
      2
      console.log(0 || false || null || undefined); // undefined
      console.log(0 || false || null || {} || undefined); // {}
    • && (and) 若運算結果為 true,會回傳最後一個運算元,若運算結果為 false,回傳第一個結果為 false 的運算元。

      1
      2
      console.log(true && {} && -2 && ['a']); // ['a']
      console.log(1 && 0 && true & false); // 0

💎 結語

關於轉型的細節實在太多,族繁不及備載,一些極端的範例在實際撰寫時並不會用到,不求成為行走的 MDN,但求踩坑的時候能順利 debug 就好!

參考文章
MDN-BigInt
sunnyhuang-何謂強制轉型(coercion)以及如何作到轉換型別
MDN-Number.prototype.toString()
淺談JS中String()與.toString()的區別 - 程式前沿
MDN-Symbol.toPrimitive
Eddy 思考與學習-JS中的 {} + {} 與 {} + [] 的結果是什麼?