木灵鱼儿

木灵鱼儿

阅读:144

最后更新:2023/01/03/ 0:05:08

强制类型转换

简介

将值从一种类型转换为另一种类型通常被称为类型转换,这是显式的情况,而隐式的情况被称为强制类型转换。

在JavaScript中通常将它们统称为强制类型转换,而显式和隐式的区分分界线其实并不明显,它很大程度上取决你是否了解这段代码是否转换了类型,如果你知道它转换了类型,那么这段代码对于你而言就是显式的,而隐式常常是那些不了解或者是比较隐晦的代码。

var a = 42;
var b = a + ""; // 隐式强制类型转换
var c = String(a); // 显式强制类型转换

隐式转换带来的副作用其实都是相对而言的,如果你足够了解,其实它就没有副作用,只是我们在编写代码时往往都是团队合作的,显式类型转换能方便的让团队成员理解,所以一般大家都是推荐使用显式的方式。

抽象值操作

ES5 规范第 9 节中定义了一些“抽象操作”(即“仅供内部使用的操作”)和转换规则,用于类型在转换时的操作。

ToString

ToString抽象操作负责处理非字符串到字符串的强制类型转换。

基本类型值的字符串化规则为:null 转换为 "null",undefined 转换为 "undefined",true转换为 "true"。数字的字符串化则遵循通用规则,不过第 2 章中讲过的那些极小和极大的数字使用指数形式:

// 1.07 连续乘以七个 1000
var a = 1.07 * 1000 * 1000 * 1000 * 1000 * 1000 * 1000 * 1000
// 七个1000一共21位数字
a.toString(); // "1.07e21"

对于普通对象而言,除非自定义,否则toString()(Object.prototype.toString())方法其实返回的是内部属性性 [[Class]] 的值,也就是构造函数名,如"[object Object]"。

数字的toString方法预写了,他会将数组中的值字符串化后通过","逗号连接起来。

var a = [1, 2, 3];
a.toString(); // "1,2,3"

我们在使用JSON..stringify的时候,会将对象转为序列化字符串,其实也用到了ToString抽象操作,但是它不是严格意义上的强制类型转换,因为针对部分值是有特殊处理的,比如函数。

对大多数简单值来说,JSON 字符串化和 toString() 的效果基本相同,只不过序列化的结果总是字符串:

JSON.stringify(42); // "42"
JSON.stringify("42"); // ""42"" (含有双引号的字符串)
JSON.stringify(null); // "null"
JSON.stringify(true); // "true"

所有安全的JSON值都可以使用JSON..stringify字符串换化,安全的JSON 值是指能够呈现为有效 JSON 格式的值。

为了简单起见,我们来看看什么是不安全的 JSON 值。undefined、function、symbol(ES6+)和包含循环引用(对象之间相互引用,形成一个无限循环)的对象都不符合 JSON结构标准,支持 JSON 的语言无法处理它们。

JSON.stringify(..) 在对象中遇到 undefined、function 和 symbol 时会自动将其忽略,在数组中则会返回 null(以保证单元位置不变)。

JSON.stringify(undefined); // undefined
JSON.stringify(function() {}); // undefined

JSON.stringify(
    [1, undefined, function() {}, 4]
); 
// "[1,null,null,4]"

JSON.stringify({
    a: 2,
    b: function() {}
}); 
// "{"a":2}"

对包含循环引用的对象执行 JSON.stringify(..) 会出错。

如果对象中定义了 toJSON() 方法,JSON 字符串化时会首先调用该方法,然后用它的返回值来进行序列化。

很多人误以为 toJSON() 返回的是 JSON 字符串化后的值,其实不是,toJSON()准确的说是返回一个能够字符串化的安全的 JSON 值,可以是任何类型,然后再由JSON.stringify对其字符串化。

var a = {
    val: [1, 2, 3],
    // 可能是我们想要的结果!
    toJSON: function() {
        return this.val.slice(1);
    }
};

var b = {
    val: [1, 2, 3],
    // 可能不是我们想要的结果!
    toJSON: function() {
        return "[" +
            this.val.slice(1).join() +
            "]";
    }
};

