为什么局部内部类和匿名内部类只能访问final的局部变量或者成员变量

1.为什么局部内部类和匿名内部类只能访问final的局部变量或者成员变量?

  • final修饰的变量不可变
  • Java编译器实现的只是capture-by-value,并没有实现capture-by-reference。
  • 这个修改可能发生在new 匿名内部类之后
  • java值传递是通过copy方式传递
  • 每个内部类的实例都隐藏了一个指向外部类实例的引用。java只是没有显式地写出来而已。内部类访问外部类成员都是透过这个引用。之所以能有这个引用,是因为两者都是实例,都有自己的内存空间。而匿名内部类的外围环境函数只是一个函数,执行完之后,也就是匿名内部类诞生(初始化)完成的那一刻,它的生存周期就结束了。函数内部的局部变量(包括函数的参数)也就跟着被销毁了。所以产生出来的内部类根本无法像保留外部类的引用那样保留外围环境函数的引用。所以只能退而求其次,只保留一份局部变量的拷贝值。
  • 一个函数的成员变量在函数执行完之后必须销毁,是因为执行函数的内存开销是在栈上。每执行一个函数,都会在栈上压一个新的栈帧,函数的局部变量,包括参数都存在这个栈帧的局部变量表里。函数执行完之后,根据栈的LIFO顺序,当前栈帧就被从栈上弹出销毁。内存上就没有这个函数的痕迹了。
  • 匿名内部类内部,方法和作用域内的内部类内部使用的外部变量也必须是 final 的。
  • 内部类会自动拷贝外部变量的引用,为了避免:1. 外部方法修改引用,而导致内部类得到的引用值不一致 2.内部类修改引用,而导致外部方法的参数值在修改前和修改后不一致。于是就用 final 来让该引用不可改变。
  • java8新增lambda匿名内部类增加了语法糖;

    • 局部变量到匿名内部类后的这段代码没有做修改,则不需要使用final,它本身和final一样在这段作用域范围内没有修改;
    • 局部变量在外修改,则此变量需要在外复制一份临时变量或使用对象;
    • 局部变量在外无修改,在内修改则不能使用此局部变量,需要将其替换成对象,使指针不变。

先来看一下Java中的匿名内部类是如何实现的:

先定义一个接口:

public interface MyInterface {
    void doSomething();
}

然后创建这个接口的匿名子类:

public class TryUsingAnonymousClass {
    public void useMyInterface() {
        final Integer number = 123;
        System.out.println(number);

        MyInterface myInterface = new MyInterface() {
            @Override
            public void doSomething() {
                System.out.println(number);
            }
        };
        myInterface.doSomething();

        System.out.println(number);
    }
}

这个匿名子类会被编译成一个单独的类,反编译的结果是这样的:

class TryUsingAnonymousClass$1
        implements MyInterface {
    private final TryUsingAnonymousClass this$0;
    private final Integer paramInteger;

    TryUsingAnonymousClass$1(TryUsingAnonymousClass this$0, Integer paramInteger) {
        this.this$0 = this$0;
        this.paramInteger = paramInteger;
    }

    public void doSomething() {
        System.out.println(this.paramInteger);
    }
}

可以看到名为number的局部变量是作为构造方法的参数传入匿名内部类的(以上代码经过了手动修改,真实的反编译结果中有一些不可读的命名)。

如果Java允许匿名内部类访问非final的局部变量的话,那我们就可以在TryUsingAnonymousClass$1中修改paramInteger,但是这不会对number的值有影响,因为它们是不同的reference。

这就会造成数据不同步的问题。


作者:RednaxelaFX
链接:https://www.zhihu.com/question/27416568/answer/36565794
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

闭包与对象是从两个完全不同的角度描述了一件事情:一段代码与其环境的关系。
所以可以用对象来模拟(实现)闭包,也可以用闭包来模拟(实现)对象。

用既有闭包又有对象的JavaScript来举例,
用闭包模拟对象的例子:

function makeCounter() {
  var count = 0;
  var incr = function () { count++; }
  var decr = function () { count--; }
  var value = function () { return count; }
  var dispatch = function (name) {
    switch (name) {
    case "incr": return incr;
    case "decr": return decr;
    case "value": return value;
    default: return null;
    }   
  }
  return dispatch;
}

var counter = makeCounter();
var v = counter("value")(); // 0

console.log(v);

counter("incr")();
counter("incr")();
counter("incr")();
counter("decr")();
v = counter("value")(); // 2

console.log(v);

这段代码完全没有用JavaScript内建的对象和对象属性访问机制(这里假定不把function看作对象嗯),纯用闭包来模拟了“对象”的行为。语法上不太好看但意思应该很明确。

