虽然内部类这一块的内容没有Java某些部分多,但是当我们在学习或工作中或多或少地接触到内部类时,就会因某些奇葩的问题而碰壁。确实,平常我们接触最多的就是集合类容器以及反射这两大版块,而内部类的接触和使用是相对少很多,最多就在事件监听器相关的代码时碰到内部类,因此我们就很容易在这块遇到问题。现在这就来将内部类的方方面面知识详细总结一下。此处主要参考的是《Thinking In Java》和《Java核心技术》。
什么是内部类?
一个类定义在另一个类中,不管它处于外部类的哪个作用域,这个类就是内部类。
内部类的种类
内部类有多个种类,细分下一共有4种不同类型内部类:
- 成员内部类(member innber class)
- 静态内部类(static inner class)
- 局部内部类(local inner class)
- 匿名内部类(anonymous inner class)
所有类型内部类的共性就是:“内部类可以访问外部类的数据和方法,不管它们是否被private
所修饰”,就像是内部类拥有它们似的。下面详细介绍这几种内部类。
成员内部类
- 定义
成员内部类直接定义在类中,它是外部类的成员之一,并且不使用static
修饰。
- 代码
成员内部类是最普通的内部类,但也是平常使用比较频繁的内部类,它直接定义在另一个类的内部。
1 | public class OuterClass { |
这看起来很简单,InnerClass
就是OuterClass
的成员内部类,而OuterClass
称为外部类或封闭类或外围类、英文术语<enclosing class>,通常我们叫它外部类。
成员内部类可以无条件地访问外部类所有成员,不管它们静态与否。
1 | interface Selector { |
从上面这段代码可以让我们很清楚地看到,成员内部类SequenceSelector
没有使用任何约束条件就访问到外部类Sequence
的成员items
。更进一步,外部类的所有成员都可以被成员内部类无条件访问,不管它们是不是静态的,也不管它们是什么可见性。
此刻,又引申出一个问题:如果外部类和成员内部类有同名成员,那在该内部类如何访问这些外部类的成员呢?
如果我们在内部类没有使用任何约束来访问外部类的同名成员,默认是访问成员内部类的成员,这是隐藏(hide)现象,通常发生在有继承关系的类之间,针对的是双方的成员变量、静态方法以及成员内部类。
成员内部类访问外部类的同名成员,此时必须使用这个特殊语法:外部类.this.同名成员变量
和外部类.this.同名成员方法
,这个统称为.this
语法.
1 | public class OuterClass { |
这时我们就可以这样推断了:一个普通的类,如果要访问它的数据成员或成员方法,首先要做的就是创建它的对象,然后再通过这个对象的引用进行访问。在外部类访问成员内部类的成员也是一样的道理,在进行访问之前肯定要先创建内部类的对象。
1 | public class OuterClass { |
从上面的代码示例可以看出:外部类只要有内部类对象的引用,内部类的任何成员都可以被轻松访问。
成员内部类是外部类的普通成员
成员内部类是外部类的普通成员,就像是类中其它的非静态元素一样,比如非静态数据成员和非静态成员方法。
类中的所有普通成员都是依赖于类对象的存在而存在的。这就像你有一张永动机的图纸,但你没有按照图纸来创建出永动机的实物,那图纸中所描绘的都不会存在。类就像一张蓝图,而对象则是蓝图的产品,而产品中的各种部件就是类中的成员实例。
所以这就可以推断出:“要使用成员内部类创建对象,首先要创建外部类的对象”。
此时,则要使用特殊语法。
使用this.new InnerClassName()
在外部类中(除静态块和静态方法)创建成员内部类对象,使用outerClassObj.new InnerClassName()
在外部类的外部以及外部类中的静态块和静态方法中创建内部类对象。这个统称为.new
语法。
1 | "unused") ( |
成员内部类是外部类的一个普通成员,所以它可以被各种访问权限修饰符所修饰:
private
修饰,那只能在外部类的内部访问protected
修饰,那只能在同一个包下或外部类的继承链中访问public
修饰,任何地方都可以访问它- 默认访问权限修饰,那只能在同一个包下访问
成员内部类内部不可以定义静态成员。
其实这一点也是很容易理解的。这好比我们知道在Java的普通成员方法中是绝对不可以定义静态数据,这和C/C++还是有区别的,Java定死的规则我们无法改变。
静态内部类
- 定义
静态内部类其实就是静态的成员内部类,在Java中有时也称为嵌套类(nested class);它也是直接定义在类中,并使用static
修饰。
- 代码
静态内部类是外部类的静态成员,它就像类中的其它静态元素一样,就像类的静态数据成员或静态方法,不与类对象相关联。
1 | public class OuterClass { |
静态内部类只能访问外部类的静态成员,而不能访问外部类的非静态成员,并且静态内部类能够定义静态成员
1 | "unused") ( |
如果不需要内部类对象与其外部类对象之间有联系,那么就使用静态内部类。在这4种内部类中,也只有静态内部类内部能够定义静态成员。
局部内部类
- 定义
局部内部类是定义在方法或任意的作用域中。
- 代码
局部内部类对外部世界完全隐藏起来,除了该内部类所在的作用域,其它地方都不可以访问到它。
1 | "unused") ( |
局部内部类是这4种内部类中平时最少用到的。
局部内部类不能被访问权限修饰符以及static
所修饰,但能够被abstract
或final
修饰。
1 | public class OuterClass { |
局部内部类能否访问外部类的非静态成员,那主要得看局部内部类是定义在哪里了。
1 | "unused") ( |
上面的代码也很好说明,局部内部类能否访问外部类的非静态成员,那得看所定义处的方法或作用域是否为非静态的。静态块和静态方法内定义的局部内部类肯定是不能访问外部类的非静态成员的,就像静态方法不能访问类的普通成员一样,因为没有this
关联。
局部内部类只能访问方法或作用域中定义的final
局部变量。
1 | "unused") ( |
这一点也是Java所规定死的,但是为何要这样做,文章后面会有详细解释。
匿名内部类
- 定义
从字面上就能理解,匿名内部类就是没有名字的内部类。
- 代码
匿名内部类可以在任意处定义,它隐式地继承一个父类或者实现一个接口
1 | interface OnChangeListener { |
此处可看到,定义匿名内部类时并不使用关键字class
,extends
,implements
;更不能被static
或final
所修饰。
并且,定义的通用格式为:1
2
3new SuperType {
...
};
匿名内部类没有命名的构造器,但是可以用实例初始化块达到构造器的效果。
1 | abstract class Base { |
对于匿名内部类而言,实例初始化块就是它唯一的构造器,有且仅有这一个构造器,而且不能重载它。很多时候,可以使用局部内部类来代替匿名内部类所产生的效果是一样的,但是这样代码就会变得冗长且难以维护。
匿名内部类经常被我们所用,但它的使用范围却非常有限,一般匿名内部类被用作编写事件监听代码,即接口回调。其中,图形化界面代码就经常用到。
1 | import java.awt.Button; |
最后在此啰嗦地补充一句,匿名内部类和外部类相互访问成员的规则与之前所介绍过的几种内部类一样。
内部类的深层细节
疑问1 - 成员内部类的访问权限
第一个疑问:为什么成员内部类能够无条件地直接访问外部类的非静态成员?之前在介绍那几种内部类的时候我们也看到:成员内部类能够无条件地访问外部类的所有成员,而同样是非静态内部类的局部内部类以及匿名内部类能否无条件地访问外部类的所有成员,那要取决于它们定义的位置。所以,此处就拿成员内部类作为例子,只要能够了解到它内在的原理,那我们就能用同样的原理去解释其它内部类了。
首先,我们来看看《Thinking In Java》里是如何描述这一个问题的:
当生成一个内部类对象时,此对象与制造它的外围类对象(enclosing object)之间就有了一种联系,实际上,此内部类对象必定会秘密地捕获一个指向那个外围类对象的引用。然后,在访问外围类的成员时,就会用那个引用来选择外围类的成员,因此,无需任何特殊条件就能访问外围类的成员。幸运的是,编译器会帮你处理所有的细节。
按照书上这么说,编译器会帮助我们处理内部类的所有细节,那这时我们就要来看看编译后字节码文件,或许就能得知其中实现的细节。
下面还是采用最简单的成员内部类代码作为例子。
1 | public class Outer { |
这里使用jdk自带的反汇编工具javap
查看java编译器编译java源代码后所生成的字节码,输入javap -c Outer$Inner>bytecode.txt
这条命令,这样对应类的字节码信息就会保存在文本文件中。
1 | Compiled from "Outer.java" |
上面这个就是Outer$Inner.class
的字节码信息。我们可以发现这和源代码很相似,但却有一点点不相同,或许这些不同之处就是编译器编译源代码后所处理的细节。
首先,我们把集中力放到第3行代码上。
1 | final com.gtr.Outer this$0; |
这是编译器编译成员内部类Inner后给它增加了一个数据成员,并且这个数据成员是指向外部类Outer对象的引用。
看到这里,或许你就能想到:内部类就是用这个额外添加的外部类引用this$0
访问外部类的非静态数据成员。
但此处还有个小小的疑问:那这个引用的值是从何而来的呢?
要解答这个问题则要从第5行内部类的构造器以及第8、9这2条字节码指令开始看了。
1 | public com.gtr.Outer$Inner(com.gtr.Outer); |
源代码中的成员内部类Inner并没有定义构造器,所以默认使用无参构造器,但是这里我们也可以很清楚地看到,编译器会给无参构造器添加一个参数,这个参数也是指向外部类对象的引用。接着,剩下的2条字节码指令所做的操作是至关重要的。这时,大家就算看不懂这些奇怪的字节码指令,想必也能猜到这2条指令所发挥的具体作用了吧?
其中,指令aload_1
就是把指向外部类对象的引用这一参数加载到操作数栈,而指令putfield
就是把刚才加载到操作数栈的引用值赋值给内部类的数据成员this$0
进行初始化。
最后小结一下:
- 在成员内部类中是使用指向外部类对象的引用成员
this$0
访问外部类的非静态成员,只不过这细节是编译器帮我们处理。 - 这也更有说服力地证明了之前所下的定论“要使用成员内部类创建对象,首先要创建外部类的对象”,
如果没有创建外部类对象,则无法调用已经被编译器修改过的成员内部类构造器为成员this$0
赋值,这也就无法创建成员内部类对象。
疑问2 - 必须用final修饰局部对象
同时有第二个疑问:局部内部类访问外部定义的局部对象,为什么这个对象就一定要被final修饰?这个问题对于初学者来说确实是一个难题,很多书都是一句话带过就是泛泛而谈,读者对此只能是一知半解,或者也就是死记硬背这是Java中无法改变的规定。估计大多数人刚学Java的时候,面对这个问题都是这种境况,包括我自己也是这样。不过也未必,如果你精通其它编程语言,特别是C/C++这种语言,或许能举一反三来理解Java这种做法也不是不可能的。
现在还是来看一下《Thinking In Java》里所说的再继续从字节码上深究也不迟。
如果定义一个匿名内部类,并且希望它使用一个在其外部定义的对象,那么编译器会要求其参数引用是
final
的。如果你忘记了,将会得到一个编译时错误消息。
这本书只提到过这一点,并且在内部类这一章也没有对这个问题给出更多的解释。对于初学者来说,利弊都有,但个人觉得书本还是应该把这一问题说清楚会更好。而另外一本书《Java核心技术》里面对这一问题解释得比较清楚,而且还用字节码说明了这一个问题。
现在使用一个匿名内部类作为例子。
1 | interface OnChangeListener { |
代码很简单,自定义了一个监听器接口,并使用它来定义了匿名内部类,并在匿名内部类内使用外部定义的局部对象。就像之前所说道那样,它们必须用final
修饰,不然会有编译时错误。Java为何要求我们这样做呢?下面就来逐步解开这个问题。
现在,我们再来仔细想想上面那段代码的执行流程:
- 在某个地方,使用
Outer
对象调用getOnChangeListener
方法 - 随后便返回实现
OnChangeListener
接口的匿名内部类对象 - 方法
getOnChangeListener
返回后执行完毕,方法结束,属于这方法的局部对象都会被销毁,然而返回的OnChangeListener
对象生命周期还没结束 - 在某个时刻,
onChange
方法被回调,执行内部的代码时使用了parameter
、localVarA
、localVarB
3个局部对象
按照这逻辑可推断出:匿名内部类的对象和局部对象两者的生命周期长短不一致,导致内部类对象会访问可能已经不存在的局部对象。
为了解决这一个问题,Java就运用了复制这种对策,从而“延长”局部对象的生命周期。
最终结果:实际上,局部内部类对象访问的是局部对象的复制品。
局部内部类对象访问外部定义的局部对象时,实际上访问的是局部对象的复制品,这样就铁定会出现下面这种情境:
在内部类中修改局部对象的值(实际上是修改局部对象的复制品),然后在内部类的外部再使用这个局部对象,发现它的值根本没有改变。所以,使用复制这种策略虽然解决了生命周期问题,但最终实现的却不是“局部内部类访问外部定义的局部对象“这个的语义。
现在,我们终于可以着手解决最后一个问题了:
怎样做才是使得局部内部类访问局部对象的复制品与局部内部类访问真正的局部对象两者的语义效果保持一致?
答案很明显:使用final
修饰局部对象!
我们想要了解更多细节,现在还是来看匿名内部类的字节码,执行命令javap -c -private Outer$1>bytecode.txt
后便可以得到以下字节码信息。
1 | Compiled from "Outer.java" |
匿名内部类访问外部定义的局部对象时,编译器对于基本类型和引用类型所作出的处理细节是有所不同的。
首先来看onChange
方法中的一条字节码指令1
13: bipush 31
这条指令的意思是:将一个常量加载到操作数栈,常量值是31。这个值刚好和源代码中localVarA
的值是一样的。这个值是存储在常量池中,当匿名内部类有代码要使用localVarA
时,便引用常量池中对应对象的值。对于在编译期就能确定字面值的基本类型局部对象,局部内部类都会直接引用常量池中对应对象的值。这是对于基本类型局部对象的解决方案。
而对于引用类型,或者更确切地说对于运行时才能确定其值的局部对象,解决方案又是如何?那就要重点关注字节码信息中的数据成员和构造器那部分。
1 | private final int val$parameter; |
我们可以很清楚地看到:
- 除了编译器给匿名内部类额外添加的外部类引用成员
this$0
外,还多了额外增添了2个被final
修饰的私有成员val$parameter
和val$localVarB
,在$
号之后,这2个成员的名字刚好和匿名内部类所使用的其中2个局部对象的名字一样。 - 同时,匿名内部类的构造器也发生了改变。除了之前就提及过的指向外部类对象的引用这一参数,这里还额外添加了2个参数,一个是
int
类型的,另一个是Integer
类型的。 - 在匿名内部类构造器的内部,也对
val$parameter
和val$localVarB
进行赋值初始化。
从上面这个细节我们就可以总结出一个规律:局部内部类访问外部定义的局部对象时,所访问的局部对象中有多少个的值是运行时才能确定的,那编译器就会给局部内部类添加多少个局部对象复制品成员。
所以,只要局部对象的值是运行时确定的,有1个就给内部类增加1个成员,有10个就增加10个,有100个就增加100个。并通过构造器传递外部的值对它们进行初始化。而成员也被final
修饰是保证数据的统一性,使得最终的结果保持之前一直在强调的那个语义:“局部内部类访问外部定义的局部对象”。
或许这时你会觉得复制这种对策并没有运用在基本类型的局部对象中。其实不然,使用final
后,其实就已经算是把复制对策运用进去了。因为使用fianl
后局部对象的值就在常量池中进行备份了。
以上的一步一步的推断都是十分重要的,或许一大段看下来会消化不了,不过慢慢看,自己也慢慢跟着这些步骤进行思考,也会渐渐明白其中的道理。
最后,想必大家都清楚为何要使用final
修饰局部内部类所访问的局部对象了吧。
使用final
就是在使用复制策略的情况下让局部内部类访问局部对象的复制品与局部内部类访问真正的局部对象2种不同行为的语义效果保持一致。
内部类的其他特性
定义在接口中的内部类
静态内部类可以作为接口的一部分,接口中的任何类都自动被public
和static
修饰。
1 | public interface ClassInterface { |
如果你想要创建某些公共的代码,使得它们可以被某个接口的所有不同实现所共用,那么在接口内定义内部类是最有效的。
从多层嵌套内部类中访问外部类的成员
不管内部类嵌套多少层,它都能无条件地访问所有它所嵌入的外围类的所有成员。
1 | class MNA { |
内部类的继承
我们都知道创建内部类对象时,就必须将内部类中那个指向外部类对象的引用成员进行初始化,而在内部类的子类用不再存在默认给定初始化的外部类对象。此刻,我们必须使用特殊的语法来说清楚外部类与内部类的子类之间的关联。
这个特殊语法在子类的构造器中使用:enclosingClassReference.super();
。
1 | class WithInner { |
我们可以看到内部类子类InheritInner
的构造器必须有一个指向外部类对象的引用参数,并且使用特殊语法在内部类中用这个引用调用父类(即内部类)的构造器;这两个步骤缺一不可,少一个步骤编译器都会报错,而Eclipse就会报错:
No enclosing instance of type WithInner is available due to some intermediate constructor invocation.
内部类可以被覆盖吗?
内部内能像方法一样被覆盖么?
1 | class Egg { |
当继承某个外部类并“覆盖”其中的内部类时,并没有什么特别的变化,两个内部类Yolk
是完全独立的两个实体,各自在自己的命名空间内。
为什么需要内部类
这个问题或许在平日自己写代码中也会感悟到一些:使代码更加简洁紧凑,加快开发效率,代码更便于后期维护什么的。但这些都并不是重点。
逻辑组织
使用内部类可以将一些逻辑相关的类组织在一起,并控制位于内部的类的可视性。
这里是一些之前就有过的例子以及Java源代码中出现过使用内部类的类。
1 | public class Sequence { |
1 | public class LinkedList<E> |
多重继承解决方案
Java虽然只支持单继承,但它也用接口和内部类这种折中的形式实现了多重继承的效果。
每个内部类都能独立地继承自一个接口的实现,所以无论外部类是否已经继承了某个接口的实现,对于内部类都没有影响。内部类使得Java中多重继承的解决方案变得完整。
一个类要实现多个接口的情况,可以使用单一类实现多个接口,或者可以使用内部类实现那些接口。
1 | interface A { } |
如果一个类要继承多个具体类或抽象类,而不是实现接口,那就只能使用内部类才能实现多重继承。
1 | class D { } |
实现回调和闭包
使用内部类+接口这种形式实现闭包(closure)与回调(callback)更灵活、更安全。
闭包是一个可调用的对象,它记录了一些信息,这些信息来自于创建它的作用域。而通过回调,对象能够携带一些信息,这些信息允许它在稍后的某个时刻调用初始的对象。使用内部类提供闭包和回调功能比使用指针更灵活、更安全。
1 | interface Incrementable { |
内部类Closure
实现了Incrementable
接口,以提供一个返回Callee2
的“钩子”(hook)——而且是一个安全的钩子。无论谁获得此Incrementable
的引用,都只能调用increment
方法,除此之外没有其他功能。
这段代码是《Thinking In Java》上闭包与回调的演示。
事件驱动编程
控制框架(control framework)是一类特殊的应用程序框架(application framework),它用来解决响应事件的需求,主要用来响应事件的系统被称作事件驱动系统**
这里给出的也是那本书里的源代码。这里所要描述的是基于时间触发的事件驱动系统。
1 | // the common methods for any control event |
1 | //the reusable framework for control systems |
1 | // this produces a specific application of the control system, all in a single class. |
1 | public class GreenHouseController { |