Java学习笔记---多态

在面向对象的程序设计中,多态是继数据抽象和继承之后的第三种基本特性;

多态通过分离做什么(基类对象)和怎么做(导出类对象),从另一角度将接口和实现分离开来。多态不但能够改善代码的组织结构和可读性,还能够创建可扩展的程序---即无论在项目最初创建时还是在需要添加新功能时都可以“生长”的程序。

“封装”通过合并特征和行为来创建新的数据类型。“实现隐藏”则通过将细节“私有化”把接口和实现分离开来。

多态的作用则是消除类型之间的耦合关系,在前一章中,继承允许将对象视为它自己本身的类型或其基类型(向上转型)来加以处理;这种能力很重要,因为它允许将多种类型(从同一基类导出的)视为同一类来处理,而同一份代码也就可以毫无差别地运行在这些不同类型之上。

多态调用允许一种类型表现出与其他相似类型之间的区别,只要它们都是从同一基类导出来的(从同一个基类继承处很多导出类)。这种区别是根据方法行为的不同而表示出来的(复写),虽然这些方法都可以通过同一个基类来调用。

  • 向上转型
  • 转机
  • 构造器和多态
  • 协变返回类型
  • 用继承进行设计

 1.向上转型

上一章已经知道,对象即可以作为它自己本身的类型使用,也可以作为它的基类型使用。而这种把对某个对象的引用视为对其基类型的引用的做法被称为向上转型--因为在继承树的画法中,基类是放置在上方的。

但是,这么做会有一个问题:

首先,既然几个例子都要演奏乐符(Note),就应该在包中单独创建一个Note类:

扫描二维码关注公众号,回复: 6878125 查看本文章

在这里,Wind是一种Instrument,因此可以从Instrument类继承:

Music.tune()方法接受一个Instrument引用,同时也接受任何导出自Instrument的类。在main()方法中,当一个Wind引用传递到tune()方法时,就会出现这种情况,而不需要任何类型转换。因为Wind从Instrument继承而来,所以Instrument的接口必定存在于Wind中。从Wind向上转型到Instrument可能会“缩小”接口,但不会比Instrument的全部接口更窄

忘记对象类型

上面例子中, Music中的tune()方法,每个类调用的时候都使用父类对象,忘记了自己的对象类型。为什么要忘记自己的对象类型?如果让tune()接受一个Wind引用作为自己的参数,似乎会更为直观。但这样做,就需要为系统内Instrument的每种类型都编写一个新的tune()方法。按照这种推理,现在加入Stringed和Brass这两种Instrument:

这样做确实可行,但有一个缺点:必须为添加的每一个新Instrument类编写特定类型的方法。这意味着在开始时就需要更多的编程,这也意味着如果以后想添加类似tune()的新方法,或者添加自Instrument导出的新类,仍需要做大量的工作;

此外,如果忘记重载某个方法,编译器不会返回任何错误信息,这样关于类型的整个处理过程就会变得难以操纵。

如果只写这样一个简单方法,它仅接收基类作为参数,而不是那些特殊的导出类。这样情况会变得更好吗?也就是说,如果不管导出类的存在,编写的代码只是与基类打交道,会不会更好?

这正是多态所允许的。

总结多态允许一个方法只接收基类作为参数,然后基类的导出类可以通过向上转型方便的将自己的对象当成参数传入该方法中 

2.转机

在上面这个程序中,Wind.play()方法将产生输出结果,这是我们所期望的,但是看起来好像又没有什么意义;

 

接受一个Instrument引用,在这种情况下,编译器怎样才能知道这个Instrument引用指向的是Wind对象,而不是Brass或Stringed对象实际上,编译器无法得知。为了深入理解这个问题,有必要研究一下绑定这个话题。

方法调用绑定

将一个方法调用同一个方法主题关联起来被称为绑定。若在程序执行前进行绑定(由编译器和连接程序实现),叫做前期绑定。一个非面向对象编程的编译器产生的函数调用就是使用前期绑定(编译器将产生对一个具体函数名字的调用)。

Java中无法使用前期绑定,因为当编译器只有一个Instrument引用时,它无法知道究竟调用哪个方法才对。

解决的方法就是后期绑定,它的含义就是在运行时根据对象的类型进行绑定。后期绑定也叫做动态绑定或运行时绑定。编译器一直不知道对象的类型,但是方法调用机制能找到正确的方法体,并加以调用。后期绑定根据不同的编程语言有所不同,但是,不管怎样都必须在对象中安置某种“类型信息”。