反过来,用对象模拟闭包的例子,有几个现成的,我就不在这里写了。一个例子:Optimizing JavaScript variable access,用Map对象来模拟JavaScript的scope(也就是闭包的环境部分的实体)。

然后请看个我之前做的分享:SDCC 2012上做的JVM分享
主要是想给题主感受一下:It can always be done。

结合上面两点,答案就很清晰了:JVM有原生的基于类的对象支持,所以在JVM上实现一种支持闭包的语言只需要让该语言的编译器生成模拟闭包的类即可。

让我们考察下面两种情况:

  • 只有值捕获(capture-by-value):只需要在创建闭包的地方把捕获的值拷贝一份到对象里即可。Java的匿名内部类和Java 8新的lambda表达式都是这样实现的。
  • 有引用捕获(capture-by-reference):把被捕获的局部变量“提升”(hoist)到对象里。C#的匿名函数(匿名委托/lambda表达式)就是这样实现的。参考Eric Lippert大神对“hoist”一词的讲解。不要把这个“hoist”的用法跟JavaScript里说的把局部变量提前到函数开头来声明的那种用法混为一谈。

如果变量(variable)是不可变(immutable)的,那么使用者无法感知值捕获和引用捕获的区别。

  • 有些语言(例如C++11)允许显式指定捕获列表以及捕获方式(值捕获还是引用捕获),这样最清晰,不过写起来比较长;
  • 有些语言(例如JavaScript)只有引用捕获,要模拟值捕获的效果需要手动新建闭包和局部变量;
  • 有些语言(例如C#)对不可变量(const local)做值捕获,对普通局部变量做引用捕获;由于无法感知对不可变量的值捕获与引用捕获的区别,统一把这个行为描述成是引用捕获更方便一些。
  • 有些语言(例如Java)虽然目前只实现了值捕获,但是还要留着面子不承认自己只做了值捕获,所以只允许捕获不变量(final local),或者例如Java 8允许捕获事实上不变量(effectively final local)。这样,虽然实现用的是值捕获,但效果看起来跟引用捕获一样;就算以后的Java扩展到允许通用的(对可变变量的)引用捕获,也不会跟已有的代码发生不兼容。
  • 有些语言(例如Python)的lambda略奇葩,实现的是引用捕获,但是lambda内不能对捕获的变量赋值,只有原本定义那些变量的作用域里能对它们赋值。

回头再更新一些例子上来,讲讲具体的实现。有许多有趣的例子,例如说编译器可以区分哪些局部变量被捕获了而哪些没有;被引用捕获的局部变量既可以打包提升到同一个对象实例里(C#的做法),也可以每个变量单独提升到自己的一个对象里(Lua的upvalue做法);捕获和提升的时机也有一些选择范围。

Closures in Lua
看LuaJ实现的UpValue,非常直观的实现了Lua思路的upvalue——每个被捕获变量有自己的upvalue对象实例。

作者:RednaxelaFX
链接:https://www.zhihu.com/question/28190927/answer/39786939
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

Java 8语言上的lambda表达式只实现了capture-by-value,也就是说它捕获的局部变量都会拷贝一份到lambda表达式的实体里,然后在lambda表达式里要变也只能变自己的那份拷贝而无法影响外部原本的变量;但是Java语言的设计者又要挂牌坊不明说自己是capture-by-value,为了以后语言能进一步扩展成支持capture-by-reference留下后路,所以现在干脆不允许向捕获的变量赋值,而且可以捕获的也只有“效果上不可变”(effectively final)的参数/局部变量。
关于Java闭包的讨论可以参考我之前的另一个回答:JVM的规范中允许编程语言语义中创建闭包(closure)吗? - RednaxelaFX 的回答

但是Java只是不允许改变被lambda表达式捕获的变量,并没有限制这些变量所指向的对象的状态能不能变。要从lambda表达式向外传值的常见workaround之一就是用长度为1的数组:

String[] a = new String[1];
... ( () -> a[0] = "a" );
return a[0];

JDK内部自己都有些代码这么做嗯…

这种做法可以叫做“手动boxing”。那个长度为1的数组其实就是个Box。
顺带一提,.NET标准库里也有类似这样的box,例如StrongBox(T) Class (System.Runtime.CompilerServices)。强大如C#,这种box仍然有它的用途,例如配合DLR的expression tree使用——跟C#语言倒没啥关系。

当然后面的回答提到的一个观点也值得强调一下:带副作用的lambda表达式请小心使用。

© 版权声明
THE END
喜欢就支持一下吧
点赞7 分享
评论 抢沙发

请登录后发表评论