闭包

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


  
    
    
  
  
    
    
    
  

原因
var Hoisting【变量提升】

其写法与如下方式相同

var btns=document.getElementsByTagName('input');
var i;
for(i=0;i{
      alert(i);
    };
}

解决

  • let - es6
for(let i=0;i{
      alert(i);
    };
}
  • 函数作用域替代"块级作用域"
window.onload=function (){
  var btns=document.getElementsByTagName('input');
  for(var i=0;i{
    alert(i);
  };  
}

当然,自执行也可以

  • 闭包 - 创建新的环境
for(var i=0;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实现了标准的面向对象的语法,了解闭包并不能提高代码质量,如果在这种情况下,还愿意了解闭包的,那都是真爱吧

你可能感兴趣的:(闭包)