Java中除了static方法和final方法(private方法属于final方法)之外,其他所有的方法都是后期绑定。这意味着通常情况下,不必判定是否应该进行后期绑定---它会自动发生。

现在可以考虑为什么要将方法声明为final,除了前面提到的防止其他人覆盖之外,更重要的一点:这样做可以有效地“关闭”动态绑定,告诉编译器不需要对其进行绑定。这样,编译器就可以为final方法调用生成更有效的代码。

产生正确的行为

一旦知道Java中所有的方法都是通过动态绑定实现多态这个事实之后,我们就可以编写只与基类打交道的程序代码了,并且这些代码对所有的导出类都可以正确运行。换句话说,发送消息给某个对象,让该对象取断定应该做什么事。

在“几何形状”例子中,有一个基类Shape,以及多个导出类--如Circle、Square、Triangle等。下面的继承图展示它们之间的关系:

 

向上转型可以这么写:

 这里,创建了一个Circle对象,并把得到的引用立即赋值给Shape,这样做看似错误(将一种类型赋值给另外一种类型);但实际上是没问题的,因为通过继承,Circle就是一种Shape。因此,编译器认可这条语句。

假设调用一个基类方法(它已在导出类中被覆盖):

s.draw();

由于后期绑定,这个语句可以正确的调用Circle.draw()方法。

例子:

Shape基类为自它那里继承而来的所有导出类建立了一个公共接口--所有形状都可以描绘和擦除。导出类通过覆盖这些定义,来为每种特殊类型的几何形状提供单独的行为。

RandomShapeGenerator是一种“工厂”(factory),每次调用next()方法时,可以为随机选择的Shape对象产生一个引用。

向上转型发生在return语句里。每个return语句取得一个指向某个Circle、Square或者Triangle的引用,并将其以Shape类型从next()方法中发送出去。

所以,无论在什么时候调用next()方法时,不会知道具体类型到底是什么,因为总是只能获得一个通用的Shape引用。

在编译时,编译器不需要获得任何特殊信息就能进行正确的调用。对draw()方法的所有调用都是通过动态绑定进行的。

可扩展性

现在返回到“乐器”(Instrument)示例,由于多态机制,可根据自己的需求对系统添加人一多的新类型,而不需要更改tune()方法。在一个涉及良好的OOP程序中,大多数或者所有方法都会遵循tune()模型,而且只与基类接口通信。这样的程序是可扩展的,因为可以从通用的基类继承出新的数据类型,从而添加一些功能。那些操纵基类接口的方法不需要任何改动就可以应用于新类

考虑:对于“乐器”例子,如果向基类中添加更多的方法,并加入一些新类,将会出现什么情况呢?下图:

 

事实上,不需要改动tune()方法,所有的新类都能与原有类一起正确运行。即使tune()方法是单独存放在某个文件中,并且在Instrument接口中添加了其他的新方法,tune()也不需再编译就能正确运行。下面是上图的实现:

新添加的what()返回一个带有类描述的String引用;另一个新添加的方法adust()则提供每种乐器的调音方法。

在main()中,当我们将某种引用置入orchestra数组中,就会自动向上转型到Instrument。

tuen()方法完全可以忽略它周围代码所发生的全部变化,依旧照常运行。这正是我们期望多态所具有的特性。所做的代码修改,不会对程序中其他不应受到影响的部分产生破坏。

多态是一项让程序员“将改变的事物与未变的事物分离开来”的重要事物。

缺陷:“覆盖”私有方法

如果进行如下操作也是可以的:

 

我们期望输出public f(),但是由于private方法被自动认为是final方法,而且对导出类是屏蔽的。

因此,在这种情况下,Derived类中的f()方法就是一个全新的方法;既然基类中的f()方法在子类Derived中不可见,因此甚至也不能被重载

结论

只有非private方法才可以被覆盖;但是还需要密切注意覆盖private方法的现象。这时虽然编译器不会报错,但是也不会按照我们所期望的来执行。确切地说,在导出类中,对于基类中的private方法,最好采用不同的名字。

缺陷:域与静态方法

一旦了解了多态机制,可能会认为所有事物都可以多态地发生。然而,只有普通的方法调用可以是多态的。

例如:如果你直接访问某个域,这个访问就将在编译期进行解析,就像下面的示例所演示的:

当Sub对象转型为Super引用时,任何域访问操作都将由编译器解析,因此不是多态的。

在本例中,为Super.field和Sub.field分配了不同的存储空间。这样,Sub实际上包含两个称为field的域:它自己和它从Super处得到的。

然而,在引用Sub中的field时所产生的默认域并非Super版本的field域。因此为了得到Super.field,必须显式地指明super.field。

