Oct 22

话说为什么大家都喜欢JS的闭包

Lrdcq , 2018/10/22 02:35 , 程序 , 閱讀(2658) , Via 本站原創
绝大部分语言,只要有函数,或者lambda或者java这种inline的方法重写功能,都有类似于闭包或者类似保障子程序中可以使用外界变量或者参数的特效,稍微高级一点的语言都有。不过要说各个开发者的钟爱程度,唯独JS开发者万事离不开闭包的样子。

那么JS的闭包有什么特点

一个典型的闭包是这样的:
//代码片段a
var main = function() {
  var closure = new ClassFun();
  var delayer = function() {
    console.log('print = ' + closure);
    closure = null;//把变量值为空
  }
  setTimeout(delayer,500);
}

main();

在delayer方法中,可以读取和操作外界的变量closure,这就形成了一个闭包。main当场执行完成之后被释放掉了,但是main中的局部变量被delayer方法持有,并且在500毫秒后delayer方法执行后才被释放——当然,这些大家都很熟悉了。

那么,如果没有闭包,我们会怎么处理这种情况呢?如果只是传递值,那很简单:
//代码片段b
var closure = new ClassFun();
var delayer = function() {
  var closure = argument.callee.closure;
  console.log('print = ' + closure);
  closure = null;//并不会修改到外界的closure
}
delayer.closure = closure;

很好理解,因为闭包会让被持有的方法持有被闭包引用到的变量,因此让方法持有变量的是一个基本闭包干的事情了——可是,js中=都是数值拷贝——不管指向的对象是否是引用。因此这时候delayer中的closure修改,是无法修改到外界closure变量的内容的。

这个时候我们似乎需要一点更高级,更高级的特性,比如如果我们有指针?
//代码片段c
var closure = new ClassFun();
var *closure_p = &closure;
var delayer = function() {
  var *closure_p = argument.callee.closure_p;
  console.log('print = ' + *closure_p);
  *closure_p = null;//这时closure就被改变了
}
delayer.closure = closure_p;

嗯,这样就能实现js中闭包给我们实现的功能了。但是,可惜js中并没有指针功能。

意识到什么了么?是的之所以我们在编写复杂的库或者框架,在js中可以用闭包来完成相当多复杂的功能还能用得很爽。是不是就是因为,闭包给我们提供了js本不提供的,指针能力?。Answer get!

那么实际上js的closure是个啥

如果我们能检查到对象的内存状态和引用状态,这件事就很好办了。可惜目前js对内存操作并不是很方便,有一个WeakRef的方案刚进入stage3还差得远呢,试了一下node中使用人数最多的node-weak,似乎有bug。那么,还是老老实实的在chrome中断点加heapdump吧。



首先,如上代码片段a,我们在方法执行的时候断点,chrome就能展示出当前环境中引用到的闭包:Closure (main)来表示这是main方法上下文中的闭包。

點擊在新視窗中瀏覽此圖片

对应在heapdump后,我们能在(closure)对象区域中找到main方法与delayer方法各自的闭包对象的情况:

點擊在新視窗中瀏覽此圖片
點擊在新視窗中瀏覽此圖片

注意到:

- 其实是delayer方法的闭包引用了closure对象,通过这个闭包的context属性。

- 不过好像其实每一个方法闭包都有一个context的属性,只是引用有特别的闭包上下文的context看起来是新建的(比如delayer的Context@1239)其他人都是引用的同一个NativeContext。

- delayer的Context还有一个链式属性previous,引用回了NativeContext。

- 稍微熟悉jscore的同学们对以上变量实际是什么会有大概的概念了。closure对象和Context对象都是内部js对象,负责js对象的关联的。而NativeContext就是我们native的JSContext,代表了当前每一个js运行环境对应一个JSVirtualMachine,当然,它也是对应vm的GCRoot。



那么这样看来,上图中delayer持有的那个Context就是我们所说的闭包对象了。我们继续做更多验证,看代码段d与在其中一个方法中dump的内存情况
//代码片段d
var main = function() {
  var closure_1 = new ClassFun();
  var closure_2 = new ClassFun();
  var delayer1 = function() {
    console.log('print = ' + closure_1);
  }
  var delayer2 = function() {
    console.log('print = ' + closure_2);
  }
  setTimeout(delayer1,500);
  setTimeout(delayer2,1500);
}

點擊在新視窗中瀏覽此圖片

这个看起来就很高级了

- delayer1与delayer2虽然只是分别闭包了closure_1与closure_2,但是他们的Context是同一个,并且是同时持有closure_1与closure_2。

- 也就说,闭包并不是以使用闭包的函数为单位的,而是以声明闭包变量的空间为单位的——比如这里是main。这就解释了为什么代码片段a中调试面板右侧写的是Closure (main)。

- 同时,这里揭示了一个一般不会注意的现象:一个函数中不同闭包变量的生命周期打包一起了。如上图closure_1与closure_2分别在500毫秒与1500毫秒时被使用,但是closure_1在500毫秒依然被闭包持有,直到1500毫秒后才被一同释放。这个通过heapdump也可以验证。



如果我们有多层闭包,会出现什么情况呢?
//代码片段e
var father = function() {
  var closure_1 = new ClassFun();
  var main = function() {
    var closure_2 = new ClassFun();
    var delayer1 = function() {
      console.log('print = ' + closure_1);
    }
    var delayer2 = function() {
      console.log('print = ' + closure_2);
    }
    setTimeout(delayer1,500);
    setTimeout(delayer2,1500);
  }

  main();
}

father();

點擊在新視窗中瀏覽此圖片

- 这里的闭包明显形成了一个链表关系:delayer->closure(main)->closure(father)->root。

- 和上面一样的问题,delayer2明明只依赖closure(main),但是closure(main)依赖closure(father)了,结果还是把closure_1绑在一起了。

- 也就是说,如果father里面有多个函数多个闭包,closure其实是一个树状结构,而比如delayer1中查找闭包的closure_1变量时,其实是从叶子结点一层一层往上找上去的。



如果我们再包一层,但是最里层什么也不干,会怎么样?
//代码片段f
var main = function() {
  var a = new ClassFun();
  var main2 = function() {
    console.log(a); //main的a 被 main2闭包

    var mian3 = function() {
      setTimeout(function() {
        //匿名函数
        //什么也不干
      },2000);
    }

    mian3();
  }

  main2();
}

main();

以上代码,按正常理解,a虽然进了闭包,但是被立刻执行。并且setTimeout匿名函数什么也没干,按常理思考,在我们强制gc后,a应该立刻被释放了。但是事实打脸了:

點擊在新視窗中瀏覽此圖片
點擊在新視窗中瀏覽此圖片

可以看到,完全不相干的a,居然被匿名函数持有了。反过来看匿名函数,它的context拿到了mian函数的闭包上下文
累计做类似的验证比较,我们会发现:

- 代码段e所看到的闭包context确实是只有存在闭包变量才会生成的。如上代码main2与mian3都没有闭包变量,所以匿名函数拿到的context不存在它们。

- 对应的,对于每一个上下文中的函数,不管它有没有用闭包,它的闭包context链条一定会向外包一路上所有存在的context串起来,直到native context。举个栗子的话,如果以上代码是这样的:
//代码片段g
var main = function() {
  var a = new ClassFun();
  var main2 = function() {
    console.log(a); //main的a 被 main2闭包

    var mian3 = function() {
      var b = new ClassFun();
      setTimeout(function() {
        console.log(b); //main3的b 被 匿名函数闭包

      },2000);
    }

    mian3();
  }

  main2();
}

main();

我们看到的匿名函数的context链条是->context(mian3)->context(mian)->contextNative,而context(mian2)不存在。



嗯,感觉看得差不多了,并且我们也得到了很多多余的知识。

所以,我们知道closure怎么实现了吧

我们没有指针,closure对象看起来用一个map替代来表示会比较合适,因此对于代码片段a,我们可以这样描述:
//代码片段a 改写
var main = function() {

  var closureContext = {};//生成闭包context
  closureContext.closure = new ClassFun();

  var delayer = function() {
    var context = arguments.callee.context;
    console.log('print = ' + closureContext.closure);
    closureContext.closure = null;
  }
  delayer.context = closureContext;
  setTimeout(delayer,500);
}

main();

- 我们每一个需要闭包的方法,会有一个closureContext上下文,参与闭包的变量都会标记在其中,并且交给其中的函数应用。

- 执行闭包的函数对其引用的上下文拆包,读取,修改其中的内容。



同样对于后面两个更复杂的情况:
//代码片段d 改写
var main = function() {

  var closureContext = {};
  closureContext.closure_1 = new ClassFun();
  closureContext.closure_2 = new ClassFun();

  var delayer1 = function() {
    var context = arguments.callee.context;//这个context是得到的使用context
    console.log('print = ' + context.closure_1);
  }
  delayer1.context = closureContext;

  var delayer2 = function() {
    var context = arguments.callee.context;
    console.log('print = ' + context.closure_2);
  }
  delayer2.context = closureContext;

  setTimeout(delayer1,500);
  setTimeout(delayer2,1500);
}

main();

//代码片段e 改写
var father = function() {
  var closureContext = {};
  closureContext.closure_1 = new ClassFun();

  var main = function() {
    var context = arguments.callee.context;
    var closureContext = {};
    //main既有使用的context,也生成了context并且串起来了
    closureContext._previous = context;
    closureContext.closure_2 = new ClassFun();

    var delayer1 = function() {
      var context = arguments.callee.context;
      console.log('print = ' + context._previous.closure_1);//所以这里其实应该是在_previous链上查找到的
    }
    delayer1.context = closureContext;
    var delayer2 = function() {
      var context = arguments.callee.context;
      console.log('print = ' + context.closure_2);
    }
    delayer2.context = closureContext;

    setTimeout(delayer1,500);
    setTimeout(delayer2,1500);
  }
  main.context = closureContext;

  main();
}

father();



通过以上代码,结合前面headdump的内容,我们也可以抽象出js闭包的核心概念了:

- 闭包对象,调试工具右边显示的那个,headdump中非native的context,模拟实现代码中的closureContext:一个函数,如果它的局部变量被内部其他函数闭包使用了,这个函数会生成一个闭包对象,并且持有这些变量的地址引用。

- 函数闭包上下文,headdump中每一个函数的那个context属性,模拟实现代码中每个function.context:无论函数是否使用闭包特性,它都有这个属性并且值为当前函数定义上下文从内到外的所有闭包对象的链表,直到全局上下文。如果函数使用了闭包变量,将会从这个链表上查找对应变量的地址并进行操作。

- 以上对象的生成均在程序编译过程中决定,不会在运行时发生改变。

至此,就可以浅显易懂的描述出闭包是什么,也能一眼看清它为我们解决了什么问题了。

话说回来,为啥别的语言不那么在意闭包

我就从安卓/iOS的java与oc下手了。

首先看oc

oc的block引用外部变量方法真的多多了,常见的两种:
int a = 12;
__block int b = 12;

void(^main)() = ^ {
  NSLog(@"a = %d", a);
  NSLog(@"b = %d", b);

  //a = 18;//做不到
  b = 18;
};

main();

a和b代表了两种引用方式:

- b就和js一样,是真正的闭包,实际上确实是通过编译时生成的结构体_block_impl去持有b的指针__Block_byref_b_0,并且里面也就修改的指针,基本上就和代码片段c的意思一模一样。

- 而a其实是代码片段b的意思,把a的值在初始化时拷贝进了main这个block中,简单粗暴,那block中修改a确实无法影响到外侧了,所以oc直接在语法上禁止这种行为。

形成的闭包结构类似如下:
struct __main_block_impl_0 {
  struct __block_impl impl;
  struct __main_block_desc_0* Desc;
  int a;
  __Block_byref_b_0 *b; // by ref
  __main_block_impl_0(void *fp, struct __main_block_desc_0 *desc, int _a, __Block_byref_b_0 *_b, int flags=0) : a(_a), b(_b->__forwarding) {
    impl.isa = &_NSConcreteStackBlock;
    impl.Flags = flags;
    impl.FuncPtr = fp;
    Desc = desc;
  }
};

其中 int _a, __Block_byref_b_0 *_b 就是a的值和b的指针初始化时传递的地方。

对于java来说,就很难受了

java是不允许在内部修改外部的东西的,只能去读,对于参数变量,还必须声明为final。也就是说,java中全部是代码片段b那样的值拷贝,而不是引用,做不到js一样的闭包。如果我们尝试去修改它,会报错:

點擊在新視窗中瀏覽此圖片

说到这里,就觉得idea很高级了。值引用有一个破解的方法,就是和上文一样,把要修改的变量包在一个closureContext对象中传进去,修改对象的内容就行了。idea语法提示就是这样说的:

點擊在新視窗中瀏覽此圖片
點擊在新視窗中瀏覽此圖片

虽然很厉害但是这样写也太蠢了,只能说666。

结论

- 横向对比java的闭包,还是没有提供出js闭包这样的能力,并不好使。oc语言本身就有指针,灵活性也高,也没啥好说的。

- 而JS的闭包,提供了Javascript这门以引用为主的语言类似于指针的能力,并且实现与其他语言的闭包实现大相径庭,大大提升程序的灵活性,当然深受大家喜欢。


关键词:javascript , oc
logo