JSON.stringify(a); // "[2,3]
JSON.stringify(b); // ""[2,3]"

请记住,JSON.stringify(..) 并不是强制类型转换。在这里介绍是因为它涉及 ToString 强制类型转换,具体表现在以下两点。

  1. 字符串、数字、布尔值和 null 的 JSON.stringify(..) 规则与 ToString 基本相同。
  2. 如果传递给 JSON.stringify(..) 的对象中定义了 toJSON() 方法,那么该方法会在字符串化前调用,以便将对象转换为安全的 JSON 值。

ToNumber

ToNumber用于处理将非数字值当作数字来使用。

其中 true 转换为 1,false 转换为 0。undefined 转换为 NaN,null 转换为 0。

ToNumber 对字符串的处理基本遵循数字常量的相关规则 / 语法。处理失败时返回 NaN(处理数字常量失败时会产生语法错误)。不同之处是 ToNumber 对以 0 开头的十六进制数并不按十六进制处理(而是按十进制)。

对象(包括数组)会首先被转换为相应的基本类型值,如果返回的是非数字的基本类型值,则再遵循以上规则将其强制转换为数字。

将值转换为基本类型,会使用抽象操作 ToPrimitive,首先会检查是否存在valueOf() 方法,如果有并且返回了基本类型,就使用该值,如果没有则使用toString()的返回值来进行类型转换。

如果 valueOf() 和 toString() 均不返回基本类型值,会产生 TypeError 错误。

es5提供了一个Object.create(null)方法,可以创建无原型的原子对象,这个对象就不会存在valueOf() 和 toString() ,因此无法强制类型转换,使用时需要注意。

var a = {
    valueOf: function() {
        return "42";
    }
};
var b = {
    toString: function() {
        return "42";
    }
};
var c = [4, 2];
c.toString = function() {
    return this.join(""); // "42"
};

Number(a); // 42
Number(b); // 42
Number(c); // 42
Number(""); // 0
Number([]); // 0
Number(["abc"]); // NaN

ToBoolean

ToBoolea于处理将非布尔值转换为布尔值。

JavaScript中有两个关键词truefalse,分别代表着布尔类型的真值和假值。但是需要注意,数字中的1和0虽然可以转换为于 true 和 false,但是它们不是一回事,类型上就不同了。

假值

JavaScript 规范具体定义了一小撮可以被强制类型转换为 false 的值。

  • undefined
  • null
  • false
  • +0、-0 和 NaN
  • ""

假值的布尔强制类型转换结果为 false

从逻辑上讲,上述假值列表以外的值应该就是真值,但是JavaScript规范对此没有明确的定义,我们可以理解为假值列表以外全是真值。

假值对象

看到这个名字是不是很奇怪,影响中对象都是真值啊,假值对象是什么东西?

浏览器在某些特定情况下,在常规 JavaScript 语法基础上自己创建了一些外来(exotic)值,这些就是“假值对象”。

假值对象看起来和普通对象并无二致(都有属性,等等),但将它们强制类型转换为布尔值时结果为 false。

最常见的例子是 document.all,它是一个类数组对象,包含了页面上的所有元素,由DOM(而不是 JavaScript 引擎)提供给 JavaScript 程序使用。它以前曾是一个真正意义上的对象,布尔强制类型转换结果为 true,不过现在它是一个假值对象。

document.all 并不是一个标准用法,早就被废止了。

但是由于历史遗留的原因,这个api一直存在,由于现代浏览器对其的转换都是假值,所以常常用来判断是否是ie环境:

if (document.all) { /* it’s IE */ }

目前也没有什么好办法,所以我们了解一下,并少用以避开这些问题。

真值

真值就是假值列表之外的值,也就是说真值列表可以无限长,无法一一列举,所以我们只能用假值列表作为参考。

显示强制类型转换

显示的转换大部分都是通过上一章函数所讲的原生函数实现的,当然还有一些其他方法,比如parseInt这些。

具体就不细说了,大家在日常中想必用的非常多,而转换的规则上面也已经说明了。

隐式强制类型转换

隐式强制类型转换指的是那些隐蔽的强制类型转换,副作用也不是很明显。换句话说,你自己觉得不够明显的强制类型转换都可以算作隐式强制类型转换。