看起来这回成为一个令人迷惑的问题,但在实践中,并不是如此;

首先,你通常会将所有的域都设置成private,因此不能直接访问它们,其副作用是只能调用方法来访问;

另外,你可能不会对基类中的域和导出类中的域赋予相同的名字,因为这种做法容易令人迷惑。

如果某个方法是静态的,它的行为就不具有多态性

 

静态方法是与类,而非与单个的对象相关联。 

总结:

域与静态方法不具有多态性;

域是在编译期进行解析的;

静态方法与类相关联,而非与单个的对象相关联。

当使用向上转型调用域属性,会调用父类属性;当使用向上转型调用static方法时,会调用父类方法;调用非static方法时,是调用子类方法的,也就是多态性。

3.构造器和多态

构造器不同于其他种类的方法,涉及到多态时仍是如此;尽管构造器并不具有多态性(它们实际是static方法,只不过该static声明是隐式的);

但是,构造器可通过多态在复杂的层次结构中运作

构造器的调用顺序

基类的构造器总是在导出类的构造过程中被调用,而且按照继承层次逐渐向上链接,以使每个基类的构造器都能得到调用。

这样做是有意义的,因为构造器具有一项特殊任务:检查对象是否被正确地构造

导出类只能给你访问它自己的成员,不能访问基类中的成员(基类成员通常是private类型)。

只有基类的构造器才具有恰当的知识和权限来对自己的元素进行初始化

因此,必须令所有构造器都得到调用,否则就不可能正确构造完整对象。这正是为什么编译器要强制每个导出类部分都必须调用构造器的原因。

在导出类的构造器主体中,如果没有明确指定调用某个基类构造器,他就会“默默”地调用默认构造器。如果不存在默认构造器,编译器就会报错(若某个类没有构造器,编译器自动合成一个默认构造器)。

 

这个例子展示组合、继承以及多态在构建顺序上的作用:

在这里,用其他类创建了一个复杂的类,而且每个类都有一个声明它自己的构造器。

其中最重要的Sandwich,它反映了三层继承(若要计算Object,则是四层)以及三个成员对象。

当在main()里创建了一个Sandwich对象后,就可以看到输出结果。这里可以反映复杂对象调用构造器的顺序:

1).调用基类构造器。这个步骤会不断地反复递归下去,首先是构造这种层次结果的根,然后是下一层导出类,直至最底层的导出类。

2).按声明顺序调用成员的初始化方法。(每次进入一个父类的时候,在调用构造器之前,会先初始化成员方法或域)

3).调用导出类构造器的主体。

构造器的调用顺序是很重要的。当进行继承时,我们已经知道基类的一切,并且可以访问基类中任何声明为public和protected的成员。这意味着在导出类中,必须假定基类的所有成员都是有效的

一种标准方法是,构造动作一经发生,那么对象所有部分的全体成员都会得到构建。然而,在构造器内部,我们必须确保要使用的成员都已经构建完毕。为确保这一目的,唯一的办法就是首先调用基类构造器。那么在进入导出类构造器时,在基类中可供我们访问的成员都已得到初始化

此外,知道构造器中的所有成员都有效也是因为,当成员对象在类内进行定义的时候(比如上例中的b,c和l),只要有可能,就应该对他们进行初始化(也就是说,通过组合方法将对象置于类内)。

若遵循这一规则,那么就能给你保证所有基类成员以及当前对象的成员对象都被初始化了。但遗憾的是,这种做法并不适用于所有情况。

继承与清理

通过组合和继承方法来创建新类时,永远不必担心对象的清理问题,子对象通常都会留给垃圾回收器进行处理。

如果确实遇到清理的问题,那么必须用心为新类创建dispose()方法。并且由于继承的缘故,如果我们有其他作为垃圾回收一部分的特殊清理动作,就必须在导出类中覆盖dispose()。

当覆盖被继承类的dispose()方法时,务必记住调用基类版本的dispose()方法;否则,基类的清理动作就不会发生。

层次结构中的每个类都包含Characteristic和Description这两种类型的成员对象,并且它们也必须被销毁。

所以万一某个子对象要依赖于其他对象,销毁的顺序应该和初始化顺序相反。

对于字段,则意味着与声明的顺序相反(因为字段的初始化是按照声明的顺序执行的)。

对于基类(遵循C++中析构函数的形式),应该首先对其导出类进行清理,然后才是基类。这是因为导出类的清理可能会调用基类中的某些方法,所以需要使基类中的构建仍起作用而不应过早地销毁它们。

