从++[[]][+[]]+[+[]]==10?深入浅出弱类型JS的隐式转换
本文纯属原创? 如有雷同? 纯属抄袭? 不甚荣幸! 欢迎转载!
原文收录在【我的GitHub博客】,觉得本文写的不算烂的,可以点击【我的GitHub博客】顺便登录一下账号给个星星✨鼓励一下,关注最新更新动态,大家一起多交流学习,欢迎随意转载交流,不要钱,文末没有福利哦?,你懂的?。
如果你很直接,就是直白想看我的结果分析,请直接跳到[第六章](),只要你看的懂,前面的知识点可以忽略。
起因
凡是都有一个来源和起因,这个题不是我哪篇文章看到的,也不是我瞎几把乱造出来的,我也没这个天赋和能力,是我同事之前丢到群里,叫我们在浏览器输出一下,对结果出乎意料,本着实事求是的精神,探寻事物的本质,不断努力追根溯源,总算弄明白了最后的结果,最后的收获总算把js的隐式类型转换刨根问底的搞清楚了,也更加深入的明白了为什么JS是弱类型语言了。
题外话
一看就看出答案的大神可以跳过,鄙文会浪费你宝贵的时间,因为此文会很长,涉及到知识点很多很杂很细,以及对js源码的解读,而且很抽象,如果没有耐心,也可以直接跳过,本文记录本人探索这道问题所有的过程,会很长。
可能写的不太清楚,逻辑不太严密,存在些许错误,还望批评指正,我会及时更正。去年毕业入坑前端一年,并不是什么老鸟,所以我也是以一个学习者的身份来写这篇文章,逆向的记录自己学习探索的过程,并不是指点江山,挥斥方遒,目空一切的大神,如果写的不好,还望见谅。
首先对于这种问题,有人说是闲的蛋疼,整天研究这些无聊的,有啥用,开发谁会这么写,你钻牛角尖搞这些有意思吗?
对于这种质疑,我只能说:爱看不看,反正不是写给你看。
当然,这话也没错,开发过程中确实不会这么写,但是我们要把开发和学习区分开来,很多人开发只为完成事情,不求最好,但求最快,能用就行?。学习也是这样,停留在表面,会用API就行,不会去深层次思考原理,因此很难进一步提升,就是因为这样的态度才诞生了一大批一年经验重复三五年的API大神?。
但是学习就不同,学习本生就是一个慢慢深入,寻根问底,追根溯源的过程,如果对于探寻问题的本质追求都没有,我只能说做做外包就好,探究这种问题对于开发确实没什么卵用,但是对我们了解JavaScript这门语言却有极大的帮助,可以让我们对这门语言的了解更上一个台阶。JavaScript为什么是弱类型语言,主要体现在哪里,弱类型转换的机制又是什么?
有人还是觉得其实这对学习JS也没什么多大卵用,我只能说:我就喜欢折腾,你管得着?反正我收获巨多就够了。
++[[]][+[]]+[+[]]===10?这个对不对,我们先不管,先来看几个稍微简单的例子,当做练习入手。
一、作业例子:
这几个是留给大家的作业,涉及到的知识点下面我会先一一写出来,为什么涉及这些知识点,因为我自己一步步踩坑踩过来的,所以知道涉及哪些坑,大家最后按照知识点一步一步分析,一定可以得出 答案来,列出知识点之后,我们再来一起分析++[[]][+[]]+[+[]]===10?的正确性。
{}+{}//chrome:"object Object",Firfox:NaN
{}+[]//0
[]+{}//"[object Object]"
首先,关于1、2和3这三个的答案我是有一些疑惑,先给出答案,希望大家看完这篇文章能和我讨论一下自己的想法,求同存异。
4.{}+1
5.({}+1)
6.1+{}
7.[]+1
8.1+[]
9.1-[]
10.1-{}
11.1-!{}
12.1+!{}
13.1+"2"+"2"
14.1+ +"2"+"2"
15.1++"2"+"2"
16.[]==![]
17.[]===![]
这几个例子是我随便写的,几乎包含了所有弱类型转换所遇到的坑,为什么会出现这种情况,就不得不从JS这门语言的特性讲起,大家都知道JS是一门动态的弱类型语言,那么你有没有想过什么叫做弱类型?什么叫做动态?大家都知道这个概念,但有没有进一步思考呢?
今天通过这几个例子就来了解一下JS的弱类型,什么是动态暂时不做探讨。
二、强弱类型的判别
按照计算机语言的类型系统的设计方式,可以分为强类型和弱类型两种。二者之间的区别,就在于计算时是否可以不同类型之间对使用者透明地隐式转换。从使用者的角度来看,如果一个语言可以隐式转换它的所有类型,那么它的变量、表达式等在参与运算时,即使类型不正确,也能通过隐式转换来得到正确地类型,这对使用者而言,就好像所有类型都能进行所有运算一样,所以这样的语言被称作弱类型。与此相对,强类型语言的类型之间不一定有隐式转换。
三、JS为什么是弱类型?
弱类型相对于强类型来说类型检查更不严格,比如说允许变量类型的隐式转换,允许强制类型转换等等。强类型语言一般不允许这么做。具体说明请看维基百科的说明。
根据强弱类型的判别定义,和上面的十几个例子已经充分说明JavaScript 是一门弱类型语言了。
先讲一讲一些概念,要想弄懂上面题目答案的原理,首先你要彻底弄懂以下的概念,有些时候对一些东西似懂非懂,其实就是对概念和规则没有弄透,弄透之后等会回过头对照就不难理解,不先了解透这些后面的真的不好理解,花点耐心看看,消化一下,最后串通梳理一下,一层一层的往下剥,答案迎刃而解。
为了能够弄明白这种隐式转换是如何进行的,我们首先需要搞懂如下一些基础知识。如果没有耐心,直接跳到后面第四章[4.8 小结]()我总结的几条结论,这里仅给想要一步步通过过程探寻结果的人看。
四、ECMAScript的运算符、{}解析、自动分号插入
4.1 ECMAScript 运算符优先级
运算符 | 描述 | ||
---|---|---|---|
. [] () | 字段访问、数组下标、函数调用以及表达式分组 | ||
++ — - + ~ ! delete new typeof void | 一元运算符、返回数据类型、对象创建、未定义值 | ||
* / % | 乘法、除法、取模 | ||
+ - + | 加法、减法、字符串连接 | ||
<< >> >>> | 移位 | ||
< <= > >= instanceof | 小于、小于等于、大于、大于等于、instanceof | ||
== != === !== | 等于、不等于、严格相等、非严格相等 | ||
& | 按位与 | ||
^ | 按位异或 | ||
按位或 | |||
&& | 逻辑与 | ||
<span class="Apple-tab-span" style="white-space:pre"></span> | 逻辑或 | ||
?: | 条件 | ||
= oP= | 赋值、运算赋值 | ||
, | 多重求值 |
4.2 ECMAScript 一元运算符(+、-)
一元运算符只有一个参数,即要操作的对象或值。它们是 ECMAScript 中最简单的运算符。
delete
,void
,--
,++
这里我们先不扯,免得越扯越多,防止之前博文的啰嗦,这里咋们只讲重点,有兴趣的可以看看w3school(点我查看)对这几个的详细讲解。
上面的例子我们一个一个看,看一个总结一个规则,基本规则上面例子几乎都包含了,如有遗漏,还望反馈补上。
这里我们只讲 一元加法 和 一元减法 :
我们先看看ECMAScript5规范(熟读规范,你会学到很多很多)对一元加法和一元减法的解读,我们翻到11.4.6和11.4.7。
其中涉及到几个ECMAScript定义的抽象操作,ToNumber(x),ToPrimitive(x)等等 下一章详细解答,下面出现的抽象定义也同理,先不管这个,有基础想深入了解可以提前熟读ECMAScript5规范(点击查看)。
规范本来就是抽象的东西,不太好懂不要紧,我们看看例子,这里的规范我们只当做一种依据来证明这些现象。
大多数人都熟悉一元加法和一元减法,它们在 ECMAScript 中的用法与您高中数学中学到的用法相同。
一元加法本质上对数字无任何影响:
var iNum = 20;
iNum = +iNum;//注意不要和iNum += iNum搞混淆了;
alert(iNum); //输出 "20"
尽管一元加法对数字无作用,但对字符串却有有趣的效果,会把字符串转换成数字。
var sNum = "20";
alert(typeof sNum); //输出 "string"
var iNum = +sNum;
alert(typeof iNum); //输出 "number"
这段代码把字符串 "20" 转换成真正的数字。当一元加法运算符对字符串进行操作时,它计算字符串的方式与 parseInt() 相似,主要的不同是只有对以 "0x" 开头的字符串(表示十六进制数字),一元运算符才能把它转换成十进制的值。因此,用一元加法转换 "010",得到的总是 10,而 "0xB" 将被转换成 11。
另一方面,一元减法就是对数值求负(例如把 20 转换成 -20):
var iNum = 20;
iNum = -iNum;
alert(iNum); //输出 "-20"
与一元加法运算符相似,一元减法运算符也会把字符串转换成近似的数字,此外还会对该值求负。例如:
var sNum = "20";
alert(typeof sNum); //输出 "string"
var iNum = -sNum;
alert(iNum); //输出 "-20"
alert(typeof iNum); //输出 "number"
在上面的代码中,一元减法运算符将把字符串 "-20" 转换成 -20(一元减法运算符对十六进制和十进制的处理方式与一元加法运算符相似,只是它还会对该值求负)。
4.3 ECMAScript 加法运算符(+)
在多数程序设计语言中,加性运算符(即加号或减号)通常是最简单的数学运算符。
在 ECMAScript 中,加性运算符有大量的特殊行为。
我们还是先看看ECMAScript5规范(熟读规范,你会学到很多很多)对加号运算符 ( + )解读,我们翻到11.6.1。
前面读不懂不要紧,下一章节会为大家解读这些抽象词汇,大家不要慌,但是第七条看的懂吧, 这就是为什么1+"1"="11"而不等于2的原因 ,因为规范就是这样的,浏览器没有思维只会按部就班的执行规则,所以规则是这样定义的,所以最后的结果就是规则规定的结果,知道规则之后,对浏览器一切运行的结果都会豁然开朗,哦,原来是这样的啊。
在处理特殊值时,ECMAScript 中的加法也有一些特殊行为:
-
某个运算数是 NaN,那么结果为 NaN。
-
-Infinity 加 -Infinity,结果为 -Infinity。
-
Infinity 加 -Infinity,结果为 NaN。
-
+0 加 +0,结果为 +0。
-
-0 加 +0,结果为 +0。
-
-0 加 -0,结果为 -0。
不过,如果某个运算数是字符串,那么采用下列规则:
-
如果两个运算数都是字符串,把第二个字符串连接到第一个上。
-
如果只有一个运算数是字符串,把另一个运算数转换成字符串,结果是两个字符串连接成的字符串。
例如:
var result = 5 + 5; //两个数字
alert(result); //输出 "10"
var result2 = 5 + "5"; //一个数字和一个字符串
alert(result); //输出 "55"
这段代码说明了加法运算符的两种模式之间的差别。正常情况下,5+5 等于 10(原始数值),如上述代码中前两行所示。不过,如果把一个运算数改为字符串 "5",那么结果将变为 "55"(原始的字符串值),因为另一个运算数也会被转换为字符串。
注意:为了避免 JavaScript 中的一种常见错误,在使用加法运算符时,一定要仔细检查运算数的数据类型
4.4 ECMAScript 减法运算符(-)
减法运算符(-),也是一个常用的运算符:
var iResult = 2 - 1;
减、乘和除没有加法特殊,都是一个性质,这里我们就单独解读减法运算符(-)
我们还是先看看ECMAScript5规范(熟读规范,你会学到很多很多)对减号运算符 ( - )解读,我们翻到11.6.2。
与加法运算符一样,在处理特殊值时,减法运算符也有一些特殊行为:
-
某个运算数是 NaN,那么结果为 NaN。
-
Infinity 减 Infinity,结果为 NaN。
-
-Infinity 减 -Infinity,结果为 NaN。
-
Infinity 减 -Infinity,结果为 Infinity。
-
-Infinity 减 Infinity,结果为 -Infinity。
-
+0 减 +0,结果为 +0。
-
-0 减 -0,结果为 -0。
-
+0 减 -0,结果为 +0。
-
某个运算符不是数字,那么结果为 NaN。
注释:如果运算数都是数字,那么执行常规的减法运算,并返回结果。
4.5 ECMAScript 前自增运算符(++)
直接从 C(和 Java)借用的两个运算符是前增量运算符和前减量运算符。
所谓前增量运算符,就是数值上加 1,形式是在变量前放两个加号(++):
var iNum = 10;
++iNum;
第二行代码把 iNum 增加到了 11,它实质上等价于:
var iNum = 10;
iNum = iNum + 1;
我们还是先看看ECMAScript5规范(熟读规范,你会学到很多很多)对前自增运算符 ( ++ )解读,我们翻到11.4.4。
此图有坑,后面会说到,坑了我很久。。。
看不懂这些抽象函数和词汇也不要紧,想要深入了解可以通读ECMAScript5规范中文版,看几遍就熟悉了,第一次看见这些肯定一脸懵逼,这是什么玩意,我们只要明白++是干什么就行,这里不必去深究v8引擎怎么实现这个规范的。
至于
var a=1;
console.log(a++);//1
var b=1;
cosole.log(++b);//2
还弄不明白的该好好补习了,这里不在本文的知识点,也不去花篇幅讲解这些,这里我们只要明白一点: 所谓前增量运算符,就是数值上加 1 。
4.6 ECMAScript 自动分号(;)插入
尽管 JavaScript 有 C 的代码风格,但是它不强制要求在代码中使用分号,实际上可以省略它们。
JavaScript 不是一个没有分号的语言,恰恰相反上它需要分号来就解析源代码。 因此 JavaScript 解析器在遇到由于缺少分号导致的解析错误时,会自动在源代码中插入分号。
4.6.1例子
var foo = function() {
} // 解析错误,分号丢失
test()
自动插入分号,解析器重新解析。
var foo = function() {
}; // 没有错误,解析继续
test()
4.6.2工作原理
下面的代码没有分号,因此解析器需要自己判断需要在哪些地方插入分号。
(function(window, undefined) {
function test(options) {
log('testing!')
(options.list || []).forEach(function(i) {
})
options.value.test(
'long string to pass here',
'and another long string to pass'
)
return
{
foo: function() {}
}
}
window.test = test
})(window)
(function(window) {
window.someLibrary = {}
})(window)
下面是解析器"猜测"的结果。
(function(window, undefined) {
function test(options) {
// 没有插入分号,两行被合并为一行
log('testing!')(options.list || []).forEach(function(i) {
}); // <- 插入分号
options.value.test(
'long string to pass here',
'and another long string to pass'
); // <- 插入分号
return; // <- 插入分号, 改变了 return 表达式的行为
{ // 作为一个代码段处理
foo: function() {}
}; // <- 插入分号
}
window.test = test; // <- 插入分号
// 两行又被合并了
})(window)(function(window) {
window.someLibrary = {}; // <- 插入分号
})(window); //<- 插入分号
解析器显著改变了上面代码的行为,在另外一些情况下也会做出错误的处理。
4.6.3 ECMAScript对自动分号插入的规则
我们翻到7.9章节,看看其中插入分号的机制和原理,清楚只写以后就可以尽量以后少踩坑
必须用分号终止某些 ECMAScript 语句 ( 空语句 , 变量声明语句 , 表达式语句 , do-while 语句 , continue 语句 , break 语句 , return 语句 ,throw 语句 )。这些分号总是明确的显示在源文本里。然而,为了方便起见,某些情况下这些分号可以在源文本里省略。描述这种情况会说:这种情况下给源代码的 token 流自动插入分号。
还是比较抽象,看不太懂是不是,不要紧,我们看看实际例子,总结出几个规律就行,我们先不看抽象的,看着头晕,看看具体的总结说明, 化抽象为具体 。
首先这些规则是基于两点:
-
以换行为基础;
-
解析器会尽量将新行并入当前行,当且仅当符合ASI规则时才会将新行视为独立的语句。
4.6.3.1 ASI的规则
1. 新行并入当前行将构成非法语句,自动插入分号。
if(1 < 10) a = 1
console.log(a)
// 等价于
if(1 < 10) a = 1;
console.log(a);
2. 在continue,return,break,throw后自动插入分号
return
{a: 1}
// 等价于
return;
{a: 1};
3. ++、--后缀表达式作为新行的开始,在行首自动插入分号
a
++
c
// 等价于
a;
++c;
4. 代码块的最后一个语句会自动插入分号
function(){ a = 1 }
// 等价于
function(){ a = 1; }
4.6.3.2 No ASI的规则
1. 新行以 ( 开始
var a = 1
var b = a
(a+b).toString()
// 会被解析为以a+b为入参调用函数a,然后调用函数返回值的toString函数
var a = 1
var b =a(a+b).toString()
2. 新行以 [ 开始
var a = ['a1', 'a2']
var b = a
[0,1].slice(1)
// 会被解析先获取a[1],然后调用a[1].slice(1)。
// 由于逗号位于[]内,且不被解析为数组字面量,而被解析为运算符,而逗号运算符会先执
行左侧表达式,然后执行右侧表达式并且以右侧表达式的计算结果作为返回值
var a = ['a1', 'a2']
var b = a[0,1].slice(1)
3. 新行以 / 开始
var a = 1
var b = a
/test/.test(b)
// /会被解析为整除运算符,而不是正则表达式字面量的起始符号。浏览器中会报test前多了个.号
var a = 1
var b = a / test / .test(b)
4. 新行以 + 、 - 、 % 和 * 开始
var a = 2
var b = a
+a
// 会解析如下格式
var a = 2
var b = a + a
5. 新行以 , 或 . 开始
var a = 2
var b = a
.toString()
console.log(typeof b)
// 会解析为
var a = 2
var b = a.toString()
console.log(typeof b)
到这里我们已经对ASI的规则有一定的了解了,另外还有一样有趣的事情,就是“空语句”。
// 三个空语句
;;;
// 只有if条件语句,语句块为空语句。
// 可实现unless条件语句的效果
if(1>2);else
console.log('2 is greater than 1 always!');
// 只有while条件语句,循环体为空语句。
var a = 1
while(++a < 100);
4.6.4 结论
建议绝对不要省略分号,同时也提倡将花括号和相应的表达式放在一行, 对于只有一行代码的 if 或者 else 表达式,也不应该省略花括号。 这些良好的编程习惯不仅可以提到代码的一致性,而且可以防止解析器改变代码行为的错误处理。
关于JavaScript 语句后应该加分号么?(点我查看)我们可以看看知乎上大牛们对着个问题的看法。
4.7 ECMAScript 对{}的解读,确切说应该是浏览器对{}的解析
js引擎是如何判断{}是代码块还是对象的?
这个问题不知道大家有没有想过,先看看几个例子吧?
首先要深入明白的概念:
4.7.1 JavaScript的语句与原始表达式
原始表达式是表达式的最小单位——它不再包含其他表达式。javascript中的原始表达式包括this关键字、标识符引用、字面量引用、数组初始化、对象初始化和分组表达式,复杂表达式暂不做讨论。
语句没有返回值,而表达式都有返回值的,表达式没有设置返回值的话默认返回都是undefined。
在 javascript
里面满足这个条件的就函数声明、变量声明(var a=10是声明和赋值)、for语句、if语句、while语句、switch语句、return、try catch。
但是 javascript 还有一种函数表达式,它的形式跟函数声明一模