显式强制类型转换旨在让代码更加清晰易读,而隐式就像它的对立面,为此对于隐式转换大家都是疯狂吐槽,什么弱类型语言,不如强类型之类的,但是我们是否真的要对它退避三舍呢?

其实不然,它的存在自然是有它的道理,比如:隐式强制类型转换的作用是减少冗余,让代码更简洁。

如果我们需要对一个类型进行转换,在没有隐式转换的情况下,可能需要写好几个方法去实现,就好似typescript中我们需要将一个类型声明成另一个类型,需要通过先as unknown的方式在as xxxx的方式去定义。

这个过程非常繁琐,如果我们能省略这些中间步骤,抽象和隐藏那些细枝末节,有助于提高代码的可读性,我们就可以更加专注于整体逻辑。

然而隐式强制类型转换也会带来一些负面影响,有时甚至是弊大于利。因此我们更应该学习怎样去其糟粕,取其精华。

很多开发人员认为如果某个机制有优点 A 但同时又有缺点 Z,为了保险起见不如全部弃之不用。

我不赞同这种“因噎废食”的做法。不要因为只看到了隐式强制类型转换的缺点就想当地认为它一无是处。它也有好的方面,希望越来越多的开发人员能加以发现和运用。

字符串和数字之间的隐式强制类型转换

常见的就是+运算符合了,除了运算还能用于拼接字符串。

var a = "42";
var b = "0";
var c = 42;
var d = 0;
a + b; // "420"
c + d; // 42

简单来说就是,如果 + 的其中一个操作数是字符串,则执行字符串拼接;否则执行数字加法,如何操作数是一个对象,会先通过valueOf得到基本类型,如果没有得到,再通过 toString方法,最终将得到的类型进行判断或者转换类型进行处理。

a + ""这样的隐式转换十分常见,一些对隐式强制类型转换持批评态度的人也不能免俗。

这本身就很能说明问题,无论怎样被人诟病,隐式强制类型转换仍然有其用武之地。

但是需要注意,+String()的转换是有区别的, String会直接使用 ToString规则,而+会先使用
ToPrimitive 规则处理对象。

var a = {
    valueOf: function() {
        return 42;
    },
    toString: function() {
        return 4;
    }
};

a + ""; // "42"
String(a); // "4"

减法:

var a = [3];
var b = [1];

a - b; // 2

为了执行减法运算,a 和 b 都需要被转换为数字,它们首先被转换为字符串(通过toString()),然后再转换为数字。

布尔值到数字的隐式强制类型转换

在将某些复杂的布尔逻辑转换为数字加法的时候,隐式强制类型转换能派上大用场。当然这种情况并不多见,属于特殊情况特殊处理。

function onlyOne() {
    var sum = 0;
    for (var i = 0; i < arguments.length; i++) {
        // 跳过假值,和处理0一样,但是避免了NaN
        if (arguments[i]) {
            sum += arguments[i];
        }
    }
    return sum == 1;
}

var a = true;
var b = false;

onlyOne(b, a); // true
onlyOne(b, a, b, b, b); // true

隐式强制类型转换为布尔值

相对布尔值,数字和字符串操作中的隐式强制类型转换还算比较明显。下面的情况会发生布尔值隐式强制类型转换。

  1. if (..) 语句中的条件判断表达式。
  2. for ( .. ; .. ; .. ) 语句中的条件判断表达式(第二个)。
  3. while (..) 和 do..while(..) 循环中的条件判断表达式。
  4. ? : 中的条件判断表达式。
  5. 逻辑运算符 ||(逻辑或)和 &&(逻辑与)左边的操作数(作为条件判断表达式)。

以上情况中,非布尔值会被隐式强制类型转换为布尔值,遵循前面介绍过的 ToBoolean 抽象操作规则。

var a = 42;
var b = "abc";
var c;
var d = null;

if (a) {
    console.log("yep"); // yep
}

while (c) {
    console.log("nope, never runs");
}

c = d ? a : b;
c; // "abc"

if ((a && d) || c) {
    console.log("yep"); // yep
}

|| 和 &&

