15

(a == 1 && a == 2 && a == 3)为true,你所不知道的那些答案

 3 years ago
source link: https://zhuanlan.zhihu.com/p/148146120
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

(a == 1 && a == 2 && a == 3)为true,你所不知道的那些答案

看到这个标题,一部分同学的第一反应可能是,又是这个老套的问题,人家都讲过好多遍了你还讲。同学,你想错啦。我可不是在炒冷饭。今天我们要从这个问题,延伸出更多的知识,保证超出你的预期。让我们开始吧。

我记得我第一次看到这个题目的时候,感觉很吃惊,也很好奇;wow,还可以这样吗?这激起了我很大的兴趣去了解这个问题。我就迫不及待的想着怎么解决这个问题。后来使用了隐式转换这个比较常用的方法算是达到了题目的要求。当然,解题的方法还有很多,让我们一起来探索一下吧。

解题的基本思路

副作用 side effect

当我们看到a == 1 && a == 2 && a == 3的时候,我们首先要明白以下几点

  • 这个表达式中含有&&,当&&左边的表达式的值为false的时候,那么&&右边的表达式就不再计算了。
  • a == 1在这个比较的过程,首先需要获取a的值,这涉及到对a的读取。如果a的类型不是一个数字类型的值,这又会涉及到数据的类型转换相关的知识。
  • 这个表达式是从左到右进行运算的,所以我们可以在a == 1计算之后对a的值进行更新,使a == 2能够继续成立

使用一个对象,进行隐式类型转换

const a = (function() {
    let i = 1;
    return {
        valueOf: function() {
            return i++;
        }
    }
})();

console.log(a == 1 && a == 2 && a == 3); // true

上面这种解决方案应该是最容易想到的方案了,我们通过一个立即执行的函数,返回一个对象。这个对象的valueOf方法的返回值是i++,也就是说在返回i值之前,会将i的值增加1,然后返回之前i的值。我们在计算a == 1 && a == 2 && a == 3的过程中其实进行的步骤是这样的。

  • 计算a == 1的值,在比较的过程中对象a会转换为数字1,然后和==右边的数值进行比较,结果为true。此时i的值为2
  • 计算a == 2的值,在比较的过程中对象a会转换为数字2,然后和==右边的数值进行比较,结果为true。此时i的值为3
  • 计算a == 3的值,在比较的过程中对象a会转换为数字3,然后和==右边的数值进行比较,结果为true。此时i的值为4
  • true && true && true表达式的结果为true,所以输出结果为true。 大家如果对对象的隐式类型转换不是很熟悉的话,可以参考我之前写的一篇文章深入理解JS对象隐式类型转换的过程

定义一个全局的属性

let i = 1;

Reflect.defineProperty(this, 'a', {
    get() {
        return i++;
    }
});

console.log(a === 1 && a === 2 && a === 3);

我们还可以通过Reflect.defineProperty定义一个全局的属性a,当属性a被访问的时候就会调用上面定义的getter方法,所以和上面对象的隐式类型转换过程是一样的。每次比较之后,i的值会增加1。这个方案的好处是,我们可以使用===而不是==,因为不需要进行类型转换,直接返回的就是相应的数字值。

在比较过程中修改获取属性的方法

Reflect.defineProperty(this, 'a', {
    configurable: true,
   get() {
      Reflect.defineProperty(this, 'a', {
            configurable: true,
         get() {
            Reflect.defineProperty(this, 'a', {
               get() {
                  return 3;
               },
            });
            return 2;
         },
      });
      return 1;
   },
});

console.log(a === 1 && a === 2 && a === 3);

上面这个方法,在每次获取属性a值的时候,都会设置它下一次读取的值。因为属性的descriptor默认的configurablefalse。所以我们需要在前两次将其设置为true以便我们接下来能够对其进行修改。这个方法不仅可以让我们使用===,而且我们还可以改变比较的顺序。比如a === 1 && a === 3 && a === 2,只需要在上面代码中对应的位置修改为相应的值就可以了。这个方法在目前来说是比较好的一种方案。

其它类似的方案

const a = {
   reg: /\d/g,
   valueOf: function() {
      return this.reg.exec(123)[0];
   },
};

console.log(a == 1 && a == 2 && a == 3);

