Java语言中,引用是一个非常重要的程序元素。引用的作用是操作对象,大部分情况下程序员都必须通过引用来调用对象的属性和方法。
5.5.1引用的概念和作用
在本书的第4章中曾经介绍了如何创建对象,例如创建一个Person类的对象可以用以下语句实现:
Person p = new Person();
之前的章节中,都把这行代码中的p称为“对象”。其实严格来说,这行代码中的p并不是真正的对象,它的真实身份其实是一个“引用”。在Java语言中,引用是用来操作对象的程序元素,它与对象之间的关系就如同是遥控器与电视机的关系一样。在日常生活中,人们可以通过遥控器去操作电视机。而在程序中,程序员通过引用去操作对象。程序员通过引用调用对象的某个方法,就如同是通过遥控器启动电视机的某项功能一样。因此才说“引用是用来操作对象的程序元素”。
当认识了“引用”这个概念之后,“Person p = new Person();”这条语句的含义也应该被重新解读。在这条语句中,“=”左边的部分表示创建了一个Person类的引用p,“=”右边的部分表示创建了一个Person类的对象,而“=”本身表示一种指向行为,也就是说用引用p指向了“=”右边的那个对象。当一个引用与某个对象建立了指向关系以后,就可以通过这个引用去操作它所指向的对象。这就如同是把遥控器对准了电视机,一旦电视机处于遥控器的信号辐射范围内,遥控器就能操作电视机。
本节揭示了p的真实身份其实是一个引用,那么如何才能证明p是一个引用而不是一个对象呢?请看【例05_06】
【例05_06 验证p的真实身份】
Exam05_06.java
public class Exam05_06 {
public static void main(String[] args) {
Person p1, p2;
p1 = new Person();// ①
p1.age = 20;// ②
p2 = p1;// ③
p2.age = 22;// ④
System.out.println("p1的年龄是:" + p1.age);// ⑤
}
}
在【例05_06】中出现了p1和p2。下面以p1和p2的身份分别是对象和引用来推导程序的运行结果。为了方便表述,特为程序中的各条语句以①、②、③......进行编号。
首先假设p1、p2的身份是对象,那么当执行了语句①之后,p1完成了实例化,而语句②的执行使得p1的age属性值变成了20。当执行了语句③之后,p2也完成了实例化,并且p2的各项属性值与p1是完全相同的,因为“=”运算符有赋值的作用。接下来,当执行了语句④之后, p2的age属性值变成了22,由于p1和p2都是对象,并且它们是两个完全独立的对象,所以p1对象的age属性应该仍然是20。由此推断语句⑤的执行结果应该是“p1的年龄是:20”。
接下来假设p1、p2的身份是引用,那么执行了语句①之后,内存中产生了一个Person类的对象,并且由引用p1指向了这个对象。语句②的作用是通过引用p1把内存中的Person对象的age属性赋值为20。因为p2也是一个引用,所以语句③的作用其实是让p2与p1指向同一个对象。既然p1和p2都指向了同一个对象,那么通过p1和p2操作的就是同一个Person对象。语句④的作用就是通过引用p2来修改这个Person对象的age属性值,使之成为22。既然p2与p1指向的是同一个对象,那么语句⑤中的p1.age也应该是22,由此推断语句⑤的执行结果应该是“p1的年龄是:22”。实际运行【例05_06】的程序代码,可以看到其运行结果如图5-9所示:
图5-9 【例05_06】运行结果
通过图5-9可以看出,程序的运行符合第二种假设所得到的结果。由此就可以证明:p1和p2都是引用而不是对象。本节之前一直把语句“Person p = new Person();”中的p称为对象,并未揭示其引用的真实身份,这是仅仅为了让初学者更加易于理解程序代码。
一个对象可以同时被多个引用所指向,如【例05_06】中,内存中的Person对象可以同时被p1和p2两个引用所指向。但是,一个引用却不能同时指向多个对象。引用的指向也并不是固定不变的,引用在指向了某个对象之后,还可以通过“=”使之重新指向另外一个对象。
当语句中仅声明了引用,而没有用“=”使其指向任何对象时,就表示该引用没有指向任何对象。例如:
Person p;
在这条语句中,仅有一个引用p,它没有指向任何对象。一个引用如果没有指向任何对象,程序员就不能通过这个引用调用任何属性和方法,否则编译器会提示“The local variable p may not have been initialized”的语法错误,这个提示语翻译成汉语就是:本地变量p没有被初始化。虽然在编译器的提示语中把p称为“variable”,也就是变量的意思,但各位读者仍然要清楚p是一个引用,它与用来存储基础数据类型的变量是完全不同的。
在Java语言中,还有一种特殊的对象,专业上把它称之为“空对象”。所谓“空对象”就是“不存在的对象”,Java语言用关键字null表示空对象,任何引用都可以指向空对象,例如:
Person p = null;
这行代码表示Person类的引用p指向了空对象。一旦某个引用指向了空对象,通过该引用调用相应的属性或方法时,都将在程序运行时引发错误。
绝大部分情况下,引用都会指向一个实际存在的对象。所以在引用已经指向了一个真实对象、并且不影响理解程序语义的情况下,仍然会把“引用”直接称为“对象”。例如把“引用p所指向的对象”直接表述为“p对象”,这种简化的书写方式仅仅是为了表述方便,望各位读者知悉。
在本书第3章曾经介绍过增强型for循环的使用,通过第3章的【例03_08】可知:如果有一个数组,并且该数组中的所有元素都是基础数据类型,那么使用增强型for循环无法修改该数组的元素值。但是如果该数组的元素是引用数据类型,那么通过增强型for循环修改元素又会出现另一种情形,请看例05_07。
【例05_07 使用增强型for循环修改元素属性值】
Num.java
public class Num {
int x;
public Num(int x) {
this.x = x;
}
}
Exam05_07.java
public class Exam05_07 {
public static void main(String[] args) {
Num[] array = { new Num(1), new Num(2), new Num(3) };
for (Num n : array) {// 使用增强型for循环修改元素属性值
n.x = n.x + 2;
}
for (Num n : array) {
System.out.print(n.x + " ");
}
}
}
【例05_07】总共包含两个类。Num类中定义了int型的属性x,并且Num类还定义了一个构造方法用来初始化x的值。在Exam05_07类中,首先创建了一个Num类的数组array,数组中3个元素的x属性值分别是1、2、3。接下来使用增强型for循环依次修改数组中3个元素的x属性值,最后再次使用一个增强型for循环输出元素的x属性值。【例05_07】的运行结果如图5-10所示:
图5-10 【例05_07】运行结果
通过运行结果图可以看出:数组中3个元素的x属性值分别由原来的1、2、3变成了3、4、5。程序的运行结果说明使用增强型for循环能够修改引用数据类型数组元素的属性值,这与第3章【例03_08】修改基础数据类型数组元素值的运行效果完全不同,这究竟是为什么呢?各位读者首先需要知道:Java语言的数组可以分为两种类型:一种是用来存放如int、double这样的基础数据类型元素,这种数组被称为“基础数据类型数组”,另一种是用来存放引用的数组,它被称为“引用数据类型数组”。引用数据类型数组的每一个元素都是引用,这些引用分别指向了一个对象(也可能指向空对象)。引用数据类型数组的结构如图5-11所示:
图5-11 引用数据类型数组结构图
对于引用数据类型数组来说,增强型for循环的“当前元素”其实也是一个引用,这个引用由对应的数组元素为之赋值,这就使得在执行循环时,当前元素与数组元素指向了同一个对象。以【例05_07】的第一个增强型for循环举例说明:数组中第1个元素array[0]指向了一个Num对象,当执行第1轮循环时,当前元素n由array[0]为它赋值,因此当前元素n也指向了这个Num对象。既然这两个引用指向了同一个对象,那么通过当前元素n就可以修改array[0]所指对象的x属性值。当执行第2轮循环时,当前元素n又由数组的第2个元素array[1]为之赋值,因此通过当前元素n又能修改array[1]所指对象的x属性值。以此类推,当循环运行结束后,数组中每一个元素所指对象的x属性值都能被修改。这就是为什么程序员能够通过增强型for循环修改引用数据类型数组元素的属性值。
第2章中曾经讲过:使用final关键字修饰变量,可以使该变量仅能被赋值一次。与之类似,如果使用final关键字修饰引用,那么这个引用在指向了一个对象(包括空对象)之后,就再也不能指向其他任何对象。例如:
final Person p;//①
p = new Person();//②
以上代码中,语句①声明了Person类的引用p,该引用被final关键字所修饰。语句②使p指向了一个Person对象,在语句②之后,如果程序员试图用引用p指向其他对象,就会导致出现语法错误。当然,以上代码也可以缩减为以下形式:
final Person p = new Person();
需要注意的是:出现在引用之前的final关键字与对象的属性值无关,也就是说即使在引用的前面添加了final关键字,程序员仍然可以通过这个引用修改对象的属性值,例如:
final Person p = new Person();
p.age = 20;
p.age = 30;
以上代码中,引用p虽然被final关键字修饰,但程序员仍然可以通过引用p多次修改对象的age属性。这说明引用前面的final关键字并不会妨碍属性值的修改。如果程序员希望age属性只能被赋值一次,需要在定义Person类时单独在age属性前添加final关键字。
5.5.2父类的引用指向子类的对象
引用也有类型之分,例如语句“Person p;”当中,p就是一个Person类的引用。通常情况下,用某种类型的引用指向其他类型的对象会出现语法错误,所以,既然p是Person类的引用,它就应该指向Person类的对象。但在Java语言中还存在一种特殊情况,即:用某种类型的引用可以指向其子孙类的对象。例如, Person类是Student类的父类,语句“Person p = new Student();”也是符合语法的。为什么Java语言中一种引用可以指向其子孙类的对象呢?就是因为子孙类本质上是归属于祖先类的,它是祖先类中的一个特定种类。在程序中,Person类表示“人”, Student类表示“学生”,按照这个逻辑,引用p可以指向所有的“人”对象,而“学生”也是一种“人”,引用p当然也可以指向“学生”对象,因此“Person p = new Student();”这条语句是完全符合语法的。
如果子类重写了父类的某个方法,那么当父类的引用指向子类对象时,通过该引用去调用这个方法,将会调用到子类重写过的方法。【例05_08】能够很好的展示这个特性。
【例05_08 父类引用指向子类对象时方法的执行】
Exam05_08.java
public class Exam05_08 {
public static void main(String[] args) {
Person p = new Student();// 父类的引用指向子类对象
p.sum(100);
}
}
【例05_08】的运行结果如图5-12所示:
图5-12 【例05_08】运行结果
通过【例05_08】的程序代码以及运行结果图可以看出:当父类(Person)的引用p指向子类(Student)对象时,通过引用p调用sum()方法,虚拟机所执行的是子类重写过的sum()方法而非父类原本的sum()方法。由此可以得出结论:当子类重写了父类的方法、并且由父类的引用指向子类对象时,虚拟机会根据对象的类型而不是引用的类型来决定执行哪一个方法。这是一个很重要的结论,希望各位读者牢记。
当父类的引用指向子类对象时,父类的引用只能调用到父类已经定义了的方法,无法调用到子类自身扩展出的方法。即使子类的方法与父类中的某个方法的名称相同,只要没有对父类方法形成覆盖,它就属于子类自身扩展出的方法,这种情况下父类的引用就无法通过方法名称调用到子类的这个方法。下面的【例05_09】能够帮助读者理解虚拟机调用同名方法的规则。
【例05_09 同名方法调用规则】
Father.java
public class Father {
public void method(Object a) {
System.out.println("父类方法");
}
}
Child.java
public class Child extends Father{
public void method(String a) {
System.out.println("子类方法");
}
}
Exam05_09.java
public class Exam05_09 {
public static void main(String[] args) {
Father f = new Child();//父类引用指向子类对象
f.method("abc");
}
}
【例05_09】总共涉及3个类,其中Father和Child是父子类关系。父类Father中定义了method()方法,方法的参数类型是Object。子类Child中也定义了同名的method()方法,但方法的参数类型是String,所以子类method()方法并没有对父类形成覆盖。在Exam05_09类的main()方法中,用父类的引用指向了子类的对象,并且通过引用调用了method()方法。【例05_09】的运行结果如图5-13所示:
图5-13 【例05_09】运行结果
从图5-13可以看出,语句“f.method(“abc”);”在执行时调用的是父类中的method()方法。很多读者都不明白:给method()方法传递了String类型的对象作为参数,如果按照参数类型匹配原则,应该调用子类中的method()方法,而事实上调用的却是父类中的method()方法,这是为什么呢?就是因为子类的method()方法没有覆盖父类的method()方法,因此它属于自身扩展出来的方法,虽然这个方法与参数的匹配度更高,但父类的引用通过方法名并不能调用到它。
方法的执行取决于对象的类型,而属性的调用却是取决于引用的类型。也就是说:子类屏蔽了父类的某个属性、并且由父类的引用指向子类对象,此时通过该引用去调用属性,将会调用到父类的属性而不是子类的属性。【例05_10】能够很好的展示这个特性。
【例05_10父类引用指向子类对象时属性的调用】
A.java
public class A{
int x = 1;
}
B.java
public class B extends A{
int x = 5;
}
Exam05_10.java
public class Exam05_10 {
public static void main(String[] args) {
A a = new B();
System.out.println("x的值为:"+a.x);
}
}
【例05_10】的运行结果如图5-14所示:
图5-14 【例05_10】运行结果
【例05_10】总共涉及到3个类,其中A是B的父类。A类中定义了一个int型的属性x,其默认值是1,而其子类B也定义一个同名属性x,其默认值是5,这样的话,子类B中的x就屏蔽了父类中的x属性。在Exam05_10类中,由父类(A)的引用指向了子类(B)的对象,并且通过引用a调用了x属性。据运行结果图5-14可以看出:所调用到的是父类(A)的属性而不是子类(B)的属性,这与【例05_08】中通过引用去调用方法的结果是完全相反的。通过【例05_10】又可以得到结论:当子类屏蔽了父类的属性、并且由父类的引用指向子类对象时,虚拟机会根据引用的类型而不是对象的类型来决定调用哪一个属性,各位读者也要牢记这个结论。
除此文字版教程外,小伙伴们还可以点击这里观看我在本站的视频课程学习Java。