-
学习须知
1.面向对象的相关请先查看面向对象入门
2.Java操作符、数据类型和流程控制请先查看Java数据类型、操作符和执行流程控制
-
一、Java中类和接口
-
1.1 类
根据***“面向对象入门”***可知,一个对象包含了数据(用来表示对象的状态或存储的内容)以及可以对数据操作的方法。而类描述了具有相同特性(数据元素)和行为(功能)的对象的集合,即是对象的一种抽象,用来定义对象的结构。
那么Java中就存在数据的定义方式和方法的定义方式 —— 注意:在这先不谈论数据的访问权限
//这里以Point类举例,另见“Java数据类型、操作符和执行流程控制”
public class Point { //定义一个类,名字叫Point,用来表示坐标轴上的某个点
private double x;
private double y; //分别用变量表示x,y来表示横坐标和纵坐标
public Point(double x, double y) { //构造器,用来创建一个Point对象
this.x = x;
this.y = y;
}
//两个get方法,分别用来返回x和y
public double getX() {
return this.x;
}
public double getY() {
return this.y;
}
//两个set方法,分别用来设置一个Point对象的x或y
public void setX(double x) {
this.x = x;
}
public void setY(double y) {
this.y = y;
}
public static void print(Point p) {
System.out.println("x:" + p.x + ",y:" + p.y);
}
public static void main(String[] args) {
Point p = new Point(1.0, 1.0);
Point anotherPoint ap = p; //重新声明了一个变量anotherPoint,类型同样是Point
print(p);
print(ap); //分别打印两个变量,可以看到打印结果相同
}
}
1.根据block作用域,上述的x、y、getX()、getY()、setX(double x)、setY(double y)、print(Point p)和main(String[] args)是同层级的。
2.其中x和y定义了一个Point对象需要具备的数据,即横坐标和纵坐标。
3.getX()、getY()、setX(double x)和setY(double y)四种方法用于访问和修改一个Point对象的横纵坐标。这些方法的定义符合“面向对象入门”中所说的“型构”的描述。
4.类中还定义了构造器“Point(double x, double y)”,可以看到其和方法不同的是它没有返回值;可以通过new关键字来调用构造器,可以实例化出一个Point对象。构造器有两个参数用来指定实例化出来的Point对象表示哪一个点。
-
1.1.1 构造器
1.构造器是Java实例化一个类的唯一途径(后续通过反射、序列化等来创建实例,本质还是调用构造器)。构造器就是为了让对象在被使用前,可以被恰当的初始化。
2.构造器没有返回值,一般和new关键字合用来实例化一个类,并得到一个对象的引用。
3.构造器必须与类名相同。
4.没有参数的构造器叫“无参构造器”或者“默认构造器”;如果在类的定义中没有任何相关构造器的代码,那么编译器会添加一个默认构造器,如果显式编写了构造器就不会添加默认构造器。
注意: 根据“Java数组的指定初始化值”的分析可知,实例化一个类得到的一个堆(heap)中对象,其内存空间被分配后也是全部赋予了默认值(基础类型赋予默认值,引用类型赋予null),然后在通过构造器的代码对相关数据重新赋值。 这种默认行为在实际解决问题的场景中没有现实意义,因为一个对象就应该有其确切的合理的数据才能完成其功能;这种初始化只是一种对Java之前语言的改进,避免出现完全性问题(如C和C++,C++的构造器就是初始化过程,但在构造器执行之前不会给对象的数据赋予默认值)。
注意:后续所说的初始化都是指代码层面的初始化,对象创建伊始的自动初始化不再强调!!!
构造器的特殊任务:检查对象是否被正确创建。 也就是说,一个类中只有其自身的构造器才能保证一个对象被正确初始化(正确初始化是指按对象合理的数据对实例进行初始化,而非仅仅是默认值,这一点上和C++是一致的)。另见“1.1.4初始化顺序”、“1.1.6继承”和“1.1.8super关键字”。
-
1.1.2 关键字this
1.this是当前对象的内部引用,在有些面向对象语言中是self。
2.对象被访问和操作只能通过引用,如下述代码表示给p发送了getX()消息,getX()的作用是返回对象的横坐标x。从而可以在外部得到某个Point对象的横坐标。
double x = p.getX();
那么方法getX()返回的是p的横坐标而非ap的横坐标,原因就在于this表示当前对象,上述的调用过程,消息的接收者就是p,所以当前对象就是p。
3.在setX(double x)、setY(double y)和构造器Point(double x, double y)中都出现了变量名和对象数据重名的情况,此时单一的变量名x或y所表示的是形参(指定了方法在被调用时需要传入的数据的类型,并用存储基本类型或引用类型的一个copy,参考“Java数据类型、操作符和执行流程控制”中“引用”类型一节),而非当前对象的数据(也叫实例变量)。所以这里通过this.x = x;的形式,将形参所代表的值赋值给当前对象。
4.假设有如下方法
// 一个移动的操作,用来将一个Point对象移动到一个位置
public void move(double x, double y) {
// this.x = x;
// this.y = y; 这里可以分别设置Point对象的x和y,但不这么做
setX(x);
setY(y); //因为设置Point对象的横纵坐标都存在,所以这里直接调用相应的set方法
}
对象只能通过引用来访问和操作,那么上述方法为什么可能编译和运行正常,且产生正确的行为?原因是,上述的调用过程默认有隐式的this,即和下述代码是一样的:
public void move(double x, double y) {
this.setX(x);
this.setY(y); //因为设置Point对象的横纵坐标都存在,所以这里直接调用相应的set方法
}
所以,一个方法内部如果调用了另一个方法,那么被调用的方法有默认的隐式this;如果在方法体中,对象的实例变量和局部变量(包括形参)不重名,那么此时也可以去掉显式的this,如
public void setX(double newX) {
x = newX;
}
5.this也可用于方法的返回值(在Builder模式比较常见)
public Point move(double x, double y) { // move方法执行后,返回一个Point对象
this.setX(x);
this.setY(y);
return this; // 返回当前对象,当前对象肯定是Point的某一对象
}
//在main中就可以有如下的调用
p.move(2.0, 2.0).move(3.0, 3.0).move(4.0, 4.0); // 一个Point对象的连续连续移动
//等效于
p = p.move(2.0, 2.0);
p = p.move(3.0, 3.0);
p = p.move(4.0, 4.0);
6.this也可以作为参数传递给其他方法。(这在许多设计模式中都有应用)
7.构造器重载和this
// 重载请参考下一小节
// 假设一个类有多种数据来源进行数据初始化,但在初始化前,有一些相同的相同的初始化过程,例如下面伪码
public Test() {
a;
b;
...
}
public Test(String filePath) { // 根据文件路径
a;
b;
...
}
public Test(Other other) { // 其他方式,Other可以表示互联网连接或者其他某种远程对象
a;
b;
...
}
//那么可以对第二种和第三种构造器进行改造
public Test(String filePath) { // 根据文件路径
this();
...
}
public Test(Other other) { // 其他方式,Other可以表示互联网连接或者其他某种远程对象
this();
...
}
根据“1.1.1 构造器”,构造器中的初始化代码其实是一种对象实例化的后置赋值行为,但在对象可用前被执行;再根据this表示当前对象,这种调用方式也就可以理解了,其实就是一种代码的复用形式,类似于在一个方法中调用另一个方法。
注意:这种语法只能作为构造器中的首个语句(另指明了在一个构造器内无法多次使用该语法) —— 理解: 构造器的代码是为了完成恰当的初始化,那么没有必要重复初始化;另外,如果不是放在首句,可能导致当前构造器的一些初始化过程被this()这种方式覆盖,放在后面的话,即使覆盖了this()中的内容,编写当前构造器的人员也应该明白他正在做的是什么。
-
1.1.3 方法重载(overload)
如果方法名唯一,那么就不会出现方法重载,方法重载出现的唯一理由是功能相同的方法需要处理不同的入参。(一个重要原因是,构造器也必须支持重载(对象可以通过不同参数和不同途径创建),所有构造器的名称都是和类名一致)
典型的例子就是Java类库中的工具类,如java.util.Arrays中:
public static String toString(int[] a) { ... }
public static String toString(boolean[] a) { ... }
//两个方法都是用于格式化输出数组的内容,只是两个方法处理的数据不同
方法重载只与方法名和参数列表(“方法签名”,比型构少了返回值)有关;具体调用了哪一个方法由传参决定。(子父类的方法重载还和访问权限相关)(这里先不讨论static关键字,只需知道方法的重载和static有无无关)
// 上述Arrays的重载例子,int[]和boolean[]完全是两种不同的类型,
// 的确可以通过传入的实参解析到正确的方法,但是如果存在向上转型,结果又会如何呢?
// 该例子从《Thinking in Java》复制得到
public class PrimitiveOverloading {
void f1(char x) { printnb("f1(char) "); }
void f1(byte x) { printnb("f1(byte) "); }
void f1(short x) { printnb("f1(short) "); }
void f1(int x) { printnb("f1(int) "); }
void f1(long x) { printnb("f1(long) "); }
void f1(float x) { printnb("f1(float) "); }
void f1(double x) { printnb("f1(double) "); }
void f2(byte x) { printnb("f2(byte) "); }
void f2(short x) { printnb("f2(short) "); }
void f2(int x) { printnb("f2(int) "); }
void f2(long x) { printnb("f2(long) "); }
void f2(float x) { printnb("f2(float) "); }
void f2(double x) { printnb("f2(double) "); }
void f3(short x) { printnb("f3(short) "); }
void f3(int x) { printnb("f3(int) "); }
void f3(long x) { printnb("f3(long) "); }
void f3(float x) { printnb("f3(float) "); }
void f3(double x) { printnb("f3(double) "); }
void f4(int x) { printnb("f4(int) "); }
void f4(long x) { printnb("f4(long) "); }
void f4(float x) { printnb("f4(float) "); }
void f4(double x) { printnb("f4(double) "); }
void f5(long x) { printnb("f5(long) "); }
void f5(float x) { printnb("f5(float) "); }
void f5(double x) { printnb("f5(double) "); }
void f6(float x) { printnb("f6(float) "); }
void f6(double x) { printnb("f6(double) "); }
void f7(double x) { printnb("f7(double) "); }
void testConstVal() {
printnb("5: ");
f1(5);f2(5);f3(5);f4(5);f5(5);f6(5);f7(5); print();
}
void testChar() {
char x = 'x';
printnb("char: ");
f1(x);f2(x);f3(x);f4(x);f5(x);f6(x);f7(x); print();
}
void testByte() {
byte x = 0;
printnb("byte: ");
f1(x);f2(x);f3(x);f4(x);f5(x);f6(x);f7(x); print();
}
void testShort() {
short x = 0;
printnb("short: ");
f1(x);f2(x);f3(x);f4(x);f5(x);f6(x);f7(x); print();
}
void testInt() {
int x = 0;
printnb("int: ");
f1(x);f2(x);f3(x);f4(x);f5(x);f6(x);f7(x); print();
}
void testLong() {
long x = 0;
printnb("long: ");
f1(x);f2(x);f3(x);f4(x);f5(x);f6(x);f7(x); print();
}
void testFloat() {
float x = 0;
printnb("float: ");
f1(x);f2(x);f3(x);f4(x);f5(x);f6(x);f7(x); print();
}
void testDouble() {
double x = 0;
printnb("double: ");
f1(x);f2(x);f3(x);f4(x);f5(x);f6(x);f7(x); print();
}
public static void main(String[] args) {
PrimitiveOverloading p =
new PrimitiveOverloading();
p.testConstVal();
p.testChar();
p.testByte();
p.testShort();
p.testInt();
p.testLong();
p.testFloat();
p.testDouble();
}
}
/* Output:
5: f1(int) f2(int) f3(int) f4(int) f5(long) f6(float) f7(double)
char: f1(char) f2(int) f3(int) f4(int) f5(long) f6(float) f7(double)
byte: f1(byte) f2(byte) f3(short) f4(int) f5(long) f6(float) f7(double)
short: f1(short) f2(short) f3(short) f4(int) f5(long) f6(float) f7(double)
int: f1(int) f2(int) f3(int) f4(int) f5(long) f6(float) f7(double)
long: f1(long) f2(long) f3(long) f4(long) f5(long) f6(float) f7(double)
float: f1(float) f2(float) f3(float) f4(float) f5(float) f6(float) f7(double)
double: f1(double) f2(double) f3(double) f4(double) f5(double) f6(double) f7(double)
*/
1.PrimitiveOverloading例子中,字面量5默认是被处理为int的(参考整型);
2.从PrimitiveOverloading可知,如果一个实参的类型范围小于等于方法形参类型,那么会一层层往上提升,解析到最符合实参类型的方法。另外char类型如果提升是先提升为int。
注意: 在“数值型基本类型的相互转换”中,其用虚线表明向上转型存在精度丢失,但未指明int、long、float和double的提升关系,从这里可以看到在提示时,int -> long > float -> double 。
public class Demotion {
void f1(char x) { print("f1(char)"); }
void f1(byte x) { print("f1(byte)"); }
void f1(short x) { print("f1(short)"); }
void f1(int x) { print("f1(int)"); }
void f1(long x) { print("f1(long)"); }
void f1(float x) { print("f1(float)"); }
void f1(double x) { print("f1(double)"); }
void f2(char x) { print("f2(char)"); }
void f2(byte x) { print("f2(byte)"); }
void f2(short x) { print("f2(short)"); }
void f2(int x) { print("f2(int)"); }
void f2(long x) { print("f2(long)"); }
void f2(float x) { print("f2(float)"); }
void f3(char x) { print("f3(char)"); }
void f3(byte x) { print("f3(byte)"); }
void f3(short x) { print("f3(short)"); }
void f3(int x) { print("f3(int)"); }
void f3(long x) { print("f3(long)"); }
void f4(char x) { print("f4(char)"); }
void f4(byte x) { print("f4(byte)"); }
void f4(short x) { print("f4(short)"); }
void f4(int x) { print("f4(int)"); }
void f5(char x) { print("f5(char)"); }
void f5(byte x) { print("f5(byte)"); }
void f5(short x) { print("f5(short)"); }
void f6(char x) { print("f6(char)"); }
void f6(byte x) { print("f6(byte)"); }
void f7(char x) { print("f7(char)"); }
void testDouble() {
double x = 0;
print("double argument:");
f1(x);f2((float)x);f3((long)x);f4((int)x);
f5((short)x);f6((byte)x);f7((char)x);
}
public static void main(String[] args) {
Demotion p = new Demotion();
p.testDouble();
}
}
/* Output:
double argument:
f1(double)
f2(float)
f3(long)
f4(int)
f5(short)
f6(byte)
f7(char)
*/
3.如果实参的类型范围大于形参的范围,那么在调用过程中,需要先将实参强制转换为窄类型。
-
1.1.4 初始化顺序
构造器可以为对象的数据提供初始化值,所以在面向对象编程的过程中,已经足够了。但在Java语法层面,提供了若干种初始化实例变量的语法。
// 首先需要了解一种语法,叫做“初始化块”,其的作用域是和对象的数据、方法同级的,其作用就是给对象的数据初始化
public class Test {
private int i;
// 根据构造器一节的内容可知,如果没有“初始化块”,无论调用哪个构造器来实例化,i都会先初始化0
// 而Test(int i)中将对象的i设置为入参的值
public Test(){
}
public Test(int i){
this.i = i;
}
{
i = 5; // 初始化块就是和对象实例变量同级的一个block
}
}
上述Test类加上了初始化块,在调用Test()实例化是,对象的i等于5;调用Test(int i)时,表现上i仍旧等于入参,但其细节并非如此。
可以通过在Test(int i)中在this.i=1;语句前加入一个打印语句,通过Test(int i)实例化对象时会打印该语句,其结果是5。所以可以推测***“初始化块在构造器执行之前执行”*** ,反编译得到的结果更是证明了此点。另外可以发现,在编译后,初始化块其实被编译进了每一个构造器中,插入在显式的代码语句之前。
//反编译代码如下
public class Test {
public Test();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: aload_0
5: iconst_5
6: putfield #2 // Field i:I
9: return
public Test(int);
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: aload_0
5: iconst_5
6: putfield #2 // Field i:I
9: aload_0
10: iload_1
11: putfield #2 // Field i:I
14: return
}
// 在构造器与this的介绍中,伪码可以改为如下形式
{
a;
b;
}
public Test() {
...
}
public Test(String filePath) { // 根据文件路径
...
}
public Test(Other other) { // 其他方式,Other可以表示互联网连接或者其他某种远程对象
...
}
// 对象实例变量的初始值还可以在声明时指定,如在Test的i声明处直接赋值
private int i = 100;
// 通过反编译,也可以看到其也是会被编译进每一个构造器中
// 声明赋值和初始化块编译后,在编译后的顺序,完全取决于其在代码中的前后顺序
// 若只是如private int i = 100;其余都不改动,反编译可以看出赋值100方式在赋值5之前
// 如果把初始化块提到声明赋值之前,如下
{
i = 5; // 初始化块就是和对象实例变量同级的一个block
}
private int i = 100;
// 那么编译后,赋值5就发生在赋值100之前,但两种都在构造器语句之前
// 自然的,实例变量的初始化也可以是某个方法的返回值,该方法调用也会被编译进构造器中
通过上面的分析可知,在初始化块中之所以可以直接引用到当前对象的实例变量,就是因为本质上其会编译进构造器中。
多个实例变量的初始化是根据其声明顺序进行的。但可能会遇到问题:
// 如果存在多个实例变量,那么其初始化过程和声明顺序相关
public class Test {
private int i = 20000;
private int j = 10000;
}
// 反编译得到的构造器是
// public Test();
// Code:
// 0: aload_0
// 1: invokespecial #1 // Method java/lang/Object."<init>":()V
// 4: aload_0
// 5: sipush 20000
// 8: putfield #2 // Field i:I
// 11: aload_0
// 12: sipush 10000
// 15: putfield #3 // Field j:I
// 18: return
//如果两个实例变量声明的顺序调换了,那么编译进构造器时也会调换
// 实例变量的初始化也可以调用方法,因为都会编译进构造器,默认就是用this隐式指定的
public class Test {
private int i = f(); // ① 等同于private int i = this.f();且编译通过
private int j = g(i); // ② 等同于private int j = this.g(this.i);且编译通过
public int f() {
System.out.println(this.i);
return 11;
}
public int g(int i) {
return i + 1;
}
}
// 上面的程序完全没有任何问题,是可以编译和运行的
// 如果调换了①和②的顺序,就会编译报错:非法前向引用
// 根据上述的一系列分析,无论哪种初始化,都会被编译进构造器中(按顺序放在构造器最前面),而构造器代码是在对象实例化、自动初始化后执行的
// 所以即使调换了顺序,也只是j会被赋值为1(i=0,自动初始化为0),但实际是直接编译报错了
//
// 可以理解为,Java编译器更趋向于上述方法为合理的初始化
// 按下述的方式显式的避免“非法前向引用”的立足点是对象实例化时有默认的初始化(即i和j先指定了0)
// 这样的编译期报错能更有效地提示程序员初始化顺序:一个变量在有效的初始化前不应该被使用,除非程序员清楚初始化过程从而显式的改变(比如用下述方式)
// 要想j=1 i=11,需要改为如下
public class Test {
private int j;
private int i; // 互换了申明顺序
{
j = g(i);
i = f();
}
public int f() {
System.out.println(this.i);
return 11;
}
public int g(int i) {
return i + 1;
}
}
-
1.1.5 类相关
关键字static修饰的内容是和类相关的,其可以修饰类和方法,从而得到“静态方法”和“静态变量”的术语(也就类方法或者类变量)。—— 对应的就是实例方法或实例变量,前文所提基本属于实例范畴
static修饰的内容一个重要规则是,无法访问实例变量和实例方法。
1.实际上,在概念层面,对象包含数据和方法;所以实例变量和实例方法都是和运行时实例化出来的对象绑定在一起的。而“和类相关”表示是和类本身绑定的,也就是说在使用类方法和类变量时,可能都没有对象的实例化。
2.以“人类”举例,其可以有一个眼睛数量eyeCount这样的实例变量,用以表示一个实例拥有的眼睛数量。分析可知,一个正常人的眼睛数量是固定的,可以在构造器中指定赋值为2;此时可以发现,所有人实例的眼睛数量都是2,由此可以衍生出一个人类本身的属性,可以取名eyeNumber,其值等于2。
private static final int eyeNumber = 2;
// private和fianl之后讨论
// private指定eyeNumber不会被外部代码访问
// final保证eyeNumber不会被修改
3.相反,某个实例对象是可以访问类变量和类方法的。理解:某个对象是通过实例化某个类创建的,那么这个对象可以访问类所属的内容是合理的。此时,eyeNumber的默认引用是类名Person,即Person.eyeNumber。如果用this来作为引用,并不会报错,但应该避免这样的使用方式。用对象的引用来调用类方法在编译时也不会报错,但编译的结果可以看出,字节码命令仍旧使用的invokestatic,且关联的是类名。实例方法的调用也被称为“给对象发送消息”,但对象作为前缀来使用static内容会造成混淆,因为此时根本没有给任何对象发送消息。
public Person (){
...
this.eyeCount = eyeNumber;
...
}
4.静态数据的初始化(这里不讨论加载的细节)
-
静态域的数据只会被初始化一次,发生在类被加载的时候。
-
a.实际在分配静态数据的内存时,数据已经进行了原始初始化;之后会根据显式的值重新赋值(基本类型直接修改内存,引用类型则引用堆中对象)。所以实际会发生多次赋值过程,只是整个初始化过程只会执行一次。
-
b.类加载会发生在某个类第一次被使用的时候,即静态域的访问或对象的创建。(通过class文件的二进制格式,可以发现构造器其实也是有static修饰的,所以可以归于 “静态内容被访问”)
-
c.如果静态域是一个引用类型并赋予了某个对象,那么被引用的对象所属的类也会被加载,一直到所需类全部完成加载。
-
1.1.6 继承
1.Java使用extends关键字来派生子类。
class Fruit extends Plant {}
class Apply extends Fruit {}
2.Java是单根继承(即只有一个直接父类),所有类(包括下述的Java接口)的最终父类都是java.lang.Object。
3.继承后数据初始化过程:
-
a.在使用某个子类被初次使用时会被加载进JVM,在加载过程中会依次加载其父类直到java.lang.Object;从Object依次往下完成静态数据的初始化过程 —— 这样确保子类在静态初始化过程中可以有效的使用父类静态数据。(参考“1.1.5”)
-
b.如果子类对象被创建,那么会在堆上分配数据内存(子类和父类的实例域都会被分配,即使出现重名)。从父类往下完成实例变量的初始化过程,直至子类对象完成初始化过程。(初始化过程参考“1.1.4”和“1.1.8”)
-
c.类变量是在类加载过程中分配内存和完成初始化的。在继承后,虽然可以通过“子类实例.父类static域”和“子类类名.父类static域”,访问和修改的内存空间是同一个。
-
1.1.7 方法重写(override)
当父类的某个方法不满足子类的行为,子类可以重写该方法。注意:这里的行为肯定是对外的行为,即外部可以给对象发送的消息,在概念层面属于接口,在语言层面就是满足访问权限。私有的内容无论是实例域还是方法,都是其实现的一部分,是被封装的。
根据重写的意思,是为了改变父类行为,所以自然而然需要被重写的方法型构与父类一致。Java语言中,方法重写可以不完全一致,只需要满足条件(不满足会编译报错):
- a.子类方法的返回值类型范围应该等于或小于父类方法的返回值,这被称为“协变返回类型”(从Java 5开始,并且返回值类型需要是继承或实现关系,基础类型间没有协变的情况)。
- b.子类方法声明抛出的异常的类型范围应该等于或小于父类所声明的。
- c.子类方法的访问权限要大于等于父类方法的权限。
- d.可以用@Override修饰被重写的方法,从而更为有效地进行编译器检查。
注意:静态方法不存在重写。即使编写相同型构的静态方法编译可过,@Override修饰后也会报错;另外静态的内容是和类本身相关的,而重写是发生在子父类对象之间的。
// 虽然静态方法不存在重写,但通过对象作为调用者调用时,仍旧会屏蔽父类中的类方法
public class Test {
public static int f() {return 1;};
}
class A extends Test {
// @Override 必须注释掉,否则编译器报错
public static int f() {return 2;}
public static void main(String[] args) {
System.out.println(new A().f()); // 2,这里和A.f()调用产生的字节码是类似的
System.out.println(Test.f());; // 1
}
}
// 1.1.9中的动态绑定提到,对象在其数据中保存了某种类型信息,从而为方法调用机制产生动态绑定服务
// 推测,第一次实际调用了A的静态方法,是通过对象解析到了产生的实例所属的类。
// *** 通过这样的方式似乎也发生了重写,在行为上表现出了子类行为,但实际并不是 ***
// 一个类的静态成员,还是提倡用其类名作为限定,而不是其的对象
-
1.1.8 关键字super
super的用法和this类似,可以在子类中作为父类方法或父类实例域的主调 ;也可用于放在构造器首句来指明调用某一个父类构造器。但和this不同,其作用是告知编译器查找父类对象的方法、实例域或构造器,并不是一个引用了父类对象的引用。
一般重写会有两种情况:
- 1.子类完全重写父类行为,子类和父类在某个消息的处理上完全不一致;
- 2.子类添加了父类的功能,也就是说子类在实现某个逻辑时,需要父类的逻辑并以此进行扩展。
而在语言层面,产生了方法重写,就等于抛弃了父类的实现,为了实现父类代码的复用又不直接copy代码,可以使用super来调用到父类的实现。
public class Super {
protected int i = 0;
protected int j = 1;
protected void f() {
System.out.println("super-f");
}
protected void g() {
System.out.println("super-g");
}
protected void x() {
System.out.println("super-x");
}
}
class Suber extends Super {
protected int i = 2; // 与父类中同名的实例域i
protected void f() { // 重写,模拟完全不与父类相同
System.out.println("suber-f");
}
protected void g() { // 重写,模拟扩展父类的实现
super.g(); // 调用父类的实现
System.out.println("suber-g");
}
protected void y() {
super.x();
System.out.println("suber-y");
this.x();
}
private void printField() {
System.out.println(this.i);
System.out.println(super.i);
System.out.println(this.j);
System.out.println(this.j);
}
public static void main(String[] args) {
Suber suber = new Suber();
// 方法
// 重写后的方法
suber.f(); // suber-f
suber.g(); // super-g suber-g
// 子类增加的方法
suber.y(); // super-x suber-y super-x
// 子类继承的方法
suber.x(); // super-x
// 直接输出实例域
System.out.println(suber.i); // 2
// 调用方法打印实例域
suber.printField(); // 2 0 1 1
}
}
- 上述例子中,f()和g()分别模拟了重写的两种情况;当然父类实现的调用时机完全由子类决定,并非需要第一个语句,但一般父类的实现放在首句以便无误地完成父类逻辑。
- 在父类中有一个x()方法,而在子类中有一个y(),两种没有发生重写,而在y()中通过this和super都成功调用了父类中的x()。
- 在main()中直接以子类实例suber作为消息x()的接收者,也是成功访问了父类的方法。
- 一般在面向对象编程中,不存在父类实例域和子类实例域重名的问题。如果存在同名情况,实际存储时也对应两个不同的内存空间,子类默认会屏蔽父类的同名实例域。如果需要在子类的实例方法中访问父类的同名实例域,也可以通过super,方法printField()模拟了这种情况。
- 静态成员都是和类相关的,所以即使重名了或重载了,在编译层面也是可以正确找到对应的域或方法。也就不存在super的用法了。
通过上述的现象,似乎super就是一个指定了父类对象的引用,从而去调用父类的实例域和方法。但方法printField中的行为又有些奇怪:对于同名的实例变量i,用super来区分时,的确像是一个引用;但是对于父类中的实例域j,在子类中可以同时使用this和super作为限定。根据this表示当前对象的说法,似乎子类实例复制了一份父类的实例域j。
class Value {
}
// 修改上述实例代码以验证j是否被copy
public class Super {
// 使用对象,而非基本类型,是避免使用基础类型时,数值相等情况造成的干扰
// 使用对象时,使用==表示两个引用指向同一个对象
protected Value value = new Value();
}
class Suber extends Super {
// 打印j
private void equalsField() {
System.out.println(this.value == super.value);
}
// 通过super关键字修改
private void setSuperField(Value value) {
super.value = value;
}
// 通过this关键字修改
private void setThisField(Value value) {
this.value = value;
}
public static void main(String[] args) {
Suber suber = new Suber();
suber.equalsField(); // true
suber.setSuperField(new Value());
suber.equalsField(); // true
suber.setSuperField(new Value());
suber.equalsField(); // true
}
}
通过修改和输出可知,如果在this表示当前对象和super表示父类对象的前提下,如果value(类比上一个例子中的j)会在子类实例中copy一份,那么表示this.value和super.value引用的是同一个对象,之后修改this和super中任意一个对Value的引用,都将导致equalsField输出false,但实际都输出了true。所以在存储上value只有一份,super不是一个对父类对象的引用:实例化子类的时候的确会给父类的实例域分配内存,但是在子类实例化完成后,在堆中只存在一个对象。
super不是一个对父类实例的引用,那么用super调用父类方法又是怎么成功的呢?实际一个对象除了其自身数据外,还包含了类型信息,如其所属的类,直接父类以及实现的接口等等。在某一个子类被JVM加载时,这些类型信息也会被加载,那么一个类的实例具体可以调用哪些实例方法就可以明确了,也就是说会为所有的类提供一个独立的方法表,该表列举了一个类的所有实例方法方法签名(包括从父类继承的,如果发生重写则指明属于子类)和方法体的入口地址,使用this和super的作用是指定查询哪一个方法表。
用super调用父类的构造器和用this调用本类的构造器有相似性,它们都必须放在构造体的第一个语句,如果没有显式调用,就会默认调用父类的无参构造器。如果没有显式调用父类构造器并且父类没有无参构造器,那么子类编译报错。
在1.1.4提到,所有在构造器外的初始化代码都会编译进构造器的构造体之前,但是插入的位置肯定在调用父类构造器之后。因为构造器是检验对象完成合理初始化的唯一途径(见1.1.1),为了确保在子类实例的初始化过程中,父类所定义的实例域已经准备就绪,那么只能通过父类构造器确保,所以一定有一个子类构造器显式或隐式的调用了父类构造器。 —— 这里用super来指定父类构造器,在一定程度上造成super是引用的误会。
由于super和this都只能是第一个语句,那么就不能同时使用。注意:构造器是无法循坏调用的,所以构造器发生重载时一定有一个构造器可以显式或隐式的调用父类的构造器。
// 编译无法通过,因为构造器在循坏调用
public Super() {
this(1);
}
public Super(int i) {
this();
}
另外,super不是引用的又一证据是:super不能像this那样进行赋值或作为方法返回值。
-
1.1.9 再论多态
-
1.1.9.1 向上转型
通过继承,可以将子类型当成是父类型,因为子类型包含了父类型的接口。即所有可以发给父类型对象的消息,子类型对象也可以接收并产生子类型对象的行为。
类型的继承结构一般是由下至上,故称为向上转型。
-
1.1.9.2 后期绑定
一个方法调用和该方法的执行主体(即方法体)联系起来,称为绑定。
后期绑定也可称为动态绑定和运行时绑定。即在运行时刻,根据消息接收的对象所属的类型,来寻找恰当的方法。编译器本身无法知道具体的对象类型,这是在运行时的调用机制确定的,所以需要在对象实例上安置某种“类型信息”,以便该机制可以找到正确的待执行方法体。
所以在编译期,编译器只需检查某个引用变量是否正确引用了该变量所属类型或子类型的对象,该引用变量的类型称为编译期类型,被引用的对象所属的类型称为运行期类型。但限制是编译器要求通过引用来操作对象时,只能发送父类所具备的消息。
Java默认就是动态绑定的。
-
1.1.9.3 多态
向上转型和动态绑定自然而然的就产生了“多态”。通俗的说,就是客户代码只需一个编译期类型,在运行时动态的引用运行期类型的实例,其产生的具体行为由该实例决定。这样相同的代码,在运行时就可以表现出不同的结果。
注意:只有实例方法具有多态的特性。根据多态产生的原因很好理解:它是和对象绑定的,所以static修饰的内容肯定没有多态特性。实例域也没有多态特性,即使访问权限允许,因为实例域(实例变量,field)是数据的一部分,而非行为的一部分;另外实际的编程过程中,也不会定义和父类实例域重名的实例域;并且实例域一般是私有不可直接访问的,即使重名了也是访问不到,this作为默认引用,其代表的是当前对象,而非父类对象。
-
1.1.9.4 构造器和多态
对象实例化过程中,自动初始化过程是和分配存储空间时同步进行的。意味着,子类对象在创建时,父类构造器执行完毕前,子类对象的所有实例域都完成了默认初始化。此时子类对象处于一个中间状态,其可以被使用,但是还没有完成程序所期望的初始化过程。
根据1.1.8关于方法调用的逻辑可知,多态的发生和数据初始化本身无关。如果在父类构造器中调用了某个方法来完成父类对象的初始化,而该方法又被子类重写了,那么在初始化过程中就会表现出多态,从而造成不可预测的问题(比如在子类的重写方法中,使用了子类对象的某个实例域,但此时子类对象只完成了默认初始化)。
-
1.1.10 清理
无论是组合还是继承,如果需要在对象销毁前做一些显式的动作,其销毁顺序应该符合对象创建时初始化过程的逆序过程。
-
1.1.11 instanceof
纯继承是指父类定义了完整的接口定义,已经确定它所能完成的全部功能。这时完全可以用子类对象来替换父类对象,在使用上除了接口完全不需要知道子类对象的额外信息。(“is a”,又称置换法则,表明出现父类对象的任何地方都可以用子类对象置换)
但子类往往会具有额外的方法来实现其他的特性。在一些情况下,就需要使用这些额外的方法。(is like a)。所以向下转型的唯一理由是:在暂时忽略对象的实际类型后,需要使用对象的全部功能。
向上转型是安全的,但无法确保向下转型的安全性,无法从一个编译期类型得到一个对象的实际运行期类型。在运行时如果需要向下转型,那么就会检查转换的类型是否是对象支持的类型,如果不是就会报出运行期异常ClassCastException。(运行时对类型进行检查的行为称为“运行时类型识别-RTTI”)
// instanceof 可以为正确的向下转型提供帮助
// 其判断某一个引用变量所引用的运行时对象,是否属于某个类的实例(注意:由于继承一个子类实例可以向上转型,也就可以说该子类实例是一个父类实例;同样适用于interface)
Object o = new String("hello");
String s;
if(o instanceof String) {
s = (String) o;
}
boolean isInteger = o instanceof Integer; // false
// Integer integer = (Integer) o; // 运行时异常 ClassCastException
// 该运算返回一个boolean值,表示被引用的对象是否是某个类的实例
// 根据instanceof运算符的运用场景可知,第一个操作数的类型和第二个操作数类型需要有继承关系(或者类型一致,类型一致那返回就是true,没有什么实际意义;如果第一操作数类型范围比第二操作数的小,也没有实际意义,因为向上转型是安全的)
Integer i = 123;
// boolean isString = i instanceof String; 编译错误
// null可以赋值给所有的引用变量,所以null可以作为instanceof的左侧操作数,右侧操作数可以是任何的引用类型,但恒返回flase。因为null表示还未引用对象,自然不会是第二个操作数的实例或者“子”实例
-
1.1.12 final关键字
1.其可以修饰数据、方法和类。根据该关键字“最后的”的意思,表示其修饰的内容是最终的,在一定程度上被修饰的内容无法被改变。
2.如果一个方法被final修饰,那么该方法无法被重写,以防止子类修改行为的实现。
// 注意:private修饰的方法表示只有本类可以调用,也就是private方法是一个类的实现的一部分(性质上和数据一样)。
// 所以private也就无法被重写,其在字节码层面某人会加上final修饰。
// 如果一个方法在父类是private的,在子类中有相同型构的方法,其也不是重写,只是一个属于子类的方法。
另外,在概念上方法重写发生在子父类对外的接口层面,Java中的权限控制扩大了这一范围。
3.如果一个类被final修饰,那么该类不能被继承。
// 此时只是表示该类不能被继承,并不影响final来修饰数据。
// 虽然可以用final修饰方法,没有实际意义(字节码层面不会被方法添加上final对应的code,静态方法也是)。
4.用final修饰数据时,其主要的含义是该变量无法被重新赋值。
// Java要求所有的变量在使用前,都需要被初始化
// 所以定义局部变量,在后续想要使用时,如果没有任何的值或引用,编译器会报错
// 用final修饰局部变量只是表明该局部变量无法被多次赋值,包括形参
public class Test {
// 形参的初始化由方法调用出入的实参决定
private static void f(final int i) {
System.out.println(i);
// i = new Random(10).nextInt(); Cannot assign a value to final variable 'i'
}
public static void main(String[] args) {
final int j = new Random(10).nextInt();
// j = new Random(20).nextInt(); 编译错误 Variable 'j' might already have been assigned to
f(new Random(30).nextInt()); // 当方法的参数用final修饰时,表示无法在方法内部为形参重新赋值
final Value value = new Value(new Random(40).nextInt());
// value = new Value(new Random(50).nextInt()); Cannot assign a value to final variable 'value'
value.setI(new Random(60).nextInt()); // 引用变量用final修饰后,其只是表明引用不可变,即只能引用一个对象,而对象本身的修改不受限制
}
}
class Value {
private int i;
public Value(int i) {
this.i = i;
}
public void setI(int i) {
this.i = i;
}
public int getI() {
return this.i;
}
}
注意:上述例子表明,final修饰的变量并不表明在编译期就明确了值(用nextInt在运行时生成的返回值给基本类型赋值)。另外,无法再次被赋值这一件事,对于基本类型的变量来说其值不会再改变; 对于引用变量来说只是无法再引用其他对象,对象本身的数据仍旧可以被修改(数组也是引用类型,故可以改变数组的内容;其大小不可改变是因为数组被实例化时,空间大小已定)。
// 对象的实例域有三个位置进行初始化:声明处、初始化块和构造器
// 类变量有两个位置进行初始化:声明处、静态初始化块
// 根据final修饰变量后,变量只能被赋值一次可知,这两种变量需要在其可能的初始化处选择其一进行赋值
public class Test {
private final int i = new Random(10).nextInt();
private final int j;
private final int k; // 如果将初始化块和构造器去掉,编译器会给该类添加一个构造器
// 该构造器需要两个参数,根据参数声明顺序作为形参顺序,给j和k赋值
// 如果主动编写了构造器,而没有完全覆盖final实例域的赋值,那么编译就会报错
{
j = new Random(20).nextInt();
}
public Test() {
k = new Random(30).nextInt();
}
// 这里引入一个空白final的概念:声明为final但未在声明处赋予初值的域(包括静态)
// 注意:这里使用new Random(int).nextInt()作为赋值方式是为了说明,final的值可以在运行时指定
// 在声明处或初始化块中赋值都是默认的赋值逻辑,即便不同对象在运行时产生了不同的值
// 实际上,可能需要外部信息来完成一个域是final的初始化,这就需要在构造器中增加参数来更为灵活的为final实例域赋值
private static final int I = new Random(40).nextInt();
private static final int J; // 静态域由于是在类加载过程中完成代码层面的初始化过程,所以必须要求在变量声明处或静态初始化块中指定
static {
J = new Random(50).nextInt();
}
}
注意:在1.1.4、1.1.5和1.1.6中关于初始化的过程中提到,无论是实例变量还是类变量,在分配空间后就会完成自动初始化(二进制零值)。所以上述的初始化指的是代码要求的初始化过程。所以final修饰符实际应该作用在内存分配并完成自动初始化之后。这里再次说明final变量被赋予的值可以在运行时产生。最后,在用final修饰后,两种变量无法为彼此覆初值,因为两者的初始化时机不同(静态方法或静态初始化本身无法直接操作实例域;而对象的初始化过程无法确保哪个对象是第一次创建,所以final类变量只能在加载时赋初值)。
// 字面量替换
// 1.使用final修饰的变量;2.在声明处赋值;3.被赋的值在编译期可以被确定
// 上述关于final的例子中,被赋的值都是在运行时动态产生的,在编译期无法知道
public class Test {
public static void main(String[] args) {
byte b = 1;
b = (byte) (1 + b); // 在讲述基本变量时,提到基本类型在运算中的提升,这里需要强制转换
byte two = 1 + 1;
// 这样是可行的,因为编译器可以计算出two的值,反编译结果
// 0: iconst_2
// 1: istore_1
final byte final_b = 1;
byte another_b = final_b + 1;
// 这里可以看到,用final修饰后,final_b有了字面量1的效果,反编译结果
// 0: iconst_2
// 1: istore_2
// 和1+1的区别是在第二个命令,差异是将int型值存入的位置不同
final short s;
s = 365;
short anotherS = (short) (s + 1);
// 如果未在声明处指定值,也无法替换成字面量
// 直接给出一个字面量数值,无法形成语句:Not a statement
// 若方法只有final byte final_b = 1;语句,反编译后方法直接return
// 如果先声明final变量再赋值,形成两条语句,那么反编译后就会有iconst_1和istore_1指令;和byte b = 1;的反编译结果相同
}
}
注意:这种“字面量替换”意味着在使用到变量的地方,本质使用了其值(字节码层面减少了取值和存值的过程);这种替换能作用在基本类型和String引用类型的变量上。只有在final变量在声明处初始化了编译期能明确的值时才有这种替换效果。。可能会想让一个方法是final的,用其返回值赋值来初始化final的局部变量,但仍旧不会有效果:因为用final修饰方法时表示方法无法被重写而不是作用在返回值上;另一方面方法调用发生在运行时。
String类和基本类型的包装类都是一个不可变类(不可变量是指实例化出一个对象后,该对象的数据无法被改变),这里对final的规则同样适用于String类。
// 那么是否static域也有替换效果呢?
public class Test {
public static final byte b = 1;
// static { ①
// b = 1;
// }
// public static final byte b = get(); ②
// public static byte get() {
// return 1;
// }
public static void main(String[] args) {
System.out.println(b);
}
}
// 如果声明为上述形式,那么main的反编译为
// public static void main(java.lang.String[]);
// Code:
// 0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
// 3: iconst_1
// 4: invokevirtual #4 // Method java/io/PrintStream.println:(I)V
// 7: return
// 如果声明为①和②的方式,那么反编译为
// public static void main(java.lang.String[]);
// Code:
// 0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
// 3: getstatic #3 // Field b:B
// 6: invokevirtual #4 // Method java/io/PrintStream.println:(I)V
// 9: return
// 对比可以发现,前者在字节码上使用的是iconst_1,而后者有了一个静态的调用过程getstatic
// 如果是final的实例域呢?
public class Test {
public final byte i = 1;
public byte j = i; // ①
// public byte j = 1; ②
}
// 使用①或②,反编译后构造器的字节码命令是相同的,可以说明final实例域i具有了替换效果
// 反编译结果
// public byte j = 1;
// public Test();
// Code:
// 0: aload_0
// 1: invokespecial #1 // Method java/lang/Object."<init>":()V
// 4: aload_0
// 5: iconst_1
// 6: putfield #2 // Field i:B
// 9: aload_0
// 10: iconst_1
// 11: putfield #3 // Field j:B
// 14: return
// public byte j = i;
// public Test();
// Code:
// 0: aload_0
// 1: invokespecial #1 // Method java/lang/Object."<init>":()V
// 4: aload_0
// 5: iconst_1
// 6: putfield #2 // Field i:B
// 9: aload_0
// 10: iconst_1
// 11: putfield #4 // Field j:B
// 14: return
// 对比发现第11:的命令参数不同,命令本身是一致的
// 变量i的赋值过程无法延迟到构造器中,因为构造器中的初始化过程是最后的,那么也就意味着给j初始化时,final的i没有被有效的初始化,就会报错
// 即使延迟到初始化块中,给i赋值的初始化块也必须放在i的声明之后,①语句之前,但此时没有替换效果
// public Test();
// Code:
// 0: aload_0
// 1: invokespecial #1 // Method java/lang/Object."<init>":()V
// 4: aload_0
// 5: iconst_1
// 6: putfield #2 // Field i:B
// 9: aload_0
// 10: aload_0
// 11: getfield #2 // Field i:B
// 14: putfield #3 // Field j:B
// 17: return
// 对比发现在给j赋值的过程中,多了从i取值的过程
// 如果调用方法来完成final实例域的初始化,也没有替换效果
// 可以有如下的声明方式,使用基础类型byte和short的+特性,进一步验证了替换
// 其中b和s换其他任何一种初始化方式,aByte和aShort编译都会报错,需要强制转换
public class Test {
// 声明了一个static常量,类型byte
public static final byte b = 1;
// 声明aByte正确,因为aByte可以在编译期确认为3
public static byte aByte = b + 2;
// 声明一个final的实例域,类型是s
public final short s = 1;
// 实例化处的对象,其aShort的值根据实例常量s也可以确定其值
public short aShort = s + 123;
public static void main(String[] args) {
// b是类常量,所以在访问权限允许的情况下,就是一个字面量
byte localByte = b + 1;
// 由于取到实例的s,必须先实例化,而实例化发生在运行时,所以必须强制转换
short localShort = (short) (new Test().s + 123);
}
}
-
1.1.13 可变参数列表
可变参数列表是指,方法的形参列表的最后一个参数,可以使用形如“int... ints”的形式作为形参类型声明,其表示可以匹配任意数量的实参。在一定程度上,其等效于“int[] ints”。方法中包含可变参数列表的方法称为“可变方法”。
public class Test {
// public void print(int[] ints) {}
// public void print(int... ints) {} 编译错误 无法在Test中同时声明print(int...)和print(int[])
}
// 虽然两者类似,但在反编译层面并不会将“int...”转换为“int[]”,所以两者不完全“等效”
// 另外,可变参数只能放在形参列表的最后(也就意味着一个方法只能有一个可变参数),而数组的形式可以放在形参列表的任意位置且数量不限
public class Test {
public static void f(int[] ints) {
System.out.println(ints.getClass()); // class [I
for (int anInt : ints) {
System.out.println(anInt);
}
}
public static void g(int... ints) {
System.out.println(ints.getClass()); // class [I
for (int anInt : ints) {
System.out.println(anInt);
}
}
public static void x(Object[] objects) {
System.out.println(objects.length);
for (Object object : objects) {
System.out.println(object);
}
}
public static void y(Object... objects) {
System.out.println(objects.length);
for (Object object : objects) {
System.out.println(object);
}
}
public static void main(String[] args) {
int[] ints = {1, 2, 3};
// 在调用上,显示的数组类型参数,传入的实参也必须是一个相应数组
f(ints);
// 而可变参数列表可以动态的增加传入实参的个数,在调用上更方便
g(4, 5, 6);
// 也可以将一个数组类型的数据作为实参调用g(int...)
g(ints);
// 可变参数列表也可以传入0个实参,而数组类型的参数需要更为繁琐的代码
f(new int[]{}); // 即f(new int[0]);
g();
// 通过打印方法参数的类型可知,可变参数在运行时实际得到的也是一个数组类型
// 当给可变参数出入若干实参时,编译器会完成填充数组的过程
// 可变参数和自动拆装箱可以一起使用
g(10, new Integer(20), 30);
// Object作为所有类型的最终父类,所有的类型都可以转换成Object
// 对于x(Object[])来说,需要明确传入一个Object[]的实参
x(new Object[]{"abc", 1, new Byte("2")});
// strings的类型是String[],意味着其每一个元素都是String,也是Object,所以String[]可以自动转型为Object[]
String[] strings = {"a", "b", "c"};
x(strings);
// 这里会有一个迷惑的行为,因为所有实例都可以说是一个Object的实例,那么一个String[]的数组实例自然就是一个Object实例
// 所以在理解上,可以认为y(Object...)的实参只有一个,就是一个String[]数组
// 但从输出可知,默认是将strings向上转型为了Object[],而非转成了单一的Object
y(strings); // 会有编译提示 Confusing argument 'strings', unclear if a varargs or non-varargs call is desired
y((Object[]) strings);
y((Object) strings); // 下述两种明确表明向上转换的类型
// 由于Object的特殊性才会有这样的现象,对于其他的子父类型,可以肯定也是向上转型为“父类型[]”
}
}
可变参数列表使得方法重载变得复杂:
public class Test1 {
public static void f(Object... objects) {
System.out.println("Object... objects");
}
public static void f(String... strings) {
System.out.println("String... strings");
}
// 不能同时声明f(String... strings)和f(String[] strings)
// public static void f(String[] strings) {
// System.out.println("String[] strings");
// }
public static void f(Object object) {
System.out.println("Object object");
}
public static void f(String string) {
System.out.println("String string");
}
public static void main(String[] args) {
// String[]可以向上转型为Object[]
f("abc", "dfg"); // 但这里会匹配最合适的方法,即String...
f("abc", 1); // 此时实参列表的类型不同,只能只用Object...
f("abc"); // 调用f(String string)
f(1); // 调用f(Object object)
f(); // 这里调用的是String...
// 如果增加一个f(Integer... integers)方法,那么f()就会编译报错
// Test 中的方法 f(java.lang.String...) 和 Test 中的方法 f(java.lang.Integer...) 都匹配
// 所以可变参数重载时,如果可变参数对应的数组类型可以安全向上转型,那么f()会选择最底层子类的可变参数方法
// 而String[]和Integer[]是不相关的数组引用,无法转型,所以无法确定调用哪个方法
// 通过上述输出,编译器可以找到最为合理的方法进行参数匹配
// 多个实参时优先选择最为合适的子类可变参数
// 如果是单个参数,那么选择最为合适的子类参数
// 这里和“1.1.3 方法重载”类似
}
}
public class Test2 {
// 在使用时,可以同时存在“Integer... integers”和“int... ints”重载方法,但无法调用
// 虽然Integer是基本类型int的包装类,但数组类型Integer[]和int[]完全是两种不同的引用类型
public static void f(Integer... integers) {
System.out.println("Integer... integers");
}
public static void f(int... ints) {
System.out.println("int... ints");
}
public static void main(String[] args) {
f(1, 2); // Test 中的方法 f(java.lang.Integer...) 和 Test 中的方法 f(int...) 都匹配
}
}
public class Test3 {
public static void f(int... ints) {
System.out.println("int... ints");
}
public static void f(char... chars) {
System.out.println("char... chars");
}
public static void main(String[] args) {
f(1, 2); // int... ints
f('a', 'b'); // char... chars
f('a', 1); // int... ints
// f('a', 1)中,'a'是基本类型的自动向上转型,是单个元素的向上转型,等效于new int[]{'a', 1}
// 而int[]和char[]是不同的类型,并不会像单个基本类型是那样自动向上转型
}
}
public class Test4 {
public static void f(String... strings) {
System.out.println("String... strings");
}
public static void f(String string, String... strings) {
System.out.println("String string, String... strings");
}
public static void main(String[] args) {
f("abc"); // Test 中的方法 f(java.lang.String...) 和 Test 中的方法 f(java.lang.String,java.lang.String...) 都匹配
}
}
public class Test5 {
// 对于f()的重载很容易区分,只需根据第一个参数的类型即可
public static void f(String... strings) {
System.out.println("String... strings");
}
public static void f(int i, String... strings) {
System.out.println("int i, String... strings");
}
public static void g(int... ints) {
System.out.println("int... ints");
}
public static void g(float l, int... ints) {
System.out.println("long l, int... ints");
}
public static void m(String... strings) {
System.out.println("String... strings");
}
public static void m(Object object, String... strings) {
System.out.println("Object object, String... strings");
}
public static void main(String[] args) {
f("abc"); // String... strings
g(1, 1); // int... ints
// 但是,如果g()中可变参数列表声明为Integer,编译就会报错:Test 中的方法 g(java.lang.Integer...) 和 Test 中的方法 g(float,java.lang.Integer...) 都匹配
m("abc", "abc"); // String... strings
}
}
除了上述列举的重载情况外,还可以有很多不同的情况,有些可以很简单地明确调用关系而有些需要更深的研究(也可能和编译器、JVM运行的细节相关,这里不再过多研究):所以需要重载时,尽量不要使用可变参数列表。
-
1.3 接口
-
1.3.1 抽象类
抽象类的概念可以参考入门,这里梳理下Java中的抽象类。
-
使用abstract关键字标识抽象类或抽象方法;抽象方法只有型构,没有方法体(与空实现不同)
-
有抽象方法的类只能是抽象类(抽象方法来源:1.自己定义;2.继承;3.实现接口时没全部实现)
-
抽象类中可以拥有具体类中的所有成员内容
-
在语法上,final无法被继承或重写,而abstract要求继承或重写,所以两者不可共用
-
在语法上,private方法默认有final修饰,abstract方法需要子类实现,故无法一起使用
-
多态发生在子父类的对象上,所以语法上static方法无法用abstract修饰(static与类相关)
-
在概念上就可以明确,在行为上可以是抽象的,但不存在抽象的数据,故无法用abstract修饰变量
-
抽象类一般上为子类提供模板,实现部分通用逻辑,定义通用数据结构
-
1.3.2 接口 - interface
Java中的关键字interface符合在入门中对接口的定义,即对象所能定义(执行)的所有操作型构的集合称为该对象的接口。
接口是一种更为纯粹的抽象,其有点类似于一个纯抽象类(即所有方法都是抽象的)。但即便是纯抽象类也可以提供部分实现,Java中的interface对接口的概念作了进一步强化。接口在一定程度上表示类与类之间的交互协议,不关心具体对象的实例化,不关心具体的行为实现。
- 在interface中所有可定义的成员都默认使用public修饰
- interface中定义域默认会添加static final修饰(想要显式修饰也只能用public、static和final);interface的static域无法使用“空白final”的赋值方式,虽然可以调用其他的静态方法在声明处初始化,但一般是定义为常量(即编译器可明确值)。
- interface中没有构造器
- interface中定义的接口会默认用abstract修饰(子类重写时访问权限修饰符必须是public,见方法重写);Java 8后运行有static方法和default方法
- 在interface定义嵌套类、嵌套接口和嵌套枚举等默认会增加static修饰
- 其是一个新的关键字,在语法使用上就是一种新的类型(和类、数组并列),但这只是语法上的优化。根据interface被编译后的字节码,interface默认继承自java.lang.Object。
// 1.Java 8之后允许在interface中增加static方法
// 2.Java 8之后允许在interface中增加默认方法,该方法属于接口的一部分,需要显式用default修饰并且提供方法体。其一般作为接口的通用实现,比如一个计算流程的模板。
public interface InterfaceTest {
// 定义了一个static域,并未指定编译型初值,由有运行时静态方法getInt的返回值赋值
int i = getInt();
// Java 8允许interface中存在static方法
static int getInt() {
return 0;
}
// 一个普通的方法型构,返回值为int,方法默认使用public abstract修饰,故没有方法体
int get();
// Java 8允许interface中存在default方法,其是接口(不是指关键字interface,而是概念层面的接口)的一部分
// default方法提供一个默认的通用实现,可以被实现类所实例化的对象使用
// 一般作为模板,可能指定该interface被使用时默认的接口调用顺序,即外部系统获取到其实现类的对象后,可以调用向上转型,使用default方法完成一系列相关的调用,而不需要完全自定义式的调用
// default方法的访问权限默认也是public的,注意和访问权限中的包访问权限区分(另参考“Java包结构”)
default void useInt() {
System.out.println(get());
}
// default方法属于接口的一部分,所以自然可以被重写
// 该方法默认打印当前对象所属具体类的名称
default void toBeOverride() {
System.out.println(this.getClass().getName());
}
}
// 实现一个类使用关键字 implements
public class InterfaceImpl implements InterfaceTest {
// 必须重写接口中的abstract方法,否则实现类也必须声明为abstract类
// 这里返回接口InterfaceTest的静态域
@Override
public int get() {
return i;
}
// default方法不要求被重写,但可以根据需要进行重写
@Override
public void toBeOverride() {
System.out.println("override default method!");
}
}
public class Main {
public static void main(String[] args) {
// 接口是一个更为纯粹的抽象类,那么自然可以用来声明变量
InterfaceTest anInterface = new InterfaceImpl();
anInterface.useInt();
anInterface.toBeOverride();
}
}
-
1.3.2.1 类的多实现
在Java中,使用extends来组织类之间的继承关系时,只能是单继承的(即一个类只能有一个直接父类,最终父类为java.lang.Object)。而用implements组织类和interface之间的实现关系时,可以多实现。
类中定义了其实例化产生的对象的数据,如果在继承上使用多重继承,虽然可以向上转型为多种类型,但子类却杂糅了可能完全不相关的若干个继承体系的实现细节。而接口是一种更为纯粹的抽象类,它没有任何的具体实现(即没有数据方面的定义,一般default也很少用),那么多个接口的组合也更合乎情理。
public interface InterfaceTest_A {
int getInt();
}
public interface InterfaceTest_B {
Object getObject();
}
// 一个普通类,方法型构和InterfaceTest_B一致,其使用自动包装将2.0包装成Double类的实例,并向上转型为Object返回
public class SuperClass {
public Object getObject() {
return 2.0;
}
}
// 一个类可以继承一个类,并同时实现若干个接口
// 这样一个子类(实现类)既可以向上转型为父类,也可以向上转型为所实现的接口,类似于“多继承”了
public class InterfaceImpl extends SuperClass implements InterfaceTest_A, InterfaceTest_B {
@Override
public int getInt() {
return 1;
}
// 一个具体类在实现接口时,必须实现全部的abstract方法
// 但通过继承SuperClass,其实已经实现InterfaceTest_B,所以即使把下面的方法注释掉,InterfaceImpl也是正确的
// 这里可以立即为是重写了父类的实现,而不是实现了InterfaceTest_B的abstract方法
// 这里还使用到了协变返回类型
@Override
public String getObject() {
return "override while implements";
}
}
-
1.3.2.2 接口的继承
接口之间可以通过继承来扩展接口,而接口间是允许多继承的。
interface A {
void f();
int g();
}
interface B {
int f();
}
interface C {
int f(int i);
int g();
}
// Test接口继承了A和C,也就意味着Test中有三个型构,即void f();、int g();和int f(int i);
// 注意A和C中都有int g();,型构完全相同,所以实现Test的类仅有一个int g(){}
interface Test extends A, C { }
// 如果g()方法的返回值不同,一个是int,一个是double,那么无法永远实现Test接口,因为返回值不作为方法签名
// 如果是协变返回类型,一个返回Object,一个返回String,那么实现Test接口时,实现类中的g()返回值就只能是String —— 基本类型和引用类型无法相互协变,不能被基本类型包装类误导
// 注意无法继承或实现A和C,因为它们的f()的方法签名相同,但返回值不同,所以无法正确的继承或实现
-
1.3.2.3 嵌套接口
public interface Test {
// Inner默认有public static修饰
// 关于嵌套类或嵌套接口修饰符的说明参考“内部类”一节
interface Inner {
void f();
}
}
// 嵌套接口可以认为只是interface定义的位置改变了,其不会影响类实现外部的接口
// 也就是说如果一个类想实现Test接口,那么只需实现Test即可,不需要关心嵌套的Inner的接口
// 编译后有一个Test$Inner.class文件,如果需要用嵌套接口声明变量或在implements列表中,需要使用Test.Inner的形式(使用static import时可以直接使用Inner,见“Java包结构”)
// 一般嵌套接口很少存在,然而在语法上允许下面的使用方式(内部类参考下一节)
// 定义一个普通类
public class Test {
// 定义一个私有的内部接口
private interface Inner {
void f();
}
// 定义一个共有的内部实现类,实现Inner接口
// 私有内部接口也可以被实现为public类
public class InnerImpl implements Inner {
@Override
public void f() {
System.out.println("Test类内部私有接口的实现类");
}
}
// Test类的一个接口方法,向上转型为Inner返回
public Inner getInnerObject() {
return new InnerImpl();
}
public void userInnerObject(Inner inner) {
inner.f();
}
}
class Main {
public static void main(String[] args) {
Test test = new Test();
// Test.Inner innerObject = test.getInnerObject(); 这里无法声明Test.Inner的变量,因为Inner时private
// 由于InnerImpl是public的,所以可以用来声明变量和强制转换
Test.InnerImpl innerObject = (Test.InnerImpl) test.getInnerObject();
// InnerImpl实现了内部接口Inner,但编译完成后class文件也有f()的实现部分,所以可以调用f()
innerObject.f();
// 直接将getInnerObject的返回值作为userInnerObject的参数
// 因为Inner时Test的内部私有接口,那么Test类的对象自然有对其的访问能力
test.userInnerObject(test.getInnerObject());
// 由于Inner是私有的,所以虽然可以直接创建InnerImpl的对象,但不能向上转型为Inner(外部对Inner一无所知,使用者无法知道InnerImpl实现了Inner)
}
}
-
1.3.2.4 抽象类和接口
任何抽象性都应该是应真正的需求产生的。Java中的interface是一种更为纯粹的抽象,但其也增加了额外级别的间接性,由此会带来额外的复杂性。
恰当的原则应该是优先选择类而不是接口(interface)。从类开始,如果接口的必需性变得非常明确,那么就进行重构。使用interface的核心原因:1.允许向上转型为多种基类型(以及以此带来的灵活性);2.创建一种消息的交互协议,而非具体实现(抽象类允许有实现部分)。
在入门中提到的“面向接口编程”,其中的接口是对象的型构的总和,而不是指Java中的interface。
-
1.4 内部类
在类或接口的内部可以定义类,作为外围类或外围接口的一种成员;内部类允许一些逻辑相关的类组织在一起,并控制这些内部类的可见性,像是一种代码隐藏机制。比如在实现一个类时需要用到一种特殊的存储结构,该结构只适用于该类,那么就可以将该存储结构定义为一个私有的内部类。
由于内部类作为一个外围类的成员,所以其可见性和变量、方法一样,有四种(另参考“Java包结构”)。但在谈论语法特性时,private和public已足够。由于访问权限的限制,内部类可能在外部类的外部是不可见的。
-
1.4.1 静态内部类(嵌套类)
静态内部类即在一个类内由static修饰的内部类,静态内部类中可以使用上文所述的语法,包括继承和实现。
它是外围类的类成员,所以无法直接访问外部内的实例成员(见1.1.5),但可以访问和修改外围类的可变静态变量或调用静态方法(即使是私有的,private意味着只有当前类中可用,内部类作为类的一部分,自然也在当前类中)。
内部类的访问权限控制符对外围类是不起作用的(外围类的访问控制权限对内部类同样无效),但在外围类的外部有效(如Test.main()和OutTest.main())。在Test.main()中直接访问静态内部类的私有类变量。在Test.main()也表明了如果在外围类中需要使用静态内部类的对象以及实例成员,也需要通过“new 静态内部类构造器”的形式。
静态内部类在外围类中使用和普通类没有区别(也可以被继承);在外围类的外部(如OutTest),该静态内部类的访问权限允许的情况下,声明变量、实例化对象或继承时需要加上外围类前缀。
public class Test {
// 外围类定义了一个私有的静态变量
// 在Test以外的类中无法被访问,如在OutTest中
private static int i = 1;
// 声明为public的,这样Test外部也可以被使用
public static class Inner {
// 在内部类定义了一个私有的静态变量
private static int j = 2;
//定义了一个内部类的实例方法,用来打印外围类的私有静态变量
public void printI() {
System.out.println(i);
}
}
public static void main(String[] args) {
Inner inner = new Inner();
inner.printI();
System.out.println(Inner.j); // 在外围类可以访问到内部类的私有域,但外围类的外部不行
}
}
class OutTest {
public static void main(String[] args) {
Test.Inner inner = new Test.Inner();
inner.printI();
// System.out.println(Test.i); 'i' has private access in 'Test'
// System.out.println(Test.Inner.j); 'j' has private access in 'Test.Inner'
}
}
由于interface中默认使用public static修饰,如果在interface内定义类,那就只能是静态内部类;在语法层面,该静态内部类甚至可以实现所在的外部接口。(一些特殊情景,需要使用main()来测试,为了不将main()编译在class文件中,可以定义一个私有的静态内部类来运行main())
-
1.4.2(实例)内部类
没有使用static修饰的内部类即普通内部类,或者实例内部类。和实例变量、实例方法类似,普通内部类的主调是外围类的对象,所以无法在普通内部类中定义static成员。更为深入的理解,外围类对象是在运行时被实例化,而static的内容可能在类加载过程中被使用,加载过程先于对象实例化过程。
static内容无法直接访问非static内容,所以在外围类的static成员中无法直接使用内部类(但可以用来声明变量)。
public class Test {
private int i = 1;
public class Inner {
private int j = 2;
public void printI() {
System.out.println(i);
}
}
private Inner getInnerObject() {
return new Inner();
// 方法内部有一个默认的引用this,用来指代当前的对象,实例方法内部它是默认的主调
// 所以上述语句可以改为“return this.new Inner();”
}
public static void main(String[] args) {
Inner inner; // 可以声明变量
// 由于普通内部类没有静态成员,所以没有可以用类名作为主调的内容可供使用
// static方法无法直接访问非static内容,所以也没法直接使用构造器
// 创建一个外围类对象
Test test = new Test();
inner = test.getInnerObject(); // 通过调用getInnerObject来得到内部类对象的引用,赋值给inner
// 调用printI()
inner.printI(); // 1
}
}
从上述的例子可以看到,在内部类的实例方法中,可以访问到外围类对象的实例域。这是可以理解的:普通内部类和外围类实例方法是同一级别,同一性质的(即都是用外围类对象作为消息的接收者,可以访问外围类对象的所有数据),自然可以向外围类实例方法那样来访问外围类的实例域。
但此时有外围类对象和内部类对象两个对象,在实例方法中用this作为自身的默认引用,也就是说在内部类实例方法中this应该表示内部类对象(如果把Inner的实例域j改为i,那么printI的输出将会是2),那么如何引用到外围类对象呢?使用“外围类类名.this”。
// 可以将上述的pirntI()改为如下,显式的指明使用外围类对象的实例域
//
public void printI() {
System.out.println(Test.this.i);
}
内部类对象在实例化时必须存在其外围类对象,在内部类对象中会有一个引用到外围类对象的引用。通过下图可知,外围类对象没有默认指向内部类实例的引用变量,所以如果在外围类的实例方法中需要使用内部类对象的数据或方法,也只能用内部类对象作为接收者。
在外围类的实例方法中可以直接通过new的形式作为实例化内部类的途径,在方法中用this作为默认的消息接收者,那么在外围类static块和static方法或外围类的外部中也可以用类似的形式创建内部类对象。
class OutTest {
public static void main(String[] args) {
Test.Inner inner = new Test().new Inner();
}
}
同静态内部类一样,普通内部类也可以嵌套多层,也可以正确访问到所有所嵌外围类的相应成员内容。(实际在使用过程中并不会如此)
-
1.4.3 内部类的向上转型
和普通类一样,内部类可以使用继承和实现,所以多态和向上转型仍旧适用。由于内部类可以是private的访问权限,那么客户代码无法向下转型,这种方式防止客户依赖任何可能的类型信息,实现细节完全被隐藏。在私有内部类和向上转型同时作用时,在内部类中增加新的接口是没有价值的。
-
1.4.4 局部内部类
局部内部类是定义在方法中或某个块(block,形如{...})的类。在不同的方法或块中完全可以定义同名的类,不会有命名冲突。局部内部类的作用域就只有在块中(方法其实也是一个块),所以无法用访问修饰符和static修饰。除了能访问作用域内的常量(如果一个局部变量在局部内部类中使用,该变量显式或隐式的会被final修饰),和静态内部类、普通内部类一样,局部内部类也可以访问到外围类对应的成员。 —— 注意:即使定义了局部内部类的方法没有被调用,局部内部类也会被编译为一个class文件。
-
1.4.5 匿名内部类
创建匿名内部类的同时,就会实例化一个该内部类的对象,所以匿名内部类不能是抽象类,即必须实现所有方法。
new 实现的接口名() | 父类构造器(可能的参数列表) {
...
}
语法如上,其含义是实现一个接口或者继承一个类,并实例化出对象,并且返回一个向上转型后的引用。和普通类或其他的内部类不同,这种方式不能同时使用继承和实现,而且也仅能实现一个接口。
匿名内部类没有类名,所以它只有一个隐式的无参构造器。由于interface不能定义构造器,所以在用匿名内部类实现某一个interface时,语法上不能传参。虽然不能显式的添加构造器,但可以在匿名内部类的实例域声明处和初始化块中完成想要的初始化。
public class Test {
// 该static方法得到一个匿名内部类对象,并且被向上转型为OutTest接口
public static OutTest getAnonymousObject(int initValue) {
OutTest outTest = new OutTest() {
// 定义一个实例域,其初始化由外部入参决定
private int i = initValue;
{
System.out.println("初始化块可以完成类似构造器的行为");
}
@Override
public void f() {
System.out.println("必须实现所有方法");
System.out.println(i);
}
};
// initValue = 1;
// 如果在这里对入参重新赋值,那么匿名内部类的初始化就会报错
// Variable 'initValue' is accessed from within inner class, needs to be final or effectively final
// 在匿名内部类中用到的外部变量,必须是final的;如果没有指定final,但也产生final的效果
// 如果将匿名内部类改用局部内部类实现:
// 1.如匿名内部类直接在声明处初始化“private int i = initValue;”,也不能对initValue重新赋值
// 2.如果给局部内部类增加一个int构造器,将initValue作为参数的形式传递给构造来创建对象,那么可以对initValue重新赋值
// 静态内部类和普通内部类都无法完成“private int i = initValue;”形式的字段初始化,通过构造器来完成初始化时,和局部内部类第二种情况一样
// 类似的,如果是通过继承父类来创建匿名内部类对象,将initValue传入父类的int构造器中,initValue也不是“effectively final”的
return outTest;
}
public static void main(String[] args) {
getAnonymousObject(1).f();
}
}
interface OutTest {
void f();
}
在一定程度上,局部内部类和匿名内部类是可以互换的。使用局部内部类的理由是:1.需要一个命名的构造器或者重载构造器,而匿名内部类只能使用初始化块;2.由此衍生,需要不止一个该局部内部类对象。
-
1.4.6 内部类继承问题
-
继承外围类后,在子类中定义一个同名的内部类并不会达到“覆盖”的效果。外围类像是一个命名空间,定义在不同的外围类的同名内部类是完全独立的实体(从内部类编译后产生的class文件名称就可证明)。
-
继承有访问权限的静态内部类和继承普通类没有区别。
-
由于普通内部类是和外围类的对象绑定的,所以在继承普通内部类时,子类构造器需要特殊处理。
// Test继承了普通内部类,其构造器需要如下
public class Test extends Outer.Inner {
public Test(Outer outer) {
outer.super();
}
}
class Outer {
class Inner {}
}
根据子父类对象和内外部类对象的内存模型可知,在继承了普通内部类后,创建子类对象的内存模型应该如上所示。分析可知:1.在实例化Inner对象时,是通过外围类对象作为依托,通过“Outer对象.new”;2.子类构造器必须隐式或显式调用父类的构造器;3.Inner子类对象应该显式的需要一个外围类Outer的对象引用。Test的构造器就是上述三点的综合。
-
1.4.7 内部类使用
静态内部类的使用除了访问权限的限制外,基本和普通类是一样的。然而普通内部类由于以外围类的对象为依托,从而可以访问到外围类实例对象的数据和实现(外围类也访问内部类的数据和实现)。在一定程度上,两者彼此间都破坏了封装性。
一般来说,使用外围类实现接口就已足够,但普通内部类仍旧有用武之地。一方面,私有内部类和向上转型同时存在时,对完完全隐藏了实现细节;另一方面,外围类是否继承了某个类或实现了了某一个interface并不会影响内部类的继承、实现体系,由此普通内部类就可以作为Java“多继承”的补充。接口的多实现在一定程度上体现了“多继承”,但内部类给类的实现的“多重继承”提供了可能(Java是单根继承,所以外围类只有一个直接父类,换言之无法做到“实现”层面的多重继承)。
interface A {}
interface B {}
// 直接实现A和B
class X implements A, B {}
// 只实现A
class Y implements A {
// 使用匿名内部类的形式返回B实现类的实例
public B getBObject() {
return new B() {};
}
}
// 使用上,类X可以直接向上转型为A和B
// 而Y可以向上转型为A,但如果需要使用B需要先得到Y的实例
// 但如果A和B是抽象类或具体类,同时继承A和B是无法做到的。
// 此时使用内部类就可以在“实现”层面的多重继承:内部类可以访问到外围类的实现,那么内部类就类似糅合了两种实现(自身和外围类)。
闭包是一个可调用的对象,它记录了一些来自于创建它的作用域的信息。可以发现,普通内部类符合该定义,它依托外围类对象(创建内部类的作用域),并且在作用域内可以访问和操作外围类的所有成员。
从而普通内部类也就满足了“回调”(通过回调,对象携带一些信息,这些信息允许在之后某个时刻调用初始的对象),也就是说内部类对象的实例方法可以访问外围类对象,那么在得到了内部类对象引用的情况下,可以编写程序来影响外围类对象。
// 这里选自《Thinking in Java》,但在Callee2作了些修改
// 接口时一种规范和协议,所以一个接口在一定层面上要求了实现类怎么实现这个接口
// 这里可以理解为,要实现Incrementable必须定义一个实例域,每次调用增加该实例域的值
interface Incrementable {
void increment();
}
// 一个实现了Incrementable的简单类,并使得Callee1在实现上满足接口要求
class Callee1 implements Incrementable {
private int i = 0;
public void increment() {
i++;
System.out.println(i);
}
}
// 一个已经存在的父类,他并没有实现Incrementable接口,但有一个相同型构的increment()
// 不能认为该increment()和Incrementable接口中是相同的概念
class MyIncrement {
public void increment() {
System.out.println("Other operation");
}
static void f(MyIncrement mi) { mi.increment(); }
}
// If your class must implement increment() in
// some other way, you must use an inner class:
// 这里Callee2已经继承了具体类MyIncrement,如果要想让Callee2也实现Incrementable以此来满足“协议”
// 那么就不能简单的让Callee2实现Incrementable,因为父类和接口中相同型构的方法在含义上是完全不同的,如果直接继承,那么会使得increment()同时两种行为
class Callee2 extends MyIncrement {
private int i = 0;
// 和《Thinking in Java》中不同,这里不重写实现,但增加了实例域用来递增
public void increment() {
super.increment();
// Callee2可能会在父类的原先基础上扩展实现
// i++;
// System.out.println(i); 注释书中内容,移到内部类中
}
// 定义了一个内部类实现Incrementable
private class Closure implements Incrementable {
public void increment() {
// 实现Incrementable的功能
i++;
System.out.println(i);
// 这里不能调用this.increment(),会形成无限递归
// 即使在外围类Callee2未进行重写,在这里Callee2.this仍旧可以找到父类MyIncrement的方法体,见1.1.8
Callee2.this.increment();
}
}
// 由于Callee2无法向上转型为Incrementable,那么需要某个方法来返回内部类对象
Incrementable getCallbackReference() {
return new Closure();
}
}
// 模拟一个只和Incrementable交互的客户端
class Caller {
private Incrementable callbackReference;
Caller(Incrementable cbh) { callbackReference = cbh; }
void go() { callbackReference.increment(); }
}
class Callbacks {
public static void main(String[] args) {
Callee1 c1 = new Callee1();
Callee2 c2 = new Callee2();
MyIncrement.f(c2); // Other operation,这里直接输出了父类的实现,和书中内容不同
// 模拟在客户端中得到Incrementable
Caller caller1 = new Caller(c1);
Caller caller2 = new Caller(c2.getCallbackReference());
// 简单实现接口
caller1.go(); // 1
caller1.go(); // 2
// 除了实例域的增加,还产生了外围类对象的方法调用,这里就是一种“回调”
caller2.go(); // 1 Other operation
caller2.go(); // 2 Other operation
}
}
应用程序框架是被设计用以解决某类特定问题的一个类或一组类,通常是模板方法的一个例子。控制框架是一种特殊的应用程序框架,用来解决响应事件的需求。主要用来响应事件的系统称为“事件驱动系统”。在《Thinking in Java》中有一个室温控制的例子,请参看书中10.8.2。
待续。。。
-
1.5 Lambda
-
二、Java类设计