你懂 JavaScript 嗎?#8 強制轉型(Coercion)

你所不知道的 JS

強制轉型(coercion)到底是一個有用的功能,還是設計上的缺陷呢?

很難做決定

本文主要會談到

前言

強制轉型(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 引擎在背後偷偷幫我們做的工作。在這個部份會來探討 ToStringToNumberToBooleanToPrimitive 這幾個抽象的值運算,來看看到底在強制轉型時背地裡做了什麼好事。

偷偷地在幹什麼?

ToString

任何非字串的值被強制轉型為字串時,會遵循 ES5 的規格中的 ToString 來運作。

規則簡單說明如下

ToString Conversions

圖片來源:ToString Conversions

JSON 的字串化(JSON Stringification)

順道一提 JSON 的字串化。

JSON 的字串化 JSON.stringify 將值序列化(serialize)為 JSON 字串,這個轉為 JSON 字串的過程與 ToString 規則有關,但並不等於強制轉型。

規則簡單說明如下

JSON.stringify(42); // "42"
JSON.stringify(true); // "true"
JSON.stringify(null); // "null"
JSON.stringify('Hello World'); // ""Hello World"",字串會在外面再包一層引號!

範例如下。

若陣列中某個元素的值為非法值則會自動以 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,可為陣列或函式)來自訂過濾機制,決定序列化過程中應該包含哪些屬性。

const someObj = {
  a: 2,
  b: function () {},
};

JSON.stringify(someObj, ['a']); // "{"a":2}"
const someObj = {
  a: 2,
  b: function () {},
};

JSON.stringify(someObj, function (key, value) {
  if (key !== 'b') {
    return value;
  }
});

// "{"a":2}"

ToNumber

若需要將非數字值當成數字來操作,像是做數學運算,就會遵循 ES5 的規格中的 ToNumber 來運作。

規則簡單說明如下

ToNumber Conversions

圖片來源: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 來運作。

ToBoolean Conversions

圖片來源:ToBoolean Conversions

Falsy 值

在 JavaScript 中會被轉為 false 的值有

我們只要熟記這幾個值就可以了! d(d'∀')

而除了以上的值之外,都會被轉為 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 規格

規則簡單說明如下

明確的強制轉型(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

同場加映:浮點數轉為整數

使用 ~~ 將浮點數轉為整數,其運作方式為反轉兩次而得到截斷小數的結果,類似 !! 的真偽值雙次否定。

這裡有兩件事情要注意…

Math.floor(-29.8); // -30
(~~-29.8 - // -29
  29.8) |
  0; // -29

明確的剖析數值字串(Numeric String)

除了使用 Numer(..) 將值強制轉型為數字外,還可用 parseInt(..) 剖析而得到數字。parseInt(..) 的用途是將字串剖析為數字,它接受一個字串作為輸入,若輸入非字串的值則會使用 ToString 強制轉為字串。

Numer(..)parseInt(..) 的差異在於

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 是簡單的基本型別,則就會轉為 undefinednulltruefalse或數字(非常大或非常小的數字以指數呈現)的字串格式。

如下範例,數字 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

說明如下

注意,前面提到的 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

在什麼狀況下會隱含地將值強制轉為布林呢?

轉換規則可參考前面提到的 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)會將第一個運算元做布林測試或強制轉型為布林以便測試。

因此可應用於

範例如下。

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 的強制轉型規則如下

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
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

這裡要說明一下,===== 其實都會做型別的檢查,只是當面對型別不同時的反應是不一樣的而已。

規則

如果型別相同,就會以同一性做比較,但要注意

如果型別不同,則會先將其中一個或兩個值先做強制轉型(可遞迴),再用型別相同的同一性做比較。

!=!== 就是先分別做 ===== 再取否定(!)即可。

可參考規格

範例 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

有幾個例外需要注意…

範例如下。

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';

答案揭曉。

說明

總結:如何安全地使用隱含的強制轉型?

若允許強制轉型,但又希望能避免「難以預料」的強制轉型(上例),這裡有一些建議…

以下是一定很安全的強制轉型,使用 == 即可,不需要用 ===

也許世界上大多數的開發者都詬病 JavaScript 中「隱含的強制轉型」的這部份,覺得這是個壞東西,但也許它其實是減少冗贅、反覆套用和非必要實作細節的好方法,而前提是,必須要能清楚了解強制轉型的規則。

JavaScript Equality Table

下圖為 JavaScript 中的相等性,此圖視覺化了所有的比較項目。

JavaScript Equality Table

圖片來源:JavaScript Equality Table

抽象的關係式比較

這裡要來談比較運算子(comparison)的部份,意即 <(小於)、 >(大於)、<=(小於等於)、>=(大於等於),例如:a > b 表示比較 a 是否大於 b。

其比較規則為

  1. 若兩個運算元皆為字串時,就直接依照字典字母順序做比較。
  2. 除了 1 之外的狀況都適用

注意

範例如下,由於 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

這裡要注意的是…

回顧

看完這篇文章,我們到底有什麼收穫呢?藉由本文可以理解到…

References


同步發表於2019 鐵人賽


恭喜讀完「導讀,型別與文法」最困難的部份「強制轉型」!明天見 (*´∀`)~♥

乾杯


補充

(2019/04/29)

Jest 中,toBeTruthytoBeFalsy 會做強制轉型,可看成是使用 == 做比較;因此若希望能精準測試,則會改用 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);

You-Dont-Know-JS javascript 你所不知道的JS 2019鐵人賽 你懂JavaScript嗎? 鐵人賽 Jest Unit Test 單元測試 You-Dont-Know-JS-Types-and-Grammar undefined operator 運算子 NaN 你懂 JavaScript 嗎?2019 iT 邦幫忙鐵人賽 系列文