在面向对象(OOP)的程序设计语言中,封装,继承,多态与数据抽象是其基本特征。
在java语言中,
封装就是合并属性与行为来创建一种新的数据类型,
继承则是表明数据类型之间的某种关系(is-a或is-like-a),
多态则是表明这种关系在实际场景中的运用,即行为中的做什么和怎么做,
数据抽象则是想隐藏这种数据类型中的部分属性或是隐藏部分行为的实现细节,即你知道我可以做什么,不用知道我为什么要这样做。
举例来说:一只猫,它有体重,毛发颜色,寿命。它能吃饭,睡觉,并且它是一种动物,像是加菲猫品种。
封装:猫,即一种数据类型,体重,毛发颜色和寿命是它的属性,吃饭,睡觉是它的行为。
继承:动物本身也是一种数据类型,而猫就是一种动物(is-a),像是加菲猫品种(is-like-a)。
多态:猫可以做什么呢,它可以吃饭,也可以去睡觉(即做什么),吃饭的话是喂它吃鱼还是猫粮呢,要睡觉的话是白天睡还是晚上睡呢?(怎么做)
数据抽象:你不用关心它的寿命(什么时候会死),为什么要吃饭(不吃会死)和为什么要睡觉(不睡会死)。
所以,对应到类中来,多态就是把做什么和怎么做分开了,做什么是指调用哪个方法,怎么做是指方法的实现方案。分开了则是指调用哪个方法和怎么实现方法不在同一时间确定。
实质上,多态就是指父类引用指向子类对象,来产生不同的行为。这样做的好处是,可以消除类型之间的耦合(类与类之间的依赖程度)关系。看下面的例子:
看下输出:
Piano和Guitar类都继承自基类Instrument类,主类中playMusic方法,需要传递一个Instrument类型的参数,但是在main方法中,传递的却是Piano和Guitar类型的参数,而且程序能正常运行并且调用到正确的方法。
如果没有继承与多态,我们需要下图这样写,才能调用到3个类中不同的方法:
这样做的话,类Piano与Guitar便与主类UseOfPolyTest有了耦合,也就是依赖,如果不重载playMusic方法,那么就不能调用到他们各自的方法,继承和多态刚好可以消除这里的耦合,使代码冗余大大减少。
那么问题就来了:为什么可以通过继承与多态实现这样的功能呢?如果我们只写这样一个简单的方法,它仅接受基类作为参数,而不是那些特殊的导出类,编写的代码只是与基类打交道,会不会更好呢?事实上,这正是多态所允许的。那么这就要讨论多态的原理了。
上面的例子中,方法playMusic接受一个Instrument类型的对象,但是在调用的时候传递的却是它的子类对象的引用,主类在调用方法时好像忘记了对象的类型一样,这是奇怪之处,也是多态的体现。
这里有了一个疑问:方法playMusic(Instrument i)接受一个Instrument引用,那么在这种情况下,编译器怎么样才能知道这个Instrument引用指向的是Piano对象,而不是Guitar对象呢?
答案是:编译器也无法得知,既然编译器不知道,那就肯定不是在编译期确定的。那么肯定就是在代码运行时确定的,具体表现就是确定方法调用与调用方法对象之间的关系,那么这个关系的确定就称为绑定。由于绑定动作发生在运行时,所以多态也称运行时绑定或者后期绑定,相比于静态的编译期,多态也称为动态绑定。
动态绑定很抽象,我们不禁要问:JVM是如何绑定的呢?
关于这个问题,《Thinking In Java》里面作者是这么说的,如果一种语言想实现后期绑定,就必须具有某种机制,以便在运行时能判断对象的类型,从而调用恰当的方法。也就是说,编译器一直不知道对象的类型,但是方法调用机制能够找到正确的方法体,并加以调用。后期绑定机制随编程语言的不同而有所不同,但是只要想一下就会得知,不管怎么样都必须在对象中安置某种”类型信息“。
所以,动态绑定的过程可以理解为下面几个过程:
注意步骤2中发生了类型转换。我们知道基本数据类型可以发生类型转换,如byte,short,char和int之间;那么引用类型之间也可以,但需要满足一定的条件(有继承关系)。那么这里发生的转换是从Piano对象转到Instrument对象,即从子类转换父类,是向上转换,
也称之为向上转型。向上转型的定义就是指把某个对象的引用视为对其基类型的引用的行为。
来验证一下动态绑定里面的第三点,在Instrument类中定义一个它自己独有的方法,然后尝试创建不同的对象来访问它:
看下输出结果:
createType()方法是基类所独有的,两个导出类并没有覆盖这个方法,当我们使用向上转型传入2个子类的对象引用时,由于在子类对象中并没有找到这个方法的存在,所以只能调用基类的createType()方法。
在我们了解到java中几乎所有方法都是动态绑定(后期绑定)的,并且通过动态绑定来实现它的多态性的这个事实后,我们几乎可以说多态是无处不在的。即便是在有多重继承的场景里使用它,通过多态也能产生正确的行为。看下面的例子:
看一下输出:
可以看到,无论是有几层继承关系,通过动态绑定的机制和多态的特性,对象覆盖的方法都能进行正确的调用。
那么是不是存在特殊情况呢,或者说是不是说还有某种类型的方法不是动态绑定,因而不能使用多态呢?下面便是特例:
(1)final方法
java中当方法被final关键字修饰时,表明这个方法不是动态绑定的,并不具有多态性,而且final方法不能被覆盖(重写)。看下面的例子:
在基类Shape01里面定义了一个final修饰的change()方法,在主测试类中分别测试一下各种情况的调用:
输出结果毫无疑问,都是调用了基类中的方法内容。那这时有些人就会想,因为子类中没有覆盖基类中的方法,那当然只能调用到基类的方法啦。那我们尝试覆盖一下,把上图中圈住的内容去掉注释,看看会出现什么情况:
可以看到,子类中尝试想要覆盖基类的方法均报错了,无论如何修饰方法。而都是同一个错误:在基类中的这个方法是final修饰的,在子类中无法覆盖。编译器甚至都不允许我们重写定义这个方法名,更别提覆盖这个方法了。也验证了final方法无法被覆盖的这个结论。
同时,我们也可以看到,虽然基类的final方法不能被子类对象覆盖,但是子类是继承基类的,子类对象也可以使用该方法。
(2)private方法
java中当方法被private关键字修饰时,表明这个private方法不是动态绑定的,并不具有多态性,而且private方法不能被覆盖(重写)。所以说private方法也是属于final方法的。
这里先提出一个疑问:子类有没有继承到基类的私有方法呢?
在回答问题前先理解一下继承的概念,我们知道,当创建一个子类对象时,会首先创建一个它的基类的子对象。这句话也可以理解为:
在一个子类对象被创建的时候,首先会在内存中创建一个它的基类子对象,这个子对象包含了基类的所有属性和方法。然后在基类子对象外部放上子类独有的属性和方法,两者合起来形成一个子类的对象。所以子类继承基类,子类对象确实拥有基类对象中所有的属性和方法,但是基类对象中的私有属性和方法,是跟基类绑定的,子类是无法访问到的,只是拥有,但不能使用。
所以关于有些说法:子类对象无法继承到基类的私有属性和私有方法,我认为是错的。
private方法不能被子类覆盖,也就是说private方法也是属于final方法的。比起final方法来,区别在于:final方法不能被覆盖,但是在其他类或者它的子类中,还可以访问到它,但是private方法除了不能被覆盖外,还不能被其它类或者其子类访问到。它只能在它自身的类中被访问。
看下面的例子:
我删掉了动态绑定方法的内容,以免影响视觉。测试类中的TestChangeValue()方法需要传入一个Shape03类型的参数,然后调用Shape03类中的ChangeValue()方法,但是这里报错了,看看什么错误:
编译器说ChangeValue()是Shape03类的私有方法,无法在类外被访问到。那在它的子类中去访问一下?在子类中定义一个TestChangeValue()方法试试:
编译器同样报错。这里可以看到private关键字的管控力是多么强劲。接下来验证一下private方法是否能被覆盖(重写),将上图中子类注释掉的内容恢复一下:
因为Circle03类中2个方法重载的原因,我去掉了一个,关注重点:貌似在子类中可以“覆盖”基类中的private方法呢,编译器也没有报错。这是什么原因呢?
关于上图中的这种情况,在 《Thinking In Java》一书中,作者是这样解释的:“覆盖”只有在某方法是基类的接口的一部分时才会出现,即,必须能将一个对象向上转型为它的基类类型并调用相同的方法,如果某方法为private,它就不是基类的接口的一部分,它仅是一些隐藏于类中的程序代码,只不过是具有相同的名称而已,但如果在导出类中以相同的名称生成一个public,protected或包访问权限的方法的话,该方法就不会产生在基类中出现的“仅具有相同名称”的情况,此时你并没有覆盖该方法,仅是生成了一个新的方法,由于private方法无法触及而且能有效隐藏,所以除了把它看成是因为它所归属的类的组织结构的原因而存在外,其他任何事物都不需要考虑到它。
感觉解释的很含蓄,那以我自己的理解来解释一下:覆盖只能出现在在基类中的具有动态绑定的方法上,当基类中的某动态绑定方法被其子类覆盖后,在创建子类对象并向上转型为基类类型时,基类类型引用调用该方法时会产生调用子类中该方法的行为,即实际上调用的是子类中的方法内容,这正是多态的体现。当基类中定义了某个private方法并且恰好它的子类也定义了一个相同名称的,具有public,protected或包访问权限的方法时,子类并没有覆盖基类中的这个方法,仅仅只是在子类中生成了一个新的方法,这个方法与基类中的private方法不具有多态性。或许这样解释可以理解的更清晰一点,那来验证一下上面这个结论:
如果能够将Circle03类向上转型为Shape03类并且调用这个同名方法,输出Circle03类中这个方法的内容,那就说明这个同名方法是动态绑定的,而且可以被覆盖了:
这里由于主类是ShapesTest03类,而ChangeValue()方法在Shape03类中是私有的,在外部类中无法调用到,我们在Shape03类中去实验:
看看输出结果是什么:
输出结果很清晰,第一个输出运用了向上转型,但输出的是基类中的方法内容,所以可以得出结论:private方法不是动态绑定的,并不具有多态性,而且private方法不能被覆盖(重写)。
第二个输出没有使用向上转型,调用的只是自己类中的一个普通方法。
(3)static方法
static关键字修饰的方法属于类而非类的对象,static方法不是动态绑定的,那么它就不具有多态性,所以它不能被覆盖。看下面的例子:
首先,还是来测试一下能否在子类中重新定义它,如果连重新定义都不行,就不用谈覆盖和多态性了。解除注释看一下:
似乎没有问题,可以重新定义static方法,那把static关键字去掉,有没有问题呢?试一下:
编译器报错了,说明static方法可以在子类中重新定义,但也必须是static的才行。再来测试一下是否具有多态性,
输出结果:
第一行输出就清晰的说明了static方法不是动态绑定的,并不具有多态性,而且static方法不能被覆盖(重写)。
第二行输出则说明了对于子类Circle04来说,这个static方法只是它的一个普通的静态方法。
再来看看如果存在多重继承的情况下,对于静态方法来说,调用情况又是怎样的呢?看下面一个例子:
给定的继承关系为:Shape05类为基类,四边形类Quadrangle05继承基类,类中没有重新定义基类的static方法;正方形类Square05继承四边形类,类中有重新定义基类的static方法;三角形类Triangle05继承基类,类中有重新定义基类的static方法;等边三角形类EquilateraTriangle05继承三角形类,类中没有重新定义基类的static方法。然后在测试类中定义一个modifyValueTest()方法,该方法的参数为一个Shape05类型的引用。
首先来测试使用modifyValueTest()方法时的输出情况,预期输出:因为modifyValueTest()接受的参数为Shape05类型的参数,所以当希望创建它的子类对象并传入时,一定是经过了向上转型的,因为static方法并不具有多态性,那么无论是哪种类型的子类对象向上转型,调用的方法应该都是基类中的static方法。
输出结果与我们的预期输出一样,没有问题。
再来看看不使用向上转型,直接用各自的对象去调用时的输出情况,预期输出:当没有使用向上转型时,每个static方法均为该类中的一个普通static方法,当创建各自的对象去调用时,调用的是自己本类中的static方法中的内容。
输出结果与我们的预期似乎有点不同,来分析一下:四边形类Quadrangle05继承基类,类中没有重新定义基类的static方法;当调用这个方法时,首先会去自己的类中找这个static方法,结果没有找到,但是它继承了基类,它的对象中包含了基类的子对象,所以它便去基类中找,发现基类中有定义这个static方法,并且正好可以使用它,于是就调用基类中的方法,输出的也是基类方法中的内容;正方形类Square05继承四边形类,当然也是继承基类的,在创建它的对象时,它的对象中包含了一个它的父类Quadrangle05类的子对象,这个子对象中当然也包括了基类的子对象。调用方法时,首先会去在自己的类中寻找这个方法,发现自己有定义,那么就直接调用自己的方法,输出自己方法中的内容;三角形类Triangle05继承基类,调用时先去自己类中找,发现有定义,于是输出自己类中该方法的内容。等边三角形类EquilateraTriangle05继承三角形类,也继承基类,在调用时,先去自己类中找,发现没有,再去它的父类三角形类中去找,发现有定义,于是输出它父类中的该方法中的内容。所以有了上面的输出结果。
我们可以总结到,在多重继承的关系里,当不使用向上转型时,基类中的static方法版本总是在最后时刻才会被调用,在继承的自上向下的N阶继承关系中,任何一个子类都可以调用这个static方法,哪怕该子类中并没有定义过这个static方法。因为有根基类中定义的这个static方法的保底版本存在。而且调用根基类中的这个static方法的版本的优先级是最低的。在某一子类M调用该static方法时,会自下往上的查找它的每一级父类,直到根基类。在查找的过程中,只要有一个它的父类中有重新定义过该static方法,那么输出就是调用的那一个父类中的staic方法的版本内容。
对于static方法的调用,优先度最高的调用方式是(类名.方法名)去调用,而且这也是java中推荐的调用方式,因为static方法是与类绑定的,与类的对象是没有绑定关系的。所以上面的调用方式也可以更改为:
输出结果与创建类的对象,然后用对象去调用的结果是一样的。只不过我们应该优先使用这种方式。
缺陷一:类的域没有多态性
域可以理解为类的属性,具体指的则是类的成员变量等信息,因为类中的字段是在编译期进行解析的,所以它不具有多态性。看下面的例子:
先来预期分析一下:sup使用了向上转型,getField()方法被子类覆盖,调用的肯定是子类Sub中的getField()方法,输出应该为1,如果Super类中的成员变量能够被覆盖的话,那么sup.field的调用结果肯定是调用子类Sub的field字段,输出也应该是1;
sub是直接创建的子类对象的引用,sub.field的值肯定是Sub类中的field字段值,为1,sub.getField()方法也是调用自己的方法,输出为1,sub.getSuperField()方法中,返回的是基类中的field字段值,那肯定是0。看下控制台的输出结果:
第二行的输出与预期输出一致,没有问题,第一行中sup.field字段值为0,说明基类Super中的成员变量并没有被子类Sub中的所覆盖,所以可以得到验证:类的域没有多态性。
结果分析:当Sub对象被向上转型为Super引用时,任何域访问操作都将由编译器解析,因此不是多态的,在本例中为Super.field和Sub.field分配了不同的存储空间,这样,Sub实际包含两个称为field的域,它自己的和它从Super处得到的,然而,在引用Sub中的field所产生的默认域并不是Super版本的field域,因此,为了得到Super.field,必须显示的指明super.field。所以为了避免混淆,在实际编写代码时,应该避免这样的问题具体操作为:1.把基类的域设置为private,使子类不能直接访问它们,通过在基类中定义非private方法并在子类中使用这个方法来获得。2.将基类和子类中的域定义为不同的名称。
缺陷二:构造器没有多态性
构造器不同于其它种类的方法,涉及到多态时仍是如此。构造器不具有多态性,因为构造器实质上是static方法。只不过该static声明是隐式的。尽管构造器不是多态的,但还是有必要知道构造器怎样通过多态在复杂的层次结构中运作的。
编译器强制调用构造器的原因:当我们创建一个导出类对象时,基类的构造器总是在导出类的构造过程中被调用,而且按照继承层次逐渐向上链接,以使每个基类的构造器都能够得到调用。那么编译器为什么会强制调用基类的构造器呢?因为构造器具有一项特殊任务:检查对象是否被正确而完整的构造。构造导出类对象时,编译器会强制要求先调用基类的构造器,构造基类对象。因为只有基类的构造器才具有恰当的知识和权限对自己的元素进行初始化,所以如果不令每一个构造器都得到调用外,就不可能正确的构造完整对象。
多层次关系的构造器调用顺序:1.调用基类构造器,这个步骤会不断的反复递归下去,首先是构造这种层次结构的根,然后是下一层导出类,等等,直到最底层的导出类。2.按声明顺序调用成员的初始化方法。3.调用导出类的构造主体。
构造器内部的多态方法的行为
构造器调用的层次结构带来了一个问题,如果在一个构造器的内部调用正在构造的对象的某个动态绑定方法,那会发生什么情况呢?看下面的例子:
在基类的构造器中,调用了一个被子类覆盖的方法draw(),那输出的会是哪个版本的内容呢?,看下输出内容:
输出的是子类中的内容,可见在基类的构造器中调用一个动态绑定方法,输出的是该方法被覆盖后的内容。似乎,这样的调用并没有什么问题,那注意看一下输出的radius的值,并不是定义时默认的初值1,而是0。这就很奇怪了。这种情况往往会导致程序出现问题而无法找到问题的原因在哪里。
解析一下:问题出现的原因就藏在程序初始化的顺序里面。再来说明一遍对象的创建过程和初始化顺序:
1.运行测试类ConstructorTest时,所发生的第一件事情就是尝试访ConstructorTest.main()方法,因为它是static的,这样,java加载器开始启动并找到ConstructorTest类的编译代码并加载它。这将产生一个ConstructorTest的Class对象,首次加载ConstructorTest的Class对象时,会执行ConstructorTest类中的所有静态初始化动作。如果类中有静态的成员变量的话,执行他们的初始化动作。例如类中定义了一个静态变量static int a = 2,那么a的值将被赋值为2。静态初始化动作只会在Class类对象首次加载时进行一次;
2.执行main方法中的代码new RoundGlyph(5),发现想要调用构造器创建一个RoundGlyph对象。由于构造器也是static的,java加载器开始启动并找到RoundGlyph类的编译代码并加载它,加载它的过程中发现它有一个基类Glyph(通过关键字extends得知),于是继续加载基类Glyph(如果有多重继承,这个加载会一直持续,直到根基类)。加载完成后创建了RoundGlyph的Class对象和Glyph的Class对象后并执行RoundGlyph类和Glyph类中的所有静态初始化动作,2个类中均没有静态成员,这里无法体现到;
3.两个类加载完成后,可以创建RoundGlyph的对象了。这时将首先在堆上为RoundGlyph对象分配足够的内存(存储空间),这块存储空间将会被清零(通过将对象内存设置为二进制0实现),所以对象中的成员变量的初值都是零值,对于基本类型成员来说是0,对于引用来说,是null。所以RoundGlyph中的成员radius的初值是0;
4.开始调用基类的构造器,创建基类子对象。将首先在堆上为Glyph对象分配足够的内存(存储空间),这块存储空间将会被清零,所以基类对象中的成员变量的初值都是零值,对于基本类型成员来说是0,对于引用来说,是null;
5.执行基类中出现于成员变量定义处的初始化动作,包括字段定义处初始化和显式实例初始化(前面的文章中有详细说明显式实例);
7.执行基类构造器中的主体内容。基类子对象构造完成。对于有多重继承关系的类来说,4-7这几个过程会重复进行。直到这些基类子对象全部创建完成;
8.执行子类中出现于成员变量定义处的初始化动作,包括字段定义处初始化和显式实例初始化,这时,成员radius会被赋值为1;
9.执行子类构造器中的主体内容。子类子对象构造完成。
了解上面的这个过程后。再来分析问题的原因就很轻松了。在执行基类构造器中的主体内容时,子类成员radius仍然还是内存中的零值,并没有被初始化,所以,在调用基类构造器中的draw()方法时,由于被子类重载,调用的是子类中draw()方法,此时输出radius的值,就是0了。
虽然在构造器中调用动态绑定的方法具有多态性,但应该还是避免在构造器中调用其他方法的行为,这也是编写构造器时的一条有效的准则:用尽可能简单的方法使对象进入正常状态。在构造器中唯一能够安全调用的那些方法是final方法和private方法(自动属于final方法),这些方法不能被覆盖,也就不会出现上面的那种问题了。