从输出结果可以看到,Frog对象的所有部分都是按照创建的逆序进行销毁

在上面的示例中还应该注意到,Frog对象拥有其自己的成员对象。Frog对象创建了它自己的成员对象,并且知道它们应该存活多久(只要Frog存活着),因此Frog对象知道何时调用dispose()取释放其成员对象。

然而,如果这些成员对象中存在于其他一个或多个对象共享的情况,问题就变得更加复杂了,你就不能简单地假设你可以调用dispose()。

在这种情况下,也许就必需使用引用计数来跟踪仍旧访问着共享对象的对象数量了。下面是相关的代码:

static long counter跟踪所创建的Shared的实例的数量,还可以为id提供数值。counter的类型是long而不是int,这样可以防止溢出。

id是final的,因为我们不希望它的值在对象声明周期中被改变。

在将一个共享对象附着到类上时,必须记住调用addRef(),但是dispose()方法将耿总引用数,并决定何时执行清理。使用这种技巧需要加倍地细心,但是如果你正在共享需要清理的对象,那么就没有太多的选择余地。

构造器内部的多态方法的行为

构造器调用的层次结构带来了一个有趣的两难问题。如果在构造器的内部调用正在构造的对象的某个动态绑定方法,那会发生什么情况呢?

在一般的方法内部,动态绑定的调用是在运行时才决定的,因为对象无法知道它是属于方法所在的那个类,还是属于那个类的导出类。

如果要调用构造器内部的一个动态绑定方法,就要用到那个方法的被覆盖后的定义。然而,这个调用的效果可能相当难于预料,因为被覆盖的方法在对象被完全构造之前就会被调用。

从概念上讲,构造器的工作实际上是创建对象(这并非是一件平常的工作)。在任何构造器内部,整个对象可能只是部分形成--我们只知道基类对象已经进行初始化。

如果构造器只是在构建对象过程中的一个步骤,并且该对象所属的类是从这个构造器所属的类导出的,那么导出部分在当前构造器正在被调用的时刻仍旧是没有被初始化的。

然而,一个动态绑定的方法调用却会向外深入到继承层次结构内部,它可以调用导出类里的方法。

如果,在构造器内部这样做,那么就可能调用某个方法,而这个方法所操纵的成员可能还未进行初始化---这将会招致灾难。

通过继承,初始化构造器的时候,如果基类中某个方法(非final,和private)被覆盖,那么如果在构造器中调用该方法,因为多态的而缘故,会调用导出类中的方法,这时因为导出类还未初始化,会出现很奇怪的问题

 

Glyph.draw()方法设计为将要覆盖,这种覆盖是在RoundGlyph中发生的。但是Glyph构造器会调用这个方法,结果导致了对RoundGlyph.draw()的调用,这看似使我们的目的。

但是从结果看,当Glyph的构造器调用draw()方法时,radius不是默认初始值1,而是0(这个时候radius在导出类中还没有被初始化)。

现在可以完善初始化顺序了: 

1).在其他事物发生之前,将分配给对象的存储空间初始化成二进制的零;

2).如前面所述那样调用基类构造器。此时,调用被覆盖后的draw()方法(要在调用RoundGlyph构造器之前调用),由于步骤一的缘故,此时radius是0;

3).按照声明的顺序调用成员的初始化方法。

4).调用导出类的构造器主体

这么做的一个优点就是,所有东西都至少初始化成零(特殊数据类型则是与“零”等价的值),而不是仅仅留作垃圾,其中包括通过“组合”而嵌入类内部的对象引用,其值是null。

所以,如果忘记为该引用进行初始化,就会在运行时出现异常。查看输出结果时,会发现其他所有东西的值都会是零,这通常也正是发现问题的证据。

因此,编写构造器时有一条有效的准则:“尽可能用简单的方法使对象进入正常状态;如果可以的话,避免调用其他方法”。

在构造器内唯一能够安全调用的那些方法是基类中的final方法(也适用与private,它们自动属于final方法)。这些方法不会被覆盖,因此也就不会出现上面的问题。 

4.协变返回类型

Java SE5中添加了协变返回类型,表示在导出类中的被覆盖的方法可以返回基类方法的返回类型的某种导出类型。

 

Java SE5与Java较早版本之间的主要差异就是较早的版本将强制process()的覆盖版本必须返回Grain,而不能返回Wheat,尽管Wheat是从Grain导出的,因而也应该是一种合法的返回类型。协变返回类型允许返回更具体的Wheat类型。

5.用继承进行设计

学习了多态之后,似乎所有的东西都可以被继承,因为多态是一种如此巧妙的东西。

事实上每当我们使用现成的类来建立新类时,如果首先考虑使用继承技术,反倒会加重我们的设计负担,使事情变得不必要地复杂起来。

更好的方式是首先选择“组合”,尤其是不能十分确定应该使用哪一种方式时。组合不会强制我们的程序设计进入继承的层次结构中。而且,组合更加灵活,因为它可以动态选择类型(因此也就选择了行为);

相反,继承在编译时就需要知道确切类型。

 

在这里,Stage对象包含一个对Actor的引用,而Actor被初始化为HappyActor对象。意味着performPlay()会产生某种特殊行为。既然引用在运行时可以与另一个不同的对象重新绑定起来,所以SadActor对象的引用可以在actor中被替换,然后由performPlay()产生的行为也随之改变。

这样一来,在运行期间获得了动态灵活性(这也称状态模式)。与此相反,我们不能在运行期间决定继承不同的对象,因为它要求在编译期间完全确定下来。

一条通用的准则是:“用继承表达行为间的差异,并用字段表达状态上的变化”。

在上面例子中,通过继承得到了两个不同的类,用于表达act()方法的差异;而Stage通过运用组合使自己的状态发生变化,在这种情况下,这种状态的改变也就产生了行为的改变。

纯继承与扩展

采用“纯粹”的方式来创建继承层次结构似乎是最好的方式。也就是说,只有在基类中已经建立的方法才可以在导出类中被覆盖,如下图所示:

这被称作纯粹的“is-a”(是一种)关系,因为一个类的接口已经确定了应该是什么。继承可以确保所有的导出类具有基类的接口,且绝对不会少。按上图那么做,导出类也将具有和基类一样的接口

也可以认为这是一种纯替代,因为导出类可以完全代替基类,而在使用它们时,完全不需要知道关于子类的任何额外信息:

 

也就是说,基类可以接收发送给导出类的任何消息,因为二者有着完全相同的接口。我们只需从导出类向上转型,永远不需知道正在处理的对象的确切类型,所有这一切都是通过多态来处理的

按这种方式考虑,似乎is-a关系才是唯一明智的做法,但是extends关键字似乎在怂恿我们取扩展接口才是解决特定问题的完美方案。这可以称为"is-like-a"(像一个)关系,因为导出类就像是一个基类--他有着相同的基本接口,但是它还具有由额外方法实现的其他特性

 

虽然这是一种有用且明智的方法(依赖于具体情况),但是也有缺点。导出类中接口的扩展部分不能被基类方法,因此,一旦我们向上转型,就不能使用那些新方法

 

在这种情况下,如果不进行向上转型,这样的问题也就不会出现。但是通常情况下,我们需要重新查明对象的确切类型,以便能够访问该类型所扩充的方法

向下转型与运行时类别识别

由于向上转型(在继承层次中向上移动)会丢失具体的类型信息,所以,想着通过向下转型--也就是在继承层次上向下移动---应该能够获取类型信息。

向上转型是安全的,因为基类不会具有大于导出类的接口,通过基类接口发送的消息保证都能被接受;

但是,对于向下转型,比如:我们无法知道一个“几何形状”就是一个“圆”,可以是三角形、正方形或其他一些类型。

要解决这个问题,必须有某种方法来确保向下转型的正确性

在C++中,必须执行一个特殊的操作来获得安全的向下转型,但是在Java中,所有转型都会得到检查,所以即使我们只是进行一次普通的加括号形式的类型转换,在进入运行期时仍会对其进行检查,以确保它的确是我们希望的那种类型。如果不是,便会返回ClassCastException(类转型异常)。这种在运行期间对类型进行检查的行为称作“运行时类型识别”(RRTI)。

运行时类别识别:

MoreUseful接口扩展了Useful接口,但是由于它是继承而来的,所以它也可以向上转型到Useful类型。既然数组中的两个对象都属于Useful类,所以我们可以调用f()和g()这两个方法。如果试图调用u()方法(它只存在于MoreUseful),就会返回一条编译时出错消息。

若想访问MoreUseful对象的扩展接口,可以尝试进行向下转型。如果所转类型是正确的类型,那么转成功;否则,就会返回一个ClassCastException异常(在这里x[0]无法向下转型)。{ThrowsException}注释标签告知本书的构建系统:在运行该程序时,预期抛出一个异常

RTTI的内容步进包括转型处理,例如:它还提供一种方法,使你可以在试图向下转型之前,查看你所要处理的类型。

猜你喜欢

转载自www.cnblogs.com/ifreewolf/p/11246976.html