前言
我曾经在一篇博文如何理解对象赋值给接口的操作(关键在对象!)中聊过这个问题,但受限于当时水平,表达并不准确,有一些不是很恰当的描述。
最近又遇到类似的问题,在翻阅了很多大佬写的博客,并阅读了Thinking in Java,以及在和实验室的小伙伴讨论了之后,我对这个问题有了更深层次的理解,本篇就是来详细讲讲我对于这个问题的理解和思考,希望对大家有所帮助。
一、引例
为了更好的代入问题,这里我们先来看几个例子:
例子一:
public class Father { protected String name = "父亲属性"; } public class Son extends Father { protected String name = "儿子属性"; public static void main(String[] args) { Father sample = new Son(); System.out.println("调用的属性:" + sample.name); } }
结果:调用的为父亲的属性。
例子二:
public class Father { protected String name = "父亲属性"; public String getName() { return name; } } public class Son extends Father { protected String name = "儿子属性"; public String getName() { return name; } public static void main(String[] args) { Father sample = new Son(); System.out.println("调用的属性:" + sample.getName()); } }
结果:调用的是儿子的属性
可以看到子类对象“赋值”给父类对象后,调用方法时是调用子类的,可调用属性时,却是调用父类的属性,这是为什么呢?
在解释这个问题之前,我想先聊聊另外一个问题——“Java中的指针”。
二、Java中是指针还是引用
1.Java中没有指针?
在讲这个问题之前,我们必须要明白Java中一个很重要的问题——“Java中有没有指针?”。
有些没学过c的朋友可能还不知道什么指针。
指针(Pointer)是编程语言中的一个对象,利用地址,它的值直接指向(Pointed to)存在电脑存储器中另一个地方的值。也就是通过地址可以找到所需的变量单元,可以说,地址指向该变量单元。
那么Java中有没有指针呢?
有些初学Java的小伙伴可能会说:“Java中哪里有指针呢?我见都没见过”。
确实,Java中没有指针的概念,但是学过c语言的小伙伴很快就会有新的疑惑——“如果Java不存在指针的话,那么是如何实现复杂的数据结构?”
事实上,Java可以说处处存在指针!
JAVA中的对象类型本质上应该叫做 对象指针 类型。那么传统的对象类型呢?在JAVA里已经不见了踪影! 既然没有了传统的对象类型,那么对象指针变量 前面的也就可以不要了。对象指针变量也就可以简称为对象变量了, 反正也不会和其它概念混淆!
所有的对象变量都是指针,没有非指针的对象变量,想不用指针都不行,这就是指针的泛化和强化。
不叫指针了,就叫对象变量,这就是概念上的淡化和弱化。 没有了指针的加减运算,也没有了*、->等运算符,这是对指针的简单化。
虽然没有指针的名但有指针的实。
2.指针?引用?
但你要是仔细一想,这里的指针似乎跟c语言中的指针又有所区别。
在C++的对象指针里面,出现的指针运算符主要有以下几个:*,->。
运算符*是返回指针所指向的对象,而->是返回指针所指向对象的数据成员或方法成员。由于不存在对象变量,而是通过指针来访问对象的,因此Java中不需要提供*运算符,这是Java优化了C++的一个指针问题。对于->运行符,Java的指针是提供的,不过是采用.运算符的方式提供,看起来与C++中对象变量的.运算符一样,其实意义是有不一样的地方。
如:
String s = new String(“abc”); s.indexOf(“a”);
与下面C++代码等价的
String *s = new String(“abc”); s->indexOf(“a”);
总的来看主要有以下三点区别:
C++中的指针是可以参与和整数的加减运算的,当一个指对指向一个对象数组时,可以通过自增操作符访问该数组的所有元素;并且两个指针能进行减运算,表示两个指表所指向内存的“距离”。而Java的指针是不能参与整数运算和减法运算的。
C++中的指针是通过new运算或对象变量取地址再进行赋值而初始化的,可以指向堆中或栈中的内存空间。Java同样是类似的,通new运算得到初始化。Java中指针只能是指向堆中的对象,对象只能生存在堆中。C语言是可以通过远指针来指向任意内存的地址,因而可以该问任意内存地址(可能会造成非法访存);Java中的指针向的内存是由JVM来分配的中,由于new运算来实现,不能随所欲为地指向任意内存。
C++中通过delete和delete[] 运算符进行释放堆中创建的对象。如果对象生存周期完结束,但没有进行内存释放,会出现内存泄露现象。在Java中,程序员不用显式地释放对象,垃圾回收器会管理对象的释放问题,不用程序员担心。Java使用了垃圾回收机制使得程序不用再管理复杂的内存机制,使软件出现内存泄露的情况减少到最低。
对于这个问题,在Tinking in Java中是这样描述的:
Java 利用万物皆对象的思想和单一一致的语法方式来简化问题。虽万物皆可为对象,但我们所操纵的标识符实际上只是对对象的“引用” [^1]。
“名字代表什么?玫瑰即使不叫玫瑰,也依旧芬芳”。(引用自 莎士比亚,《罗密欧与朱丽叶》)。
所有的编程语言都会操纵内存中的元素。有时程序员必须要有意识地直接或间接地操纵它们。在 C/C++ 中,对象的操纵是通过指针来完成的。
Java 利用万物皆对象的思想和单一一致的语法方式来简化问题。虽万物皆可为对象,但我们所操纵的标识符实际上只是对对象的“引用” [^1]。 举例:我们可以用遥控器(引用)去操纵电视(对象)。只要拥有对象的“引用”,就可以操纵该“对象”。换句话说,我们无需直接接触电视,就可通过遥控器(引用)自由地控制电视(对象)的频道和音量。此外,没有电视,遥控器也可以单独存在。就是说,你仅仅有一个“引用”并不意味着你必然有一个与之关联的“对象”。
下面来创建一个 String 引用,用于保存单词或语句。代码示例:
String s;
这里我们只是创建了一个 String 对象的引用,而非对象。直接拿来使用会出现错误:因为此时你并没有给变量 s 赋值–指向任何对象。通常更安全的做法是:创建一个引用的同时进行初始化。代码示例:
String s = “asdf”;
Java 语法允许我们使用带双引号的文本内容来初始化字符串。同样,其他类型的对象也有相应的初始化方式。
这么看来如果用引用这个词描述更加准确,并且官方也是这样来描述。
[^1]: 这里可能有争议。有人说这是一个指针,但这假定了一个潜在的实现。此外,Java 引用的语法更类似于 C++ 引用而非指针。在《Thinking in Java》 的第 1 版中,我发明了一个新术语叫“句柄”(handle),因为 C++ 引用和Java引用有一些重要的区别。作为一个从 C++ 的过来人,我不想混淆 Java 可能的最大受众 —— C++ 程序员。在《Thinking in Java》的第 2 版中,我认为“引用”(reference)是更常用的术语,从 C++ 转过来的人除了引用的术语之外,还有很多东西需要处理,所以他们不妨双脚都跳进去。但是,也有些人甚至不同意“引用”。在某书中我读到一个观点:Java 支持引用传递的说法是完全错误的,因为 Java 对象标识符(根据该作者)实际上是“对象引用”(object references),并且一切都是值传递。所以你不是通过引用传递,而是“通过值传递对象引用。人们可以质疑我的这种解释的准确性,但我认为我的方法简化了对概念的理解而又没对语言造成伤害(嗯,语言专家可能会说我骗你,但我会说我只是对此进行了适当的抽象。)
但我更倾向于另一个解释:
Java中的引用与C++中的引用是不同的,并且Java中的引用更像C++中的指针。因此,可以认为Java中的引用就是指针,是一种限制的指针,不能参与整数运行和指向任意位置的内存,并且不用显示回收对象。除了Java外,就本文开头提到的C#以及VB.NET中出现的引用,都类似于C++中的指针。Java中的采用引用的说法,其实是想程序员忘记指针所带来的痛苦;Java的引用比C++中的指针好用得多了,也容易管理,同时提供内存管理机制,让大家用得安心,写得放心而已。
Java中的引用就是一种有限制的指针!
三、动态绑定和静态绑定
好了,在理解了Java中的引用后,我们就可以开始理解编译器的一些行为以及Java底层的一些操作(因为很多操作都和指针息息相关)。
那么,回到之前的问题上来,为什么当子类对象赋给父类时,调用属性是父类,调用方法却是子类呢?
要解释这个问题我们还得知道另外一个概念——动态绑定和静态绑定。
1.什么是动态绑定/静态绑定
先来看看百度百科给的解释:
静态绑定:
静态绑定是指在程序运行前就已经知道方法是属于那个类的,在编译的时候就可以连接到类的中,定位到这个方法。
动态绑定:
动态绑定是指在执行期间(非编译期)判断所引用对象的实际类型,根据其实际的类型调用其相应的方法。程序运行过程中,把函数(或过程)调用与响应调用所需要的代码相结合的过程称为动态绑定。
我们看到动态绑定和静态绑定是一种解析运行代码的一种机制,那么为什么要有动态绑定和静态绑定之分呢?
换言之,我们为什么不全都用静态绑定或者全都用动态绑定呢?
2.静态绑定和动态绑定的优缺点
要回答这个这个问题就需要回答静态绑定和动态绑定的好处。
Java为什么对属性要采取静态的绑定方法。这是因为静态绑定是有很多的好处,它可以让我们在编译期就发现程序中的错误,而不是在运行期。这样就可以提高程序的运行效率!而对方法采取动态绑定是为了实现多态,多态是Java的一大特色。多态也是面向对象的关键技术之一,所以Java是以效率为代价来实现多态这是很值得的。
而在Thinking in Java中作者将静态绑定称为早期绑定,把动态绑定称为后期绑定,他是这么来描述的:
这个问题的答案,是面向对象程序设计的妙诀:在传统意义上,编译器不能进行函数调用。由非 OOP 编译器产生的函数调用会引起所谓的早期绑定,这个术语你可能从未听说过,不会想过其他的函数调用方式。这意味着编译器生成对特定函数名的调用,该调用会被解析为将执行的代码的绝对地址。
通过继承,程序直到运行时才能确定代码的地址,因此发送消息给对象时,还需要其他一些方案。为了解决这个问题,面向对象语言使用后期绑定的概念。当向对象发送信息时,被调用的代码直到运行时才确定。编译器确保方法存在,并对参数和返回值执行类型检查,但是它不知道要执行的确切代码。
为了执行后期绑定,Java 使用一个特殊的代码位来代替绝对调用。这段代码使用对象中存储的信息来计算方法主体的地址(此过程在多态性章节中有详细介绍)。因此,每个对象的行为根据特定代码位的内容而不同。当你向对象发送消息时,对象知道该如何处理这条消息。在某些语言中,必须显式地授予方法后期绑定属性的灵活性。例如,C++ 使用 virtual 关键字。在这些语言中,默认情况下方法不是动态绑定的。在 Java 中,动态绑定是默认行为,不需要额外的关键字来实现多态性。
当提及方法绑定时,我觉得他的解释相当精确,借用一下:
将一个方法调用和一个方法主体关联起来称作绑定。若绑定发生在程序运行前(如果有的话,由编译器和链接器实现),叫做前期绑定。你可能从来没有听说这个术语,因为它是面向过程语言不需选择默认的绑定方式,例如在 C 语言中就只有前期绑定这一种方法调用。
上述程序让人困惑的地方就在于前期绑定,因为编译器只知道一个 Instrument 引用,它无法得知究竟会调用哪个方法。
解决方法就是后期绑定,意味着在运行时根据对象的类型进行绑定。后期绑定也称为动态绑定或运行时绑定。当一种语言实现了后期绑定,就必须具有某种机制在运行时能判断对象的类型,从而调用恰当的方法。也就是说,编译器仍然不知道对象的类型,但是方法调用机制能找到正确的方法体并调用。每种语言的后期绑定机制都不同,但是可以想到,对象中一定存在某种类型信息。
Java 中除了 static 和 final 方法(private 方法也是隐式的 final)外,其他所有方法都是后期绑定。这意味着通常情况下,我们不需要判断后期绑定是否会发生——它自动发生。
为什么将一个对象指明为 final ?正如前一章所述,它可以防止方法被重写。但更重要的一点可能是,它有效地”关闭了“动态绑定,或者说告诉编译器不需要对其进行动态绑定。这可以让编译器为 final 方法生成更高效的代码。然而,大部分情况下这样做不会对程序的整体性能带来什么改变,因此最好是为了设计使用 final,而不是为了提升性能而使用。
总的来说,动态绑定就是通过方法调用机制在运行期将方法调用和方法主题绑定,它的出现就是为了实现多态,但一些不了解机制的人看到引例中的程序后往往会摸不着头脑。
怎么样,是不是感觉打开了新世界的大门?
3.要不要用继承?
学习过方法绑定机制(多态的实现)之后,一切看似都可以被继承,因为多态是如此巧妙的工具。这就引出了新的问题——到底要不要用继承?
我的建议是不要,在应用层面的开发中能不用继承就少用继承,为什么?
继承虽好,能从一些相似的类中抽象从而达到解耦合的目的,但是当我们使用继承时,容易引发一系列问题,就比如这篇文章引例所体现的问题——使用继承容易发生混乱,使设计变得复杂,尤其对于一些新手而言更是如此。
当然不止我的观点如此,设计模式中的合成复用原则也为此而定:
合成复用原则(Composite Reuse Principle,CRP)又叫组合/聚合复用原则(Composition/Aggregate Reuse Principle,CARP)。
它要求在软件复用时,要尽量先使用组合或者聚合等关联关系来实现,其次才考虑使用继承关系来实现。
如果要使用继承关系,则必须严格遵循里氏替换原则。合成复用原则同里氏替换原则相辅相成的,两者都是开闭原则的具体实现规范。
同样的,Thinking in Java也是这么讲的:
学习过多态之后,一切看似都可以被继承,因为多态是如此巧妙的工具。这会给设计带来负担。事实上,如果利用已有类创建新类首先选择继承的话,事情会变得莫名的复杂。
更好的方法是首先选择组合,特别是不知道该使用哪种方法时。组合不会强制设计是继承层次结构,而且组合更加灵活,因为可以动态地选择类型(因而选择相应的行为),而继承要求必须在编译时知道确切类型。
总结
如何理解子类对象“赋值”给父类呢?
注:以下是我认为比较通俗的讲法(具体实现肯定比这更复杂)
首先我们要明白所谓“赋值”只是一种引用的传递,这种引用可以理解成c语言中的指针,只不过不同的是Java中的引用是一种有约束的指针。
再者就是要明白方法调用和方法实体是两个东西,你可以把方法调用看作是是一种用来标记的符号,而方法实体就是方法实际存储的地方,实际上方法区有一种叫做虚方法表的东西用来记录方法调用和方法的实际地址之间的关系。
当我们把一个子类对象传递给父类(基类)时,只是一个引用的传递,我们程序编写者可能会知道这是子类对象“赋值”给父类,但编译器不知道啊,所以在编译阶段,它只知道这是父类,但至于是不是子类对象,它得在程序运行时才能知道。在编译阶段,编译器就把属性和用final,static,private关键字修饰的方法先与父类方法实体绑定了,但又要实现多态,所以在运行阶段,jvm采用一种方法调用机制来让其他方法与其真正的方法实体绑定。这就是所谓的动态绑定和静态绑定。
心得
强推Thinking in Java这本书,这本书解答了我很多的疑惑,因为在我们日常学习Java时往往是一种哲学的眼光去学习(比如学习某个知识点,如果你不懂,老师可能会给你打个比方),不可否认,这种学习方式在我们前期学习中起到了很大作用,但是我们也该看见,如果我们不接触底层的一些机制,不去从语言设计者的角度去思考一些问题,那么我们将永远浮于技术表面。
但是当你去接触底层的一些机制后,你会发现之前遇到的一些难题就会迎刃而解。
但这不是说让你死揪底层的东西不放,我一直信奉中庸之道,我们身为开发者,应当既有那种探究底层的精神,又该有那种大局观。从不同角度去认识一项技术,我觉得这才是一个合格的开发者该做的。