第二章: 回调 - 顺序的大脑

优质
小牛编辑
130浏览
2023-12-01

顺序的大脑

我相信大多数读者都曾经听某个人说过(甚至你自己就曾这么说),“我能一心多用”。试图表现得一心多用的效果包含幽默(孩子们的拍头揉肚子游戏),平常的行为(边走边嚼口香糖),和彻头彻尾的危险(开车时发微信)。

但我们是一心多用的人吗?我们真的能执行两个意识,有意地一起行动并在完全同一时刻思考/推理它们两个吗?我们最高级的大脑功能有并行的多线程功能吗?

答案可能令你吃惊:可能不是这样。

我们的大脑其实就不是这样构成的。我们中大多数人(特别是A型人格!)都是自己不情愿承认的一个一心一用者。其实我们只能在任一给定的时刻考虑一件事情。

我不是说我们所有的下意识,潜意识,大脑的自动功能,比如心跳,呼吸,和眨眼。那些都是我们延续生命的重要任务,我们不会有意识地给它们分配大脑的能量。谢天谢地,当我们在3分钟内第15次刷朋友圈时,我们的大脑在后台(线程!)继续着这些重要任务。

相反我们讨论的是在某时刻我们的意识最前线的任务。对我来说,是现在正在写这本书。我还在这完全同一个时刻做其他高级的大脑活动吗?不,没有。我很快而且容易分心——在这最后的几段中有几十次了!

当我们 模拟 一心多用时,比如试着在打字的同时和朋友或家人通电话,实际上我们表现得更像一个快速环境切换器。换句话说,我们快速交替地在两个或更多任务间来回切换,在微小,快速的区块中 同时 处理每个任务。我们做的是如此之快,以至于从外界看开我们在 平行地 做这些事情。

难道这听起来不像异步事件并发吗(就像JS中发生的那样)?!如果不,回去再读一遍第一章!

事实上,将庞大复杂的神经内科世界简化为我希望可以在这里讨论的东西的一个方法是,我们的大脑工作起来有点儿像事件轮询队列。

如果你把我打得每一个字(或词)当做一个单独的异步事件,那么现在这一句话上就有十几处地方,可以让我的大脑被其他的事件打断,比如我的感觉,甚至只是我随机的想法。

我不会在每个可能的地方被打断并被拉到其他的“处理”上去(谢天谢地——要不这本书永远也写不完了!)。但是它发生得也足够频繁,以至于我感到我的大脑几乎持续不断地切换到各种不同的环境(也就是“进程”)。而且这和JS引擎可能会感觉到的十分相像。

执行与计划

好了,这么说来我们的大脑可以被认为是运行在一个单线程事件轮询队列中,就像JS引擎那样。这听起来是个不错的匹配。

但是我们需要比我们刚才分析的更加细致入微。在我们如何计划各种任务,和我们的大脑实际如何运行这些任务之间,有一个巨大,明显的不同。

再一次,回到这篇文章的写作的比拟上来。在我心里的粗略计划轮廓是继续写啊写,顺序地经过一系列在我思想中定好的点。我没有在这次写作期间计划任何的打扰或非线性的活动。但无论如何,我的大脑依然一直不停地切换。

即便在操作级别上我们的大脑是异步事件的,但我们还是用一种顺序的,同步的方式计划任务。“我得去商店,然后买些牛奶,然后去干洗店”。

你会注意到这种高级思维(规划)方式看起来不是那么“异步”。事实上,我们几乎很少会故意只用事件的形式思考。相反,我们小心,顺序地(A然后B然后C)计划,而且我们假设一个区间有某种临时的阻塞迫使B等待A,使C等待B。

当开发者编写代码时,他们规划一组将要发生的动作。如果他们是合格的开发者,他们会 小心地规划。比如“我需要将z的值设为x的值,然后将x的值设为y的值”。

当我们编写同步代码时,一个语句接一个语句,它工作起来就像我们的跑腿todo清单:

  1. // 交换`x`与`y`(通过临时变量`z`)
  2. z = x;
  3. x = y;
  4. y = z;

这三个赋值语句是同步的,所以x=y会等待z=x完成,而y=z会相应地等待x=y完成。另一种说法是这三个语句临时地按照特定的顺序绑在一起执行,一个接一个。幸好我们不必在这里关心任何异步事件的细节。如果我们关心,代码很快就会变得非常复杂!

