如何在不污染代码的前提下使用现存代码是需要技巧的。在本章,将学习到两种方式来达到这个目的:
- 第一种方式直接了当。在新类中创建现有类的对象。这种方式叫做“组合”(Composition),通过这种方式复用代码的功能,而非其形式。
第二种方式更为微秒。创建现有类类型的新类。照字面理解:采用现有类形式,又无需在编码时改动其代码,这种方式就叫做“继承”(Inheritance),编译器会做大部分的工作。继承是面向对象编程(OOP)的重要基础之一。
组合语法
在前面的学习中,“组合”(Composition)已经被多次使用。你仅需要把对象的引用(object references)放置在一个新的类里,这里使用了组合。例如,假设你需要一个对象,其中内置了几个String对象,两个基本类型(primitives)的属性字段,一个其他类的对象。对于非基本类型对象,将引用直接放置在新类中,对于基本类型属性字段则仅进行声明。
1 | // reuse/SprinklerSystem.java |
这两个类中定义的一个方法是特殊的:toString()。每个非基本类型对象都有一个toString()方法,在编译器需要字符串但它有对象的特殊情况下调用该方法。因此,在[1]中,编译器看到你试图“添加”一个WaterSource类型的字符串对象。因为字符串只能拼接另一个字符串,所以它就会调用toString()将source转换成一个字符串。然后,它可以拼接这两个字符串并将结果字符串传递给System.out.println()。要创建的任何类允许这种行为,只需要编写一个toString()方法。在toString()上使用@Override注解来告诉编译器,以确保正确地覆盖。@Override是可选的,但它有助于验证你没有拼写错误。类中的基本类型字段自动初始化为零,正如object Everywhere一章中所述。但是对象引用被初始化为null,如果你尝试调用其任何一个方法,你将得到一个异常。方便的是,打印null引用却不会得到异常。
编译器不会为每个引用创建一个默认对象,这是有意义的,因为在许多情况下,这会导致不必要的开销。初始化引用有四种方法:
- 当对象被定义时。这意味着它们总是在调用构造函数之前初始化。
- 在该类的构造函数中。
- 在实际使用对象之前。这通常称为延迟初始化。在对象创建开销大且不需要每次都创建对象的情况下,它可以减少开销。
- 使用实例初始化
1 | // reuse/Bath.java |
在Bath构造函数中,有一个代码块在所有初始化发生前就已执行了。当你不在定义初始化时,仍然不能保证在向对象引用发送消息之前执行任何初始化——如果你试图对未初始化的引用调用方法,则未初始化的引用将产生运行时异常。
当调用toString()时,它将赋值s4,以便在使用字段的时候所有的属性都已被初始化。
继承语法
继承是所有面向对象语言的一个组成部分。事实证明,在创建类时总要继承,因为除非显式地继承其它类,否则就隐式地继承Java的标准根类对象(Object)。
组合的语法很明显,但是继承使用了一种特殊的语法。当你继承时,你说,这个新类与那个旧类类似。你可以在类主体的左括号前的代码中声明这一点,使用关键字extends后跟基类的名称。当你这样做时,你将自动获取基类中的所有字段和方法:
1 | // reuse/Detergent.java |
首先,在Cleanser的append()方法中,使用+=操作符将字符串连接到s,这是Java设计人员“重载”来处理字符串的操作符之一。
第二,Cleanser和Detergent都包含了一个main()方法。你可以为每个类创建一个main();这允许对每个类进行简单的测试。当你完成测试时,不需要删除main();你可以将其留在以后的测试中。即使程序中有很类都有main()方法,唯一运行的只有在命令行上调用的main()。这里,当你使用java Detergent时候,就调用了Detergent.main()。但是你也可以使用java.Cleanser来调用Cleanser.main(),即使Cleanser不是一个公共类。即使类只具有包访问权限,也可以访问public main()。
在这里,Detergent.main()显式地调用Cleanser.main(),从命令行传递相同的参数。
Cleanser中的所有方法都是公开的。请记住,如果不使用任何访问修饰符,则成员默认为包访问权限,这只允许包内成员访问。因此,如果没有访问修饰符,那么包内的任何人都可以使用这些方法。例如,Detergent就没有问题。但是,如果其他包中的类继承Cleanser,则该类只能访问Cleanser的公共成员。因此,为了允许继承,一般规则是所有字段为私有,所有方法为公共。在特定的情况下,你必须进行调整,但这是一个有用的指南。
Cleanser的接口中有一组方法:append()、dilute()、apply()、scrub()和toString()。因为Detergent是从Cleanser派生的,所以它会在接口中自动获取所有这些方法,即使你没有在Detergent中看到所有这些方法的显示定义。那么,可以把继承看作是复用类。如在scrub()中所见,可以使用基类中定义的方法并修改它。在这里,你可以在新类中调用基类的该方法。但是在scrub()内部,不能简单地调用scrub(),因为这会产生递归调用。为了解决这个问题,Java的super关键字引用了当前类继承的“超类”。因此表达式super.scrub()调用方法scrub()的基类版本。
继承时,你不受限于使用基类的方法。你还可以像向类添加任何方法一样向派生类添加新方法:只需定义它。方法foam()就是一个例子。Detergent.main()中可以看到,对于Detergent对象,你可以调用Cleanser和Detergent中可用的所有方法。
初始化基类
现在涉及到两个类:基类和派生类。想象派生类生成的结果对象可能会让人感到困惑。从外部看,新类和基类具有相同的接口,可能还有一些额外的方法和字段。但是继承并不只是复制基类的接口。当你创建派生类的对象时,它包含基类的子对象。这个子对象与你自己创建基类的对象是一样的。只是从外部看,基类的子对象被包装在派生类的对象中。
必须正确初始化基类子对象,而且只有一种方法可以保证这一点:通过调用基类构造函数在构造函数中执行初始化,该构造函数具有执行基类初始化所需的所有适当信息和特权。Java自动在派生类构造函数中插入对基类构造函数的调用:
1 | // reuse/Cartoon.java |
构造从基类“向外”进行,因此基类在派生类构造函数能够访问它之前进行初始化。即使不为Cartoon创建构造函数,编译器也会为你合成一个无参构造函数,调用基类构造函数。
带参数的构造函数
上面的所有例子中构造函数都是无参数的;编译器很容易调用这些构造函数,因为不需要参数。如果没有无参数的基类构造器,或者必须调用具有参数的基类构造函数,则必须使用super关键字和适当的参数列表显示地编写对基类构造函数的调用:
1 | // reuse/Chess.java |
如果没有BoardGame构造函数中调用基类构造函数,编译器就会报错找不到Game()的构造函数。此外,对基类构造函数的调用必须是派生类构造函数中的第一个操作。
委托
Java不直接支持的第三种重用关系称为委托。这介于继承和组合之间,因为你将一个成员对象放在正在构建的类中,但同时又在新类中公开来自成员对象的所有方法。例如,宇宙飞船需要一个控制模块:
1 | // reuse/SpaceShipControls.java |
建造宇宙飞船的一种方法是使用继承:
1 | // reuse/DerivedSpaceShip.java |
然而,DerivedSpaceShip并不是真正的“一种”SpaceShipControls,即使你“告诉”DerivedSpaceShip调用forward()。更准确地说,一艘宇宙飞船包含了SpaceShipControls,同时SpaceShipControls中的所有方法都暴露在宇宙飞船中。委托解决了这个难题:
1 | // reuse/SpaceShipDelegation.java |
方法被转发到底层control对象,因此接口与继承的接口是相同的。但是,你对委托有更多的控制,因为你可以选择只在成员对象中提供方法的子集。虽然Java语言不支持委托,但是开发工具常常支持。
结合组合与继承
你经常同时使用组合和继承。下面的例子展示了使用继承和组合创建类,以及必要的构造函数初始化:
1 | // reuse/PlaceSetting.java |
尽管编译器强制你初始化基类,并要求你在构造函数的开头就初始化基类,但它并不监视你以确保你初始化了成员对象。注意类是如何干净地分离的。你甚至不需要方法重用代码的源代码。你最多只导入一个包。
保证适当的清理
Java没有C++中析构函数的概念,析构函数是在对象被销毁时自动调用的方法。原因可能是,在Java中,通常是忘掉而不是销毁对象,从而允许垃圾收集器根据需要回收内存。通常这是可以的,但是有时你的类可能在其生命周期中执行一些需要清理的活动。初始化和清理章节提到,你无法知道垃圾收集器何时会被调用,甚至它是否会被调用。因此,如果你想为类清理一些东西,必须显示地编写一个特殊的方法来完成它,并确保客户端程序员知道他们必须调用这个方法。最重要的是——正如“异常”章节中描述的——你必须通过在finally子句中放置此类清理来防止异常。
请考虑一个在屏幕上绘制图片的计算机辅助设计系统的例子:
1 | // reuse/CADSystem.java |
这个系统中的所有东西都是某种Shape。除了使用Super调用该方法的基类版本外,每个类还覆盖dispose()方法。特定的Shape类——Circle、Triangle和Line,都有“draw”构造函数,尽管在对象的生命周期中调用的任何方法都可以负责做一些需要清理的事情。每个类都有自己的dispose()方法来将非内存的内容恢复到对象存在之前的状态。
在main()中,有两个关键字是你以前没有见过的,在“异常”一章之前不会详细解释:try和finally。try关键字表示后面的块是一个受保护的区域,这意味着它得到了特殊处理。其中一个特殊处理是,无论try块如何退出,在这个保护区域之后的finally子句中的代码总是被执行的。这里,finally子句的意思是,“无论发生什么,始终调用x.dispose()。”
在清理方法中,还必须注意基类和成员对象清理方法的调用顺序,已防一个子对象依赖于另一个子对象。首先,按与创建的相反顺序执行特定于类的所有清理工作。然后调用基类清理方法。
在很多情况下,清理问题不是问题;你只需要让垃圾收集器来完成这项工作。但是,当你必须执行显式清理时,就需要多做努力,更加细心,因为在垃圾收集方面没有什么可以依赖的。可能永远不会调用垃圾收集器。如果调用,它可以按照它想要的任何顺序回收对象。除了内存回收外,你不能依赖垃圾收集器来做任何事情。如果希望进行清理,可以使用自己的清理方法,不要使用finalize()。
名称隐藏
如果Java基类的方法名多次重载,则在派生类中重新定义该方法名不会隐藏任何基类版本。不管方法是在这个级别定义的,还是在基类中定义的,重载都会起作用:
1 | // reuse/Hide.java |
Homer的所有重载方法在Bart中都是可用的,尽管Bart引入了一种新的重载方法。
你已经看到Java5 @Override注解,它不是关键字,但是可以像使用关键字一样使用它。当你打算重写一个方法时,你可以选择添加这个注解,如果你不小心用了重载而不是重写,编译器会产生一个错误。
1 | // reuse/Lisa.java |
{WillNotCompile}表示该文件排除在Gradle构建之外,但是若手工编译它,你将看到:method does not override a mthod from its superclass方法不会重写超类中的方法,@Override注解能防止你意外地重载。
组合与继承的选择
组合和继承都允许在新类中放置子对象(组合是显式的,而继承是隐式的)。
当你想在新类中包含了一个已有类的功能时,使用组合,而非继承。也就是说,在新类中嵌入一个对象(通常是私有的),以实现其功能。新类的使用者看到的是你所定义的新类的接口,而非嵌入对象的接口。
有时让类的用户直接访问到新类中的组合成分是有意义的。只需要成员对象声明为public即可(可以把这当做“半委托”的一种)。成员对象隐藏了具体实现,所以这是安全的。当用户知道你正在组装一组部件时,会使得接口更加容易理解:
1 | // reuse/Car.java |
因为在这个例子中car的组合也是问题分析的一部分,所以声明成员为public有助于客户端程序员理解如何使用类,且降低了类创建者面临的代码复杂度。但是,记住这是一个特例,属性还是应该声明为private。
当使用继承时,使用一个现有类并开发出它的新版本。通常意味着使用一个通用类,并为了某个特殊需求将其特殊化。稍微思考下,你就会发现,用一个交通工具对象来组成一部车是毫无意义的——车不包含交通工具。这种“是一个”的关系是用继承来表达的,而“有一个”的关系则用组合来表达。
protected
在理想世界中,仅靠关键字private就足够了。在实际项目中,却经常想把一个事务尽量对外界隐藏,而允许派生类的成员访问。
关键字protected就起这个作用。它表示“就类的用户而言,这是private的。但对于任何继承它的子类或在同一包中的类,它是可访问的。”(protected也提供了包访问权限)
尽管可以创建protected属性,但是最好的方式是将属性声明为private以一直保留更改底层实现的权利。然后通过protected控制类的继承者的访问权限。
1 | // reuse/Orc.java |
change()方法可以访问set()方法,因为set()方法是protected。注意到。类Orc的toString()方法也使用了基类的版本。
向上转型
继承最重要的方面不是为新类提供方法。它是新类与基类的一种关系,这种关系可以表述为“新类是已有类的一种类型”。
这种描述并非是解释继承的一种花哨方式,这是直接由语言支持的。例如,假设有一个基类Instrument代表音乐乐器和一个派生类Wind。因为继承保证了基类的所有方法在派生类中也是可用的,所以任意发送给基类的消息也能发送给派生类。如果Instrument有一个play()方法,那么Wind也有该方法。这意味着你可以准确地说Wind对象也是一种类型的Instrument:
1 | // reuse/Wind.java |
tune()方法接受了一个Instrument类型的引用。但是,在Wind的main()方法里,tune()方法却传入了一个Wind引用。鉴于Java对类型检查十分严格,一个接收一种类型的方法接收了另一种类型看起来很奇怪,除非你意识到Wind对象同时也是一个Instrument对象,而且Instrument的tune方法一定存在于Wind中。在tune()中,代码对Instrument和所有Instrument的派生类起作用,这种把Wind引用转换为Instrument引用的行为称作向上转型。
该术语是基于传统的类继承图:图最上面是根,然后向下铺展。
继承图中派生类转型为基类是向上的,所以通常称作向上转型。因为从一个更具体的类转化为一个更一般的类,所以向上转型永远是安全的,也就是说,派生类是基类的一个超集。它可能比基类包含更多的方法,但它必须至少具有与基类一样的方法。在向上转型期间,类接口只可能失去方法,不会增加方法。这就是为什么编译器在没有任何明确转型或其他特殊标记的情况下,仍然允许向上转型的原因。
也可以执行与向上转型相反的向下转型,但会有问题。
再论组合和继承
在面向对象编程中,创建和使用代码最有可能的方法是将数据和方法一起打包到类中,然后使用该类的对象。也可以使用已有的类通过组合来创建新类。继承其实不太常用。因此尽管在教授OOP的过程中我们多次强调继承,但这并不意味着要尽可能使用它。恰恰相反,尽量少使用它,除非确实使用继承有帮助的。一种判断使用组合还是继承的最清晰的方法是问一问自己是否需要把新类向上转型为基类。如果必须向上转型,那么继承就是必要的,但如果不需要,则进一步考虑是否该采用继承。“多态”一章提出了一个使用向上转型的最有力的理由,但是只要记住问一问“我需要向上转型吗?”,就能在这两者中作出较好的选择。
final关键字
根据上下文环境,Java的关键字final的含义有些稍微不同,但通常它指的是“这是不能被改变的”。防止改变有两个原因:设计或效率。因为这两个原因相差很远,所以有可能误用关键字final。
以下几节讨论了可能使用final的三个地方:数据、方法和类。
final数据
许多编程语言都有某种方法告诉编译器有一块数据是恒定不变的。恒定是有用的,如:
- 一个永不改变的编译时常量
- 一个在运行时初始化就不会改变的值。
对于编译时常量这种情况,编译器可以把常量带入计算中;也就是说,可以在编译时计算,减少了一些运行时的负担。在Java中,这类常量必须是基本类型,而且用关键字final修饰。你必须在定义常量的时候进行赋值。
一个被static和final同时修饰的属性只会占用一段不能改变的存储空间。
当用final修饰对象引用而非基本类型时,其含义会有一点困惑。对于基本类型,final使数值恒定不变,而对于对象引用,final使引用恒定不变。一旦引用被初始化指向了某个对象,它就不能改为指向其他对象。但是,对象本身是可以修改的,Java没有提供将任意对象设置为常量的方法。这一限制同样适用数组,数组也是对象:
1 | // reuse/FinalData.java |
因为valueOne和Value_TWO都是带有编译时值的final基本类型,它们都可用作编译时常量,没有多大区别。VALUE_THREE是一种更加典型的常量定义的方法:public意味着可以在包外访问,static强调只有一个,final说明是一个常量。
按照惯例,带有恒定初始值的final static基本变量命名全部使用大写,单词之间用下划线分隔。
我们不能因为某数据被final修饰就认为在编译时可以知道它的值,由上例的i4和INT_5可以看出,它们在运行时才会赋值随机数。示例部分也展示了将final值定义为static和非static的区别。此区别只有当值在运行时被初始化时才会显示,因为编译器对编译时数值一视同仁。当运行程序时就能看到这个区别。注意到fd1和fd2的i4值不同,但INT_5的值并没有因为创建了第二个FinalData对象而改变,这因为它是static的,在加载时已经被初始化,并不是每次创建新对象时都初始化。
v1到VAL_3变量说明了final引用的意义。正如你在main()中所见,v2是final的并不意味着你不能修改它的值。因为它是引用,所以只是说明它不能指向一个新的对象。这对于数组具有同样的意义,数组只不过是另一种引用。看起来,声明引用为final没有声明基本类型final有用。
空白final
空白final指的是没有初始化值的final属性。编译器确保空白final在使用前必须被初始化。这样既能使一个类的每个对象的final属性值不同,也能保持它的不变性。
1 | // reuse/BlankFinal.java |
你必须在定义时或在每个构造器中执行final变量的赋值操作。这保证了final属性在使用前已经被初始化过了。
final参数
在参数列表中,将参数声明为final意味着在方法中不能改变参数指向的对象或基本类型:
1 | // reuse/FinalArguments.java |
方法f()和g()展示了final基本类型参数的使用情况。你只能读取而不能修改参数。这个特性主要用于传递数据给匿名内部类。
final方法
使用final方法的原因有两个。第一个原因是给方法上锁,防止子类通过覆写改变方法行为。这是处于继承的考虑,确保方法的行为不会因继承而改变。
过去建议使用final方法的第二个原因是效率。在早期的Java实现中,如果将一个方法指明为final,就是同意编译器把该方法的调用转化为内嵌调用。当编译器遇到final方法的调用时,就会很小心地跳过普通的插入代码以执行方法的调用机制,而用方法体内实际代码的副本替代方法调用。这消除了方法调用的开销。但是如果一个方法很大代码膨胀,你也许就看不到内嵌带来的性能提升,因为内嵌调用带来的性能提高被花费在方法里的时间抵消了。
在最近的Java版本中,虚拟机可以探测到这些情况(尤其是hotspot技术),并优化去掉这些效率反而降低的内嵌调用方法。有很长一段时间,使用final来提高效率都被阻止。你应该让编译器和JVM处理性能问题,只有在为了明确禁止覆写方法时才使用final。
final与private
类中所有的private方法都隐式地指定为final。因为不能访问private方法,所以不能覆写它。可以给private方法添加final修饰,但是并不能给方法方法带来额外的含义。
以下情况会令人困惑,当你试图覆写一个private方法时,看上去奏效,而且编译器不会给出错误信息:
1 | // reuse/FinalOverridingIllusion.java |
“覆写”只发生在方法是基类的接口时。也就是说,必须能将一个对象向上转型为基类并调用相同的方法。如果是一个方法是private的,它就不是基类接口的一部分。它只是隐藏在类内部的代码,且恰好有相同的命名而已。但是如果你在派生类中以相同的命名创建了public、protected或包访问权限的方法,这些方法与基类中的方法没有联系,你没有覆写方法,而是创建新的方法而已。由于private方法无法触及且能有效隐藏,除了把它看作类中的一部分,其它任何事务都不需要考虑它。
final类
当说一个类是final(final关键字在类定义之前),就意味着它不能被继承。之所以这么做,是因为类的设计就是永远不需要改动,或者处于安全考虑不希望它有子类。
1 | // reuse/Jurassic.java |
final类的属性可以根据个人选择是或不是final。同样,非final类的属性也可以根据个人选择是或不是final。然而,由于final类禁止继承,类中所有的方法都被隐式地指定为final,所以没有办法覆写它们。你可以在final类中的方法加上final修饰符,但不会增加任何意义。
final忠告
在设计类时将一个方法指明为final看上去是明智的。你可能会觉得没人会覆写那个方法。有时是对的。
但请留意你的假设。通常来说,预见一个类如何被复用是很困难的,特别是通用类。如果将一个方法指定为final,可能会防止其他程序员的项目中通过继承来复用你的类,而这仅仅是因为你没有想到它被以那种方式使用。
Java标准类库就是一个很好的例子。尤其是Java1.0/1.1的Vector类被广泛地使用,然而它的所有方法出于“效率”考虑(然而并没有提升效率)全被指定为final,如果不指定final的话,可能会更加有用。很容易想到,你可能会继承并覆写这么一个基础类,但是设计者们认为这么做不合适。有两个讽刺的原因:第一,Stack继承自Vector,就是说Stack是个Vector,但从逻辑上来说不对。尽管如此,Java设计者们仍然这么做,在用这种方式创建Stack时,他们应该意识到了final方法过于约束。
第二,Vector中的很多重要方法,比如说addElement()和elementAt()方法都是同步的。在“并发编程”一章中看到同步会导致很大的执行开销,可能会抹煞final带来的好处。这加强了程序员永远无法正确猜到优化应该发生在何处的观点。如此笨拙的设计却出现在每个人都要使用的标准库中,太糟糕了。庆幸的是,现代Java容器用ArrayList代替了Vector,它的行为要合理得多。不幸的是,仍然有很多新代码使用旧的集合类库,其中就包括Vector。
Java1.0/1.1标准类库中另一个重要的类是Hashtable(后来被HashMap取代),它不含任何final方法。Hashtable就比Vector中的方法名简洁得多。对于类库的使用者来说,这是一个本不应该如此草率的事情。这种不规则的情况造成用户需要做更多的工作——这是对粗糙的设计和代码的又一讽刺。
类初始化和加载
在许多传统语言中,程序在启动时一次性全部加载。接着初始化,然后程序开始运行。必须仔细控制这些语言的初始化过程,以确保static初始化的顺序不会造成麻烦。
Java中不存在这样的问题,因为它采用了一种不同的方式加载。因为Java中万物皆对象,所以加载活动就容易得多。记住每个类的编译代码都存在于它自己独立的文件中,该文件只有在使用程序代码时才会被加载。一般来说“类的代码在首次使用时加载”,这通常是指创建类的第一个对象,或者是访问了类的static属性或方法。构造器也是一个static方法尽管它的static关键字是隐式的。因此,准确地说,一个类当它任意一个static成员被访问时,就会被加载。
首次使用时就是static初始化发生时。所有的static对象和static代码块在加载时按照文本的顺序依次初始化。static变量只被初始化依次。
继承和初始化
了解包括继承在内的整个初始化过程是有帮助的,这样可以对所发生的一切有全局性的把握:
1 | // reuse/Beetle.java |
当执行java Beetle,首先会试图访问Be’etle类的main()方法(一个静态方法),加载器启动并找出Beetle类的编译代码(在名为Beetle.class的文件中)。在加载过程中,编译器注意到有一个基类,于是继续加载基类。不论是否创建了基类的对象,基类都会被加载。
如果基类还存在自身的基类,那么第二个基类也将被加载,以此类推。接下来,根基类(例子中根基类是Insect)的static的初始化开始执行,接着是派生类,依次类推。这点很重要,因为派生类中的static的初始化可能依赖基类成员是否被正确地初始化。
至此,必要的类都加载完毕,对象可以被创建了。首先,对象中的所有基本类型变量都被置为默认值,对象引用置为null——这是通过对象内存设为二进制零值一举生成的。接着会调用基类的构造器。本例中是自动调用的,但是你也可以使用super调用指定的基类构造器(在beetle构造器中的第一步操作)。基类构造器和派生类构造器一样以相同的顺序经历相同的过程。当基类构造器完成之后,实例变量按文本顺序初始化。最终,构造器的剩余部分被执行。