魔幻語言 JavaScript 系列之型別轉換、寬鬆相等以及原始值

咖啡的香味是暗色的發表於2018-04-11

編譯自:[1] + [2] – [3] === 9!? Looking into assembly code of coercion.

全文從兩個題目來介紹型別轉換、寬鬆相等以及原始值的概念:

[1] + [2] – [3] === 9

如果讓 a == true && a == false 的值為 true

第二道題目是譯者加的,因為這其實是個很好的例子,體現出 JavaScript 的魔幻之處

變數值都具有型別,但仍然可以將一種型別的值賦值給另一種型別,如果是由開發者進行這些操作,就是型別轉換(顯式轉換)。如果是發生在後臺,比如在嘗試對不一致的型別執行操作時,就是隱式轉換(強制轉換)。

型別轉換(Type casting)

基本包裝型別(Primitive types wrappers)

在 JavaScript 中除了 nullundefined 之外的所有基本型別都有一個對應的基本包裝型別。通過使用其建構函式,可以將一個值的型別轉換為另一種型別。

String(123); // '123'
Boolean(123); // true
Number('123'); // 123
Number(true); // 1
複製程式碼

基本型別的包裝器不會儲存很長時間,一旦完成相應工作,就會消失

需要注意的是,如果在建構函式前使用 new 關鍵字,結果就完全不同,比如下面的例子:

const bool = new Boolean(false);
bool.propertyName = 'propertyValue';
bool.valueOf(); // false

if (bool) {
  console.log(bool.propertyName); // 'propertyValue'
}
複製程式碼

由於 bool 在這裡是一個新的物件,已經不再是基本型別值,它的計算結果為 true

上述例子,因為在 if 語句中,括號間的表示式將會裝換成布林值,比如

if (1) {
    console.log(true);
}
複製程式碼

其實,上面這段程式碼跟下面一樣:

if ( Boolean(1) ) {
    console.log(true);
}
複製程式碼

parseFloat

parseFloat 函式的功能跟 Number 建構函式類似,但對於傳參並沒有那麼嚴格。當它遇到不能轉換成數字的字元,將返回一個到該點的值並忽略其餘字元。

Number('123a45'); // NaN
parseFloat('123a45'); // 123
複製程式碼

parseInt

parseInt 函式在解析時將會對數字進行向下取整,並且可以使用不同的進位制。

parseInt('1111', 2); // 15
parseInt('0xF'); // 15
 
parseFloat('0xF'); // 0
複製程式碼

parseInt 函式可以猜測進位制,或著你可以顯式地通過第二個引數傳入進位制,參考 MDN web docs

而且不能正常處理大數,所以不應該成為 Math.floor 的替代品,是的,Math.floor 也會進行型別轉換:

parseInt('1.261e7'); // 1
Number('1.261e7'); // 12610000
Math.floor('1.261e7') // 12610000
 
Math.floor(true) // 1
複製程式碼

toString

可以使用 toString 函式將值轉換為字串,但是在不同原型之間的實現有所不同。

String.prototype.toString

返回字串的值

const dogName = 'Fluffy';
 
dogName.toString() // 'Fluffy'
String.prototype.toString.call('Fluffy') // 'Fluffy'
 
String.prototype.toString.call({}) // Uncaught TypeError: String.prototype.toString requires that 'this' be a String
複製程式碼

Number.prototype.toString

返回將數字的字串表示形式,可以指定進製作為第一個引數傳入

(15).toString(); // "15"
(15).toString(2); // "1111"
(-15).toString(2); // "-1111"
複製程式碼

Symbol .prototype.toString

返回 Symbol(${description})

Boolean.prototype.toString

返回 “true”“false”

Object.prototype.toString

返回一個字串 [ object $ { tag } ] ,其中 tag 可以是內建型別比如 “Array”,“String”,“Object”,“Date”,也可以是自定義 tag。

const dogName = 'Fluffy';
 
dogName.toString(); // 'Fluffy' (String.prototype.toString called here)
Object.prototype.toString.call(dogName); // '[object String]'
複製程式碼

隨著 ES6 的推出,還可以使用 Symbol 進行自定義 tag。

const dog = { name: 'Fluffy' }
console.log( dog.toString() ) // '[object Object]'
 
dog[Symbol.toStringTag] = 'Dog';
console.log( dog.toString() ) // '[object Dog]'
複製程式碼

或者

const Dog = function(name) {
  this.name = name;
}
Dog.prototype[Symbol.toStringTag] = 'Dog';
 
const dog = new Dog('Fluffy');
dog.toString(); // '[object Dog]'
複製程式碼

還可以結合使用 ES6 class 和 getter:

class Dog {
  constructor(name) {
    this.name = name;
  }
  get [Symbol.toStringTag]() {
    return 'Dog';
  }
}
 
const dog = new Dog('Fluffy');
dog.toString(); // '[object Dog]'
複製程式碼

Array.prototype.toString

在每個元素上呼叫 toString,並返回一個字串,並且以逗號分隔。

const arr = [
  {},
  2,
  3
]
 
arr.toString() // "[object Object],2,3"
複製程式碼

強制轉換

如果瞭解型別轉換的工作原理,那麼理解強制轉換就會容易很多。

數學運算子

加號運算子

在作為二元運算子的 + 如果兩邊的表示式存在字串,最後將會返回一個字串。

'2' + 2 // '22'
15 + '' // '15'
複製程式碼

可以使用一元運算子將其轉換為數字:

+'12' // 12
複製程式碼

其他數學運算子

其他數學運算子(如 -/)將始終轉換為數字。

new Date('04-02-2018') - '1' // 1522619999999
'12' / '6' // 2
-'1' // -1
複製程式碼

上述例子中,Date 型別將轉換為數字,即 Unix 時間戳

邏輯非

如果原始值是 ,則使用邏輯非將輸出 ,如果 ,則輸出為 。 如果使用兩次,可用於將該值轉換為相應的布林值。

!1 // false
!!({}) // true
複製程式碼

位或

值得一提的是,即使 ToInt32 實際上是一個抽象操作(僅限內部,不可呼叫),將一個值轉換為一個有符號的 32 位整數

0 | true          // 1
0 | '123'         // 123
0 | '2147483647'  // 2147483647
0 | '2147483648'  // -2147483648 (too big)
0 | '-2147483648' // -2147483648
0 | '-2147483649' // 2147483647 (too small)
0 | Infinity      // 0
複製程式碼

當其中一個運算元為 0 時執行按位或操作將不改變另一個運算元的值。

其他情況下的強制轉換

在編碼時,可能會遇到更多強制轉換的情況,比如這個例子:

const foo = {};
const bar = {};
const x = {};
 
x[foo] = 'foo';
x[bar] = 'bar';
 
console.log(x[foo]); // "bar"
複製程式碼

發生這種情況是因為 foobar 在轉換為字串的結果均為 “[object Object]”。就像這樣:

x[bar.toString()] = 'bar';
x["[object Object]"]; // "bar"
複製程式碼

使用模板字串的時候也會發生強制轉換,在下面例子中重寫 toString 函式:

const Dog = function(name) {
  this.name = name;
}
Dog.prototype.toString = function() {
  return this.name;
}
 
const dog = new Dog('Fluffy');
console.log(`${dog} is a good dog!`); // "Fluffy is a good dog!"
複製程式碼

正因為如此,寬鬆相等(==)被認為是一種不好的做法,如果兩邊型別不一致,就會試圖進行強制隱式轉換。

看下面這個有趣的例子:

const foo = new String('foo');
const foo2 = new String('foo');
 
foo === foo2 // false
foo >= foo2 // true
複製程式碼

在這裡我們使用了 new 關鍵字,所以 foofoo2 都是字串包裝型別,原始值都是 foo 。但是,它們現在引用了兩個不同的物件,所以 foo === foo2 將返回 false。這裡的關係運算子 >= 會在兩個運算元上呼叫 valueOf 函式,因此比較的是它們的原始值,'foo' > = 'foo' 的結果為 true

[1] + [2] - [3] === 9

希望這些知識都能幫助揭開這個題目的神祕面紗

  1. [1] + [2] 將呼叫 Array.prototype.toString 轉換為字串,然後進行字串拼接。結果將是 “12”
    • [1,2] + [3,4] 的值講師 “1,23,4”
  2. 12 - [3],減號運算子會將值轉換為 Number 型別,所以等於 12-3,結果為 9
    • 12 - [3,4] 的值是 NaN,因為"3,4" 不能被轉換為 Number

總結

儘管很多人會建議儘量避免強制隱式轉換,但瞭解它的工作原理非常重要,在除錯程式碼和避免錯誤方面大有幫助。

【譯文完】

再談點,關於寬鬆相等和原始值

這裡看另一道題目,在 JavaScript 環境下,能否讓表示式 a == true && a == falsetrue

就像下面這樣,在控制檯列印出 ’yeah':

// code here
if (a == true && a == false) {
    console.log('yeah');
}
複製程式碼

關於寬鬆相等(==),先看看 ECMA 5.1 的規範,包含 toPrimitive:

  • 11.9.3 The Abstract Equality Comparison Algorithm
  • 9.1 ToPrimitive

稍作總結

規範很長很詳細,簡單總結就是,對於下述表示式:

x == y
複製程式碼
  • 型別相同,判斷的就是 x === y
  • 型別不同
    • 如果 x,y 其中一個是布林值,將這個布林值進行 ToNumber 操作
    • 如果 x,y 其中一個是字串,將這個字串進行 ToNumber 操作
    • 若果 x,y 一方為物件,將這個物件進行 ToPrimitive 操作

至於 ToPrimitive,即求原始值,可以簡單理解為進行 valueOf()toString() 操作。

稍後我們再詳細剖析,接下來先看一個問題。

Question:是否存在這樣一個變數,滿足 x == !x

就像這樣:

// code here
if (x == !x) {
    console.log('yeah');
}
複製程式碼

可能很多人會想到下面這個,畢竟我們也曾熱衷於各種奇技淫巧:

[] == ![] // true
複製程式碼

但答案絕不僅僅侷限於此,比如:

var x = new Boolean(false);

if (x == !x) {
    console.log('yeah');
}
// x.valueOf() -> false
// x is a object, so: !x -> false


var y = new Number(0);
y == !y // true
// y.valueOf() -> 0
// !y -> false
// 0 === Number(false) // true
// 0 == false // true
複製程式碼

理解這個問題,那下面的這些例子都不是問題了:

[] == ![]
[] == {}
[] == !{}
{} == ![]
{} == !{}
複製程式碼

在來看看什麼是 ToPrimitive

ToPrimitive

看規範:8.12.8 [[DefaultValue]] (hint)

如果是 Date 求原始值,則 hint 是 String,其他均為 Number,即先呼叫 valueOf() 再呼叫 toString()

如果 hint 為 Number,具體過程如下:

  1. 呼叫物件的 valueOf() 方法,如果值是原值則返回
  2. 否則,呼叫物件的 toString() 方法,如果值是原值則返回
  3. 否則,丟擲 TypeError 錯誤
// valueOf 和 toString 的呼叫順序
var a = {
    valueOf() {
        console.log('valueof')
        return []
    },
    toString() {
        console.log('toString')
        return {}
    }
}

a == 0
// valueof
// toString
// Uncaught TypeError: Cannot convert object to primitive value


// Date 型別先 toString,後 valueOf
var t = new Date('2018/04/01');
t.valueOf = function() {
    console.log('valueof')
    return []
}
t.toString = function() {
    console.log('toString')
    return {}
}
t == 0
// toString
// valueof
// Uncaught TypeError: Cannot convert object to primitive value
複製程式碼

到目前為止,上面的都是 ES5 的規範,那麼在 ES6 中,有什麼變化呢

ES6 中 ToPrimitive

7.1.1ToPrimitive ( input [, PreferredType] )

在 ES6 中嗎,是可以自定義 @@toPrimitive 方法的,這是 Well-Known Symbols(§6.1.5.1)中的一個。JavaScript 內建了一些在 ECMAScript 5 之前沒有暴露給開發者的 symbol,它們代表了內部語言行為。

來自 MDN 的例子:

// 沒有 Symbol.toPrimitive 屬性的物件
var obj1 = {};
console.log(+obj1); // NaN
console.log(`${obj1}`); // '[object Object]'
console.log(obj1 + ''); // '[object Object]'

// 擁有 Symbol.toPrimitive 屬性的物件
var obj2 = {
    [Symbol.toPrimitive](hint) {
        if (hint == 'number') {
            return 10;
        }
        if (hint == 'string') {
            return 'hello';
        }
        return true;
    }
};
console.log(+obj2); // 10 -- hint is 'number'
console.log(`${obj2}`); // 'hello' -- hint is 'string'
console.log(obj2 + ''); // 'true' -- hint is 'default'
複製程式碼

有了上述鋪墊,答案就呼之欲出了

a == true && a == falsetrue 的答案

var a = {
    flag: false,
    toString() {
        return this.flag = !this.flag;
    }
}
複製程式碼

或者使用 valueOf()

var a = {
    flag: false,
    valueOf() {
        return this.flag = !this.flag;
    }
}
複製程式碼

或者是直接改變 ToPrimitive 行為:

// 其實只需設定 default 即可
var a = {
    flag: false,
    [Symbol.toPrimitive](hint) {
        if (hint === 'number') {
            return 10
        }
        if (hint === 'string') {
            return 'hello'
        }
        return this.flag = !this.flag
    }
}
複製程式碼

如果是嚴格相等呢

這個問題在嚴格相等的情況下,也是能夠成立的,這又是另外的知識點了,使用 defineProperty 就能實現:

let flag = false
Object.defineProperty(window, 'a', {
    get() {
        return (flag = !flag)
    }
})

if (a === true && a === false) {
    console.log('yeah');
}
複製程式碼

閱讀更多

相關文章