如果同步的大脑规划和同步的代码语句匹配的很好,那么我们的大脑能把异步代码规划得多好呢?

事实证明,我们在代码中表达异步的方式(用回调)和我们同步的大脑规划行为根本匹配的不是很好。

你能实际想象一下像这样规划你的跑腿todo清单的思维线索吗?

“我得去趟商店,但是我确信在路上我会接到一个电话,于是‘嗨,妈妈’,然后她开始讲话,我会在GPS上搜索商店的位置,但那会花几分钟加载,所以我把收音机音量调小以便听到妈妈讲话,然后我发现我忘了穿夹克而且外面很冷,但没关系,继续开车并和妈妈说话,然后安全带警报提醒我要系好,于是‘是的,妈,我系着安全带呢,我总是系着安全带!’。啊,GPS终于得到方向了,现在……”

虽然作为我们如何度过自己的一天,思考以什么顺序做什么事的规划听起来很荒唐,但这正是我们大脑在功能层面运行的方式。记住,这不是一心多用,而只是快速的环境切换。

我们这些开发者编写异步事件代码困难的原因,特别是当我们只有回调手段可用时,就是意识思考/规划的流动对我们大多数人是不自然的。

我们用一步接一步的方式思考,但是一旦我们从同步走向异步,在代码中可以用的工具(回调)不是以一步接一步的方式表达的。

而且这就是为什么正确编写和推理使用回调的异步JS代码是如此困难:因为它不是我们的大脑进行规划的工作方式。

注意: 唯一比不知道为什么代码不好用更糟糕的是,从一开始就不知道为什么代码好用!这是一种经典的“纸牌屋”心理:“它好用,但不知为什,所以大家都别碰!”你可能听说过,“他人即地狱”(萨特),而程序员们模仿这种说法,“他人的代码即地狱”。我相信:“不明白我自己的代码才是地狱。”而回调正是肇事者之一。

嵌套/链接的回调

考虑下面的代码:

  1. listen( "click", function handler(evt){
  2. setTimeout( function request(){
  3. ajax( "http://some.url.1", function response(text){
  4. if (text == "hello") {
  5. handler();
  6. }
  7. else if (text == "world") {
  8. request();
  9. }
  10. } );
  11. }, 500) ;
  12. } );

你很可能一眼就能认出这样的代码。我们得到了三个嵌套在一起的函数链,每一个函数都代表异步序列(任务,“进程”)的一个步骤。

这样的代码常被称为“回调地狱(callback hell)”,有时也被称为“末日金字塔(pyramid of doom)”(由于嵌套的缩进使它看起来像一个放倒的三角形)。

但是“回调地狱”实际上与嵌套/缩进几乎无关。它是一个深刻得多的问题。我们将继续在本章剩下的部分看到它为什么和如何成为一个问题。

首先,我们等待“click”事件,然后我们等待定时器触发,然后我们等待Ajax应答回来,就在这时它可能会将所有这些再做一遍。

猛地一看,这段代码的异步性质可能看起来与顺序的大脑规划相匹配。

首先(现在),我们:

  1. listen( "..", function handler(..){
  2. // ..
  3. } );

稍后,我们:

  1. setTimeout( function request(..){
  2. // ..
  3. }, 500) ;

稍后,我们:

  1. ajax( "..", function response(..){
  2. // ..
  3. } );

最后(最 稍后),我们:

  1. if ( .. ) {
  2. // ..
  3. }
  4. else ..

不过用这样的方式线性推导这段代码有几个问题。

首先,这个例子中我们的步骤在一条顺序的线上(1,2,3,和4……)是一个巧合。在真实的异步JS程序中,经常会有很多噪音把事情搞乱,在我们从一个函数跳到下一个函数时不得不在大脑中把这些噪音快速地演练一遍。理解这样满载回调的异步流程不是不可能,但绝不自然或容易,即使是经历了很多练习后。

而且,有些更深层的,只是在这段代码中不明显的东西搞错了。让我们建立另一个场景(假想代码)来展示它:

  1. doA( function(){
  2. doB();
  3. doC( function(){
  4. doD();
  5. } )
  6. doE();
  7. } );
  8. doF();

虽然根据经验你将正确地指出这些操作的真实顺序,但我打赌它第一眼看上去有些使人糊涂,而且需要一些协调的思维周期才能搞明白。这些操作将会以这种顺序发生:

  • doA()
  • doF()
  • doB()
  • doC()
  • doE()
  • doD()

你是在第一次浏览这段代码就看明白的吗?

好吧,你们肯定有些人在想我在函数的命名上不公平,故意引导你误入歧途。我发誓我只是按照从上到下出现的顺序命名的。不过让我再试一次:

  1. doA( function(){
  2. doC();
  3. doD( function(){
  4. doF();
  5. } )
  6. doE();
  7. } );
  8. doB();

现在,我以他们实际执行的顺序用字母命名了。但我依然要打赌,即便是现在对这个场景有经验的情况下,大多数读者追踪A -> B -> C -> D -> E -> F的顺序并不是自然而然的。你的眼睛肯定在这段代码中上上下下跳了许多次,对吧?

就算它对你来说都是自然的,这里依然还有一个可能肆虐的灾难。你能发现它是什么吗?

如果doA(..)doD(..)实际上不是如我们明显地假设的那样,不是异步的呢?嗯,现在顺序不同了。如果它们都是同步的(也许仅仅有时是这样,根据当时程序所处的条件而定),现在的顺序是A -> C -> D -> F -> E -> B

你在背景中隐约听到的声音,正是成千上万双手掩面的JS开发者的叹息。

嵌套是问题吗?是它使追踪异步流程变得这么困难吗?当然,有一部分是。

但是让我不用嵌套重写一遍前面事件/超时/Ajax嵌套的例子:

  1. listen( "click", handler );
  2. function handler() {
  3. setTimeout( request, 500 );
  4. }
  5. function request(){
  6. ajax( "http://some.url.1", response );
  7. }
  8. function response(text){
  9. if (text == "hello") {
  10. handler();
  11. }
  12. else if (text == "world") {
  13. request();
  14. }
  15. }

这样的代码组织形式几乎看不出来有前一种形式的嵌套/缩进困境,但它的每一处依然容易受到“回调地狱”的影响。为什么呢?

当我们线性地(顺序地)推理这段代码,我们不得不从一个函数跳到下一个函数,再跳到下一个函数,并在代码中弹来弹去以“看到”顺序流。并且要记住,这个简化的代码风格是某种最佳情况。我们都知道真实的JS程序代码经常更加神奇地错综复杂,使这样量级的顺序推理更加困难。

另一件需要注意的事是:为了将第2,3,4步链接在一起使他们相继发生,回调独自给我们的启示是将第2步硬编码在第1步中,将第3步硬编码在第2步中,将第4步硬编码在第3步中,如此继续。硬编码不一定是一件坏事,如果第2步应当总是在第3步之前真的是一个固定条件。

不过硬编码绝对会使代码变得更脆弱,因为它不考虑任何可能使在步骤前行的过程中出现偏差的异常情况。举个例子,如果第2步失败了,第3步永远不会到达,第2步也不会重试,或者移动到一个错误处理流程上,等等。

所有这些问题你都 可以 手动硬编码在每一步中,但那样的代码总是重复性的,而且不能在其他步骤或你程序的其他异步流程中复用。

即便我们的大脑可能以顺序的方式规划一系列任务(这个,然后这个,然后这个),但我们大脑运行的事件的性质,使恢复/重试/分流这样的流程控制几乎毫不费力。如果你出去购物,而且你发现你把购物单忘在家里了,这并不会因为你没有提前计划这种情况而结束这一天。你的大脑会很容易地绕过这个小问题:你回家,取购物单,然后回头去商店。

但是手动硬编码的回调(甚至带有硬编码的错误处理)的脆弱本性通常不那么优雅。一旦你最终指明了(也就是提前规划好了)所有各种可能性/路径,代码就会变得如此复杂以至于几乎不能维护或更新。

才是“回调地狱”想表达的!嵌套/缩进基本上一个余兴表演,转移注意力的东西。

如果以上这些还不够,我们还没有触及两个或更多这些回调延续的链条 同时 发生会怎么样,或者当第三步分叉成为带有大门或门闩的“并行”回调,或者……我的天哪,我脑子疼,你呢?

你抓住这里的重点了吗?我们顺序的,阻塞的大脑规划行为和面向回调的异步代码不能很好地匹配。这就是需要清楚地阐明的关于回调的首要缺陷:它们在代码中表达异步的方式,是需要我们的大脑不得不斗争才能保持一致的。