逻辑运算符,虽然称为逻辑运算法,但是和其他语言的逻辑运算符不一样,它们并不返回布尔值,而是返回操作数中的其中一个。

例:

var a = 42;
var b = "abc";
var c = null;
a || b; // 42 
a && b; // "abc"
c || b; // "abc" 
c && b; // null

在 C 和 PHP 中,上例的结果是 true 或 false,在 JavaScript(以及 Python 和 Ruby)中却是某个操作数的值。

|| 和 && 首先会对第一个操作数(a 和 c)执行条件判断,如果其不是布尔值(如上例)就先进行 ToBoolean 强制类型转换,然后再执行条件判断。

对于 || 来说,如果条件判断结果为 true 就返回第一个操作数(a 和 c)的值,如果false 就返回第二个操作数(b)的值。

&& 则相反,如果条件判断结果为 true 就返回第二个操作数(b)的值,如果为 false 就返回第一个操作数(a 和 c)的值。

|| 和 && 返回它们其中一个操作数的值,而非条件判断的结果(其中可能涉及强制类型转换)。c && b 中 c 为 null,是一个假值,因此 && 表达式的结果是 null(即 c 的值),而非条件判断的结果 false。

所以,逻辑操作符常常被我们用来做默认值,或者短路操作。

function foo(a, b) {
    a = a || "hello";
    b = b || "world";
    console.log(a + " " + b);
}

foo(); // "hello world"
foo("yeah", "yeah!"); // "yeah yeah!"
function foo() {
    console.log(a);
}

var a = 42;
a && foo(); // 42

符号的强制类型转换

隐式和显式它们的转换结果是一样的,差异仅在于代码的可读性方面,但是由于es6引入了 Symbol 符合类型,它的强制转换有一个问题,它允许使用显式强制转换,但是隐式转换会产生错误。

var s1 = Symbol("cool");
String(s1); // "Symbol(cool)"

var s2 = Symbol("not cool");
s2 + ""; // TypeError

符号不能够被强制类型转换为数字(显式和隐式都会产生错误),但可以被强制类型转换为布尔值(显式和隐式结果都是 true)。

由于规则缺乏一致性,我们要对 ES6 中符号的强制类型转换多加小心。

宽松相等和严格相等

宽松相等(loose equals)== 和严格相等(strict equals)=== 都用来判断两个值是否“相等”,但是它们之间有一个很重要的区别,特别是在判断条件上。

常见的误区是“== 检查值是否相等,=== 检查值和类型是否相等”。听起来蛮有道理,然而还不够准确。很多 JavaScript 的书籍和博客也是这样来解释的,但是很遗憾他们都错了。

正确的解释是:“== 允许在相等比较中进行强制类型转换,而 === 不允许。”

相等比较的性能

根据第一种解释,=== 似乎比 == 做的事情更多,因为它还要检查值的类型。第二种解释中 == 的工作量更大一些,因为如果值的类型不同还需要进行强制类型转换。

有人觉得 == 会比 === 慢,实际上虽然强制类型转换确实要多花点时间,但仅仅是微秒级(百万分之一秒)的差别而已。

如果进行比较的两个值类型相同,则 ===== 使用相同的算法,所以除了 JavaScript 引擎实现上的细微差别之外,它们之间并没有什么不同。

如果两个值的类型不同,我们就需要考虑有没有强制类型转换的必要,有就用 ==,没有就用 ===,不用在乎性能。

抽象相等

ES5 规范 11.9.3 节的“抽象相等比较算法”定义了 == 运算符的行为。该算法简单而又全面,涵盖了所有可能出现的类型组合,以及它们进行强制类型转换的方式。

  • 如果两个值的类型相同,就仅比较它们是否相等。例如,42等于 42,"abc" 等于 "abc"。
  • NaN 不等于 NaN
  • +0 等于 -0
  • 如果是对象,如果指向的都是同一个值既视为相等,如果不是,会发生隐式类型转换,将两者转换为相同类型后再进行比较。

1. 字符串和数字之间的相等比较

var a = 42;
var b = "42";
a === b; // false
a == b; // true

因为没有强制类型转换,所以 a === b 为 false,42 和 "42" 不相等。