上面也使用了对象的隐式类型转换,只不过valueOf函数的返回值是通过执行正则表达式的exec方法后的返回值。需要注意的是正则表达式/\d/g需要带有g修饰符,这样正则表达式可以记住上次匹配的位置。还有需要注意的是,正则表达式匹配的结果是一个数组或者null。在上述的情境中,我们需要获取匹配结果数组的第一个值。当然上面的方法也可以更改比较的顺序。

const a = [1, 2, 3];
a.join = a.shift;
console.log(a == 1 && a == 2 && a == 3);

这个方法也比较巧妙,而且代码量最少。数组a在比较的过程中涉及对象的隐式类型转换,会调用atoString方法,而toString方法会在内部调用它自己的join方法,所以也能够让上面的表达式的值为true

上面的这些方法我们可以把它们都归类为副作用,因为它们大都利用了相等比较的副作用或者读取属性的副作用。我们在平时的开发中要尽量避免这样的操作。

硬核方法,竞态条件

虽然上面说了这么多,但是其实我真正想要正式介绍给大家的却是另一个方法,那就是Race Condition,也就是竞态条件

为什么说这个方法比较硬核呢,是因为它是在底层的内存上修改一个变量的值,而不是通过一些所谓的技巧去让上面的表达式成立。而且这在现实的开发中是可能会出现的一种情况。在进入下面的讲解之前,我们需要先了解一些前置的知识点。

  • SharedArrayBuffer

SharedArrayBuffer对象用来表示一个通用的,固定长度的原始二进制数据缓冲区,类似于 ArrayBuffer对象,它们都可以用来在共享内存上创建视图。与ArrayBuffer不同的是SharedArrayBuffer不能被分离。详情可以参考SharedArrayBuffer

  • Web Worker

Web WorkerWeb内容在后台线程中运行脚本提供了一种简单的方法。线程可以执行任务而不干扰用户界面。此外,他们可以使用XMLHttpRequest执行 I/O (尽管responseXML和channel属性总是为空)。一旦创建, 一个worker 可以将消息发送到创建它的JavaScript代码, 通过将消息发布到该代码指定的事件处理程序(反之亦然)。详情可以参考使用 Web Workers

了解了前置的知识我们直接看接下来的代码实现吧。

  • index.js
// index.js
const worker = new Worker('./worker.js');
const competitors = [
   new Worker('./competitor.js'),
   new Worker('./competitor.js'),
];
const sab = new SharedArrayBuffer(1);
worker.postMessage(sab);
competitors.forEach(w => {
   w.postMessage(sab);
});
  • worker.js
// worker.js
self.onmessage = ({ data }) => {
   const arr = new Uint8Array(data);
   Reflect.defineProperty(self, 'a', {
      get() {
         return arr[0];
      },
   });
   let count = 0;
   while (!(a === 1 && a === 2 && a === 3)) {
      count++;
      if (count % 1e8 === 0) console.log('running...');
   }
   console.log(`After ${count} times, a === 1 && a === 2 && a === 3 is true!`);
};
  • competitor.js
// competitor.js
self.onmessage = ({ data }) => {
   const arr = new Uint8Array(data);
   setInterval(() => {
      arr[0] = Math.floor(Math.random() * 3) + 1;
   });
};

在开始深入上面的代码之前,你可以在本地运行一下上面的代码,在看到结果之前可能需要等上一小会。或者直接在这里打开浏览器的控制台看一下运行的结果。需要注意的是,因为SharedArrayBuffer现在仅在Chrome浏览器中被支持,所以需要我们使用Chrome浏览器来运行这个程序。

运行之后你会在控制台看到类似如下的结果:

158 running...
After 15838097593 times, a === 1 && a === 2 && a === 3 is true!

我们可以看到,运行了15838097593次才出现一次相等。不同的电脑运行这个程序所需要的时间是不一样的,就算同一台机器每次运行的结果也是不一样的。在我的电脑上运行的结果如下图所示:

Chrome控制台打印结果

下面我们来深入的讲解一下上面的代码,首先我们在index.js中创建了三个worker,其中一个worker用来进行获取a的值,并且一直循环进行比较。直到a === 1 && a === 2 && a === 3成立,才退出循环。另外两个worker用来制造Race Condition,这两个worker一直在对同一个地址的数据进行修改。

