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/
将语法树丢给解析器,解析器
执行前会进行预编译(第一次扫描),用于生成一个变量环境scope
当遇见var是,收集变量名
当遇见函数时,收集函数名与函数的描述
第二次扫描,用于执行
遇见=进行scope的修改
遇见函数(子程序),执行函数(子程序)
执行子函数就是将子函数的描述(ast)丢给解析器
首先依然是预编译
不同于主程序的预编译,这里需要获取形参也需要实现获取调用函数的指向(this),不用在意是先声明名在赋值还是直接赋值,总之预编译结束后,我们可以在环境中获取到调用函数的指向和形参
执行同上
执行完以后会返回c()(),而后在执行下一个程序,此处省略这个过程
js执行5 -子程序预编译.png闭包是获取上级环境变量的需求
this指的是调用当前函数的指向,而父级作用域指的是语法(ast)的上一级
所以,需要将作用域链加入scope中
如果了解到,执行程序一定会扫描两次,而且执行完成后,会摧毁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实现了标准的面向对象的语法,了解闭包并不能提高代码质量,如果在这种情况下,还愿意了解闭包的,那都是真爱吧
网友评论