而 a == b 是宽松相等,即如果两个值的类型不同,则对其中之一或两者都进行强制类型转换。

具体怎么转换?是 a 从 42 转换为字符串,还是 b 从 "42" 转换为数字?

ES5 规范 11.9.3.4-5 这样定义:

  1. 如果 Type(x) 是数字,Type(y) 是字符串,则返回 x == ToNumber(y) 的结果。
  2. 如果 Type(x) 是字符串,Type(y) 是数字,则返回 ToNumber(x) == y 的结果。

2. 其他类型和布尔类型之间的相等比较

== 最容易出错的一个地方是 true 和 false 与其他类型之间的相等比较。

var a = "42";
var b = true;
a == b; // false

我们都知道 "42" 是一个真值(见本章前面部分),为什么 == 的结果不是 true 呢?原因既简单又复杂,让人很容易掉坑里,很多 JavaScript 开发人员对这个地方并未引起足够的重视。

规范 11.9.3.6-7 是这样说的:

  1. 如果 Type(x) 是布尔类型,则返回 ToNumber(x) == y 的结果;
  2. 如果 Type(y) 是布尔类型,则返回 x == ToNumber(y) 的结果。

其实就是布尔类型会先转换为数字类型,然后再比较,如果类型还不相同继续转换。

上述代码b变量转换为数字为1,然后a是string类型,数字类型判定优先,于是"42"转为42与1进行比对,不相等。

所以需要注意,不要使用布尔值与其他类型进行==宽松相等判定。

3. null 和 undefined 之间的相等比较

null 和 undefined 之间的 == 也涉及隐式强制类型转换。ES5 规范 11.9.3.2-3 规定:

  1. 如果 x 为 null,y 为 undefined,则结果为 true。
  2. 如果 x 为 undefined,y 为 null,则结果为 true。

在 == 中 null 和 undefined 相等(它们也与其自身相等),除此之外其他值都不存在这种情况。

var a = null;
var b;
a == b; // true
a == null; // true
b == null; // true
a == false; // false
b == false; // false
a == ""; // false
b == ""; // false
a == 0; // false
b == 0; // false

null 和 undefined 之间的强制类型转换是安全可靠的,上例中除 null 和 undefined 以外的其他值均无法得到假阳(false positive)结果。个人认为通过这种方式将 null 和 undefined作为等价值来处理比较好。

var a = doSomething();
if (a === undefined || a === null) {
    // ..
}

使用全等这种方式感觉更繁琐一些。

4. 对象和非对象之间的相等比较

关于对象(对象 / 函数 / 数组)和标量基本类型(字符串 / 数字 / 布尔值)之间的相等比较,ES5 规范 11.9.3.8-9 做如下规定:

  1. 如果 Type(x) 是字符串或数字,Type(y) 是对象,则返回 x == ToPrimitive(y) 的结果;
  2. 如果 Type(x) 是对象,Type(y) 是字符串或数字,则返回 ToPromitive(x) == y 的结果。
var a = 42;
var b = [42];
a == b; // true

[ 42 ] 首先调用 ToPromitive 抽象操作,返回 "42",变成 "42" == 42,然后又变成 42 == 42,最后二者相等。

一些例子:

var a = "abc";
var b = Object(a); // 和new String( a )一样

a === b; // false
a == b; // true
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; // fals

因为没有对应的封装对象,所以 null 和 undefined 不能够被封装(boxed),Object(null和 Object() 均返回一个常规对象。

NaN 能够被封装为数字封装对象,但拆封之后 NaN == NaN 返回 false,因为 NaN 不等于 NaN

比较少见的情况

1. 返回其他数字

由于有人改动了原型的方法,导致转换不再真实(常见的一个面试题)。

Number.prototype.valueOf = function() {
    return 3;
};

new Number(2) == 3; // true

2. 假值的相等比较

== 中的隐式强制类型转换最为人诟病的地方是假值的相等比较。

"0" == null; // fals
"0" == undefined; // fals
"0" == false; // true -- 晕
"0" == NaN; // fals
"0" == 0; // true
"0" == ""; // fals
false == null; // false
false == undefined; // false
false == NaN; // false
false == 0; // true -- 晕!
false == ""; // true -- 晕!
false == []; // true -- 晕!
false == {}; // false
"" == null; // fals
"" == undefined; // fals
"" == NaN; // fals
"" == 0; // true -- 晕
"" == []; // true -- 晕
"" == {}; // fals
0 == null; // false
0 == undefined; // false
0 == NaN; // false
0 == []; // true -- 晕!
0 == {}; // false

以上 24 种情况中有 17 种比较好理解。比如我们都知道 "" 和 NaN 不相等,"0" 和 0 相等。

3. 极端情况

[] == ![] // true

我们看看发生了什么,首先![]进行布尔转换,得到false,于是代码等于:

[] == false

此时数组是对象,被隐式转换为"",此时字符串和布尔进行对比,数字类型优先,两个类型都被转换成数字类型,得到0 == 0,于是返回true。

2 == [2]; // true
"" == [null]; // true

[null].toString()得到的是`""

0 == "\n"; // true

"\n"会被转为数字0。

4. 安全运用隐式强制类型转换

我们要对 == 两边的值认真推敲,以下两个原则可以让我们有效地避免出错。

  • 如果两边的值中有 true 或者 false,千万不要使用 ==。
  • 如果两边的值中有 []、"" 或者 0,尽量不要使用 ==。

抽象关系比较

a < b 中涉及的隐式强制类型转换不太引人注意,不过还是很有必要深入了解一下

ES5 规范 11.8.5 节定义了“抽象关系比较”(abstract relational comparison),分为两个部分:比较双方都是字符串(后半部分)和其他情况(前半部分)。

比较双方首先调用 ToPrimitive,如果结果出现非字符串,就根据 ToNumber 规则将双方强制类型转换为数字来进行比较

var a = [42];
var b = ["43"];

a < b; // true
b < a; // false

如果比较双方都是字符串,则按字母顺序来进行比较:

var a = ["42"];
var b = ["043"];

a < b; // false

a 和 b 并没有被转换为数字,因为 ToPrimitive 返回的是字符串,所以这里比较的是 "42"和 "043" 两个字符串,它们分别以 "4" 和 "0" 开头。因为 "0" 在字母顺序上小于 "4",所以最后结果为 false。

var a = {
    b: 42
};
var b = {
    b: 43
};

a < b; // ??

结果还是 false,因为 a 是 [object Object],b 也是 [object Object],所以按照字母顺序a < b 并不成立。

下面的例子就有些奇怪了:

var a = {
    b: 42
};
var b = {
    b: 43
};

a < b; // false
a == b; // false
a > b; // false
a <= b; // true
a >= b; // true

这个也有时会出现在面试题中

为什么 a == b 的结果不是 true ?它们的字符串值相同(同为 "[object Object]"),按道理应该相等才对?实际上不是这样,你可以回忆一下前面讲过的对象的相等比较。

但是如果 a < b 和 a == b 结果为 false,为什么 a <= b 和 a >= b 的结果会是 true 呢?

因为根据规范 a <= b 被处理为 b < a,然后将结果反转。因为 b < a 的结果是 false,所以 a <= b 的结果是 true。

这可能与我们设想的大相径庭,即 <= 应该是“小于或者等于”。实际上 JavaScript 中 <= 是不大于”的意思(即 !(a > b),处理为 !(b < a))。同理 a >= b 处理为 b <= a。

相等比较有严格相等,关系比较却没有“严格关系比较”(strict relational comparison)。也就是说如果要避免 a < b 中发生隐式强制类型转换,我们只能确保 a 和 b 为相同的类型,除此之外别无他法。

与 == 和 === 的完整性检查一样,我们应该在必要和安全的情况下使用强制类型转换,如:42 < "43"。换句话说就是为了保证安全,应该对关系比较中的值进行显式强制类型转换:

var a = [42];
var b = "043";
a < b; // false -- 字符串比较!
Number(a) < Number(b); // true -- 数字比较!

版权申明

本文系作者 @木灵鱼儿 原创发布在木灵鱼儿 - 有梦就能远航站点。未经许可,禁止转载。

关于作者

站点职位 博主
获得点赞 0
文章被阅读 144

相关文章