index.js中,我们使用SharedArrayBuffer申请了一个字节大小的一段连续的共享内存。然后我们通过workerpostMessage方法将这个内存的地址传递给了3个worker

在这里我们需要注意,一般情况下,通过WorkerpostMessage传递的数据要么是可以由结构化克隆算法处理的值(这种情况下是值的复制),要么是Transferable类型的对象(这种情况下,一个对象的所有权被转移,在发送它的上下文中将变为不可用,并且只有在它被发送到的worker中可用)。更多详细内容可以参考Worker.postMessage() 。但是如果我们传递的对象是SharedArrayBuffer类型的对象,那么这个对象的代表的是一段共享的内存,是可以在主线程和接收这个对象的Worker中共享的。

competitor.js中,我们获取到了传递过来的SharedArrayBuffer对象,因为我们不可以直接操作这段内存,需要在这段内存上创建一个视图,然后才能够对这段内存做处理。我们使用Uint8Array创建了一个数组,然后设置了一个定时器一直对数组中的第一个元素进行赋值操作,赋值是随机的,可以是1,2,3中的任何一个值。因为我们有两个worker同时在做这个操作,所以就形成了Race Condition

worker.js中,我们同样在传递过来的SharedArrayBuffer对象上创建了一个Uint8Array的视图。然后在全局定义了一个属性aa的值是读取Uint8Array数组的第一个元素值。 然后是一个while循环,一直在对表达式a === 1 && a === 2 && a === 3进行求值,直到这个表达式的值为true,就退出循环。

这种方法涉及到的知识点比较多,大家可以在看后自己在实践一下,加深自己的理解。因为我们在实际的开发有可能会遇到这种情况,但是这种情况对于我们的应用程序来说并不是一个好事情,所以我们需要避免这种情况的发生。那么如何避免这种情况的发生呢?我们可以使用Atomics对象来进行相应的操作。Atomics对象提供了一组静态方法用来对 SharedArrayBuffer对象进行原子操作。如果你很有兴趣的话,可以点击Atomics继续深入的探究,在这篇文章中就不再过多的讲解了。

解题的其它思路

字符编码

const a = 1; // 字符a
const a‍ = 2; // 字符a·
const a‍‍ = 3; // 字符a··
console.log(a === 1 && a‍ === 2 && a‍‍ === 3); // true

当你看到上面代码的时候,你的第一反应肯定是怀疑我是不是写错了。怎么可以重复使用const声明同一个变量呢?我们肯定不能够使用const声明同一个变量,所以你看到的a其实是不同的a,第一个aASCII中的a,第二个a是在后面添加了一个零宽的字符,第三个a是在后面添加了两个零宽的字符。所以其实它们是不一样的变量,那么表达式a === 1 && a‍ === 2 && a‍‍ === 3true就没有什么疑问了。

这个方法其实是利用了零宽字符,创建了三个我们肉眼看着一样的变量。但是它们在程序中属于三个变量。如果你把上面的代码复制到Chrome的控制台中,控制台就会给出很显眼的提示,提示的图片如下所示。

零宽字符在Chrome浏览器控制台的展示

如果你把上面的代码复制到WebStrom中,后两个变量的背景是黄色的,当你鼠标悬浮在上面的时候,WebStrom会给你一些提示,提示你对应的变量使用了不同语言的字符。

Identifier contains symbols from different languages: [LATIN, INHERITED]
Name contains both ASCII and non-ASCII symbols: a‍
Non-ASCII characters in an identifier

我们有时在开发中也会遇到这种情况,肉眼看明明是相等的两个值,比较的结果却是不相等的,这个时候可以考虑一下是不是出现了上面这种情况。

关于让a == 1 && a == 2 && a == 3为true,这篇文章涵盖了大部分的解决方法。每一个方法的背后都代表了一些知识点,我们的目的不是记住这些方法,而是需要了解这些方法背后的知识和原理。这样以后我们遇到了类似的问题才知道如何去解决,才能够做到举一反三。

这篇文章到这里就结束了,如果大家对这篇文章有什么建议和意见都可以在这里反馈给我,文章如有更新,会第一时间更新在我的博客dreamapplehappy/blog,关注我学习更多实用有趣的前端知识哟~

参考链接:


Recommend

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK