闭包

作者: 刘程源 | 来源:发表于2019-07-25 02:26 被阅读0次

    1.初识

    网上对闭包的解释非常多,大多数解释包含两个关键词,一个是获取,一个是变量,所以在初识时,我们可以认为,所谓的闭包就是

    获取变量的一种形式(方法/原理/实现....)

    无所谓于后面应该是什么,关键在于获取变量

    1.1 变量作用域

    //1.是否(应该)可以获取到
      console.log(a);
    function fn(){
      //2.是否(应该)可以获取到
      console.log(a);
      var a = 1;
      //3.是否(应该)可以获取到
      console.log(a);
    }
      //4.是否(应该)可以获取到
      console.log(a);
    

    这种对变量是否可以访问到的限制被称为作用域

    注:
    由于js作用域不是特别的"完善",下面,我们就先通过作用域实现更为完善的java来进行作用域方面的讨论

    1.2 java - 变量作用域

    think in java中描述

    大多数程序设计语言都提供了“作用域”(Scope)的概念。对于在作用域里定义的名字【变量】,作用域同时决定了它的“可见性”以及“存在时间”。在C,C++和Java里,作用域是由花括号的位置决定的。参考下面这个例子:

    {
      int x = 12;
      /* only x available */
      {
        int q = 96;
        /* both x & q available */
      }
      /* only x available */
      /* q “out of scope” */
    }
    

    作为在作用域里定义的一个变量,它只有在那个作用域结束之前才可使用。

    就这个例子而言,我们可以看到作用域有明显的嵌套关系
    假如,我们把外层的花括号称为父作用域,内部的花括号称为子作用域,我们是否可以说子作用域内可以访问父作用域内的变量?
    (子{}内可以访问父{}内的变量)?

    要验证这个问题,首先要了解作用域的产生和分类

    1.3 java - 作用域分类

    这里通过java生成{}语法,将其{}分为三类

    public class Test {//类级作用域
            public static void main(String []args) {//函数(全局方法)作用域
                {//块级作用域     
                }
            }
    
            void say(){//方法作用域
                {//块级作用域     
                }
            }   
    }
    
    • 类级
      伴随类生成的类级{}

    java中要求一个类就是一个文件,所以类级产生的{},这里可以理解为根{}

    • 方法级
      伴随方法生成的方法/函数级作用域

    java中任何的方法必须定义在类里,所以方法级{},这里可以理解为二级{}

    • 块级作用域
      生成在方法内的块级作用域

    正常情况下,无法使块包裹方法,所有块级{},这里可以理解为三级{}

    如果要验证子作用域内是否可以访问父作用域内的变量
    实际上就是

    在块级{}内,是否可以直接获取方法与类级{}的变量
    在方法{}内,是否可以直接获取类级{}的变量

    下面,通过几个例子来进行一定的验证这一说法

    • 例子1 - 块级与方法
    public class Test {
            public static void main(String []args) {
                 String name = "name";
                 for(int i =0;i<3;i++){
                    //应该不应该获取name?
                    System.out.println(name);
                 }
        }   
    }
    

    我们知道,这两种写法是一样的

    public class Test {
            public static void main(String []args) {
                 String name = "name";
    
                 System.out.println(name);
                 System.out.println(name);
                 System.out.println(name);
        }       
    }
    

    毫无疑问,如果块级{},无法获取方法级{}的变量,那他就是bug

    上面是有某个结果来逆推整体现象,这里需要了解一下编译原理,才能了解这里实现的具体原因

    3java作用域12.png

    主程序执行时,会生成一个执行空间使主程序可以依次执行,并生成一个变量空间以保存执行时所需要的变量

    3java作用域13.png

    当遇见块级作用域时,会开辟一个新的空间,并将块级内的描述和变量空间传递给新的空间


    3java作用域14.png

    在程序执行完后,摧毁整个空间

    • 例子2 - 方法 - 类
    public class Test {
            public String name1 = "name1";
    
            public void say(){
                //应该不应该访问?
                System.out.println(name1);
                {//应该不应该访问?
                    System.out.println(name1);  
                }
            }
    
            public static void main(String []args) {
                new Test().say();
        }   
    }
    

    因为块级{}可以访问方法级{}的变量,所以,这个问题的关键是方法级{}是否可以直接访问类级{}的变量

    这里通过编译原理来进行分析,当主程序遇见子程序,会如何处理

    3java作用域17.png 3java作用域18.png

    子程序被调用时,系统会开辟一个新空间,将子程序的描述,父级程序的指向,和形参传递给这个空间进行执行

    3java作用域19.png

    执行完以后,摧毁整个空间

    不同于块级作用域,当主程序执行子程序时,不会传递变量空间,只会给子程序形参和父级程序的指向

    可以看到,子程序只可以通过父级程序的指向(this)来间接的获取变量,而不能直接获取主程序的变量空间

    但实际上例子中的写法是可以的

    究其原因是因为,匿名this,也就是语法糖

    public class Test {
            public String name1 = "name1";
    
            public void say(){
                System.out.println(name1 == this.name1);
            }
    
            public static void main(String []args) {
                new Test().say();
        }   
    }
    
    • 例子3 - static
      有一个static引起的特例
    public class Test {
            public static String name1 = "name1";
            public String name2 = "name2";
    
            public void say(){
                //1.应该不应该访问? -- name1
                System.out.println(name1);
                //2.应该不应该相同 -- true
                System.out.println(this.name1 == name1);
            }
    
    
            public static void main(String []args) {
                new Test().say();
                //3.是否应该可以? -- name1
                System.out.println(name1);
                //4.是否应该可以? -- false
                System.out.println(name2);
        }   
    }
    

    这里实际上就是static的语义规则,如果可以,也是可以用匿名this来解释,不做过多的解释

    总之,我们知道,
    子作用域内是可以直接访问父作用域内的变量
    1.直接得到变量空间 如块
    2.通过匿名this,进行实现的 如方法

    • 例子4 - 匿名类/内部类
      java其实还提供了了一种,由类或方法(块)包裹另一个类的实现 - 匿名类或者内部类
    public class Test {
    
            interface Addition{
                public int exc(int a,int b );
            }
    
            public static void main(String []args) {
                String name = "name";
    
                Addition add = new Addition(){
                    public int exc(int a,int b ){
                        //1.是否可以或应该获取name? -- true
                        System.out.println(name);
                        //2.是否可以理解为? -- false
                        System.out.println(this.name);
                        return a+b;
                    }
                };
    
                System.out.println( add.exc(11,22));
        }   
    }
    

    1.如果你认定了,子{}内是可以直接访问父{}内的变量,那他就可以
    2.如果你认定了,开辟新空间后,直接写属性就是匿名this的语法糖,那他就不行

    但这种写法是可以的,很显然在匿名this之外,还有一种处理方式,就是闭包

    所以java的闭包我们就可以这样描述

    通过匿名this无法解释的直接调用非形参与实参的现象

    • 例子5 - 内部类
    public class Test {
            String name = "name";
    
            class Addition{
                public int exc(int a,int b ){
                    System.out.println(name); 
                    return a+b;
                }
            }
    
            public int say(int a,int b){
                return new Addition().exc(a,b);
            }
    
            public static void main(String []args) {
                System.out.println(new Test().say(11,33));
        }
        
    }
    
    • 例子6 - lambad
    public class Test {
    
            interface Addition{
                public int exc(int a,int b );
            }
    
            public static void main(String []args) {
                String name = "name";
    
                Addition addition=(int a,int b ) -> { System.out.println(name); return a+b;}; 
    
                System.out.println( addition.exc(11,33));
        }
        
    }
    

    lambad 表达式内,this更是具有特殊的意义

    1.4 java闭包 - 安全

    为了安全,实行闭包的变量将会强行final
    即,如果想修改闭包相关的属性,如

    public class Test {
    
            interface Addition{
                public int exc(int a,int b );
            }
    
            public static void main(String []args) {
                String name = "name";
                            //是否可以修改?
                name = "123";
                Addition add = new Addition(){
                    public int exc(int a,int b ){
                        System.out.println(name);
                        return a+b;
                    }
                };
    
                System.out.println( add.exc(11,22));
        }
        
    }
    
    public class Test2 {
            interface Addition{
                public int exc(int a,int b );
            }
    
            public static void main(String []args) {
                String name = "name";
                //是否可以修改?
                name = "123";
                Addition addition=(int a,int b ) -> { System.out.println(name); return a+b;}; 
    
                System.out.println( addition.exc(11,33));
        }
    }
    

    以下错误,总会有一个

    从内部类引用的本地变量必须是最终变量或实际上的最终变量
    从lambda 表达式引用的本地变量必须是最终变量或实际上的最终变量
    

    1.5 总结

    在java中,闭包
    是获取变量的一种实现,
    是通过匿名this无法解释的直接调用非形参与实参的现象

    有了这个初步认识以后,再看看专业书籍怎么描述

    2 深入

    闭包 = 环境 + 函数

    2.1 编译原理

    找一本编译原理的书籍,比如《程序设计语言》

    3.6章引入环境的约束

    在3.3节的讨论中可以看到,作用域规则如何确定了程序中一个给定语句的引用环境......

    浅约束

    ...这种让作为参数传递的子程序推迟建立引用环境约束的方式,称为浅约束...

    深约束

    ...在子程序第一次被作为参数传递时就做好环境约束,在该子程序被调用时恢复这个环境,是很意义的,这种引用环境的早期约束称为深约束...

    子章节子程序闭包

    为了实现深约束,需要创建引用环境的一种显示表示形式(一般而言,就是使子程序将来在被调用是在其中运行的东西),并将它与对有关子程序的引用捆绑在一起,这种捆绑起来产生的整体被称为闭包......

    简单来说
    闭包 = 环境 + 子程序(函数/方法)

    注:
    具体内容参考编译原理相关的书籍

    2.2 再看java

    以java中例子为例,可知


    1.png

    当执行子程序前,创建好子程序的描述,并开辟一个新的空间,子程序的执行环境

    当执行到子程序时,开辟一个新的空间,并将子程序的描述,形参,与子程序的执行环境交给新的环境,当程序执行完后,摧毁整个空间

    2.png

    因为每次执行时,闭包的环境都相同,所以修改name会引起java不允许执行环境数据的修改

    如果可以通过参数或this获取属性,不需要开辟新的空间,那自然也就不存在所谓的环境,也就不存在闭包

    2.3. js闭包 --作用域

    js闭包的整体体现与java相同,毕竟大家都是现代化编程语言,这种老概念早就达成高度一致了,这里引用了解java闭包的过程,来快速的梳理一下js的闭包

    首先是三级作用域,js也是有{}来描述作用域而且也是三级作用域

    • 根作用域 - 全局
    • 二级作用域 - 函数
    • 三级作用域 - 块

    这里沿用验证子作用域可以访问父作用域的逻辑来进行讨论

    • 例子1 函数与块
    function fn(){//函数作用域
        var name1 = "name1";
        console.log(name2);
        for(var i=0;i<3;i++){//块函数作用域
            var name2 = "name2";
            console.log(name1);
        }
        console.log(name2);
    }
    
     fn();
    

    依然是那句话,实现不了就是bug

    但这里有一个"bug",块级{}外,也可以访问到块级{}内的属性因为他违反了

    作为在作用域里定义的一个变量,它只有在那个作用域结束之前才可使用。

    的描述

    并非js的作用域不同于常见语言,而是与java匿名this一样,这也是一个骚操作语法糖

    简单地说,他与如下写法一直

        var name1 = "name1";
        var name2;
        console.log(name2);
        for(var i=0;i<3;i++){//块函数作用域
            name2 = "name2";
            console.log(name1);
        }
        console.log(name2);
    

    块级作用域内的变量会升级到最近的函数或全局作用域中,这就是var Hoisting【变量提升】
    当然,你也可以说这就是定义var的一个语义,无非是很长一段时间里,js有且只有var一种声明类型
    这也就造成了js有块级作用域,但你无法再块级作用域里声明变量的现象

    这里其实又有一个问题,java是经过编译的语言,他有骚操作语法糖很正常,js是解释型语言,它又是如何做到变量提升这种明显像编译过的骚操作语法糖呢?
    因为他会预编译。。

    历史早就证明,没有块级作用域是很容易出问题,这里顺带提一下块级作用域的通用替代方案
    块级作用域的替代方案 -- 自执行

    var name1 = "name1";
    (function(){//函数(模拟块级)作用域
      var name2 = "name2";
      console.log(name1,this.name1 == name1);//可访问全局变量
    })()
    console.log(name2)//无法获取
    

    在没有全面面向对象前(不存在的),这种通过函数级作用域模拟块级作用域的方法非常常见

    • 例子2 全局与块
    //全局作用域
    var name1 = "name1";
    
    for(var i=0;i<3;i++){
      console.log(name1)
    }
    

    不同于java,这里的三级{}可以直接写在根作用域下,我们把根作用域理解为一个匿名函数也是可以的

    • 例子3 全局与函数
      java可以用匿名this来解释类与方法间变量的直接引用问题
      毫无疑问,js也可以
    //全局作用域
    var name1 = "name1";
    
    function fn(){//函数作用域
        console.log(name1);
        console.log(name1 == this.name1);
    }
    
    fn();
    
    • 例子4 闭包

    回顾一下什么是闭包?
    通过匿名this,无法解释访问变量的现象

    java如何利用闭包?
    类或方法包含一个其他类

    编译原理对闭包的解释?
    闭包 = 函数 + 环境

    在加上对js语法的理解,我们可以这么认为
    在js中,直接定义函数,函数内可以通过匿名thiswindow访问全局变量(不算闭包)
    而后在函数内,在定义一个函数,在定义的函数内引入其他{}内的变量

    function fn(){//函数作用域
        var name2 = "name2";
        
        return function (){//另一个函数级作用域
            console.log(name2);
            console.log(name2 == this.name2);//this解释不了
        }
    }
    

    2.4 js闭包 -- 经典题

    2.4.1 经典题

    点击btn,获取btn被赋值的编号i

    <html>
      <head>
        <meta charset="utf-8">
        <script>
        window.onload=function (){
          var btns=document.getElementsByTagName('input');
          for(var i=0;i<btns.length;i++){
              btns[i].onclick=()=>{
                alert(i);
              };
          }
        };
        </script>
      </head>
      <body>
        <input type="button" value="1">
        <input type="button" value="2">
        <input type="button" value="3">
      </body>
    </html>
    

    原因
    var Hoisting【变量提升】

    其写法与如下方式相同

    var btns=document.getElementsByTagName('input');
    var i;
    for(i=0;i<btns.length;i++){
        btns[i].onclick=()=>{
          alert(i);
        };
    }
    

    解决

    • let - es6
    for(let i=0;i<btns.length;i++){
        btns[i].onclick=()=>{
          alert(i);
        };
    }
    
    • 函数作用域替代"块级作用域"
    window.onload=function (){
      var btns=document.getElementsByTagName('input');
      for(var i=0;i<btns.length;i++){
          btnShow(btns[i],i)
      }
    };
    
    function btnShow(btn,i){
      btn.onclick=()=>{
        alert(i);
      };  
    }
    

    当然,自执行也可以

    • 闭包 - 创建新的环境
    for(var i=0;i<btns.length;i++){
      btns[i].onclick=(i)=>{
        return ()=>{
            alert(i);
        }
      }
    }
    

    3. 深究

    如果还要深究js闭包的实现机制的话,那可能就需要具体的了解一下js执行的过程

    var a = "a";
    b="b";
    
    function c(){//函数作用域
        var d = "d";
        
        return function (){
            console.log(d);
        }
    }
    
    c()();
    
    ast.png

    首先获取抽象语法树,我们可以将后续的执行过程 == 遍历语法树

    如果想看语法树的结果,可以通过以下地址访问
    https://astexplorer.net/

    js执行1-预编译.png

    将语法树丢给解析器,解析器

    执行前会进行预编译(第一次扫描),用于生成一个变量环境scope
    当遇见var是,收集变量名
    当遇见函数时,收集函数名与函数的描述

    js执行2 -执行.png

    第二次扫描,用于执行
    遇见=进行scope的修改
    遇见函数(子程序),执行函数(子程序)

    js执行3 -子程序预编译.png

    执行子函数就是将子函数的描述(ast)丢给解析器

    首先依然是预编译
    不同于主程序的预编译,这里需要获取形参也需要实现获取调用函数的指向(this),不用在意是先声明名在赋值还是直接赋值,总之预编译结束后,我们可以在环境中获取到调用函数的指向和形参

    js执行4 -执行.png

    执行同上

    执行完以后会返回c()(),而后在执行下一个程序,此处省略这个过程

    js执行5 -子程序预编译.png

    闭包是获取上级环境变量的需求
    this指的是调用当前函数的指向,而父级作用域指的是语法(ast)的上一级
    所以,需要将作用域链加入scope中

    js执行5-2 -子程序预编译.png

    如果了解到,执行程序一定会扫描两次,而且执行完成后,会摧毁scope以及闭包需要创建一个新的环境的需求,那在执行这段程序时,他又有可能是这样的

    js执行5-3 -子程序预编译.png

    如果还想在深入,可以了解以下内容

    • 词法作用域
      静态作用域/动态作用域

    动态作用域
    在bash环境下运行以下代码,求输出结果

    #!/bin/bash
    a=a
    function b () {
        a=$a+b;
        echo $a;
    }
    function c () {
        local a=2;
        b;
        b;
    }
    b
    c
    
    
    read -n 1
    

    我们可以简单的理解为,子程序运行时有且只有一块作用域,即从主程序到子程序后,只存在一块子程序变量环境空间,待子程序调用子子程序时,依然引入该变量环境,待返回到主程序后,该作用域被摧毁
    即变量的约束依赖函数的调用链称为动态作用域

    静态作用域就是每个函数,包括子子程序,都有自己的环境空间,用以杜绝互相干扰的问题,并利用作用域链,实现{}之间的语法关系,也就是与运行时环境无关,只要看语法就可预测的结果
    即变量的约束依赖函数的作用域链称为静态作用域

    • 作用域规则
      就是那个3.3章节的讨论

    • 作用域链
      作用域的集合

    • gc回收
      3.3章会有描述
      这里特指具体的算法

    等更加编译原理的内容,此处不再赘述

    闭包的历史与总结

    这里顺便简述一下闭包的历史
    早期语言因为内存,实现复杂度等原因,以动态作用域为主的,所以虽然有名为封装理念,但执行的具体结果究竟是什么,只有运行时才知道,开发者需要一块只作用于具体函数的变量,即使他们是共享的也没问题

    这就是所谓的 闭包 = 函数 + 环境

    当到了静态作用域后,函数的执行内容完全可以预测,尤其是通过面向对象的努力,this,也从调用链转向到了作用域链上,程序也变得越来越可控,闭包也就越来的越可有可无
    就像java,如果代码规范严格点,怕不是这辈子都碰不到匿名类,但这并不影响我们实现功能

    当然在js中this有明显的指向调用链的意义,这也是解释脚本的普遍做法,而他早期的面向对象的方式也过于邪门,了解一些闭包总没有错,不过在es6实现了标准的面向对象的语法,了解闭包并不能提高代码质量,如果在这种情况下,还愿意了解闭包的,那都是真爱吧

    相关文章

      网友评论

          本文标题:闭包

          本文链接:https://www.haomeiwen.com/subject/qdgqrctx.html