
本书作者Joshua Bloch曾是Sun Microsystems的Java架构师,现在是Google的工程师。
这是一本任何想要提高技能和编写更好代码的Java开发人员的必读之书。里面全是干货。
“我很希望我10年前就能拥有这本书。有人可能认为我不需要任何Java方面的书籍,但是我需要这本书。”——Java之父James Gosling
目录
引言
本书的目标是帮助读者更加有效地使用 Java 编程语言及其基本类库 java.lang、java.util和java.io,以及子包java.util.concurrent 和java.util.function 等。本书也会时不时地讨论到其他的类库。
本书一共包含90个条目,每个条目讨论一条规则。这些规则反映了最有经验的优秀程序员在实践中常用的一些有益的做法。全书以一种比较松散的方式将这些条目组织成12章,每一章都涉及软件设计的一个主要方面。因此,并不一定需要按部就班地从头到尾阅读本书,因为每个条目都有一定程度的独立性。这些条目相互之间经常交叉引用,因此可以很容易地在书中找到自己需要的内容。
自从本书上一个版本出版之后,Java平台中又增加了许多新特性。本书中大多数条目都以一定的方式用到了这些特性。下表列出了主要特性所在的主要章节或条目。
大多数条目都通过程序示例进行了说明。本书一个突出的特点是,包含了许多用来说明设计模式(Design Pattern)和习惯用法(Idiom)的代码示例。当需要参考设计模式领域的标准参考书[Gamma 95]时,还为这些设计模式和习惯用法提供了交叉引用。
许多条目都包含有一个或多个应该在实践中避免的程序示例。像这样的例子,有时候也叫作”反模式”(Antipattern),在注释中清楚地标注为”//Never do this!”。对于每一种情况,条目中都解释了为什么此例不好,并提出了另外的解决方法。
本书不是针对初学者的,而是假设读者已经熟悉Java 编程语言。如果你还不熟悉,请考虑先参阅一本好的 Java人门书籍,比如 Peter Sestoft 的《Java Precisely》[Sestoftl6]。本书适用于任何具有实际Java 工作经验的程序员,对于高级程序员,也应该能够提供一些发人深思的东西。
本书中大多数规则都源于少数几条基本的原则。清晰性和简洁性最为重要:组件的用户永远也不应该被其行为所迷惑。组件要尽可能小,但又不能太小[本书中使用的术语”组件”(Component),是指任何可重用的软件元素,从单个方法,到包含多个包的复杂框架,都可以是一个组件]。代码应该被重用,而不是被拷贝。组件之间的依赖性应该尽可能地降到最小。错误应该尽早被检测出来,最好是在编译时就发现并解决。
虽然本书中的规则不会百分之百地适用于任何时刻和任何场合,但是,它们确实体现了绝大多数情况下的最佳编程实践。你不应该盲目地遵循这些规则,但偶尔有了充分理由之后,可以去打破这些规则。同大多数学科一样,学习编程艺术首先要学会基本的规则,然后才能知道什么时候可以打破这些规则。
本书大部分内容都不是讨论性能的,而是关心如何编写出清晰、正确、可用、健壮、灵活和可维护的程序来。如果你能够做到这一点,那么要想获得所需要的性能往往也就水到渠成了(详见第67条)。有些条目确实谈到了性能问题,甚至有的还提供了性能指标。但在提及这些指标的时候,会出现”在我的机器上”这样的话,所以应该把这些指标视同近似值。
有必要提及的是,我的机器是一台过时的家用电脑,CPU是四核Intel Core i7-4770K,主频 3.5 GHz,内存 DDR3-1866 CL9 16G,在 Microsoft Windows 7 Professional SP1(64 位)操作系统平台上运行Azul公司发行的OpenJDK:Zulu9.0.0.15版本。
讨论Java 编程语言及其类库特性的时候,有时候必须要指明具体的发行版本。为了简单起见,本书使用了平时大家惯用的昵称,而不是正式的发行名称。下表列出了发行名称与昵称之间的对应关系:
尽管这些示例都很完整,但是它们注重可读性更甚于注重完整性。它们直接使用了java.util 和java.io包中的类。为了编译这些示例程序,可能需要在程序中加上一行这些示例。
本书采用的大部分技术术语都与《The JavaLanguage Specification,Java SE8Edition》[JLS]相同。有一些术语则值得特别提及一下。Java 语言支持四种类型:接口(包括注释)类(包括 enum)、数组和基本类型。前三种类型通常被称为引l用类型(reference type),类实例和数组是对象(object),而基本类型的值则不是对象。类的成员(member)由它的域(field)、方法(method)、成员类(member class)和成员接口(member interface)组成。方法的签名(signature)由它的名称和所有参数类型组成;签名不包括方法的返回类型。
本书也使用了一些与 《 Th巳 Java Language Specification 》 不同的术语。 例如,本书用术语”继承”(inheritance)作为”子类化”( subclassing)的同义词。 本书不再使用”接口继承” 这种说法,而是简单地说, 一个类实现(implement)了一个接口,或者一个接口扩展( extend )了另一个接口。 为了描述”在没有指定访问级别的情况下所使用的访问级别”,本书使用了传统的描述性术语”包级私有”(package-private),而不是技术性术语”包级访问”(package access)级别 [ JLS. 6.6. 1 ] 。
本书也使用了一些在《The Java Language Specification》中没有定义的术语。术语”导出的API”(exported API),或者简单地说API,是指类、接口、构造器(constructor)、成员和序列化形式(serialized form),程序员通过它们可以访问类、接口或者包。(术语API是 Application Programming Interface 的简写,这里之所以使用API 而不用接口,是为了不与Java语言中的interface类型相混淆。)使用API编写程序的程序员被称为该API的用户(user),在类的实现中使用了 API 的类被称为该 API 的客户端(client)。
类、接口、构造器、成员以及序列化形式被统称为 API 元素(API element)。API由所有可在定义该API的包之外访问的API元素组成。任何客户端都可以使用这些API元素,而 API 的创建者则负责支持这些 API元素。Javadoc 工具类在它的默认操作模式下也正是为这些元素生成文档,这绝非偶然。不严格地讲,一个包的导出API是由该包中的每个公有(public)类或者接口中所有公有的或者受保护的(protected)成员和构造器组成。
在Java 9 中新增了模块系统(module system)。如果类库使用了模块系统,其 API 就是类库的模块声明导出的所有包的导出API组合。
第二章 创建和销毁对象
本章的主题是创建和销毁对象:何时以及如何创建对象,何时以及如何避免创建对象,如何确保它们能够适时地销毁,以及如何管理对象销毁之前必须进行的各种清理动作。
第1条:用静态工厂方法代替构造器
对于类而言,为了让客户端获取它自身的一个实例,最传统的方法就是提供一个公有的构造器。还有一种方法,也应该在每个程序员的工具箱中占有一席之地。类可以提供一个公有的静态工厂方法(static factory method),它只是一个返回类的实例的静态方法。下面是类型值转换成了一个Boolean对象引用:
1 | public static Boolean valueOf(boolean b) { |
注意,静态工厂方法与设计模式[Gamma95]中的工厂方法(FactoryMethod)模式不同。本条目中所指的静态工厂方法并不直接对应于设计模式(Design Pattern)中的工厂方法。
如果不通过公有的构造器,或者说除了公有的构造器之外,类还可以给它的客户端提供静态工厂方法。提供静态工厂方法而不是公有的构造器,这样做既有优势,也有劣势。
静态工厂方法与构造器不同的第一大优势在于,它们有名称。如果构造器的参数本身没有确切地描述正被返回的对象,那么具有适当名称的静态工厂会更容易使用,产生的客户端代码也更易于阅读。例如,构造器BigInteger(int,int,Random)返回的BigInteger可能为素数,如果用名为 BigInteger.probablePrime 的静态工厂方法来表示,显然更为清楚。(Java 4版本中增加了这个方法。)
一个类只能有一个带有指定签名的构造器。编程人员通常知道如何避开这一限制:通过提供两个构造器,它们的参数列表只在参数类型的顺序上有所不同。实际上这并不是个好主意。面对这样的API,用户永远也记不住该用哪个构造器,结果常常会调用错误的构造器。并且在读到使用了这些构造器的代码时,如果没有参考类的文档,往往不知所云。
由于静态工厂方法有名称,所以它们不受上述限制。当一个类需要多个带有相同签名的构造器时,就用静态工厂方法代替构造器,并且仔细地选择名称以便突出静态工厂方法之间的区别。
静态工厂方法与构造器不同的第二大优势在于,不必在每次调用它们的时候都创建一个新对象。这使得不可变类(详见第17条)可以使用预先构建好的实例,或者将构建好的实例缓存起来,进行重复利用,从而避免创建不必要的重复对象。Boolean.valueOf(boolean)方法说明了这项技术:它从来不创建对象。这种方法类似于享元(Flyweight)模式[Gamma95]。如果程序经常请求创建相同的对象,并且创建对象的代价很高,则这项技术可以极大地提升性能。
静态工厂方法能够为重复的调用返回相同对象,这样有助于类总能严格控制在某个时刻哪些实例应该存在。这种类被称作实例受控的类(instance-controlled)。编写实例受控的类有几个原因。实例受控使得类可以确保它是一个Singleton(详见第3条)或者是不可实例实例,即当且仅当a==b时,a.equals(b)才为true。这是享元模式[Gamma95]的基础。枚举(enum)类型(详见第 34条)保证了这一点。
静态工厂方法与构造器不同的第三大优势在于,它们可以返回原返回类型的任何子类型的对象。这样我们在选择返回对象的类时就有了更大的灵活性。
这种灵活性的一种应用是,API可以返回对象,同时又不会使对象的类变成公有的。以这种方式隐藏实现类会使 API变得非常简洁。这项技术适用于基于接口的框架(interface-based framework)(详见第 20条),因为在这种框架中,接口为静态工厂方法提供了自然返回类型。
在 Java 8 之前,接口不能有静态方法,因此按照惯例,接口 Type 的静态工厂方法被放在一个名为 Types 的不可实例化的伴生类(详见第 4条)中。例如,Java Collections Framework的集合接口有 45个工具实现,分别提供了不可修改的集合、同步集合,等等。几乎所有这些实现都通过静态工厂方法在一个不可实例化的类(java.util.Collections)中导出。所有返回对象的类都是非公有的。
现在的Collections Framework API 比导出 45 个独立公有类的那种实现方式要小得多,每种便利实现都对应一个类。这不仅仅是指 API数量上的减少,也是概念意义上的减少:为了使用这个API,用户必须掌握的概念在数量和难度上都减少了。程序员知道,被返回的对象是由相关的接口精确指定的,所以他们不需要阅读有关的类文档。此外,使用这种静态工厂方法时,甚至要求客户端通过接口来引用被返回的对象,而不是通过它的实现类来引用被返回的对象,这是一种良好的习惯(详见第64条)。
从Java 8版本开始,接口中不能包含静态方法的这一限制成为历史,因此一般没有任何理由给接口提供一个不可实例化的伴生类。已经被放在这种类中的许多公有的静态成员,应该被放到接口中去。但是要注意,仍然有必要将这些静态方法背后的大部分实现代码,单独放进一个包级私有的类中。这是因为在Java 8中仍要求接口的所有静态成员都必须是公有的。在Java 9中允许接口有私有的静态方法,但是静态域和静态成员类仍然需要是公有的。
静态工厂的第四大优势在于,所返回的对象的类可以随着每次调用而发生变化,这取决于静态工厂方法的参数值。只要是已声明的返回类型的子类型,都是允许的。返回对象的类也可能随着发行版本的不同而不同。
EnumSet(详见第 36 条)没有公有的构造器,只有静态工厂方法。在OpenJDK实现中,它们返回两种子类之一的一个实例,具体则取决于底层枚举类型的大小:如果它的元素有64个或者更少,就像大多数枚举类型一样,静态工厂方法就会返回一个RegalarEumSet实例,实例,用一个1ong 数组进行支持。
这两个实现类的存在对于客户端来说是不可见的。如果RegularEnumSet不能再给小的枚举类型提供性能优势,就可能从未来的发行版本中将它删除,不会造成任何负面的影响。同样地,如果事实证明对性能有好处,也可能在未来的发行版本中添加第三甚至第四个心它是EnumSet的某个子类。
静态工厂的第五大优势在于,方法返回的对象所属的类,在编写包含该静态工厂方法的类时可以不存在。这种灵活的静态工厂方法构成了服务提供者框架(Service ProviderFramework)的基础,例如 JDBC(Java 数据库连接)API。服务提供者框架是指这样一个系统:多个服务提供者实现一个服务,系统为服务提供者的客户端提供多个实现,并把它们从多个实现中解耦出来。
服务提供者框架中有三个重要的组件:服务接口(ServiceInterface),这是提供者实现的;提供者注册API(Provider Registraticn API),这是提供者用来注册实现的;服务访问API(ServiceAccessAPI),这是客户端用来获取服务的实例。服务访问API是客户端用来指定某种选择实现的条件。如果没有这样的规定,API就会返回默认实现的一个实例,或者允许客户端遍历所有可用的实现。服务访问API是”灵活的静态工厂”,它构成了服务提供者框架的基础。
服务提供者框架的第四个组件服务提供者接口(ServiceProviderInterface)是可选的,它表示产生服务接口之实例的工厂对象。如果没有服务提供者接口,实现就通过反射方式进行实例化(详见第 65条)。对于 JDBC来说,Connection 就是其服务接口的一部分,DriverManager.registerDriver 是提供者注册 API, DriverManager.getConnection是服务访问 API,DriVer 是服务提供者接口。
服务提供者框架模式有着无数种变体。例如,服务访问API可以返回比提供者需要的更丰富的服务接口。这就是桥接(Bridge)模式[Gamma95]。依赖注人框架(详见第5条)可以被看作是一个强大的服务提供者。从Java 6版本开始,Java平台就提供了一个通用的服务提供者框架java.util.ServiceLoader,因此你不需要(一般来说也不应该)再自己编写了(详见第 59条)。JDBC不用 ServiceLoader,因为前者出现得比后者早。
静态工厂方法的主要缺点在于,类如果不含公有的或者受保护的构造器,就不能被子类化。例如,要想将Collections Framework中的任何便利的实现类子类化,这是不可能的。但是这样也许会因祸得福,因为它鼓励程序员使用复合(composition),而不是继承(详见第18条),这正是不可变类型所需要的(详见第17条)。
静态工厂方法的第二个缺点在于,程序员很难发现它们。在API文档中,它们没有像构造器那样在API文档中明确标识出来,因此,对于提供了静态工厂方法而不是构造器的类来说,要想查明如何实例化一个类是非常困难的。Javadoc工具总有一天会注意到静态工厂方法。同时,通过在类或者接口注释中关注静态工厂,并遵守标准的命名习惯,也可以弥补这一劣势。下面是静态工厂方法的一些惯用名称。这里只列出了其中的一小部分:
from一—类型转换方法,它只有单个参数,返回该类型的一个相对应的实例,例如:
1 | Date d = Date.from(instant); |
口of一一聚合方法,带有多个参数,返回该类型的一个实例,把它们合并起来,例如:
1 | Set<Rank> faceCards = EnumSet.of(JACK, QUEEN, KING) ; |
valueOf一—比from 和of更烦琐的一种替代方法,例如:
1 | BigInteger prime = BigInteger.valueOf(Integer.MAX_VALUE); |
instance 或者 getInstance—返回的实例是通过方法的(如有)参数来描述的,但是不能说与参数具有同样的值,例如:
1 | StackWalker luke = StackWalker.getInstance(options); |
create 或者 newInstance—像 instance 或者 getInstance一样,但 create或者 newInstance 能够确保每次调用都返回一个新的实例,例如:
1 | Object newArray = Array.newInstance(classObject, arrayLen) ; |
用。Type 表示工厂方法所返回的对象类型,例如:
1 | FileStore fs = Files.getFileStore(path); |
Type 表示工厂方法所返回的对象类型,例如:
1 | BufferedReader br = Files.newBufferedReader(path); |
type- getType 和 newType 的简版,例如:
1 | List<Complaint> litany = Collections.list(legacyLitany); |
简而言之,静态工厂方法和公有构造器都各有用处,我们需要理解它们各自的长处。静态工厂经常更加合适,因此切忌第一反应就是提供公有的构造器,而不先考虑静态工厂。
第2条:遇到多个构造器参数时要考虑使用构建器
静态工厂和构造器有个共同的局限性:它们都不能很好地扩展到大量的可选参数。比如用一个类表示包装食品外面显示的营养成分标签。这些标签中有几个域是必需的:每份的含量、每罐的含量以及每份的卡路里。还有超过 20个的可选域:总脂肪量、饱和脂肪量、转化脂肪、胆固醇、钠,等等。大多数产品在某几个可选域中都会有非零的值。
对于这样的类,应该用哪种构造器或者静态工厂来编写呢?程序员一向习惯采用重叠构造器(telescoping constructor)模式,在这种模式下,提供的第一个构造器只有必要的参数,第二个构造器有一个可选参数,第三个构造器有两个可选参数,依此类推,最后一个构造器包含所有可选的参数。下面有个示例,为了简单起见,它只显示四个可选域:
1 | //Telescoping constructor pattern - does not scale well! |
当你想要创建实例的时候,就利用参数列表最短的构造器,但该列表中包含了要设置的所有参数:
1 | NutritionFacts cocaCola =new NutritionFacts(240,8,100,0,35,27); |
这个构造器调用通常需要许多你本不想设置的参数,但还是不得不为它们传递值。在这个例子中,我们给fat传递了一个值为0。如果”仅仅”是这6个参数,看起来还不算太糟糕,问题是随着参数数目的增加,它很快就失去了控制。
简而言之,重叠构造器模式可行,但是当有许多参数的时候,客户端代码会很难编写,并且仍然较难以阅读。如果读者想知道那些值是什么意思,必须很仔细地数着这些参数来探个究竟。一长串类型相同的参数会导致一些微妙的错误。如果客户端不小心颠倒了其中两个参数的顺序,编译器也不会出错,但是程序在运行时会出现错误的行为(详见第51条)。
遇到许多可选的构造器参数的时候,还有第二种代替办法,即JavaBeans模式,在这种模式下,先调用一个无参构造器来创建对象,然后再调用setter方法来设置每个必要的参数,以及每个相关的可选参数:
1 | // JavaBeans Pattern - allows inconsistency,mandates mutability |
这种模式弥补了重叠构造器模式的不足。说得明白一点,就是创建实例很容易,这样产生的代码读起来也很容易:
1 | NutritionFacts cocaCola = new NutritionFacts(); |
遗憾的是,JavaBeans模式自身有着很严重的缺点。因为构造过程被分到了几个调用中,在构造过程中JavaBean可能处于不一致的状态。类无法仅仅通过检验构造器参数的有效性来保证一致性。试图使用处于不一致状态的对象将会导致失败,这种失败与包含错误的代码大相径庭,因此调试起来十分困难。**与此相关的另一点不足在于,JavaBeans 模式使得把类做成不可变的可能性不复存在(详见第17条),**这就需要程序员付出额外的努力来确保它的线程安全。
当对象的构造完成,并且不允许在冻结之前使用时,通过手工”冻结”对象可以弥补这些不足,但是这种方式十分笨拙,在实践中很少使用。此外,它甚至会在运行时导致错误,因为编译器无法确保程序员会在使用之前先调用对象上的freeze方法进行冻结。
幸运的是,还有第三种替代方法,它既能保证像重叠构造器模式那样的安全性,也能保证像JavaBeans 模式那么好的可读性。这就是建造者(Builder)模式[Gamma95]的一种形式。它不直接生成想要的对象,而是让客户端利用所有必要的参数调用构造器(或者静态工厂),得到一个 builder 对象。然后客户端在builder 对象上调用类似于 setter 的方法,来设置每个相关的可选参数。最后,客户端调用无参的buila方法来生成通常是不可变的对象。这个 builder 通常是它构建的类的静态成员类(详见第 24 条)。下面就是它的示例:
1 | //Builder Pattern |
注意NutritionFacts是不可变的,所有的默认参数值都单独放在一个地方。builder的设值方法返回 builder 本身,以便把调用链接起来,得到一个流式的 API。下面就是其客户端代码:
1 | NutritionFacts cocaCola = new NutritionFacts.Builder(240, 8) |
这样的客户端代码很容易编写,更为重要的是易于阅读。Builder模式模拟了具名的可选参数,就像Python 和 Scala 编程语言中的一样。
为了简洁起见,示例中省略了有效性检查。要想尽快侦测到无效的参数,可以在builder 的构造器和方法中检查参数的有效性。查看不可变量,包括 build方法调用的构造器中的多个参数。为了确保这些不变量免受攻击,从builder复制完参数之后,要检查对象域(详见第50条)。如果检查失败,就抛出IllegalArgumentException(详见第72条),其中的详细信息会说明哪些参数是无效的(详见第75条)。
Builder模式也适用于类层次结构。使用平行层次结构的builder时,各自嵌套在相应的类中。抽象类有抽象的 builder,具体类有具体的 builder。假设用类层次根部的一个抽象类表示各式各样的比萨:
1 | // Builder pattern for class hierarchies |
注意,Pizza.Builder 的类型是泛型(generic type),带有一个递归类型参数(recursivetype parameter),详见第 30 条。它和抽象的 self 方法一样,允许在子类中适当地进行方法链接,不需要转换类型。这个针对 Java 缺乏 self类型的解决方案,被称作模拟的 self 类型(simulated self-type)。
这里有两个具体的 Pizza 子类,其中一个表示经典纽约风味的比萨,另一个表示馅料内置的半月型(calzone)比萨。前者需要一个尺寸参数,后者则要你指定酱汁应该内置还是外置:
1 | public class NyPizza extends Pizza { |
注意,每个子类的构建器中的buila方法,都声明返回正确的子类:NyPizza.Builder的build方法返回NyPizza,而 Calzone.Builder中 的则返回Calzone。在该方法中,子类方法声明返回超级类中声明的返回类型的子类型,这被称作协变返回类型(covariant return type)。它允许客户端无须转换类型就能使用这些构建器。
为了简洁起见,下列客户端代码示例假设是在枚举常量上静态导人:
1 | NyPizza pizza = new NyPizza.Builder(SMALL) |
与构造器相比,builder 的微略优势在于,它可以有多个可变(varargs)参数。因为builder是利用单独的方法来设置每一个参数。此外,构造器还可以将多次调用某一个方法而传人的参数集中到一个域中,如前面的调用了两次 addTopping 方法的代码所示。
Builder模式十分灵活,可以利用单个builder构建多个对象。builder的参数可以在调用build方法来创建对象期间进行调整,也可以随着不同的对象而改变。builder 可以自动填充某些域,例如每次创建对象时自动增加序列号。
Builder模式的确也有它自身的不足。为了创建对象,必须先创建它的构建器。虽然创建这个构建器的开销在实践中可能不那么明显,但是在某些十分注重性能的情况下,可能就成问题了。Builder模式还比重叠构造器模式更加冗长,因此它只在有很多参数的时候才使用,比如4个或者更多个参数。但是记住,将来你可能需要添加参数。如果一开始就使用构造器或者静态工厂,等到类需要多个参数时才添加构造器,就会无法控制,那些过时的构造器或者静态工厂显得十分不协调。因此,通常最好一开始就使用构建器。
简而言之,如果类的构造器或者静态工厂中具有多个参数,设计这种类时,Builder模式就是一种不错的选择,特别是当大多数参数都是可选或者类型相同的时候。与使用重叠构造器模式相比,使用Builder模式的客户端代码将更易于阅读和编写,构建器也比JavaBeans更加安全。
第3条:用私有构造器或者枚举类型强化Singleton属性
Singleton是指仅仅被实例化一次的类[Gamma95]。Singleton 通常被用来代表一个无状态的对象,如函数(详见第 24 条),或者那些本质上唯一的系统组件。**使类成为 Singleton会使它的客户端测试变得十分困难,**因为不可能给 Singleton 替换模拟实现,除非实现一个充当其类型的接口。
实现 Singleton 有两种常见的方法。这两种方法都要保持构造器为私有的,并导出公有的静态成员,以便允许客户端能够访问该类的唯一实例。在第一种方法中,公有静态成员是个final域:
1 | //Singleton with public final field |
私有构造器仅被调用一次,用来实例化公有的静态 final域Elvis.INSTANCE。由于缺少公有的或者受保护的构造器,所以保证了 Elvis 的全局唯一性:一旦 Elvis 类被实例化,将只会存在一个Elvis 实例,不多也不少。客户端的任何行为都不会改变这一点,但要提醒一点:享有特权的客户端可以借助AccessibleObject.setAccessible方法,通过反射机制(详见第65条)调用私有构造器。如果需要抵御这种攻击,可以修改构造器,让它在被要求创建第二个实例的时候抛出异常。
在实现Singleton的第二种方法中,公有的成员是个静态工厂方法:
1 | // Singleton with static factory |
对于静态方法 Elvis.getInstance 的所有调用,都会返回同一个对象引用,所以,永远不会创建其他的 Elvis 实例(上述提醒依然适用)。
公有域方法的主要优势在于, API很清楚地表明了这个类是一个Singleton : 公有的静
态域是final 的,所以该域总是包含相同的对象引用。 第二个优势在于它更简单。
静态工厂方法的优势之一在于,它提供了灵活性:在不改变其API的前提下,我们可以改变该类是否应该为 Singleton 的想法。工厂方法返回该类的唯一实例,但是,它很容易被修改,比如改成为每个调用该方法的线程返回一个唯一的实例。第二个优势是,如果应用程序需要,可以编写一个泛型Singleton工厂(generic singleton factory)(详见第30条)。使用静态工厂的最后一个优势是,可以通过方法引用(methodreference)作为提供者,比如Elvis::instance 就是一个 Supplier
为了将利用上述方法实现的 Singleton 类变成是可序列化的(Serializable)(详见第 12章),仅仅在声明中加上 implements Serializable是不够的。为了维护并保证 Singleton,必须声明所有实例域都是瞬时(transient)的,并提供一个readResolve 方法(详见第 89条)。否则,每次反序列化一个序列化的实例时,都会创建一个新的实例,比如,在我们的例子中,会导致”假冒的Elvis”。为了防止发生这种情况,要在Elvis 类中加入如下 readResolve 方法:
1 | //readResolve method topreserve singleton property |
实现 Singleton 的第三种方法是声明一个包含单个元素的枚举类型:
1 | //Enum singleton-the preferred approach |
这种方法在功能上与公有域方法相似,但更加简洁,无偿地提供了序列化机制,绝对防止多次实例化,即使是在面对复杂的序列化或者反射攻击的时候。虽然这种方法还没有广泛采用,但是单元素的枚举类型经常成为实现Singleton 的最佳方法。注意,如果 Singleton必须扩展一个超类,而不是扩展 Enum 的时候,则不宜使用这个方法(虽然可以声明枚举去实现接口)。
第4条:通过私有构造器强化不可实例化的能力
有时可能需要编写只包含静态方法和静态域的类。这些类的名声很不好,因为有些人在面向对象的语言中滥用这样的类来编写过程化的程序,但它们也确实有特别的用处。我们可以利用这种类,以java.lang.Math 或者java.util.Arrays 的方式,把基本类型的值或者数组类型上的相关方法组织起来。我们也可以通过java.util.Collections的方式,把实现特定接口的对象上的静态方法,包括工厂方法(详见第1条)组织起来。(从Java8开始,也可以把这些方法放进接口中,假定这是你自已编写的接口可以进行修改。)最后,还可以利用这种类把final类上的方法组织起来,因为不能把它们放在子类中。
这样的工具类(utilityclass)不希望被实例化,因为实例化对它没有任何意义。然而,在缺少显式构造器的情况下,编译器会自动提供一个公有的、无参的缺省构造器(defaultconstructor)。对于用户而言,这个构造器与其他的构造器没有任何区别。在已发行的API中常常可以看到一些被无意识地实例化的类。
企图通过将类做成抽象类来强制该类不可被实例化是行不通的。该类可以被子类化,并且该子类也可以被实例化。这样做甚至会误导用户,以为这种类是专门为了继承而设计的(详见第19条)。然而,有一些简单的习惯用法可以确保类不可被实例化。由于只有当类不包含显式的构造器时,编译器才会生成缺省的构造器,因此只要让这个类包含一个私有构造器,它就不能被实例化:
1 | //Noninstantiableutility class |
由于显式的构造器是私有的,所以不可以在该类的外部访问它。AssertionError 不是必需的,但是它可以避免不小心在类的内部调用构造器。它保证该类在任何情况下都不会被实例化。这种习惯用法有点违背直觉,好像构造器就是专门设计成不能被调用一样。因此,明智的做法就是在代码中增加一条注释,如上所示。
这种习惯用法也有副作用,它使得一个类不能被子类化。所有的构造器都必须显式或隐式地调用超类(superclass)构造器,在这种情形下,子类就没有可访问的超类构造器可调用了。
第5条:优先考虑依赖注人来引用资源
有许多类会依赖一个或多个底层的资源。例如,拼写检查器需要依赖词典。因此,像下面这样把类实现为静态工具类的做法并不少见(详见第4条):
1 | // Inappropriate use of static utility -inflexible & untestable! |
同样地,将这些类实现为 Singleton 的做法也并不少见(详见第3条):
1 | // Inappropriate use of singleton - inflexible & untestable! |
以上两种方法都不理想,因为它们都是假定只有一本词典可用。实际上,每一种语言都有自己的词典,特殊词汇还要使用特殊的词典。此外,可能还需要用特殊的词典进行测试。因此假定用一本词典就能满足所有需求,这简直是痴心妄想
建议尝试用 SpellChecker 来支持多词典,即在现有的拼写检查器中,设 dictionary域为 nonfinal,并添加一个方法用它来修改词典,但是这样的设置会显得很笨拙、容易出错,并且无法并行工作。静态工具类和 Singleton 类不适合于需要引用底层资源的类。
这里需要的是能够支持类的多个实例(在本例中是指 SpellChecker),每一个实例都使用客户端指定的资源(在本例中是指词典)。满足该需求的最简单的模式是,当创建一个新的实例时,就将该资源传到构造器中。这是依赖注入(dependency injection)的一种形式:词典(dictionary)是拼写检查器的一个依赖(dependency),在创建拼写检查器时就将词典注入(injected)其中。
1 | //Dependency injection provides flexibility and testability |
依赖注人模式就是这么简单,因此许多程序员使用多年,却不知道它还有名字呢。虽然这个拼写检查器的范例中只有一个资源(词典),但是依赖注人却适用于任意数量的资源,以及任意的依赖形式。依赖注人的对象资源具有不可变性(详见第17条),因此多个客户端可以共享依赖对象(假设客户端们想要的是同一个底层资源)。依赖注入也同样适用于构造器、静态工厂(详见第1条)和构建器(详见第2条)。
这个程序模式的另一种有用的变体是,将资源工厂(factory)传给构造器。工厂是可以被重复调用来创建类型实例的一个对象。这类工厂具体表现为工厂方法(FactoryMethod)模式[Gamma95]。在Java 8中增加的接口 Supplier
1 | Mosaic create(Supplier<? extends Tile> tileFactory){...} |
虽然依赖注人极大地提升了灵活性和可测试性,但它会导致大型项目凌乱不堪,因为它通常包含上千个依赖。不过这种凌乱用一个依赖注入框架(dependencyinjectionframework)便可以终结,如Dagger[Dagger]、Guice[Guice]或者 Spring[Spring]。这些框架的用法超出了本书的讨论范畴,但是,请注意:设计成手动依赖注人的 API,一般都适用于这些框架。
总而言之,不要用 Singleton 和静态工具类来实现依赖一个或多个底层资源的类,且该资源的行为会影响到该类的行为;也不要直接用这个类来创建这些资源。而应该将这些资源或者工厂传给构造器(或者静态工厂,或者构建器),通过它们来创建类。这个实践就被称作依赖注人,它极大地提升了类的灵活性、可重用性和可测试性。
第6条:避免创建不必要的对象
一般来说,最好能重用单个对象,而不是在每次需要的时候就创建一个相同功能的新对象。重用方式既快速,又流行。如果对象是不可变的(immutable)(详见第 17条),它就始终可以被重用。
作为一个极端的反面例子,看看下面的语句:
1 | Strings=new String("bikini");// DoN'T Do THIS! |
该语句每次被执行的时候都创建一个新的 String 实例,但是这些创建对象的动作全都是不必要的。传递给 String 构造器的参数(“bikini”)本身就是一个 String 实例,功能方面等同于构造器创建的所有对象。如果这种用法是在一个循环中,或者是在一个被频繁调用的方法中,就会创建出成千上万不必要的 String 实例。
改进后的版本如下所示:
1 | String S = "bikini"; |
这个版本只用了一个 String 实例,而不是每次执行的时候都创建一个新的实例。而且,它可以保证,对于所有在同一台虚拟机中运行的代码,只要它们包含相同的字符串字面常量,该对象就会被重用[JLS,3.10.5]。
对于同时提供了静态工厂方法(static factory method)(详见第1条)和构造器的不可变类,通常优先使用静态工厂方法而不是构造器,以避免创建不必要的对象。例如,静态工厂方法Boolean.valueOf(String)几乎总是优先于构造器Boolean(String),注意构造器Boolean(String)在Java 9中已经被废弃了。构造器在每次被调用的时候都会创建一个新的对象,而静态工厂方法则从来不要求这样做,实际上也不会这样做。除了重用不可变的对象之外,也可以重用那些已知不会被修改的可变对象。
有些对象创建的成本比其他对象要高得多。如果重复地需要这类”昂贵的对象”,建议将它缓存下来重用。遗憾的是,在创建这种对象的时候,并非总是那么显而易见。假设想要编写一个方法,用它确定一个字符串是否为一个有效的罗马数字。下面介绍一种最容易的方法,使用一个正则表达式:
1 | // Performance can be greatly improved! |
这个实现的问题在于它依赖 String.matches 方法。虽然 String.matches 方法最易于查看一个字符串是否与正则表达式相匹配,但并不适合在注重性能的情形中重复使用。问题在于,它在内部为正则表达式创建了一个 Pattern 实例,却只用了一次,之后就可以进行垃圾回收了。创建Pattern 实例的成本很高,因为需要将正则表达式编译成一个有限状态机(finite state machine)。
为了提升性能,应该显式地将正则表达式编译成一个 Pattern 实例(不可变),让它成为类初始化的一部分,并将它缓存起来,每当调用isRomanNumeral方法的时候就重用同一个实例:
1 | //Reusing expensive object for improved performance |
改进后的isRomanNumeral方法如果被频繁地调用,会显示出明显的性能优势。在我的机器上,原来的版本在一个8字符的输入字符串上花了1.1μs,而改进后的版本只花了0.17μs,速度快了6.5倍。除了提高性能之外,可以说代码也更清晰了。将不可见的Pattern实例做成 final 静态域时,可以给它起个名字,这样会比正则表达式本身更有可读性。
如果包含改进后的isRomanNumeral方法的类被初始化了,但是该方法没有被调用,那就没必要初始化 ROMAN域。通过在isRomanNumeral方法第一次被调用的时候延迟初始化(lazily initializing)(详见第83 条)这个域,有可能消除这个不必要的初始化工作,但是不建议这样做。正如延迟初始化中常见的情况一样,这样做会使方法的实现更加复杂,从而无法将性能显著提高到超过已经达到的水平(详见第67条)。
如果一个对象是不变的,那么它显然能够被安全地重用,但其他有些情形则并不总是指这样一个对象:它把功能委托给一个后备对象(backing object),从而为后备对象提供一个可以替代的接口。由于适配器除了后备对象之外,没有其他的状态信息,所以针对某个给定对象的特定适配器而言,它不需要创建多个适配器实例。
例如,Map 接口的keySet 方法返回该 Map 对象的 Set 视图,其中包含该 Map 中所有的键(key)。乍看之下,好像每次调用keySet都应该创建一个新的 Set实例,但是,对于一个给定的 Map 对象,实际上每次调用 keySet 都返回同样的 Set 实例。虽然被返回的 Set实例一般是可改变的,但是所有返回的对象在功能上是等同的:当其中一个返回对象发生变化的时候,所有其他的返回对象也要发生变化,因为它们是由同一个Map实例支撑的。虽然创建keySet 视图对象的多个实例并无害处,却是没有必要,也没有好处的。
装箱基本类型(Boxed Primitive Type)混用,按需要自动装箱和拆箱。自动装箱使得基本类型和装箱基本类型之间的差别变得模糊起来,但是并没有完全消除。它们在语义上还有着微妙的差别,在性能上也有着比较明显的差别(详见第61条)。请看下面的程序,它计算所有int 正整数值的总和。为此,程序必须使用long 算法,因为 int 不够大,无法容纳所有int正整数值的总和:
1 | //Hideously slow!Can you spot the object creation? |
这段程序算出的答案是正确的,但是比实际情况要更慢一些,只因为打错了一个字符。变量 sum 被声明成 Long 而不是 long,意味着程序构造了大约 2^31个多余的Long 实例(大约每次往Long sum 中增加 long时构造一个实例)。将 sum 的声明从Long 改成long,在我的机器上使运行时间从6.3秒减少到了0.59秒。结论很明显:要优先使用基本类型而不是装箱基本类型,要当心无意识的自动装箱。
不要错误地认为本条目所介绍的内容暗示着”创建对象的代价非常昂贵,我们应该要尽可能地避免创建对象”。相反,由于小对象的构造器只做很少量的显式工作,所以小对象的创建和回收动作是非常廉价的,特别是在现代的JIVM实现上更是如此。通过创建附加的对象,提升程序的清晰性、简洁性和功能性,这通常是件好事。
反之,通过维护自己的对象池(object pool)来避免创建对象并不是一种好的做法,除非池中的对象是非常重量级的。正确使用对象池的典型对象示例就是数据库连接池。建立数据库连接的代价是非常昂贵的,因此重用这些对象非常有意义。而且,数据库的许可可能限制你只能使用一定数量的连接。但是,一般而言,维护自己的对象池必定会把代码弄得很乱,同时增加内存占用(footprint),并且还会损害性能。现代的JVM实现具有高度优化的垃圾回收器,其性能很容易就会超过轻量级对象池的性能。
与本条目对应的是第50条中有关”保护性拷贝”(defensive copying)的内容。本条目提及”当你应该重用现有对象的时候,请不要创建新的对象”,而第50条则说”当你应该创建新对象的时候,请不要重用现有的对象”。注意,在提倡使用保护性拷贝的时候,因重用对象而付出的代价要远远大于因创建重复对象而付出的代价。必要时如果没能实施保护性拷贝,将会导致潜在的Bug和安全漏洞;而不必要地创建对象则只会影响程序的风格和性能。
第7条:消除过期的对象引用
当你从手工管理内存的语言(比如C或C++)转换到具有垃圾回收功能的比如Java语言时,程序员的工作会变得更加容易,因为当你用完了对象之后,它们会被自动回收。当你第一次经历对象回收功能的时候,会觉得这简直有点不可思议。它很容易给你留下这样的印象,认为自己不再需要考虑内存管理的事情了,其实不然。
请看下面这个简单的栈实现的例子:
1 | //Can you spot the "memoryleak"? |
这段程序(它的泛型版本请见第29条)中并没有很明显的错误。 无论如何测试,它都会成功地通过每一项测试,但是这个程序中隐藏着一个问题。 不严格地讲,这段程序有一个”内存泄漏”,随着垃圾回收器活动的增加,或者由于内存占用的不断增加,程序性能的降低会逐渐表现出来。 在极端的情况下,这种内存泄漏会导致磁盘交换(DiskPaging),甚至导致程序失败(OutOfMemoryError 错误),但是这种失败情形相对比较少见。
那么,程序中哪里发生了内存泄漏呢?如果一个栈先是增长,然后再收缩,那么,从栈中弹出来的对象将不会被当作垃圾回收,即使使用栈的程序不再引用这些对象,它们也不会被回收。这是因为栈内部维护着对这些对象的过期引用(obsolete reference)。所谓的过期引用,是指永远也不会再被解除的引用。在本例中,凡是在 elements 数组的”活动部分”(active portion)之外的任何引用都是过期的。活动部分是指 elements 中下标小于 size 的那些元素。
在支持垃圾回收的语言中,内存泄漏是很隐蔽的(称这类内存泄漏为”无意识的对象保持”(unintentional object retention)更为恰当)。如果一个对象引用被无意识地保留起来了,象。即使只有少量的几个对象引用被无意识地保留下来,也会有许许多多的对象被排除在垃圾回收机制之外,从而对性能造成潜在的重大影响。
这类问题的修复方法很简单:一旦对象引用已经过期,只需清空这些引用即可。对于上述例子中的 Stack 类而言,只要一个单元被弹出栈,指向它的引用就过期了。pop 方法的修订版本如下所示:
1 | public Object pop(){ |
清空过期引用的另一个好处是,如果它们以后又被错误地解除引用,程序就会立即抛出 NullPointerException 异常,而不是悄悄地错误运行下去。尽快地检测出程序中的错误总是有益的。
当程序员第一次被类似这样的问题困扰的时候,他们往往会过分小心:对于每一个对象引用,一旦程序不再用到它,就把它清空。其实这样做既没必要,也不是我们所期望的,因为这样做会把程序代码弄得很乱。清空对象引用应该是一种例外,而不是一种规范行为。消除过期引用最好的方法是让包含该引用的变量结束其生命周期。如果你是在最紧凑的作用域范围内定义每一个变量(详见第57条),这种情形就会自然而然地发生。
那么,何时应该清空引用呢?Stack类的哪方面特性使它易于遭受内存泄漏的影响呢?简而言之,问题在于,Stack类自己管理内存。存储池(storage pool)包含了elements 数组(对象引用单元,而不是对象本身)的元素。数组活动区域(同前面的定义)中的元素是已分配的(allocated),而数组其余部分的元素则是自由的(free)。但是垃圾回收器并不知道这一点;对于垃圾回收器而言,elements 数组中的所有对象引用都同等有效。只有程序员知道数组的非活动部分是不重要的。程序员可以把这个情况告知垃圾回收器,做法很简单:一旦数组元素变成了非活动部分的一部分,程序员就手工清空这些数组元素。
一般来说,只要类是自己管理内存,程序员就应该警惕内存泄漏问题。一旦元素被释放掉,则该元素中包含的任何对象引用都应该被清空。
内存泄漏的另一个常见来源是缓存。一旦你把对象引用放到缓存中,它就很容易被遗忘掉,从而使得它不再有用之后很长一段时间内仍然留在缓存中。对于这个问题,有几种可能的解决方案。如果你正好要实现这样的缓存:只要在缓存之外存在对某个项的键的引用,该项就有意义,那么就可以用WeakHashMap 代表缓存;当缓存中的项过期之后,它们就会自动被删除。记住只有当所要的缓存项的生命周期是由该键的外部引用而不是由值决定时,WeakHashMap 才有用处。
的推移,其中的项会变得越来越没有价值。在这种情况下,缓存应该时不时地清除掉没用的项。这项清除工作可以由一个后台线程(可能是ScheduledThreadPoolExecutor)来完成,或者也可以在给缓存添加新条目的时候顺便进行清理。LinkedHashMap 类利用它的 removeEldestEntry方法可以很容易地实现后一种方案。对于更加复杂的缓存,必须直接使用java.lang.ref。
内存泄漏的第三个常见来源是监听器和其他回调。如果你实现了一个API,客户端在这个API中注册回调,却没有显式地取消注册,那么除非你采取某些动作,否则它们就会不断地堆积起来。确保回调立即被当作垃圾回收的最佳方法是只保存它们的弱引用(weakreference),例如,只将它们保存成 WeakHa shMap 中的键。
由于内存泄漏通常不会表现成明显的失败,所以它们可以在一个系统中存在很多年。往往只有通过仔细检查代码,或者借助于 Heap 剖析工具(Heap Profiler)才能发现内存泄漏问题。因此,如果能够在内存泄漏发生之前就知道如何预测此类问题,并阻止它们发生,那是最好不过的了。
第8条:避免使用终结方法和清除方法
终结方法(finalizer)通常是不可预测的,也是很危险的,一般情况下是不必要的。使用终结方法会导致行为不稳定、性能降低,以及可移植性问题。当然,终结方法也有其可用之处,我们将在本条目的最后再做介绍;但是根据经验,应该避免使用终结方法。在Java 9中用清除方法(cleaner)代替了终结方法。清除方法没有终结方法那么危险,但仍然是不可预测、运行缓慢,一般情况下也是不必要的。
在C++中,析构器是回收一个对象所占用资源的常规方法,是构造器所必需的对应物。在Java 中,当一个对象变得不可到达的时候,垃圾回收器会回收与该对象相关联的存储空间,并不需要程序员做专门的工作。C++的析构器也可以被用来回收其他的非内存资源。而在Java 中,一般用trY-finally 块来完成类似的工作(详见第9 条)。
终结方法和清除方法的缺点在于不能保证会被及时执行[JLS,12.6]。从一个对象变得不可到达开始,到它的终结方法被执行,所花费的这段时间是任意长的。这意味着,注重时间(time-critical)的任务不应该由终结方法或者清除方法来完成。例如,用终结方法或者清的资源。如果系统无法及时运行终结方法或者清除方法就会导致大量的文件仍然保留在打开状态,于是当一个程序再也不能打开文件的时候,它可能会运行失败。
及时地执行终结方法和清除方法正是垃圾回收算法的一个主要功能,这种算法在不同的JVM实现中会大相径庭。如果程序依赖于终结方法或者清除方法被执行的时间点,那么这个程序的行为在不同的JM中运行的表现可能就会截然不同。一个程序在你测试用的JVM平台上运行得非常好,而在你最重要顾客的JVM平台上却根本无法运行,这是完全有可能的。
延迟终结过程并不只是一个理论问题。在很少见的情况下,为类提供终结方法,可能会随意地延迟其实例的回收过程。一位同事最近在调试一个长期运行的GUI应用程序的时候,该应用程序莫名其妙地出现 OutOfMemoryError 错误而死掉。分析表明,该应用程序死掉的时候,其终结方法队列中有数千个图形对象正在等待被终结和回收。遗憾的是,终结方法线程的优先级比该应用程序的其他线程的优先级要低得多,所以,图形对象的终结速度达不到它们进人队列的速度。Java 语言规范并不保证哪个线程将会执行终结方法,所以,除了不使用终结方法之外,并没有很轻便的办法能够避免这样的问题。在这方面,清除方法比终结方法稍好一些,因为类的设计者可以控制自己的清除线程,但清除方法仍然在后台运行,处于垃圾回收器的控制之下,因此不能确保及时清除。
Java语言规范不仅不保证终结方法或者清除方法会被及时地执行,而且根本就不保证它们会被执行。当一个程序终止的时候,某些已经无法访问的对象上的终结方法却根本没有被执行,这是完全有可能的。结论是:永远不应该依赖终结方法或者清除方法来更新重要的持久状态。例如,依赖终结方法或者清除方法来释放共享资源(比如数据库)上的永久锁,这很容易让整个分布式系统跨掉。
不要被 System.gc 和 System.runFinalization 这两个方法所诱惑,它们确实增加了终结方法或者清除方法被执行的机会,但是它们并不保证终结方法或者清除方法一定会被执行。唯一声称保证它们会被执行的两个方法是 System.runFinalizersOnExit,及其臭名昭著的李生兄弟 Runtime.runFinalizersOnExit。这两个方法都有致命的缺陷,并且已经被废弃很久了[ThreadStop]。
使用终结方法的另一个问题是:如果忽略在终结过程中被抛出来的未被捕获的异常,该对象的终结过程也会终止[JLS,12.6]。未被捕获的异常会使对象处于破坏的状态(corruptstate),如果另一个线程企图使用这种被破坏的对象,则可能发生任何不确定的行为。正常情况下,未被捕获的异常将会使线程终止,并打印出栈轨迹(Stack Trace),但是,如果异常发生在终结方法之中,则不会如此,甚至连警告都不会打印出来。清除方法没有这个问题,因为使用清除方法的一个类库在控制它的线程。
使用终结方法和清除方法有一个非常严重的性能损失。在我的机器上,创建一个简单的AutoCloseable对象,用try-with-resources将它关闭,再让垃圾回收器将它回收,完成这些工作花费的时间大约为 12ns。增加一个终结方法使时间增加到了550ns。换句话说,用终结方法创建和销毁对象慢了大约50倍。这主要是因为终结方法阻止了有效的垃圾回收。如果用清除方法来清除类的所有实例,它的速度比终结方法会稍微快一些(在我的机器上大约是每个实例花 500ns),但如果只是把清除方法作为一道安全网(safety net),下面将会介绍,那么清除方法的速度还会更快一些。在这种情况下,创建、清除和销毁对象,在我的机器上花了大约66ns,这意味着,如果没有使用它,为了确保安全网多花了5倍(而不是50倍)的代价。
终结方法有一个严重的安全问题:它们为终结方法攻击(finalizer attack)打开了类的大门。终结方法攻击背后的思想很简单:如果从构造器或者它的序列化对等体(readobject和 readResolve方法,详见第 12章)抛出异常,恶意子类的终结方法就可以在构造了一部分的应该已经半途天折的对象上运行。这个终结方法会将对该对象的引用记录在一个静态域中,阻止它被垃圾回收。一旦记录到异常的对象,就可以轻松地在这个对象上调用任何原本永远不允许在这里出现的方法。从构造器抛出的异常,应该足以防止对象继续存在;有了终结方法的存在,这一点就做不到了。这种攻击可能造成致命的后果。final类不会受到终结方法攻击,因为没有人能够编写出 final 类的恶意子类。为了防止非final 类受到终结方法攻击,要编写一个空的final的finalize方法。
那么,如果类的对象中封装的资源(例如文件或者线程)确实需要终止,应该怎么做才能不用编写终结方法或者清除方法呢?只需让类实现AutoCloseable,并要求其客户端终止,即使遇到异常也是如此(详见第9条)。值得提及的一个细节是,该实例必须记录效”。如果这些方法是在对象已经终止之后被调用,其他的方法就必须检查这个域,并抛出IllegalStateException异常。
那么终结方法和清除方法有什么好处呢?它们有两种合法用途。第一种用途是,当资源的所有者忘记调用它的close 方法时,终结方法或者清除方法可以充当”安全网”。虽然这样做并不能保证终结方法或者清除方法会被及时地运行,但是在客户端无法正常结束操作的情况下,迟一点释放资源总比永远不释放要好。如果考虑编写这样的安全网终结方法,就要认真考虑清楚,这种保护是否值得付出这样的代价。有些 Java 类(如 FileInputStream、FileOutputStream、ThreadPoolExecutor 和java.sql.Connection)都具有能充当安全网的终结方法。
清除方法的第二种合理用途与对象的本地对等体(native peer)有关。本地对等体是一个本地(非 Java 的)对象(native object),普通对象通过本地方法(native method)委托给一个本地对象。因为本地对等体不是一个普通对象,所以垃圾回收器不会知道它,当它的Java 对等体被回收的时候,它不会被回收。如果本地对等体没有关键资源,并且性能也可以接受的话,那么清除方法或者终结方法正是执行这项任务最合适的工具。如果本地对等体拥有必须被及时终止的资源,或者性能无法接受,那么该类就应该具有一个 close 方法,如前所述。
清除方法的使用有一定的技巧。下面以一个简单的 Room 类为例。假设房间在收回之前必须进行清除。Room 类实现了 AutoCloseable;它利用清除方法自动清除安全网的过程只不过是一个实现细节。与终结方法不同的是,清除方法不会污染类的公有API:
1 | //Anauto closeable class using a cleaner as a safety net |
内嵌的静态类 State 保存清除方法清除房间所需的资源。在这个例子中,就是 num-JunkPiles 域,表示房间的杂乱度。更现实地说,它可以是final的long,包含一个指向本地对等体的指针。State 实现了 Runnable 接口,它的 run 方法最多被 Cleanable调用一次,后者是我们在 Room 构造器中用清除器注册 State 实例时获得的。以下两种情况之一会触发 run 方法的调用:通常是通过调用 Room 的 close 方法触发的,后者又调用了Cleanable 的清除方法。如果到了 Room实例应该被垃圾回收时,客户端还没有调用close 方法,清除方法就会(希望如此)调用 State 的 run 方法。
关键是 State 实例没有引l用它的Room 实例。如果它引l用了,会造成循环,阻止 Room实例被垃圾回收(以及防止被自动清除)。因此 State必须是一个静态的嵌套类,因为非静态的嵌套类包含了对其外围实例的引l用(详见第 24条)。同样地,也不建议使用lambda,因为它们很容易捕捉到对外围对象的引用。
如前所述,Room 的清除方法只用作安全网。如果客户端将所有的 Room 实例化都包在 try-with-resource 块中,将永远不会请求到自动清除。用下面这个表现良好的客户端代码示范一下:
1 | public class Adult { |
正如所期待的一样,运行 Adult 程序会打印出 Goodbye,接着是Cleaning room。但是下面这个表现糟糕的程序又如何呢?哪一个将永远不会清除它的房间?
1 | public class Teenager{ |
你可能期望打印出Peaceout,然后是Cleaning room,但是在我的机器上,它没有打印出 Cleaning room,就退出程序了。这就是我们之前提到过的不可预见性。Cleaner 规范指出:”清除方法在 System.exit期间的行为是与实现相关的。不确保清除动作是否会被调用。”虽然规范没有指明,其实对于正常的程序退出也是如此。在我的机器上,只要在Teenager 的main方法上添加代码行 System.gc(),就足以让它在退出之前打印出 Cleaning room,但是不能保证在你的机器上也能看到相同的行为。
总而言之,除非是作为安全网,或者是为了终止非关键的本地资源,否则请不要使用清除方法,对于在Java 9之前的发行版本,则尽量不要使用终结方法。若使用了终结方法或者清除方法,则要注意它的不确定性和性能后果。
第9条:try-with-resources优先于try-finally
Java 类库中包括许多必须通过调用 close 方法来手工关闭的资源。例如 InputStream、OutputStream和java.sql.Connection。客户端经常会忽略资源的关闭,造成严重的性能后果也就可想而知了。虽然这其中的许多资源都是用终结方法作为安全网,但是效果并不理想 (详见第8条)。
根据经验,trY-finally语句是确保资源会被适时关闭的最佳方法,就算发生异常或者返回也一样:
1 | //try-finally-No longer the best way to close resources! |
这看起来好像也不算太坏,但是如果再添加第二个资源,就会一团糟了:
1 | //try-finally isugly when used withmore than oneresource! |
这可能令人有点难以置信,不过就算优秀的程序员也会经常犯这样的错误。起先,我曾经在《JavaPuzzlers》[Bloch05]第88页犯过这个错误,时隔多年竟然没有人发现。事实上,在 2007 年,close 方法在 Java 类库中有 2/3 都用错了。
即便用trY-finally语句正确地关闭了资源,如前两段代码范例所示,它也存在着些许不足。因为在try块和 finally块中的代码,都会抛出异常。例如,在firstLineOfFile方法中,如果底层的物理设备异常,那么调用readLine 就会抛出异常,基于同样的原因,调用 close 也会出现异常。在这种情况下,第二个异常完全抹除了第一个异常。在异常堆栈轨迹中,完全没有关于第一个异常的记录,这在现实的系统中会导致调试变得非常复杂,因为通常需要看到第一个异常才能诊断出问题何在。虽然可以通过编写代码来禁止第二个异常,保留第一个异常,但事实上没有人会这么做,因为实现起来太烦琐了。
当Java 7引|人 try-with-resources 语句时[JLS,14.20.3],所有这些问题一下子就全部解决了。要使用这个构造的资源,必须先实现 AutoCloseable 接口,其中包含了单个返回 void 的close 方法。Java 类库与第三方类库中的许多类和接口,现在都实现或扩展了AutoCloseable接口。如果编写了一个类,它代表的是必须被关闭的资源,那么这个类也应该实现AutoCloseable。
以下就是使用 try-with-resources 的第一个范例:
1 | //try-with-resources -the the best way to close resources! |
以下是使用 try-with-resources 的第二个范例:
1 | // try-with-resources on multiple resources - short and sweet |
使用 try-with-resources 不仅使代码变得更简洁易懂,也更容易进行诊断。以first-LineOfFile 方法为例,如果调用 readLine 和(不可见的)close 方法都抛出异常,后一个异常就会被禁止,以保留第一个异常。事实上,为了保留你想要看到的那个异常,即便多个异常都可以被禁止。这些被禁止的异常并不是简单地被抛弃了,而是会被打印在堆栈轨迹中,并注明它们是被禁止的异常。通过编程调用 getSuppressed 方法还可以访问到它们,getsuppressed 方法也已经添加在 Java 7的 Throwable 中了。
在 try-with-resources 语句中还可以使用catch子句,就像在平时的try-finally语句中一样。这样既可以处理异常,又不需要再套用一层代码。下面举一个稍费了点心思的范例,这个 firstLineOfFile 方法没有抛出异常,但是如果它无法打开文件,或者无法从中读取,就会返回一个默认值:
1 | //try-with-resources with a catch clause |
结论很明显:在处理必须关闭的资源时,始终要优先考虑用 try-with-resources,而不是用try-finally。这样得到的代码将更加简洁、清晰,产生的异常也更有价值。有了 try-with-resources 语句,在使用必须关闭的资源时,就能更轻松地正确编写代码了。实践证明,这个用try-finally是不可能做到的。
第三章 对于所有对象都通用的方法
尽管 Object 是一个具体类,但设计它主要是为了扩展。它所有的非 final方法(equals、hashCode、toString、clone 和finalize)都有明确的通用约定(general contract)因为它们设计成是要被覆盖(override)的。任何一个类,它在覆盖这些方法的时候,都有责任遵守这些通用约定;如果不能做到这一点,其他依赖于这些约定的类(例如HashMap和HashSet)就无法结合该类一起正常运作。
本章将讲述何时以及如何覆盖这些非 final的 Object 方法。本章不再讨论 finalize方法,因为第8条已经讨论过这个方法了。而Comparable.compareTo虽然不是Object方法,但是本章也将对它进行讨论,因为它具有类似的特征。
第10条:覆盖equals时请遵守通用约定
覆盖équals方法看起来似乎很简单,但是有许多覆盖方式会导致错误,并且后果非常严重。最容易避免这类问题的办法就是不覆盖équals方法,在这种情况下,类的每个实例都只与它自身相等。如果满足了以下任何一个条件,这就正是所期望的结果:
类的每个实例本质上都是唯一的。 对于代表活动实体而不是值(value)的类来说确实如此,例如 Thread。Object 提供的equals 实现对于这些类来说正是正确的行为。
类没有必要提供”没有必要提供”逻辑相等”(logical equality)的测试功能。例如,java.util.regex.Pattern 可以覆盖 equals,以检查两个 Pattern 实例是否代表同一个正则表达式,但是设计者并不认为客户需要或者期望这样的功能。在这类情况之下,从 Object 继承得到的 equals 实现已经足够了。
超类已经覆盖了equals,超类的行为对于这个类也是合适的。例如,大多数的Set 实现都从 AbstractSet 继承 equals 实现,List 实现从 AbstractList 继承 equals 实现,Map实现从 AbstractMap 继承equals 实现。
类是私有的,或者是包级私有的,可以确定它的equals 方法永远不会被调用。如果你非常想要规避风险,可以覆盖équals方法,以确保它不会被意外调用:
1 | public boolean equals(Object o){ |
那么,什么时候应该覆盖equals 方法呢?如果类具有自己特有的”逻辑相等”(logicalequality)概念(不同于对象等同的概念),而且超类还没有覆盖equals。这通常属于”值类”(value class)的情形。值类仅仅是一个表示值的类,例如Integer 或者 String。程序员在利用équals方法来比较值对象的引用时,希望知道它们在逻辑上是否相等,而不是想了解它们是否指向同一个对象。为了满足程序员的要求,不仅必须覆盖 équals 方法,而且这样做也使得这个类的实例可以被用作映射表(map)的键(key),或者集合(set)的元素,使映射或者集合表现出预期的行为。
有一种”值类”不需要覆盖equals方法,即用实例受控(详见第1条)确保”每个值至多只存在一个对象”的类。枚举类型(详见第34条)就属于这种类。对于这样的类而言,逻辑相同与对象等同是一回事,因此 Object 的 équals 方法等同于逻辑意义上的equals方法。
在覆盖équals方法的时候,必须要遵守它的通用约定。下面是约定的内容,来自Object的规范。
equals 方法实现了等价关系(equivalence relation),其属性如下:
- 自反性(reflexive):对于任何非 null的引l用值×,X.equals(x)必须返回true。
- 对称性(symmetric):对于任何非 null 的引用值×和y,当且仅当y.equals(x)返回true时,x.equals(y)必须返回true。
- 传递性(transitive):对于任何非 null 的引l用值x、Y和 z,如果x.equals(y)返回true,并且.equals(z)也返回true,那么α.equals(z)也必须返回true。
- 一致性(consistent):对于任何非 null 的引用值x和Y,只要 equals 的比较操作在对象中所用的信息没有被修改,多次调用α.equals(y)就会一致地返回true,或者一致地返回false。
- 对于任何非 null 的弓l用值×,X.equals(null)必须返回false。
除非你对数学特别感兴趣,否则这些规定看起来可能有点让人感到恐惧,但是绝对不要忽视这些规定!如果违反了,就会发现程序将会表现得不正常,甚至崩溃,而且很难找到失败的根源。 用John Donne 的话说,没有哪个类是孤立的。 一个类的实例通常会被频繁地传递给另一个类的实例。 有许多类,包括所有的集合类(collection class)在内,都依赖于传递给它们的对象是否遵守了 equals 约定。递给它们的对象是否遵守了équals约定。
现在你已经知道了违反équals约定有多么可怕,下面将更细致地讨论这些约定。值得欣慰的是,这些约定虽然看起来很吓人,实际上并不十分复杂。一旦理解了这些约定,要遵守它们并不困难。
那么什么是等价关系呢?不严格地说,它是一个操作符,将一组元素划分到其元素与另一个元素等价的分组中。这些分组被称作等价类(equivalence class)。从用户的角度来看,对于有用的equals方法,每个等价类中的所有元素都必须是可交换的。现在我们按照顺序逐一查看以下5个要求。
自反性(Reflexivity)—第一个要求仅仅说明对象必须等于其自身。很难想象会无意识地违反这一条。假如违背了这一条,然后把该类的实例添加到集合中,该集合的 contains方法将果断地告诉你,该集合不包含你刚刚添加的实例。
对称性(Symmetry)—第二个要求是说,任何两个对象对于”它们是否相等”的问题都必须保持一致。与第一个要求不同,若无意中违反这一条,这种情形倒是不难想象。例如下面的类,它实现了一个区分大小写的字符串。字符串由toString 保存,但在equals操作中被忽略。
1 | // Broken - violates symmetry! |
在这个类中,équals 方法的意图非常好,它企图与普通的字符串对象进行互操作。假设我们有一个不区分大小写的字符串和一个普通的字符串:
1 | CaseInsensitiveString cis = new CaseInsensitiveString("Polish"); |
不出所料,cis.equals(s)返回true。问题在于,虽然CaseInsensitiveString类中的 equals 方法知道普通的字符串对象,但是,String 类中的 equals方法却并不知道不区分大小写的字符串。因此,s.equals(cis)返回false,显然违反了对称性。假设你把不区分大小写的字符串对象放到一个集合中:
1 | List<CaseInsensitiveString>list =new ArrayList<>(); |
此时list.contains(s)会返回什么结果呢?没人知道。在当前的OpenJDK实现中,它碰巧返回false,但这只是这个特定实现得出的结果而已。在其他的实现中,它有可能返回true,或者抛出一个运行时异常。一旦违反了 equals 约定,当其他对象面对你的对象时,你完全不知道这些对象的行为会怎么样。
为了解决这个问题,只需把企图与 String互操作的这段代码从 équals 方法中去掉就可以了。这样做之后,就可以重构该方法,使它变成一条单独的返回语句:
1 | public boolean equals(object o){ |
- 传递性(Transitivity)一 -equals 约定的第三个要求是,如果一个对象等于第二个对象,而第二个对象又等于第三个对象,则第一个对象一定等于第三个对象。同样地,无意识地违反这条规则的情形也不难想象。用子类举个例子。假设它将一个新的值组件(valuecomponent)添加到了超类中。换句话说,子类增加的信息会影响équals 的比较结果。我们首先以一个简单的不可变的二维整数型Point类作为开始:
1 | public class Point{ |
假设你想要扩展这个类,为一个点添加颜色信息:
1 | public class ColorPoint extends Point{ |
equals 方法会是什么样的呢?如果完全不提供 equals 方法,而是直接从 Point 继承过来,在équals 做比较的时候颜色信息就被忽略掉了。虽然这样做不会违反 équals约定,但很明显这是无法接受的。假设编写了一个équals 方法,只有当它的参数是另一个有色点,并且具有同样的位置和颜色时,它才会返回true:
1 | //Brolen-violatessymmetry! |
这个方法的问题在于,在比较普通点和有色点,以及相反的情形时,可能会得到不同的结果。前一种比较忽略了颜色信息,而后一种比较则总是返回false,因为参数的类型不正确。为了直观地说明问题所在,我们创建一个普通点和一个有色点:
1 | Point p=new Point(1,2); |
然后,p.equals(cp)返回true,cp.equals(p)则返回false。你可以做这样的尝试来修正这个问题,让ColorPoint.equals 在进行”混合比较”时忽略颜色信息:
1 | //Brolen-violates transitivity! |
这种方法确实提供了对称性,但是却牺牲了传递性:
1 | ColorPoint pl = new ColorPoint(1,2,Color.RED); |
此时,pl.equals(p2)和p2.equals(p3)都返回true,但是pl.equals(p3)则返回false,很显然这违反了传递性。前两种比较不考虑颜色信息(“色盲”),而第三种比较则考虑了颜色信息。
此外,这种方法还可能导致无限递归问题:假设 Point 有两个子类,如ColorPoint和 SmellPoint,它们各自都带有这种 equals方法。那么对 myColorPoint.equals(mySmellPoint)的调用将会抛出 StackOverflowError异常。
那该怎么解决呢?事实上,这是面向对象语言中关于等价关系的一个基本问题。我们无法在扩展可实例化的类的同时,既增加新的值组件,同时又保留eguals约定,除非愿意放弃面向对象的抽象所带来的优势。
你可能听说过,在 equals 方法中用 getClass 测试代替 instanceof 测试,可以扩展可实例化的类和增加新的值组件,同时保留équals 约定:
1 | //Broken -violates Liskov substitution principle (page 43) |
这段程序只有当对象具有相同的实现类时,才能使对象等同。虽然这样也不算太糟糕,但结果却是无法接受的:Point子类的实例仍然是一个 Point,它仍然需要发挥作用,但是如果采用了这种方法,它就无法完成任务!假设我们要编写一个方法,以检验某个点是否处在单位圆中。下面是可以采用的其中一种方法:
1 | // Initialize unitCircle to contain allPoints on the unit circle |
虽然这可能不是实现这种功能的最快方式,不过它的效果很好。但是假设你通过某种不添加值组件的方式扩展了Point,例如让它的构造器记录创建了多少个实例:
1 | public class CounterPoint extends Point{ |
里氏替换原则(Liskov substitution principle)认为,一个类型的任何重要属性也将适用于它的子类型,因此为该类型编写的任何方法,在它的子类型上也应该同样运行得很好[Liskov87]。针对上述Point的子类(如CounterPoint)仍然是Point,并且必须发挥作用的例子,这个就是它的正式语句。但是假设我们将 CounterPoint 实例传给了 onUnitCircle方法。如果 Point类使用了基于 getClass 的 equals方法,无论 CounterPoint实例的x和y值是什么,onUnitCircle方法都会返回 false。这是因为像 onUnitCircle 方法所用的HashSet这样的集合,利用equals方法检验包含条件,没有任何CounterPoint实例与任何 Point 对应。但是,如果在 Point 上使用适当的基于 instanceof 的 equals 方法,当遇到 CounterPoint 时,相同的 onUnitCircle 方法就会工作得很好。
虽然没有一种令人满意的办法可以既扩展不可实例化的类,又增加值组件,但还是有一种不错的权宜之计:遵从第18条”复合优先于继承”的建议。我们不再让ColorPoint扩展Point,而是在ColorPoint 中加人一个私有的Point域,以及一个公有的视图(view)方法(详见第6条),此方法返回一个与该有色点处在相同位置的普通Point对象:
1 | //Adds a valuecomponent withoutviolating theequals contract |
在 Java平台类库中,有一些类扩展了可实例化的类,并添加了新的值组件。例如,java.sql.Timestamp 对java.util.Date进行了扩展,并增加了 nanoseconds 域。Times-tamp 的 equals 实现确实违反了对称性,如果 Timestamp 和 Date 对象用于同一个集合中,或者以其他方式被混合在一起,则会引起不正确的行为。Timestamp 类有一个免责声明,告诫程序员不要混合使用 Date 和Timestamp 对象。只要你不把它们混合在一起,就不会有麻烦,除此之外没有其他的措施可以防止你这么做,而且结果导致的错误将很难调试。Timestamp 类的这种行为是个错误,不值得仿效。
注意,你可以在一个抽象(abstract)类的子类中增加新的值组件且不违反equals 约定。对于根据第 23 条的建议而得到的那种类层次结构来说,这一点非常重要。例如,你可能有一个抽象的 Shape类,它没有任何值组件,Circle子类添加了一个 radius 域,Rectangle 子类添加了 length 和 width 域。只要不可能直接创建超类的实例,前面所述的种种问题就都不会发生。
- 一致性(Consistency)—equals 约定的第四个要求是,如果两个对象相等,它们就必须始终保持相等,除非它们中有一个对象(或者两个都)被修改了。换句话说,可变对象在不同的时候可以与不同的对象相等,而不可变对象则不会这样。当你在写一个类的时候,应该仔细考虑它是否应该是不可变的(详见第 17条)。如果认为它应该是不可变的,就必须保证équals方法满足这样的限制条件:相等的对象永远相等,不相等的对象永远不相等。
无论类是否是不可变的,都不要使équals 方法依赖于不可靠的资源。如果违反了这条禁令,要想满足一致性的要求就十分困难了。例如,java.net.URL的equals方法依赖于对URL中主机IP地址的比较。将一个主机名转变成IP地址可能需要访问网络,随着时间的推移,就不能确保会产生相同的结果,即有可能IP地址发生了改变。这样会导致URL équals 方法违反 equals 约定,在实践中有可能引l发一些问题。URL équals 方法的行为是一个大错误并且不应被模仿。遗憾的是,因为兼容性的要求,这一行为无法被改变。为了避免发生这种问题,equals方法应该对驻留在内存中的对象执行确定性的计算。
- 非空性(Non-nulity)—最后一个要求没有正式名称,我姑且称它为”非空性”,意思是指所有的对象都不能等于null。尽管很难想象在什么情况下o.equals(null)调用会意外地返回 true,但是意外抛出 NullPointerException 异常的情形却不难想象。通用约定不允许抛出 NullPointerException 异常。许多类的equals 方法都通过一个显式的 null 测试来防止这种情况:
1 | public boolean equals(Object o){ |
这项测试是不必要的。为了测试其参数的等同性,équals方法必须先把参数转换成适当的类型,以便可以调用它的访问方法,或者访问它的域。在进行转换之前,équals 方法必须使用 instanceof 操作符,检查其参数的类型是否正确:
1 | public boolean equals(Object o){ |
如果漏掉了这一步的类型检查,并且传递给équals方法的参数又是错误的类型,那么equals方法将会抛出 ClassCastException异常,这就违反了equals约定。但是,如果 instanceof 的第一个操作数为 null,那么,不管第二个操作数是哪种类型,instanceof 操作符都指定应该返回false[JLS,15.20.2]。因此,如果把 null 传给equals 方法,类型检查就会返回 false,所以不需要显式的 null 检查。
结合所有这些要求,得出了以下实现高质量équals 方法的诀窍:
使用==操作符检查”参数是否为这个对象的引用”。如果是,则返回true。这只不过是一种性能优化,如果比较操作有可能很昂贵,就值得这么做。
使用 instanceof 操作符检查”参数是否为正确的类型”。如果不是,则返回 false。一般说来,所谓”正确的类型”是指equals方法所在的那个类。某些情况下,是指该类所实现的某个接口。如果类实现的接口改进了équals 约定,允许在实现了该接口的类之间进行比较,那么就使用接口。集合接口如 Set、List、Map 和Map.Entr具有这样的特性。
把参数转换成正确的类型。 因为转换之前进行过且stanceof测试,所以确保会成功。
对于该类中的每个”关键”( significant )域,检查参数中的域是否与该对象中对应的域相匹配。如果这些测试全部成功,则返回true;否则返回 false。如果第 2 步中的类型是个接口,就必须通过接口方法访问参数中的域;如果该类型是个类,也许就能够直接访问参数中的域,这要取决于它们的可访问性。
对于既不是float 也不是 double 类型的基本类型域,可以使用==操作符进行比较;对于对象引用域,可以递归地调用equals方法;对于float域,可以使用静态Float.compare(float,float)方法;对于 double 域,则使用 Double.compare(double, double)。对 float 和 double 域进行特殊的处理是有必要的,因为存在着 Float.NaN、-0.Of 以及类似的 double 常量;详细信息请参考 JLS 15.21.1 或者 Float.equals 的文档。虽然可以用静态方法 Float.equals 和 Double.equals 对 float 和 double域进行比较,但是每次比较都要进行自动装箱,这会导致性能下降。对于数组域,则要把以上这些指导原则应用到每一个元素上。如果数组域中的每个元素都很重要,就可以使用其中一个Arrays.equals 方法。
有些对象引用域包含 null可能是合法的,所以,为了避免可能导致NullPointerException 异常,则使用静态方法 Objects.equals(Object,Object)来检查这类域的等同性。
对于有些类,比如前面提到的CaseInsensitiveString类,域的比较要比简单的等同性测试复杂得多。如果是这种情况,可能希望保存该域的一个”范式”canonical精确比较。这种方法对于不可变类(详见第17条)是最为合适的;如果对象可能发生变化,就必须使其范式保持最新。
域的比较顺序可能会影响équals方法的性能。为了获得最佳的性能,应该最先比较最有可能不一致的域,或者是开销最低的域,最理想的情况是两个条件同时满足的域。不应该比较那些不属于对象逻辑状态的域,例如用于同步操作的Lock域。也不需要比较衍生域能提高 équals 方法的性能。如果衍生域代表了整个对象的综合描述,比较这个域可以节省在比较失败时去比较实际数据所需要的开销。例如,假设有一个 Polygon 类,并缓存了该面积。如果两个多边形有着不同的面积,就没有必要去比较它们的边和顶点。
在编写完 equals 方法之后,应该问自己三个问题:它是否是对称的、传递的、一致的?并且不要只是自问,还要编写单元测试来检验这些特性,除非用AutoValue(后面会讲到)生成équals 方法,在这种情况下就可以放心地省略测试。如果答案是否定的,就要找出原因,再相应地修改 equals 方法的代码。当然,équals 方法也必须满足其他两个特性(自反性和非空性),但是这两种特性通常会自动满足。
根据上面的诀窍构建equals方法的具体例子,请看下面这个简单的PhoneNumber类:
1 | // Class with a typical equals method |
下面是最后的一些告诫:
覆盖equals时总要覆盖hashCode(详见第11条)。
不要企图让 équals 方法过于智能。如果只是简单地测试域中的值是否相等,则不难做到遵守 équals 约定。如果想过度地去寻求各种等价关系,则很容易陷人麻烦之中。把任何一种别名形式考虑到等价的范围内,往往不会是个好主意。例如,File类不应该试图把指向同一个文件的符号链接(symbolic link)当作相等的对象来看待。所幸 File 类没有这样做。
不要将 equals 声明中的 object 对象替换为其他的类型。程序员编写出下面这样的equals 方法并不鲜见,这会使程序员花上数个小时都搞不清为什么它不能正常工作:
1
2
3
4// Broken-parameter type must beObject!
public boolean equals(MyClass o) {
...
}
问题在于,这个方法并没有覆盖(override)Object.equals,因为它的参数应该是Object 类型,相反,它重载(overload)了Object.equals(详见第 52条)。在正常接受的,因为会导致子类中的 Override 注解产生错误的正值,带来错误的安全感。
@Override注解的用法一致,就如本条目中所示,可以防止犯这种错误(详见第40条)。这个équals方法不能编译,错误消息会告诉你到底哪里出了问题:
1 | //Still broken,but won't compile |
编写和测试equals(及hashCode)方法都是十分烦琐的,得到的代码也很琐碎。代替手工编写和测试这些方法的最佳途径,是使用Google开源的AutoValue框架,它会自动替你生成这些方法,通过类中的单个注解就能触发。在大多数情况下,AutoValue 生成的方法本质上与你亲自编写的方法是一样的。
IDE也有工具可以生成equals 和hashCode方法,但得到的源代码比使用Auto-Value的更加冗长,可读性也更差,它无法自动追踪类中的变化,因此需要进行测试。也就是说,让IDE 生成 equals(及 hashCode)方法,通常优于手工实现它们,因为IDE 不会犯粗心的错误,但是程序员会犯错。
总而言之,不要轻易覆盖équals方法,除非迫不得已。因为在许多情况下,从object 处继承的实现正是你想要的。如果覆盖équals,一定要比较这个类的所有关键域,并且查看它们是否遵守 équals 合约的所有五个条款。
第11条:覆盖equals时总要覆盖hashCode
在每个覆盖了equals 方法的类中,都必须覆盖hashCode 方法。如果不这样做的话,就会违反 hashCode 的通用约定,从而导致该类无法结合所有基于散列的集合一起正常运作,这类集合包括 HashMap 和 HashSet。下面是约定的内容,摘自 Object 规范:
在应用程序的执行期间,只要对象的 équals 方法的比较操作所用到的信息没有被修改,那么对同一个对象的多次调用,hashCode方法都必须始终返回同一个值。在一个应用程序与另一个程序的执行过程中,执行hashCode方法所返回的值可以不一致。
如果两个对象根据équals(Object)方法比较是相等的,那么调用这两个对象中的hashCode方法都必须产生同样的整数结果。
如果两个对象根据équals(Object)方法比较是不相等的,那么调用这两个对象序员应该知道,给不相等的对象产生截然不同的整数结果,有可能提高散列表(hashtable)的性能。
因没有覆盖hashCode而违反的关键约定是第二条:相等的对象必须具有相等的散列码(hash code)。根据类的 équals 方法,两个截然不同的实例在逻辑上有可能是相等的,但是根据 Object 类的 hashCode 方法,它们仅仅是两个没有任何共同之处的对象。因此,对象的 hashCode 方法返回两个看起来是随机的整数,而不是根据第二个约定所要求的那样,返回两个相等的整数。
假设在HashMap中用第10条中出现过的PhoneNumber类的实例作为键:
1 | Map<PhoneNumber, String> m = new HashMap<>(); |
此时,你可能期望m.get(newPhoneNumber(707,867,5309))会返回”Jenny”,但它实际上返回的是 null。注意,这里涉及两个 PhoneNumber 实例:第一个被插入 HashMap中,第二个实例与第一个相等,用于从Map 中根据 PhoneNumber 去获取用户名字。由于PhoneNumber 类没有覆盖hashCode 方法,从而导致两个相等的实例具有不相等的散列码,违反了hashCode 的约定。因此,put方法把电话号码对象存放在一个散列桶(hashbucket)中,get方法却在另一个散列桶中查找这个电话号码。即使这两个实例正好被放到同一个散列桶中,get方法也必定会返回 null,因为 HashMap 有一项优化,可以将与每个项相关联的散列码缓存起来,如果散列码不匹配,也就不再去检验对象的等同性。
修正这个问题非常简单,只需为PhoneNumber类提供一个适当的 hashCode方法即可。那么,hashCode方法应该是什么样的呢?编写一个合法但并不好用的 hashCode 方法没有任何价值。例如,下面这个方法总是合法的,但是它永远都不应该被正式使用:
1 | //The worst possiblelegal hashCode implementation-never use! |
上面这个hashCode 方法是合法的,因为它确保了相等的对象总是具有同样的散列码。但它也极为恶劣,因为它使得每个对象都具有同样的散列码。因此,每个对象都被映射到同一个散列桶中,使散列表退化为链表(linked list)。它使得本该线性时间运行的程序变成了以平方级时间在运行。对于规模很大的散列表而言,这会关系到散列表能否正常工作。
一个好的散列函数通常倾向于”为不相等的对象产生不相等的散列码”。这正是hashCode 约定中第三条的含义。理想情况下,散列函数应该把集合中不相等的实例均匀地分布到所有可能的int值上。要想完全达到这种理想的情形是非常困难的。幸运的是,相对接近这种理想情形则并不太困难。下面给出一种简单的解决办法:
声明一个 int 变量并命名为 result,将它初始化为对象中第一个关键域的散列码c,如步骤 2.a 中计算所示(如第 10条所述,关键域是指影响 equals 比较的域)。
对象中剩下的每一个关键域f都完成以下步骤:
为该域计算 int 类型的散列码 c:
- 如果该域是基本类型,则计算 Type.hashCode(f),这里的 Type 是装箱基本类型的类,与f 的类型相对应。
- 如果该域是一个对象引用,并且该类的 equals 方法通过递归地调用 equals的方式来比较这个域,则同样为这个域递归地调用 hashCode。如果需要更复杂的比较,则为这个域计算一个”范式”(canonicalrepresentation),然后针对这个范式调用hashCode。如果这个域的值为 null,则返回O(或者其他某个常数,但通常是0)。
- 如果该域是一个数组,则要把每一个元素当作单独的域来处理。也就是说,递归地应用上述规则,对每个重要的元素计算一个散列码,然后根据步骤2.b中的做法把这些散列值组合起来。如果数组域中没有重要的元素,可以使用一个常量,但最好不要用0。如果数组域中的所有元素都很重要,可以使用Arrays.hashCode 方法。
按照下面的公式,把步骤 2.a 中计算得到的散列码c 合并到 result 中:
1
result = 31 * result + C;
返回result。
写完了hashCode 方法之后,问问自己”相等的实例是否都具有相等的散列码”。要编写单元测试来验证你的推断(除非利用AutoValue 生成 equals 和hashCode方法,这样你就可以放心地省略这些测试)。如果相等的实例有着不相等的散列码,则要找出原因,并修正错误。
在散列码的计算过程中,可以把衍生域(derived field)排除在外。换句话说,如果一个域的值可以根据参与计算的其他域值计算出来,则可以把这样的域排除在外。必须排除equals 比较计算中没有用到的任何域,否则很有可能违反 hashCode 约定的第二条。
步骤2.b中的乘法部分使得散列值依赖于域的顺序,如果一个类包含多个相似的域,这样的乘法运算就会产生一个更好的散列函数。例如,如果 String 散列函数省略了这个乘法部分,那么只是字母顺序不同的所有字符串将都会有相同的散列码。之所以选择31,是因为它是一个奇素数。如果乘数是偶数,并且乘法溢出的话,信息就会丢失,因为与2相乘等价于移位运算。使用素数的好处并不很明显,但是习惯上都使用素数来计算散列结果。31有个很好的特性,即用移位和减法来代替乘法,可以得到更好的性能:31*i==(i〈< 5)-i。现代的虚拟机可以自动完成这种优化。
现在我们要把上述解决办法用到 PhoneNumber类中:
1 | // Typical hashCode method |
因为这个方法返回的结果是一个简单、确定的计算结果,它的输人只是PhoneNumber对于 PhoneNumber 的 hashCode 实现而言,上面这个方法是非常合理的,相当于 Java 平台类库中的实现。它的做法非常简单,也相当快捷,恰当地把不相等的电话号码分散到不同的散列桶中。
虽然本条目中前面给出的hashCode 实现方法能够获得相当好的散列函数,但它们并不是最先进的。它们的质量堪比Java 平台类库的值类型中提供的散列函数,这些方法对于绝大多数应用程序而言已经足够了。如果执意想让散列函数尽可能地不会造成冲突,请参阅Guava’s com.google.common.hash.Hashing [Guava]。
Objects 类有一个静态方法,它带有任意数量的对象,并为它们返回一个散列码。这过的解决方案编写出来的相比,它的质量是与之相当的。遗憾的是,运行速度更慢一些,因为它们会引发数组的创建,以便传人数目可变的参数,如果参数中有基本类型,还需要装箱和拆箱。建议只将这类散列函数用于不太注重性能的情况。下面就是用这种方法为PhoneNumber 编写的散列函数:
1 | // One-line hashCode method - mediocre performance |
如果一个类是不可变的,并且计算散列码的开销也比较大,就应该考虑把散列码缓存在对象内部,而不是每次请求的时候都重新计算散列码。如果你觉得这种类型的大多数对象会被用作散列键(hash keys),就应该在创建实例的时候计算散列码。否则,可以选择”延迟初始化”(lazily initialize)散列码,即一直到hashCode 被第一次调用的时候才初始化(见第 83条)。虽然我们的 PhoneNumber 类不值得这样处理,但是可以通过它来说明这种方法该如何实现。注意hashCode 域的初始值(在本例中是O)一般不能成为创建的实例的散列码:
1 | //hashCode method with lazily initialized cached hash code |
不要试图从散列码计算中排除掉一个对象的关键域来提高性能。虽然这样得到的散列函数运行起来可能更快,但是它的效果不见得会好,可能会导致散列表慢到根本无法使用。特别是在实践中,散列函数可能面临大量的实例,在你选择忽略的区域之中,这些实例仍然区别非常大。如果是这样,散列函数就会把所有这些实例映射到极少数的散列码上,原本应该以线性级时间运行的程序,将会以平方级的时间运行。
这不只是一个理论问题。在Java 2 发行版本之前,一个 String 散列函数最多只能使用 16个字符,若长度少于16个字符就计算所有的字符,否则就从第一个字符开始,在整个函数正好表现出了这里所提到的病态行为。
不要对hashCode方法的返回值做出具体的规定,因此客户端无法理所当然地依赖它;这样可以为修改提供灵活性。Java 类库中的许多类,比如 String 和Integer,都可以把它们的 hashCode 方法返回的确切值规定为该实例值的一个函数。一般来说,这并不是个好主意,因为这样做严格地限制了在未来的版本中改进散列函数的能力。如果没有规定散列函数的细节,那么当你发现了它的内部缺陷时,或者发现了更好的散列函数时,就可以在后面的发行版本中修正它。
总而言之,每当覆盖 equals 方法时都必须覆盖 hashCode,否则程序将无法正确运行。hashCode 方法必须遵守 Object 规定的通用约定,并且必须完成一定的工作,将不相等的散列码分配给不相等的实例。这个很容易实现,但是如果不想那么费力,也可以使用前文建议的解决方法。如第 10条所述,AutoValue 框架提供了很好的替代方法,可以不必手工编写 equals 和hashCode 方法,并且现在的集成开发环境IDE 也提供了类似的部分功能。
第12条:始终要覆盖toString
虽然 Object 提供了toString 方法的一个实现,但它返回的字符串通常并不是类的用户所期望看到的。它包含类的名称,以及一个”@”符号,接着是散列码的无符号十六进制表示法,例如 PhoneNumber@163b91。toString 的通用约定指出,被返回的字符串应该是一个”简洁的但信息丰富,并且易于阅读的表达形式”。尽管有人认为PhoneNumber@163b91算得上是简洁和易于阅读了,但是与707-867-5309比较起来,它还算不上是信息丰富的。toString 约定进一步指出,”建议所有的子类都覆盖这个方法。”这是一个很好的建议,真的!
遵守toString 约定并不像遵守equals和hashCode 的约定(见第10条和第 1l条)那么重要,但是,提供好的toString实现可以使类用起来更加舒适,使用了这个类的系统也更易于调试。当对象被传递给 println、printf、字符串联操作符(+)以及 assert,或者被调试器打印出来时,toString 方法会被自动调用。即使你永远不调用对象的toString方法,但是其他人也许可能需要。例如,带有对象引用的一个组件,在它记录的错误消息中,可能包含该对象的字符串表示法。如果你没有覆盖toString,这条消息可能就毫无用处。
如果为 PhoneNumber 提供了好的toString 方法,那么要产生有用的诊断消息会非常容易:
1 | System.out.println("Failed to connect to " + phoneNumber); |
不管是否覆盖了toString 方法,程序员都将以这种方式来产生诊断消息,但是如果没有覆盖toString 方法,产生的消息将难以理解。提供好的toString 方法,不仅有益于这个类的实例,同样也有益于那些包含这些实例的引用的对象,特别是集合对象。打印Map时会看到消息{Jenny=PhoneNumber@163b91}或{Jenny=707-867-5309},你更愿意看到哪一个?
**在实际应用中,toString方法应该返回对象中包含的所有值得关注的信息,**例如上述电话号码例子那样。如果对象太大,或者对象中包含的状态信息难以用字符串来表达,这样做就有点不切实际。在这种情况下,toString 应该返回一个摘要信息,例如”Manhattanresidential phone directory(1487536 listings)” 或者 “Thread[main, 5,main]”。理想情况下,字符串应该是自描述的(self-explanatory)。(Thread 例子不满足这样的要求。)如果对象的字符串表示法中没有包含对象的所有必要信息,测试失败时得到的报告将会像下面这样:
1 | Assertion failure: expected {abc,123},but was {abc,123} |
在实现toString 的时候,必须要做出一个很重要的决定:是否在文档中指定返回值的格式。对于值类(value class),比如电话号码类、矩阵类,建议这么做。指定格式的好处是,它可以被用作一种标准的、明确的、适合人阅读的对象表示法。这种表示法可以用于输入和输出,以及用在永久适合人类阅读的数据对象中,例如CSV文档。如果你指定了格式,通常最好再提供一个相匹配的静态工厂或者构造器,以便程序员可以很容易地在对象及其字符串表示法之间来回转换。Java平台类库中的许多值类都采用了这种做法,包括BigInteger、BigDecimal 和绝大多数的基本类型包装类(boxed primitive class)。
指定toString 返回值的格式也有不足之处:如果这个类已经被广泛使用,一旦指定格式,就必须始终如一地坚持这种格式。程序员将会编写出相应的代码来解析这种字符串表示法、产生字符串表示法,以及把字符串表示法嵌人持久的数据中。如果将来的发行版本中改变了这种表示法,就会破坏他们的代码和数据,他们当然会抱怨。如果不指定格式,就可以保留灵活性,便于在将来的发行版本中增加信息,或者改进格式。
无论是否决定指定格式,都应该在文档中明确地表明你的意图。如果要指定格式,则应该严格地这样去做。例如,下面是第 11 条中 PhoneNumber 类的toString 方法:
如果你决定不指定格式,那么文档注释部分也应该有如下所示的指示信息:
1 | /** |
对于那些依赖于格式的细节进行编程或者产生永久数据的程序员,在读到这段注释之后,一旦格式被改变,则只能自己承担后果。
无论是否指定格式,都为toString返回值中包含的所有信息提供一种可以通过编程访问之的途径。例如,PhoneNumber 类应该包含针对 area code、prefix 和 line number 的访问方法。如果不这么做,就会迫使需要这些信息的程序员不得不自己去解析这些字符串。除了降低了程序的性能,使得程序员们去做这些不必要的工作之外,这个解析过程也很容易出错,会导致系统不稳定,如果格式发生变化,还会导致系统崩溃。如果没有提供这些访问方法,即使你已经指明了字符串的格式是会变化的,这个字符串格式也成了事实上的API。
在静态工具类(详见第4条)中编写toString方法是没有意义的。也不要在大多数枚举类型(详见第34条)中编写toString方法,因为Java 已经为你提供了非常完美的方法。但是,在所有其子类共享通用字符串表示法的抽象类中,一定要编写一个toString方法。例如,大多数集合实现中的toString方法都是继承自抽象的集合类。
在第10条中讨论过的Google公司开源的AutoValue工具,会替你生成toString方但是并不特定于该类的意义(meaning)。因此,比如对于上述 PhoneNumber 类就不适合用自动生成的toString方法(因为电话号码有标准的字符串表示法),但是我们的Potion类就非常适合。也就是说,自动生成的toString 方法要远远优先于继承自 Object 的方法,因为它无法告诉你任何关于对象值的信息。
总而言之,要在你编写的每一个可实例化的类中覆盖Object 的toString实现,除非已经在超类中这么做了。这样会使类使用起来更加舒适,也更易于调试。toString 方法应该以美观的格式返回一个关于对象的简洁、有用的描述。
第13条:谨慎地覆盖clone
Cloneable 接口的目的是作为对象的一个mixin接口(mixin interface) (详见第 20条),表明这样的对象允许克隆(clone)。遗憾的是,它并没有成功地达到这个目的。它的主要缺陷在于缺少一个 clone 方法,而 Object 的 clone 方法是受保护的。如果不借助于反射(reflection)(详见第 65条),就不能仅仅因为一个对象实现了Cloneable,就调用clone方法。即使是反射调用也可能会失败,因为不能保证该对象一定具有可访问的clone 方法。尽管存在这样或那样的缺陷,这项设施仍然被广泛使用,因此值得我们进一步了解。本条目将告诉你如何实现一个行为良好的clone 方法,并讨论何时适合这样做,同时也简单地讨论了其他的可替代做法。
既然Cloneable 接口并没有包含任何方法,那么它到底有什么作用呢?它决定了 Object中受保护的 clone 方法实现的行为:如果一个类实现了Cloneable,Object 的 clone这是接口的一种极端非典型的用法,也不值得仿效。通常情况下,实现接口是为了表明类可以为它的客户做些什么。然而,对于Cloneable接口,它改变了超类中受保护的方法的行为。
虽然规范中没有明确指出,事实上,实现Cloneable接口的类是为了提供一个功能适当的公有的 clone 方法。为了达到这个目的,类及其所有超类都必须遵守一个相当复杂的、不可实施的,并且基本上没有文档说明的协议。由此得到一种语言之外的(extralinguistic)机制:它无须调用构造器就可以创建对象。
clone 方法的通用约定是非常弱的,下面是来自 Object 规范中的约定内容:
创建和返回该对象的一个拷贝。这个”拷贝”的精确含义取决于该对象的类。一般的含义是,对于任何对象X,表达式
1 | x.clone() != x |
将会返回结果true,并且表达式
1 | x.clone().getClass( == x.getClass() |
将会返回结果true,但这些都不是绝对的要求。虽然通常情况下,表达式
1 | x.clone().equals(x) |
将会返回结果true,但是,这也不是一个绝对的要求。
按照约定,这个方法返回的对象应该通过调用 super.clone 获得。如果类及其超类(Object除外)遵守这一约定,那么:
1 | x.clone().getClass() == x.getClass() |
按照约定,返回的对象应该不依赖于被克隆的对象。为了成功地实现这种独立性,可能需要在 super.clone 返回对象之前,修改对象的一个或更多个域。
这种机制大体上类似于自动的构造器调用链,只不过它不是强制要求的:如果类的clone方法返回的实例不是通过调用 super.clone 方法获得,而是通过调用构造器获得,编译器就不会发出警告,但是该类的子类调用了super.clone方法,得到的对象就会拥有错误的类,并阻止了clone 方法的子类正常工作。如果 final类覆盖了clone 方法,那么这个约定可以被安全地忽略,因为没有子类需要担心它。如果 fi nal类的 clone 方法没有调用 super.clone 方法,这个类就没有理由去实现Cloneable 接口了,因为它不依赖于Object 克隆实现的行为。
假设你希望在一个类中实现 Cloneable 接口,并且它的超类都提供了行为良好的 clone方法。首先,调用 super.clone 方法。由此得到的对象将是原始对象功能完整的克隆(clone)。在这个类中声明的域将等同于被克隆对象中相应的域。如果每个域包含一个基本类型的值,或者包含一个指向不可变对象的引用,那么被返回的对象则可能正是你所需要的对象,在这种情况下不需要再做进一步处理。例如,第11条中的 PhoneNumber 类正是如此,但要注意,不可变的类永远都不应该提供clone 方法,因为它只会激发不必要的克隆。因此,PhoneNumber的clone方法应该是这样的:
1 | //Clonemethodforclass withnoreferencestomutablestate |
为了让这个方法生效,应该修改 PhoneNumber 的类声明为实现Cloneable接口。虽然Object的clone方法返回的是Object,但这个clone方法返回的却是PhoneNumber。这么做是合法的,也是我们所期望的,因为 Java 支持协变返回类型(covariant return type)。换句话说,目前覆盖方法的返回类型可以是被覆盖方法的返回类型的子类了。这样在客户端中就不必进行转换了。我们必须在返回结果之前,先将 super.clone 从 Object转换成PhoneNumber,当然这种转换是一定会成功的。
对 super.clone 方法的调用应当包含在一个try-catch 块中。这是因为Object声明其clone 方法抛出 CloneNotSupportedException,这是一个受检异常(checkedexception)。由于 PhoneNumber实现了Cloneable 接口,我们知道调用 super.clone方法一定会成功。对于这个样板代码的需求表明,CloneNotSupportedException 应该还没有被检查到(详见第71条)。
如果对象中包含的域引用了可变的对象,使用上述这种简单的clone实现可能会导致灾难性的后果。例如第7条中的 Stack类:
1 | public class Stack { |
假设你希望把这个类做成可克隆的(cloneable)。如果它的clone 方法仅仅返回 super.clone(),这样得到的 Stack 实例,在其 size 域中具有正确的值,但是它的elements域将引用与原始Stack实例相同的数组。修改原始的实例会破坏被克隆对象中的约束条件,反之亦然。很快你就会发现,这个程序将产生毫无意义的结果,或者抛出Nu11-PointerException 异常。
如果调用 Stack类中唯一的构造器,这种情况就永远不会发生。实际上,clone 方法就是另一个构造器;必须确保它不会伤害到原始的对象,并确保正确地创建被克隆对象中的约束条件(invariant)。为了使 Stack类中的clone 方法正常工作,它必须要拷贝栈的内部信息。最容易的做法是,在elements 数组中递归地调用 clone:
1 | // Clone method for class with references to mutable state |
注意,我们不一定要将elements.clone()的结果转换成 Object[]。在数组上调用clone 返回的数组,其编译时的类型与被克隆数组的类型相同。这是复制数组的最佳习惯做法。事实上,数组是clone 方法唯一吸引l人的用法。
还要注意如果 elements 域是 final 的,上述方案就不能正常工作,因为 clone 方法是被禁止给 final 域赋新值的。这是个根本的问题:就像序列化一样,**Cloneable 架构与引用可变对象的final域的正常用法是不相兼容的,**除非在原始对象和克隆对象之间可以安全地共享此可变对象。为了使类成为可克隆的,可能有必要从某些域中去掉 final 修饰符。它的内部数据包含一个散列桶数组,每个散列桶都指向”键-值”对链表的第一项。出于性能方面的考虑,该类实现了它自己的轻量级单向链表,而没有使用Java 内部的java.util.LinkedList:
1 | public class HashTable implements Cloneable { |
假设你仅仅递归地克隆这个散列桶数组,就像我们对 Stack类所做的那样:
1 | //Broken clone method-results in shared mutable state! |
虽然被克隆对象有它自己的散列桶数组,但是,这个数组引用的链表与原始对象是一样的,从而很容易引起克隆对象和原始对象中不确定的行为。为了修正这个问题,必须单独地拷贝并组成每个桶的链表。下面是一种常见的做法:
1 | //Recursive clone method for class with complex mutable state |
私有类HashTable.Entry 被加强了,它支持一个”深度拷贝”(deep copy)方法。HashTable上的clone 方法分配了一个大小适中的、新的buckets 数组,并且遍历原始的buckets 数组,对每一个非空散列桶进行深度拷贝。Entry类中的深度拷贝方法递归地调用它自身,以便拷贝整个链表(它是链表的头节点)。虽然这种方法很灵活,如果散列桶不是很长,也会工作得很好,但是,这样克隆一个链表并不是一种好办法,因为针对列表中的每个元素,它都要消耗一段栈空间。如果链表比较长,这很容易导致栈溢出。为了避免发生这种情况,你可以在deepCopy方法中用迭代(iteration)代替递归(recursion):
1 | // Iteratively copy the linked list headed by this Entry |
克隆复杂对象的最后一种办法是,先调用 super.clone 方法,然后把结果对象中的所有域都设置成它们的初始状态(initial state),然后调用高层(higher-level)的方法来重新产生对象的状态。在我们的HashTable例子中,buckets 域将被初始化为一个新的散列桶数组,然后,对于正在被克隆的散列表中的每一个键-值映射,都调用put(keY,value)方法(上面没有给出其代码)。这种做法往往会产生一个简单、合理且相当优美的方法”快。虽然这种方法干脆利落,但它与整个Cloneable架构是对立的,因为它完全抛弃了 Cloneable 架构基础的逐域对象复制的机制。
像构造器一样,clone方法也不应该在构造的过程中,调用可以覆盖的方法(详见第19条)。如果clone 调用了一个在子类中被覆盖的方法,那么在该方法所在的子类有机会修正它在克隆对象中的状态之前,该方法就会先被执行,这样很有可能会导致克隆对象和原始对象之间的不一致。因此,上一段中讨论到的 put(keY,Value)方法要么应是final的,要么应是私有的。(如果是私有的,它应该算是非 final公有方法的”辅助方法”。)
Object的clone方法被声明为可抛出 CloneNotSupportedException异常,但是,覆盖版本的 clone 方法可以忽略这个声明。公有的 clone 方法应该省略throws 声明,因为不会抛出受检异常的方法使用起来更加轻松(详见第71条)。
为继承(详见第19条)设计类有两种选择,但是无论选择其中的哪一种方法,这个类可以使子类具有实现或不实现cloneable接口的自由,就仿佛它们直接扩展了Object一样。或者,也可以选择不去实现一个有效的clone方法,并防止子类去实现它,只需要提供下列退化了的clone实现即可:
1 | //clone method for extendable class not supporting Cloneable |
还有一点值得注意。如果你编写线程安c全的类准备实现Cloneable接口,要记住它的clone 方法必须得到严格的同步,就像任何其他方法一样(详见第 78 条)。Object的 clone 方法没有同步,即使很满意可能也必须编写同步的 clone 方法来调用 super.clone(),即实现 synchronized clone()方法。
简而言之,所有实现了 Cloneable 接口的类都应该覆盖 clone 方法,并且是公有的方法,它的返回类型为类本身。该方法应该先调用 super.clone 方法,然后修正任何需要修正的域。一般情况下,这意味着要拷贝任何包含内部”深层结构”的可变对象,并用指向新对象的引用代替原来指向这些对象的引用。虽然,这些内部拷贝操作往往可以通过递归地调用clone 来完成,但这通常并不是最佳方法。如果该类只包含基本类型的域,或者指向不可变对象的引用,那么多半的情况是没有域需要修正。这条规则也有例外。例如,代表序列号或其他唯一ID值的域,不管这些域是基本类型还是不可变的,它们也都需要被修正。
真的有必要这么复杂吗?很少有这种必要。如果你扩展一个实现了Cloneable接口的类,那么你除了实现一个行为良好的clone 方法外,没有别的选择。否则,最好提供某些其他的途径来代替对象拷贝。**对象拷贝的更好的办法是提供一个拷贝构造器(copyconstructor)或拷贝工厂(copy factory)**。拷贝构造器只是一个构造器,它唯一的参数类型是包含该构造器的类,例如:
1 | //Copy constructor |
拷贝工厂是类似于拷贝构造器的静态工厂(详见第1条):
1 | //Copyfactory |
拷贝构造器的做法,及其静态工厂方法的变形,都比 Cloneable/clone 方法具有更多的优势:它们不依赖于某一种很有风险的、语言之外的对象创建机制;它们不要求遵守尚未制定好文档的规范;它们不会与final域的正常使用发生冲突;它们不会抛出不必要的受检异常;它们不需要进行类型转换。
甚至,拷贝构造器或者拷贝工厂可以带一个参数,参数类型是该类所实现的接口。例如,按照惯例所有通用集合实现都提供了一个拷贝构造器,其参数类型为Collection 或者Map接口。基于接口的拷贝构造器和拷贝工厂(更准确的叫法应该是转换构造器(conversionconstructor)和转换工厂(conversion factory)),允许客户选择拷贝的实现类型,而不是强迫客户接受原始的实现类型。例如,假设你有一个HashSet:S,并且希望把它拷贝成一个TreeSet。clone 方法无法提供这样的功能,但是用转换构造器很容易实现:newTreeSet<>(s)。
既然所有的问题都与Cloneable接口有关,新的接口就不应该扩展这个接口,新的这个应该被视同性能优化,留到少数必要的情况下才使用(详见第67条)。总之,复制功能最好由构造器或者工厂提供。这条规则最绝对的例外是数组,最好利用clone 方法复制数组。
第14条:考虑实现Comparable接口
与本章中讨论的其他方法不同,compareTo 方法并没有在 Object类中声明。相反,它是Comparable接口中唯一的方法。compareTo方法不但允许进行简单的等同性比较,而且允许执行顺序比较,除此之外,它与 Object 的equals 方法具有相似的特征,它还是个泛型(generic)。类实现了Comparable接口,就表明它的实例具有内在的排序关系(natural ordering)。为实现Comparable 接口的对象数组进行排序就这么简单:
1 | Arrays.sort(a) ; |
对存储在集合中的Comparable对象进行搜索、计算极限值以及自动维护也同样简单。例如,下面的程序依赖于实现了Comparable接口的 String 类,它去掉了命令行参数列表中的重复参数,并按字母顺序打印出来:
1 | public class WordList{ |
一旦类实现了Comparable 接口,它就可以跟许多泛型算法(generic algorithm)以及依赖于该接口的集合实现(collection implementation)进行协作。你付出很小的努力就可以获得非常强大的功能。事实上,Java平台类库中的所有值类(value classes),以及所有的枚举类型(详见第 34条)都实现了Comparable 接口。如果你正在编写一个值类,它具有非常明显的内在排序关系,比如按字母顺序、按数值顺序或者按年代顺序,那你就应该坚决考虑实现Comparable接口:
1 | public interface Comparable<T>{ |
compareTo 方法的通用约定与 equals 方法的约定相似:
将这个对象与指定的对象进行比较。当该对象小于、等于或大于指定对象的时候,比较,则抛出 ClassCastException 异常。
在下面的说明中,符号sgn(expression)表示数学中的signum 函数,它根据表达式(expression)的值为负值、零和正值,分别返回-1、0或1。
实现者必须确保所有的x和y 都满足 sgn(x.compareTo(y))==-sgn(y.com-pareTo(x))。(这也暗示着,当且仅当y.compareTo(x)抛出异常时,x.com-pareTo(y)才必须抛出异常。)
实现者还必须确保这个比较关系是可传递的:(x.compareTo(y)>O&&y.compareTo(z)>0)暗示着x.compareTo(z)>0。
最后,实现者必须确保x.compareTo(y)==O暗示着所有的z 都满足 sgn(α.compareTo(z))== sgn(y.compareTo(z))。
强烈建议(x.compare To (y) == 0) == (x. equals (y)),但这并非绝对必要。一般说来,任何实现了Comparable接口的类,若违反了这个条件,都应该明确予以说明。推荐使用这样的说法:”注意:该类具有内在的排序功能,但是与equals 不一致。”
千万不要被上述约定中的数学关系所迷惑。如同équals 约定(详见第10条)一样,compareTo约定并没有看起来那么复杂。与equals方法不同的是,它对所有的对象强行施加了一种通用的等同关系,compareTo不能跨越不同类型的对象进行比较:在比较不同类型的对象时,compareTo可以抛出ClassCastException异常。通常,这正是compareTo 在这种情况下应该做的事情。合约确实允许进行跨类型之间的比较,这一般是在被比较对象实现的接口中进行定义。
就好像违反了hashCode约定的类会破坏其他依赖于散列的类一样,违反compareTo约定的类也会破坏其他依赖于比较关系的类。依赖于比较关系的类包括有序集合类Tree-Set 和TreeMap,以及工具类Collections 和Arrays,它们内部包含有搜索和排序算法。
现在我们来回顾一下compareTo 约定中的条款。 第一条指出,如果颠倒了两个对象引用之间的比较方向,就会发生下面的情况:如果第一个对象小于第二个对象,则第二个对象一定大于第一个对象;如果第一个对象等于第二个对象,则第二个对象一定等于第一个对象;如果第一个对象大于第二个对象,则第二个对象一定小于第一个对象。第二条指出,如果一个对象大于第二个对象,并且第二个对象又大于第三个对象,那么第一个对象一定大于第三个对象。最后一条指出,在比较时被认为相等的所有对象,它们跟别的对象做比较时一定会产生同样的结果。
这三个条款的一个直接结果是,由 compareTo方法施加的等同性测试,也必须遵守相同于équals约定所施加的限制条件:自反性、对称性和传递性。因此,下面的告诫也同样适用:无法在用新的值组件扩展可实例化的类时,同时保持compareTo 约定,除非愿意放弃面向对象的抽象优势(详见第10条)。针对equals的权宜之计也同样适用于展这个类;而是要编写一个不相关的类,其中包含第一个类的一个实例。然后提供一个”视图”(view)方法返回这个实例。这样既可以让你自由地在第二个类上实现compareTo方法,同时也允许它的客户端在必要的时候,把第二个类的实例视同第一个类的实例。
compareTo约定的最后一段是一条强烈的建议,而不是真正的规则,它只是说明了compareTo方法施加的等同性测试,在通常情况下应该返回与équals方法同样的结果。如果遵守了这一条,那么由 compareTo方法所施加的顺序关系就被认为与 équals一致。如果违反了这条规则,顺序关系就被认为与 équals 不一致。如果一个类的 compareTo方法施加了一个与 équals 方法不一致的顺序关系,它仍然能够正常工作,但是如果一个(Collection、Set或Map)的通用约定。因为对于这些接口的通用约定是按照equals方法来定义的,但是有序集合使用了由 compareTo方法而不是équals 方法所施加的等同性测试。尽管出现这种情况不会造成灾难性的后果,但是应该有所了解。
例如,以 BigDecimal 类为例,它的 compareTo 方法与equals 不一致。如果你创建了一个空的 HashSet实例,并且添加 new BigDecimal(“1.O”)和 new BigDecimal(“1.00”),这个集合就将包含两个元素,因为新增到集合中的两个BigDecimal实例,通过equals方法来比较时是不相等的。然而,如果你使用TreeSet而不是HashSet来执行同样的过程,集合中将只包含一个元素,因为这两个BigDecimal实例在通过compareTo 方法进行比较时是相等的。(详情请参阅 BigDecimal 的文档。)
编写compareTo方法与编写equals方法非常相似,但也存在几处重大的差别。因为Comparable接口是参数化的,而且comparable方法是静态的类型,因此不必进行类型检查,也不必对它的参数进行类型转换。如果参数的类型不合适,这个调用甚至无法编译。如果参数为 null,这个调用应该抛出 NullPointerException 异常,并且一旦该方法试图访问它的成员时就应该抛出异常。
CompareTo方法中域的比较是顺序的比较,而不是等同性的比较。比较对象引用域可以通过递归地调用 compareTo方法来实现。如果一个域并没有实现Comparable接口,或者你需要使用一个非标准的排序关系,就可以使用一个显式的 Comparator 来代替。或者编写自己的比较器,或者使用已有的比较器,例如针对第10条中的CaseInsensitive-String类的这个compareTo方法使用一个已有的比较器:
1 | //Single-field Comparable with object reference field |
注意 CaseInsensitiveString类实现了 Comparable
本书的前两个版本建议compareTo方法可以利用关系操作符<和》去比较整数型基本类型的域,用静态方法 Double.compare 和 Float.compare 去比较浮点基本类型域。在Java 7版本中,已经在Java 的所有装箱基本类型的类中增加了静态的compare方法。在compareTo方法中使用关系操作符<和>是非常烦琐的,并且容易出错,因此不再建议使用。
如果一个类有多个关键域,那么,按什么样的顺序来比较这些域是非常关键的。你必须从最关键的域开始,逐步进行到所有的重要域。如果某个域的比较产生了非零的结果(零代表相等),则整个比较操作结束,并返回该结果。如果最关键的域是相等的,则进一步比较次关键的域,以此类推。如果所有的域都是相等的,则对象就是相等的,并返回零。下面通过第11条中的PhoneNumber 类的compareTo方法来说明这种方法:
1 | //Multiple-field Comparable with primitive fields |
在 Java 8中,Comparator接口配置了一组比较器构造方法(comparator constructionmethods),使得比较器的构造工作变得非常流畅。之后,按照Comparable接口的要求,这些比较器可以用来实现一个compareTo方法。许多程序员都喜欢这种方法的简洁性,虽然它要付出一定的性能成本:在我的机器上,PhoneNumber实例的数组排序的速度慢了大约10%。在使用这个方法时,为了简洁起见,可以考虑使用Java 的静态导入(static import)设施,通过静态比较器构造方法的简单的名称就可以对它们进行引用。下面是使用这个方法之后 PhoneNumber 的 compareTo 方法:
1 | // Comparable with comparator construction methods |
这个实现利用两个比较构造方法,在初始化类的时候构建了一个比较器。第一个是comparingInt。这是一个静态方法,带有一个键提取器函数(key extractor function),它将一个对象引用映射到一个类型为int的键上,并根据这个键返回一个对实例进行排序的比较器。在上一个例子中,ComparingInt带有一个lambda(),它从PhoneNumber提取区号,并返回一个按区号对电话号码进行排序的Comparator
如果两个电话号码的区号相同,就需要进一步细化比较,这正是第二个比较器构造方类型为int 的键提取器函数,它会返回一个最先运用原始比较器的比较器,然后利用提取到的键继续比较。还可以随意地叠加多个thenComparingInt调用,并按词典顺序进行排序。在上述例子中,叠加了两个thenComparingInt 调用,按照第二个键为前缀且第三个键为行数的顺序进行排序。注意,并不一定要指定传人thenComparingInt 调用的键提取器函数的参数类型:Java 的类型推导十分智能,它足以为自己找出正确的类型。
Comparator 类具备全套的构造方法。对于基本类型 long 和 double 都有对应的 com-Number 例子中的 short。double 版本也可以用于 float。这样便涵盖了所有的 Java 数字型基本类型。
对象引用类型也有比较器构造方法。静态方法 comparing 有两个重载。一个带有键提取器,使用键的内在排序关系。第二个既带有键提取器,还带有要用在被提取的键上的比较器。这个名为thenComparing 的实例方法有三个重载。一个重载只带一个比较器,并用它提供次级顺序。第二个重载只带一个键提取器,并利用键的内在排序关系作为次级顺序。最后一个重载既带有键提取器,又带有要在被提取的键上使用的比较器。
compareTo或者compare方法偶尔也会依赖于两个值之间的区别,即如果第一个值小于第二个值,则为负;如果两个值相等,则为零;如果第一个值大于第二个值,则为正。下面举个例子:
1 | // BROKEN difference-based comparator - violates transitivity! |
千万不要使用这个方法。它很容易造成整数溢出,同时违反IEEE 754浮点算术标准[JLS 15.20.1,15.21.1]。甚至,与利用本条目讲到的方法编写的那些方法相比,最终得到的方法并没有明显变快。因此,要么使用一个静态方法 compare:
1 | // Comparator based on static compare method |
要么使用一个比较器构造方法:
1 | //Comparator basedon Comparator construction method |
总而言之,每当实现一个对排序敏感的类时,都应该让这个类实现Comparable接口,以便其实例可以轻松地被分类、搜索,以及用在基于比较的集合中。每当在com-pareTo方法的实现中比较域值时,都要避免使用<和>操作符,而应该在装箱基本类型的类中使用静态的compare方法,或者在Comparator接口中使用比较器构造方法。
第四章 类和接口
类和接口是 Java 编程语言的核心,它们也是 Java 语言的基本抽象单元。Java 语言提供了许多强大的基本元素,供程序员用来设计类和接口。本章阐述的一些指导原则,可以帮助你更好地利用这些元素,设计出更加有用、健壮和灵活的类和接口。
第15条:使类和成员的可访问性最小化
区分一个组件设计得好不好,唯一重要的因素在于,它对于外部的其他组件而言,是否隐藏了其内部数据和其他实现细节。设计良好的组件会隐藏所有的实现细节,把API与块的内部工作情况。这个概念被称为信息隐藏(informationhiding)或封装(encapsulation),是软件设计的基本原则之一[Parnas72]。
信息隐藏之所以非常重要有许多原因,其中大多是因为:它可以有效地解除组成系统的各组件之间的耦合关系,即解耦(decouple),使得这些组件可以独立地开发、测试、优化、使用、理解和修改。因为这些组件可以并行开发,所以加快了系统开发的速度。同时减轻了维护的负担,程序员可以更快地理解这些组件,并且在调试它们的时候不影响其他的组件。虽然信息隐藏本身无论是对内还是对外都不会带来更好的性能,但是可以有效地调节性能:一旦完成一个系统,并通过剖析确定了哪些组件影响了系统的性能(详见第67条),那些组件就可以被进一步优化,而不会影响到其他组件的正确性。信息隐藏提高了软件的可重用性,因为组件之间并不紧密相连,除了开发这些模块所使用的环境之外,它们在其他的环境中往往也很有用。最后,信息隐藏也降低了构建大型系统的风险,因为即使整个系统不可用,这些独立的组件仍有可能是可用的。
Java 提供了许多机制(facility)来协助信息隐藏。访问控制(access control)机制[JLS6.6]决定了类、接口和成员的可访问性(accessibility)。实体的可访问性是由该实体声明所在的位置,以及该实体声明中所出现的访问修饰符(private、protected 和public)共同决定的。正确地使用这些修饰符对于实现信息隐藏是非常关键的。
规则很简单:尽可能地使每个类或者成员不被外界访问。换句话说,应该使用与你正在编写的软件的对应功能相一致的、尽可能最小的访问级别。
对于顶层的(非嵌套的)类和接口,只有两种可能的访问级别:包级私有的(package-private)和公有的(public)。如果你用 public 修饰符声明了顶层类或者接口,那它就是公有的;否则,它将是包级私有的。如果类或者接口能够被做成包级私有的,它就应该被做成包级私有。通过把类或者接口做成包级私有,它实际上成了这个包的实现的一部分,而不是该包导出的API的一部分,在以后的发行版本中,可以对它进行修改、替换或者删除,而无须担心会影响到现有的客户端程序。如果把它做成公有的,你就有责任永远支持它,以保持它们的兼容性。
如果一个包级私有的顶层类(或者接口)只是在某一个类的内部被用到,就应该考虑使它成为唯一使用它的那个类的私有嵌套类(详见第24条)。这样可以将它的可访问范围从包中的所有类缩小到使用它的那个类。然而,降低不必要公有类的可访问性,比降低包级私有的顶层类的可访问性重要得多:因为公有类是包的API的一部分,而包级私有的顶层类则已经是这个包的实现的一部分。
对于成员(域、方法、嵌套类和嵌套接口)有四种可能的访问级别,下面按照可访问性的递增顺序罗列出来:
私有的(private)一只有在声明该成员的顶层类内部才可以访问这个成员。
包级私有的 (package-private) 一一声明该成员的包内部的任何类都可以访问这个成员。从技术上讲,它被称为”缺省”(default)访问级别,如果没有为成员指定访问修饰符,就采用这个访问级别(当然,接口成员除外,它们默认的访问级别是公有的)。
-受保护的(protected)一声明该成员的类的子类可以访问这个成员(但有一些限制[JLS,6.6.2]),并且声明该成员的包内部的任何类也可以访问这个成员。
- 公有的(public)一在任何地方都可以访问该成员。
当你仔细地设计了类的公有API之后,可能觉得应该把所有其他的成员都变成私有的。其实,只有当同一个包内的另一个类真正需要访问一个成员的时候,你才应该删除private 修饰符,使该成员变成包级私有的。如果你发现自己经常要做这样的事情,就应该重新检查系统设计,看看是否另一种分解方案所得到的类,与其他类之间的耦合度会更小。也就是说,私有成员和包级私有成员都是一个类的实现中的一部分,一般不会影响导出的 API。然而,如果这个类实现了 Serializable 接口(详见第 86 条和第 87 条),这些域就有可能会被”泄漏”(leak)到导出的 API 中。
对于公有类的成员,当访问级别从包级私有变成保护级别时,会大大增强可访问性。受保护的成员是类的导出的API的一部分,必须永远得到支持。导出的类的受保护成员也代表了该类对于某个实现细节的公开承诺(详见第19条)。应该尽量少用受保护的成员。
有一条规则限制了降低方法的可访问性的能力。如果方法覆盖了超类中的一个方法,子类中的访问级别就不允许低于超类中的访问级别[JLS,8.4.8.3]。这样可以确保任何可使用超类的实例的地方也都可以使用子类的实例(里氏替换原则,详见第10条)。如果违反了这条规则,那么当你试图编译该子类的时候,编译器就会产生一条错误消息。这条规则有一个特例:如果一个类实现了一个接口,那么接口中所有的方法在这个类中也都必须被声明为公有的。
为了便于测试代码,你可以试着使类、接口或者成员变得更容易访问。这么做在一定程度上来说是好的。为了测试而将一个公有类的私有成员变成包级私有的,这还可以接受,但是要将访问级别提高到超过它,这就无法接受了。换句话说,不能为了测试,而将类、接口或者成员变成包的导出的API的一部分。幸运的是,也没有必要这么做,因为可以让测试作为被测试的包的一部分来运行,从而能够访问它的包级私有的元素。
公有类的实例域决不能是公有的(详见第16条)。如果实例域是非final的,或者是一个指向可变对象的final引用,那么一旦使这个域成为公有的,就等于放弃了对存储在这个域中的值进行限制的能力;这意味着,你也放弃了强制这个域不可变的能力。同时,当这个域被修改的时候,你也失去了对它采取任何行动的能力。因此,包含公有可变域的类通常并不是线程安全的。即使域是final的,并且引用不可变的对象,但当把这个域变成公有的时候,也就放弃了”切换到一种新的内部数据表示法”的灵活性。
这条建议也同样适用于静态域,只是有一种情况例外。假设常量构成了类提供的整个抽象中的一部分,可以通过公有的静态final域来暴露这些常量。按惯例,这种域的名称由大写字母组成,单词之间用下划线隔开(详见第68条)。很重要的一点是,这些域要么包含基本类型的值,要么包含指向不可变对象的引用(详见第17条)。如果final域包含可变对象的引用,它便具有非final域的所有缺点。虽然引用本身不能被修改,但是它所引用的对象却可以被修改,这会导致灾难性的后果。
注意,长度非零的数组总是可变的,所以让类具有公有的静态final数组域,或者返回这种域的访问方法,这是错误的。如果类具有这样的域或者访问方法,客户端将能够修改数组中的内容。这是安全漏洞的一个常见根源:
1 | //Potential security hole! |
要注意,许多IDE产生的访问方法会返回指向私有数组域的引用,正好导致了这个问题。修正这个问题有两种方法。可以使公有数组变成私有的,并增加一个公有的不可变列表:
1 | private static final Thing[] PRIVATE_VALUES={... }; |
另一种方法是,也可以使数组变成私有的,并添加一个公有方法,它返回私有数组的一个拷贝:
1 | private static final Thing[] PRIVATE_VALUES = { ... }; |
要在这两种方法之间做出选择,得考虑客户端可能怎么处理这个结果。哪种返回类型会更加方便?哪种会得到更好的性能?
从Java 9开始,又新增了两种隐式访问级别,作为模块系统(module system)的一部分。一个模块就是一组包,就像一个包就是一组类一样。模块可以通过其模块声明(moduledeclaration)中的导出声明(export declaration)显式地导出它的一部分包(按照惯例,这包含在名为 module-info.java 的源文件中)。模块中未被导出的包在模块之外是不可访问的;在模块内部,可访问性不受导出声明的影响。使用模块系统可以在模块内部的包之间共享类,不用让它们对全世界都可见。未导出的包中公有类的公有成员和受保护的成员都提高了两个隐式访问级别,这是正常的公有和受保护级别在模块内部的对等体(intramodular analogues) 。 对于这种共享的需求相对罕见,经常通过在包内部重新安排类来解决。
与四个主访问级别不同的是,这两个基于模块的级别主要提供咨询。如果把模块的JAR文件放在应用程序的类路径下,而不是放在模块路径下,模块中的包就会恢复其非模块的行为:无论包是否通过模块导出,这些包中公有类的所有公有的和受保护的成员将都有正常的可访问性[Reinhold,1.2]。严格执行新引l人的访问级别的一个示例是JDK本身:Java类库中未导出的包在其模块之外确实是不可访问的。
对于传统的Java 程序员来说,不仅由受限工具的模块提供了访问保护,而且在本质上主要也是提供咨询。为了利用模块的这一特性,必须将包集中到模块中,并在模块声明中显式地表明其所有的依赖关系,重新安排代码结构树,从模块内部采取特殊的动作调解对于非模块化的包的任何访问[Reinhold,3]。现在说模块将在 JDK本身之外获得广泛的使用,还为时过早。同时,似乎最好不用它们,除非你的需求非常迫切。
总而言之,应该始终尽可能(合理)地降低程序元素的可访问性。在仔细地设计了一个最小的公有API之后,应该防止把任何散乱的类、接口或者成员变成API的一部分。除了公有静态 final域的特殊情形之外(此时它们充当常量),公有类都不应该包含公有域,并且要确保公有静态final域所引用的对象都是不可变的。
第16条:要在公有类而非公有域中使用访问方法
有时候,可能需要编写一些退化类,它们没有什么作用,只是用来集中实例域:
1 | //Degenerate classes like thisshouldnot bepublic! |
由于这种类的数据域是可以被直接访问的,这些类没有提供封装(encapsulation)的功能(详见第15条)。如果不改变API,就无法改变它的数据表示法,也无法强加任何约束条件;当域被访问的时候,无法采取任何辅助的行动。坚持面向对象编程的程序员对这种类深恶痛绝,认为应该用包含私有域和公有访问方法(getter)的类代替。对于可变的类来说,应该用公有设值方法(setter)的类代替:
1 | //Encapsulation of data by access or methods and mutators |
毫无疑问,说到公有类的时候,坚持面向对象编程思想的看法是正确的:如果类可以在它所在的包之外进行访问,就提供访问方法,以保留将来改变该类的内部表示法的灵活性。如果公有类暴露了它的数据域,要想在将来改变其内部表示法是不可能的,因为公有类的客户端代码已经遍布各处了。
然而,如果类是包级私有的,或者是私有的嵌套类,直接暴露它的数据域并没有本质的错误————假设这些数据域确实描述了该类所提供的抽象。无论是在类定义中,还是在使用该类的客户端代码中,这种方法比访问方法的做法更不容易产生视觉混乱。虽然客户端代码与该类的内部表示法紧密相连,但是这些代码被限定在包含该类的包中。如有必要,也可以不改变包之外的任何代码,而只改变内部数据表示法。在私有嵌套类的情况下,改变的作用范围被进一步限制在外围类中。
Java平台类库中有几个类违反了”公有类不应该直接暴露数据域”的告诫。显著的例子包括java.awt 包中的Point类和 Dimension 类。它们是不值得仿效的例子,相反,这些类应该被当作反面的警告示例。正如第 67条所述,决定暴露 Dimension 类的内部数据造成了严重的性能问题,而且这个问题至今依然存在。
让公有类直接暴露域虽然从来都不是种好办法,但是如果域是不可变的,这种做法的危害就比较小一些。如果不改变类的API,就无法改变这种类的表示法,当域被读取的时示一个有效的时间:
1 | //Public class withexposedimmutable fields-questionable |
简而言之,公有类永远都不应该暴露可变的域。虽然还是有问题,但是让公有类暴露不可变的域,其危害相对来说比较小。但有时候会需要用包级私有的或者私有的嵌套类来暴露域,无论这个类是可变的还是不可变的。
第17条:使可变性最小化
不可变类是指其实例不能被修改的类。每个实例中包含的所有信息都必须在创建该实例的时候就提供,并在对象的整个生命周期(lifetime)内固定不变。Java平台类库中包含许多不可变的类,其中有 String、基本类型的包装类、BigInteger 和 BigDecimal。存在不可变的类有许多理由:不可变的类比可变类更加易于设计、实现和使用。它们不容易出错,且更加安全。
为了使类成为不可变,要遵循下面五条规则:
不要提供任何会修改对象状态的方法(也称为设值方法)。
保证类不会被扩展。这样可以防止粗心或者恶意的子类假装对象的状态已经改变,从而破坏该类的不可变行为。为了防止子类化,一般做法是声明这个类成为 final的,但是后面我们还会讨论到其他的做法。
声明所有的域都是final 的。通过系统的强制方式可以清楚地表明你的意图。而且,如就必须确保正确的行为,正如内存模型(memorymodel)中所述[JLS,17.5;Goetz0616]。
声明所有的域都为私有的。这样可以防止客户端获得访问被域引用的可变对象的权限,并防止客户端直接修改这些对象。虽然从技术上讲,允许不可变的类具有公有的 final域,只要这些域包含基本类型的值或者指向不可变对象的引用,但是不建议这样做,因为这样会使得在以后的版本中无法再改变内部的表示法(详见第 15条和第 16 条)。
确保对于任何可变组件的互斥访问。如果类具有指向可变对象的域,则必须确保该类的客户端无法获得指向这些对象的引用。并且,永远不要用客户端提供的对象引用来初始化这样的域,也不要从任何访问方法(accessor)中返回该对象引用。在构造器、访问方法和readObject方法(详见第88条)中请使用保护性拷贝(defensivecopy)技术(详见第50条)。
前面条目中的许多例子都是不可变的,其中一个例子是第 11条中的 PhoneNumber,它针对每个属性都有访问方法(accessor),但是没有对应的设值方法(mutator)。下面是个稍微复杂一点的例子:
1 | //Immutablecomplex numberclass |
这个类表示一个复数(complex number,具有实部和虚部)。除了标准的 Object方法之外,它还提供了针对实部和虚部的访问方法,以及4种基本的算术运算:加法、减法、乘法和除法。注意这些算术运算如何创建并返回新的Complex实例,而不是修改这个实例。大多数重要的不可变类都使用了这种模式。它被称为函数的(functional)方法,因为这些方法返回了一个函数的结果,这些函数对操作数进行运算但并不修改它。与之相对应的更常见的是过程的(procedural)或者命令式的(imperative)方法,使用这些方法时,将一个过程作用在它们的操作数上,会导致它的状态发生改变。注意,这些方法名称都是介词(如plus),而不是动词(如 add)。这是为了强调该方法不会改变对象的值。BigInteger类和BigDecimal类由于没有遵守这一命名习惯,就导致了许多用法错误。
如果你对函数方式的做法还不太熟悉,可能会觉得它显得不太自然,但是它带来了不可变性,具有许多优点。不可变对象比较简单。不可变对象可以只有一种状态,即被创建时的状态。如果你能够确保所有的构造器都建立了这个类的约束关系,就可以确保这些约束关系在整个生命周期内永远不再发生变化,你和使用这个类的程序员都无须再做额外的工作来维护这些约束关系。另一方面,可变的对象可以有任意复杂的状态空间。如果文档中没有为设值方法所执行的状态转换提供精确的描述,要可靠地使用可变类是非常困难的,甚至是不可能的。
不可变对象本质上是线程安全的,它们不要求同步。当多个线程并发访问这样的对象时,它们不会遭到破坏。这无疑是获得线程安全最容易的办法。实际上,没有任何线程会注意到其他线程对于不可变对象的影响。所以,不可变对象可以被自由地共享。不可变类应该充分利用这种优势,鼓励客户端尽可能地重用现有的实例。要做到这一点,一个很简便的办法就是:对于频繁用到的值,为它们提供公有的静态 final常量。例如,Complex类有可能会提供下面的常量:
1 | public static final Complex ZERO = new Complex(0, 0); |
这种方法可以被进一步扩展。不可变的类可以提供一些静态工厂(详见第1条),它们例。所有基本类型的包装类和BigInteger 都有这样的静态工厂。使用这样的静态工厂也使得客户端之间可以共享现有的实例,而不用创建新的实例,从而降低内存占用和垃圾回收的成本。在设计新的类时,选择用静态工厂代替公有的构造器可以让你以后有添加缓存的灵活性,而不必影响客户端。
“不可变对象可以被自由地共享”导致的结果是,永远也不需要进行保护性拷贝(defensive copy) (详见第 50 条)。 实际上,你根本无须做任何拷贝,因为这些拷贝始终等于原始的对象。因此,你不需要,也不应该为不可变的类提供clone 方法或者拷贝构造器(详见第13条)。这一点在Java 平台的早期并不好理解,所以 String 类仍然具有拷贝构造器,但是应该尽量少用它(详见第6条)。
不仅可以共享不可变对象,甚至也可以共享它们的内部信息。例如,BigInteger类内部使用了符号数值表示法。符号用一个 int 类型的值来表示,数值则用一个 int 数组表示。negate 方法产生一个新的 BigInteger,其中数值是一样的,符号则是相反的。它
不可变对象为其他对象提供了大量的构件,无论是可变的还是不可变的对象。如果知道一个复杂对象内部的组件对象不会改变,要维护它的不变性约束是比较容易的。这条原则的一种特例在于,不可变对象构成了大量的映射键(mapkey)和集合元素(setelement);一旦不可变对象进人到映射(map)或者集合(set)中,尽管这破坏了映射或者集合的不变性约束,但是也不用担心它们的值会发生变化。
不可变对象无偿地提供了失败的原子性(详见第76条)。它们的状态永远不变,因此不存在临时不一致的可能性。
不可变类真正唯一的缺点是,对于每个不同的值都需要一个单独的对象。创建这些对象的代价可能很高,特别是大型的对象。例如,假设你有一个上百万位的 BigInteger,想要改变它的低位:
1 | BigInteger moby =....; |
flipBit方法创建了一个新的BigInteger实例,也有上百万位长,它与原来的对象只差一位不同。这项操作所消耗的时间和空间与BigInteger 的成正比。我们拿它与java.util.BitSet进行比较。与BigInteger类似,BitSet代表一个任意长度的位序列,但是与BigInteger不同的是,BitSet是可变的。BitSet类提供了一个方法,允许在固定时间(constant time)内改变此”百万位”实例中单个位的状态:
1 | BitSet moby = ...; |
如果你执行一个多步骤的操作,并且每个步骤都会产生一个新的对象,除了最后的结果之外,其他的对象最终都会被丢弃,此时性能问题就会显露出来。处理这种问题有两种办法。第一种办法,先猜测一下经常会用到哪些多步骤的操作,然后将它们作为基本类型提供。如果某个多步骤操作已经作为基本类型提供,不可变的类就无须在每个步骤单独创建一个对象。不可变的类在内部可以更加灵活。例如,BigInteger有一个包级私有的可变”配套类”(companing class),它的用途是加速诸如”模指数”(modular exponentiation)这样的困难工作。
如果能够精确地预测出客户端将要在不可变的类上执行哪些复杂的多阶段操作,这种包级私有的可变配套类的方法就可以工作得很好。如果无法预测,最好的办法是提供一个公有的可变配套类。在Java平台类库中,这种方法的主要例子是 String类,它的可变配套类是 StringBuilder(及其已经被废弃的祖先 StringBuffer)。
现在你已经知道了如何构建不可变的类,并且了解了不可变性的优点和缺点,现在我们来讨论其他的一些设计方案。前面提到过,为了确保不可变性,类绝对不允许自身被子类化。除了”使类成为final的”这种方法之外,还有另外一种更加灵活的办法可以做到这一点。不可变的类变成final的另一种办法就是,让类的所有构造器都变成私有的或者包级私有的,并添加公有的静态工厂(static factory)来代替公有的构造器(详见第1条)。为了具体说明这种方法,下面以 Complex 为例,看看如何使用这种方法:
1 | //Immutable classwith staticfactories instead of constructors |
这种方法虽然并不常用,但它通常是最好的替代方法。它最灵活,因为它允许使用多个包级私有的实现类。对于处在包外部的客户端而言,不可变的类实际上是final的,因为不可能对来自另一个包的类、缺少公有的或受保护的构造器的类进行扩展。除了允许多个实现类的灵活性之外,这种方法还使得有可能通过改善静态工厂的对象缓存能力,在后续的发行版本中改进该类的性能。
当BigInteger 和BigDecimal刚被编写出来的时候,对于”不可变的类必须为final”的说法还没有得到广泛的理解,所以它们的所有方法都有可能被覆盖。遗憾的是,为了保持向后兼容,这个问题一直无法得以修正。如果你在编写一个类,它的安全性依赖于来自不可信客户端的 BigInteger 或者BigDecimal参数的不可变性,就必须进行检查,以确定这个参数是否为”真正的”BigInteger 或者BigDecimal,而不是不可信任子类的实例。如果是后者,就必须在假设它可能是可变的前提下对它进行保护性拷贝(详见第50条):
1 | public static BigInteger safeInstance(BigInteger val){ |
本条开头关于不可变类的诸多规则指出,没有方法会修改对象,并且它的所有域都必须是final的。实际上,这些规则比真正的要求更强硬了一点,为了提高性能可以有所放松。事实上应该是这样:没有一个方法能够对对象的状态产生外部可见(extermallyvisible)的改变。然而,许多不可变的类拥有一个或者多个非 final的域,它们在第一次被请求执行这些计算的时候,把一些开销昂贵的计算结果缓存在这些域中。如果将来再次请求同样的计算,就直接返回这些缓存的值,从而节约了重新计算所需要的开销。这种技巧可以很好地工作,因为对象是不可变的,它的不可变性保证了这些计算如果被再次执行,就会产生同样的结果。
例如,PhoneNumber类的hashCode方法(详见第 11条)在第一次被调用的时候,计算出散列码,然后把它缓存起来,以备将来被再次调用时使用。这种方法是延迟初始化(lazy initialization)(详见第 83 条)的一个例子,String 类也用到了。
有关序列化功能的一条告诫有必要在这里提出来。如果你选择让自己的不可变类实现Serializable接口,并且它包含一个或者多个指向可变对象的域,就必须提供一个显式的readobject 或者 readResolve 方法,或者使用 ObjectOutputStream.writeUnshared和 ObjectInputStream.readUnshared方法,即便默认的序列化形式是可以接受的,也是如此。否则,攻击者可能从不可变的类创建可变的实例。关于这个话题的详情请参见第88条。
总之,坚决不要为每个 get方法编写一个相应的 set方法。除非有很好的理由要让类成为可变的类,否则它就应该是不可变的。不可变的类有许多优点,唯一的缺点是在特定的情况下存在潜在的性能问题。你应该总是使一些小的值对象,比如PhoneNumber和Complex,成为不可变的。(在Java平台类库中,有几个类如java.util.Date和java.awt.Point,它们本应该是不可变的,但实际上却不是。)你也应该认真考虑把一些较大的值对象做成不可变的,例如 String 和BigInteger。只有当你确认有必要实现令人满意的性能时(详见第67条),才应该为不可变的类提供公有的可变配套类。
对于某些类而言,其不可变性是不切实际的。如果类不能被做成不可变的,仍然应该尽可能地限制它的可变性。降低对象可以存在的状态数,可以更容易地分析该对象的行为,同时降低出错的可能性。因此,除非有令人信服的理由使域变成非final的,否则让每个域都是 final的。结合这条的建议和第15 条的建议,你自然倾向于:除非有令人信服的理由要使域变成是非final的,否则要使每个域都是privatefinal的。
构造器应该创建完全初始化的对象,并建立起所有的约束关系。不要在构造器或者静态工厂之外再提供公有的初始化方法,除非有令人信服的理由必须这么做。同样地,也不应该提供”重新初始化”方法(它使得对象可以被重用,就好像这个对象是由另一不同的初始状态构造出来的一样)。与所增加的复杂性相比,”重新初始化”方法通常并没有带来太多的性能优势。
通过CountDownLatch类的例子可以说明这些原则。它是可变的,但是它的状态空间被有意地设计得非常小。比如创建一个实例,只使用一次,它的任务就完成了:一旦定时器的计数达到零,就不能重用了。
最后值得注意的一点与本条目中的Complex类有关。这个例子只是被用来演示不可变性的,它不是一个工业强度的复数实现。它对复数乘法和除法使用标准的计算公式,会进行不正确的四舍五人,并且对复数NaN和无穷大也没有提供很好的语义[Kahan91,Smith62,Thomas94]。
第18条:复合优先于继承
继承(inheritance)是实现代码重用的有力手段,但它并非永远是完成这项工作的最佳工具。使用不当会导致软件变得很脆弱。在包的内部使用继承是非常安全的,在那里子类和超类的实现都处在同一个程序员的控制之下。对于专门为了继承而设计并且具有很好的文档说明的类来说(详见第19条),使用继承也是非常安全的。然而,对普通的具体类(concreteclass)进行跨越包边界的继承,则是非常危险的。提示一下,本书使用”继承”一词,含义是实现继承(当一个类扩展另一个类的时候)。本条目中讨论的问题并不适用于接口继承(当一个类实现一个接口的时候,或者当一个接口扩展另一个接口的时候)。
**与方法调用不同的是,继承打破了封装性[Snyder86]**。换句话说,子类依赖于其超类中特定功能的实现细节。超类的实现有可能会随着发行版本的不同而有所变化,如果真的发生了变化,子类可能会遭到破坏,即使它的代码完全没有改变。因而,子类必须要跟着其超类的更新而演变,除非超类是专门为了扩展而设计的,并且具有很好的文档说明。
为了说明得更加具体一点,我们假设有一个程序使用了HashSet。为了调优该程序的性能,需要查询HashSet,看一看自从它被创建以来添加了多少个元素(不要与它当前的元素数目混淆起来,它会随着元素的删除而递减)。为了提供这种功能,我们得编写一个HashSet变体,定义记录试图插人的元素的数量 addCount,并针对该计数值导出一个访问方法。HashSet 类包含两个可以增加元素的方法:add 和 addAll,因此这两个方法都要被覆盖:
1 | // Rroken -Inappropriate use of inheritance! |
这个类看起来非常合理,但是它并不能正常工作。 假设我们创建了一个实例,并利用addAll 方法添加了三个元素。顺便提一句,注意我们利用静态工厂方法 List.of 创建了一个列表,该方法是在 Java 9 中增加的。如果使用较早的版本,则用 Arrays.asList 代替:
1 | InstrumentedHashSet<String> s = new InstrumentedHashSet<>(); |
此时我们期望 getAddCount方法能返回3,但是实际上它返回的是6。哪里出错了呢?在HashSet的内部,addAll方法是基于它的 add方法来实现的,即使HashSet的文档中并没有说明这样的实现细节,这也是合理的。InstrumentedHashSet中的addAll方法首先给 addCount 增加3,然后利用 supper.addAll 来调用 HashSet 的addAll实现。然后又依次调用到被 InstrumentedHashSet 覆盖了的 add方法,每个元素调用一次。这三次调用又分别给 addCount 加了 1,所以总共增加了6:通过 addAll方法增加的每个元素都被计算了两次。
我们只要去掉被覆盖的addAll方法,就可以”修正”这个子类。虽然这样得到的类可以正常工作,但是它的功能正确性则需要依赖于这样的事实:HashSet 的 addAll方法是在它的 add方法上实现的。这种”自用性”(self-use)是实现细节,不是承诺,不能保证在Java平台的所有实现中都保持不变,不能保证随着发行版本的不同而不发生变化。因此,这样得到的InstrumentedHashSet类将是非常脆弱的。
稍微好一点的做法是,覆盖 addAll方法来遍历指定的集合,为每个元素调用一次add 方法。这样做可以保证得到正确的结果,不管 HashSet 的 addAll方法是否在 add方法的基础上实现,因为 HashSet 的 addAll 实现将不会再被调用到。然而,这项技术并没有解决所有的问题,它相当于重新实现了超类的方法,这些超类的方法可能是自用的,也可能不是,这种方法很困难,也非常耗时,容易出错,并且可能降低性能。此外,这样做并不总是可行的,因为无法访问对于子类来说是私有的域,所以有些方法就无法实现。
导致子类脆弱的一个相关的原因是,它们的超类在后续的发行版本中可以获得新的方法。假设一个程序的安全性依赖于这样的事实:所有被插人某个集合中的元素都满足某个先决条件。下面的做法就可以确保这一点:对集合进行子类化,并覆盖所有能够添加元素的方法,以便确保在加人每个元素之前它是满足这个先决条件的。如果在后续的发行版本中,超类中没有增加能插入元素的新方法,这种做法就可以正常工作。然而,一旦超类增加了这样的新方法,则很可能仅仅由于调用了这个未被子类覆盖的新方法,而将”非法的”元素添加到子类的实例中。这不是一个纯粹的理论问题。在把 Hashtable 和Vector加人到CollectionsFramework中的时候,就修正了几个这类性质的安全漏洞。
上面这两个问题都来源于覆盖(overriding)方法。你可能会认为在扩展一个类的时候,仅仅增加新的方法,而不覆盖现有的方法是安全的。虽然这种扩展方式比较安全一些,但是也并非完全没有风险。如果超类在后续的发行版本中获得了一个新的方法,并且不幸的是,你给子类提供了一个签名相同但返回类型不同的方法,那么这样的子类将无法通过编译[JLS,8.4.8.3]。如果给子类提供的方法带有与新的超类方法完全相同的签名和返回类型,实际上就覆盖了超类中的方法,因此又回到了上述两个问题。此外,你的方法是否能够遵守新的超类方法的约定,这也是很值得怀疑的,因为当你在编写子类方法的时候,这个约定压根还没有面世。
幸运的是,有一种办法可以避免前面提到的所有问题。即不扩展现有的类,而是在新的类中增加一个私有域,它引用现有类的一个实例。这种设计被称为”复合”(composition),因为现有的类变成了新类的一个组件。新类中的每个实例方法都可以调用被包含的现有类实例中对应的方法,并返回它的结果。这被称为转发(forwarding),新类中的方法被称为转发方法(forwarding method)。这样得到的类将会非常稳固,它不依赖于现有类的实现细节。即使现有的类添加了新的方法,也不会影响新的类。为了进行更具体的说明,请看下面的例子,它用复合/转发的方法来代替 InstrumentedHashSet 类。注意这个实现分为两部分:类本身和可重用的转发类(forwarding class),其中包含了所有的转发方法,没有任何其他的方法:
1 | //Wrapper class -uses composition in place of inheritance |
Set接口的存在使得InstrumentedSet类的设计成为可能,因为 Set接口保存了HashSet类的功能特性。除了获得健壮性之外,这种设计也带来了更多的灵活性。InstrumentedSet类实现了 Set接口,并且拥有单个构造器,它的参数也是Set类型。从本质上讲,这个类把一个Set转变成了另一个 Set,同时增加了计数的功能。前面提到的基于继承的方法只适用于单个具体的类,并且对于超类中所支持的每个构造器都要求有一)实现,并且可以结合任何先前存在的构造器一起工作:
1 | Set<Instant> times = new InstrumentedSet<>(new TreeSet<>(cmp)); |
InstrumentedSet 类甚至也可以用来临时替换一个原本没有计数特性的 Set 实例
1 | static void walk(Set<Dog> dogs) { |
因为每一个InstrumentedSet 实例都把另一个 Set 实例包装起来了,所以 Instru-mentedSet 类被称为包装类(wrapper class)。这也正是Decorator(修饰者)模式[Gamma95],因为 InstrumentedSet 类对一个集合进行了修饰,为它增加了计数特性。有时复合和转发的结合也被宽松地称为”委托”(delegation)。从技术的角度而言,这不是委托,除非包装对象把自身传递给被包装的对象[Lieberman86;Gamma95]。
包装类几乎没有什么缺点。需要注意的一点是,包装类不适合用于回调框架(callbackframework);在回调框架中,对象把自身的引引用传递给其他的对象,用于后续的调用(“回调”)。因为被包装起来的对象并不知道它外面的包装对象,所以它传递一个指向自身的引用(this),回调时避开了外面的包装对象。这被称为 SELF 问题[Lieberman86]。有些人担心转发方法调用所带来的性能影响,或者包装对象导致的内存占用。在实践中,这两者都不会造成很大的影响。编写转发方法倒是有点琐碎,但是只需要给每个接口编写一次构造器,转发类则可以通过包含接口的包提供。例如,Guava 就为所有的集合接口提供了转发类[ Guava ]。
只有当子类真正是超类的子类型(subtype)时,才适合用继承。换句话说,对于两个类A 和B,只有当两者之间确实存在”is-a”关系的时候,类B才应该扩展类A。如果你打算让类B扩展类A,就应该问问自己:每个B确实也是A吗?如果你不能够确定这个问题的答案是肯定的,那么B就不应该扩展A。如果答案是否定的,通常情况下,B应该包含A的一个私有实例,并且暴露一个较小的、较简单的 API:A本质上不是B的一部分,只是它的实现细节而已。
在Java 平台类库中,有许多明显违反这条原则的地方。例如,栈(stack)并不是向量(vector),所以 Stack 不应该扩展Vector。同样地,属性列表也不是散列表,所以Properties 不应该扩展 Hashtable。在这两种情况下,复合模式才是恰当的。
如果在适合使用复合的地方使用了继承,则会不必要地暴露实现细节。这样得到的API会把你限制在原始的实现上,永远限定了类的性能。更为严重的是,由于暴露了内部的细节,客户端就有可能直接访问这些内部细节。这样至少会导致语义上的混淆。例如,如果p指向 Properties实例,那么p·getProperty(key)就有可能产生与p·get(key)不同的结果:前一个方法考虑了默认的属性表,而后一个方法则继承自 Hashtable,没有考虑默认的属性列表。最严重的是,客户有可能直接修改超类,从而破坏子类的约束条件。在Properties 的情形中,设计者的目标是只允许字符串作为键(key)和值(value),但是直接访问底层的Hashtable就允许违反这种约束条件。一旦违反了约束条件,就不可能再使用Properties API 的其他部分(load 和 store)了。等到发现这个问题时,要改正它已经太晚了,因为客户端依赖于使用非字符串的键和值了。
在决定使用继承而不是复合之前,还应该问自己最后一组问题。对于你正试图扩展的机制会把超类API中的所有缺陷传播到子类中,而复合则允许设计新的API来隐藏这些缺陷。
简而言之,继承的功能非常强大,但是也存在诸多问题,因为它违背了封装原则。只有当子类和超类之间确实存在子类型关系时,使用继承才是恰当的。即便如此,如果子类和超类处在不同的包中,并且超类并不是为了继承而设计的,那么继承将会导致脆弱性(fragility)。为了避免这种脆弱性,可以用复合和转发机制来代替继承,尤其是当存在适当的接口可以实现包装类的时候。包装类不仅比子类更加健壮,而且功能也更加强大。
第19条:要么设计继承并提供文档说明,要么禁止继承
第18条提醒过我们:对于不是为了继承而设计并且没有文档说明的”外来”类进行子类化是多么危险。那么对于专门为了继承而设计并且具有良好文档说明的类而言,这又意味着什么呢?
首先,该类的文档必须精确地描述覆盖每个方法所带来的影响。换句话说,该类必须有文档说明它可覆盖(overridable)的方法的自用性(self-use)。对于每个公有的或受保护的方法或者构造器,它的文档必须指明该方法或者构造器调用了哪些可覆盖的方法,是以什么顺序调用的,每个调用的结果又是如何影响后续处理过程的(所谓可覆盖(overridable)的方法,是指非final 的、公有的或受保护的)。更广义地说,即类必须在文档中说明,在哪些情况下它会调用可覆盖的方法。例如,后台的线程或者静态的初始化器(initializer)可能会调用这样的方法。
述信息。这段描述信息是规范的一个特殊部分,写着:”Implementation Requirements”(实现要求·…..··),它由Javadoc 标签@implSpec生成。这段话描述了该方法的内部工作情况。下面举个例子,摘自java.util.AbstractCollection 的规范:
1 | public boolean remove(Object o) |
(如果这个集合中存在指定的元素,就从中删除该指定元素中的单个实例(这是项可选的操作)。更广义地说,即如果集合中包含一个或者多个这样的元素é,就从中删除掉一个,如Objects.equals(o,e)。如果集合中包含指定的元素,就返回true(如果调用的结果改变了集合,也是一样)。
实现要求:该实现遍历整个集合来查找指定的元素。如果它找到该元素,将会利用迭代器的 remove 方法将之从集合中删除。注意,如果由该集合的 iterator 方法返回的迭代器没有实现 remove 方法,该实现就会抛出 UnsupportedOperationException。)
这份文档清楚地说明了,覆盖 iterator 方法将会影响 remove 方法的行为。而且,它确切地描述了 iterator 方法返回的 Iterator 的行为将会怎样影响 remove方法的行为。与此相反的是,在第 18条的情形中,程序员在子类化 HashSet 的时候,无法说明覆盖 add 方法是否会影响 addAll 方法的行为。
关于程序文档有句格言:好的API文档应该描述一个给定的方法做了什么工作,而不是描述它是如何做到的。那么,上面这种做法是否违背了这句格言呢?是的,它确实违背了!这正是继承破坏了封装性所带来的不幸后果。所以,为了设计一个类的文档,以便它能够被安全地子类化,你必须描述清楚那些有可能未定义的实现细节。
@implSpec 标签是在Java 8 中增加的,在 Java 9 中得到了广泛应用。这个标签应该是默认可用的,但是到Java 9,Javadoc 工具仍然把它忽略,除非传入命令行参数:-tag”apiNote:a:API Note:”
为了继承而进行的设计不仅仅涉及自用模式的文档设计。为了使程序员能够编写出更加有效的子类,而无须承受不必要的痛苦,类必须以精心挑选的受保护的(protected)方法的形式,提供适当的钩子(hook),以便进入其内部工作中。这种形式也可以是罕见的实例,或者受保护的域。例如,以java.util.AbstractList 中的 removeRange 方法为例:
1 | protected void removeRange(int fromIndex, int toIndex) |
(从列表中删除所有索引处于fromIndex(含)和toIndex(不含)之间的元素。将所有符合条件的元素移到左边(减小它们索引l)。这一调用将从 ArrayList 中删除从 toIndex 到 fromIndex 之间的元素。(如果toIndex == fromIndex,这项操作就无效。)
这个方法是通过clear 操作在这个列表及其子列表中调用的。覆盖这个方法来利用列表实现的内部信息,可以充分地改善这个列表及其子列表中的clear操作的性能。
实现要求:这项实现获得了一个处在 fromIndex 之前的列表迭代器,并依次地重复调用ListIterator.next 和ListIterator.remove,直到整个范围都被移除为止。注意:如果ListIterator.remove需要线性的时间,该实现就需要平方级的时间。
参数:
- fromIndex要移除的第一个元素的索引l。
- toIndex要移除的最后一个元素之后的索引。)
这个方法对于List实现的最终用户并没有意义。提供该方法的唯一目的在于,使子类更易于提供针对子列表(sublist)的快速clear方法。如果没有removeRange方法,当在子列表(sublist)上调用clear方法时,子类将不得不用平方级的时间来完成它的工作。否则,就得重新编写整个subList机制—这可不是一件容易的事情!
因此,当你为了继承而设计类的时候,如何决定应该暴露哪些受保护的方法或者域呢?遗憾的是,并没有什么神奇的法则可供你使用。你所能做到的最佳途径就是努力思考,发挥最好的想象,然后编写一些子类进行测试。你应该尽可能地少暴露受保护的成员,因为每个方法或者域都代表了一项关于实现细节的承诺。另一方面,你又不能暴露得太少,因为漏掉的受保护方法可能会导致这个类无法被真正用于继承。
对于为了继承而设计的类,唯一的测试方法就是编写子类。如果遗漏了关键的受保护成员,尝试编写子类就会使遗漏所带来的痛苦变得更加明显。相反,如果编写了多个子类,并且无一使用受保护的成员,或许就应该把它做成私有的。经验表明,3个子类通常就足以测试一个可扩展的类。除了超类的程序设计者之外,都需要编写一个或者多个这种子类。
在为了继承而设计有可能被广泛使用的类时,必须要意识到,对于文档中所说明的自用模式(self-use pattern),以及对于其受保护方法和域中所隐含的实现策略,你实际上已经做出了永久的承诺。这些承诺使得你在后续的版本中提高这个类的性能或者增加新功能都变得非常困难,甚至不可能。因此,必须在发布类之前先编写子类对类进行测试。
还要注意,因继承而需要的特殊文档会打乱正常的文档信息,正常的文档信息是被设计用来让程序员可以创建该类的实例,并调用类中的方法。在编写本书之时,几乎还没有适当的工具或者注释规范,能够把”普通的API文档”与”专门针对实现子类的程序员的信息”区分开。
为了允许继承,类还必须遵守其他一些约束。**构造器决不能调用可被覆盖的方法,**无论是直接调用还是间接调用。如果违反了这条规则,很有可能导致程序失败。超类的构造器在子类的构造器之前运行,所以,子类中覆盖版本的方法将会在子类的构造器运行之前先被调用。如果该覆盖版本的方法依赖于子类构造器所执行的任何初始化工作,该方法将不会如预期般执行。为了更加直观地说明这一点,下面举个例子,其中有个类违反了这条规则:
1 | public class Super { |
下面的子类覆盖了方法 overrideMe,Super 唯一的构造器就错误地调用了这个方法:
1 | public final class Sub extends Super{ |
你可能会期待这个程序会打印两次日期,但是它第一次打印出的是null,因为 over-rideMe 方法被 Super 构造器调用的时候,构造器 Sub 还没有机会初始化 instant 域。注意,这个程序观察到的 final 域处于两种不同的状态。还要注意,如果 overrideMe 已经调用了 instant 中的任何方法,当 Super 构造器调用 overrideMe 的时候,调用就会抛出 NullPointerException 异常。如果该程序没有抛出 NullPointerException异常,唯一的原因就在于 println 方法可以容忍 null 参数。
注意,通过构造器调用私有的方法、final方法和静态方法是安全的,这些都不是可以被覆盖的方法。
如果类是为了继承而设计的,无论实现这其中的哪个接口通常都不是个好主意,因为它们把一些实质性的负担转嫁到了扩展这个类的程序员身上。然而,你还是可以采取一些特殊的手段,允许子类实现这些接口,无须强迫子类的程序员去承受这些负担。第13条和第86 条中会讲解这些特殊的手段。
如果你决定在一个为了继承而设计的类中实现 Cloneable 或者 Serializable接口,就应该意识到,因为 clone 和 readobject 方法在行为上非常类似于构造器,所以类似的限制规则也是适用的:无论是clone 还是readObject,都不可以调用可覆盖的方法,不管是以直接还是间接的方式。对于 readObject 方法,覆盖的方法将在子类的状态被反序列化(deserialized)之前先被运行;而对于clone 方法,覆盖的方法则是在子类的clone 方法有机会修正被克隆对象的状态之前先被运行。无论哪种情形,都不可避免地将导致程序失败。在clone 方法的情形中,这种失败可能会同时损害到原始的对象以及被克隆的对象本身。例如,如果覆盖版本的方法假设它正在修改对象深层结构的克隆对象的备份,就会发生这种情况,但是该备份还没有完成。
最后,如果你决定在一个为了继承而设计的类中实现 Serializable 接口,并且该类有一个 readResolve 或者 writeReplace 方法,就必须使 readResolve 或者 write-Replace 成为受保护的方法,而不是私有的方法。如果这些方法是私有的,那么子类将会不声不响地忽略掉这两个方法。这正是”为了允许继承,而把实现细节变成一个类的API的一部分”的另一种情形。
到现在为止,结论应该很明显了:为了继承而设计类,对这个类会有一些实质性的限制。这并不是很轻松就可以承诺的决定。在某些情况下,这样的决定很明显是正确的,比如抽象类,包括接口的骨架实现(skeletal implementation)(详见第 20 条)。但是,在另外一些情况下,这样的决定却很明显是错误的,比如不可变类(详见第17条)。
但是,对于普通的具体类应该怎么办呢?它们既不是final的,也不是为了子类化而设计和编写文档的,所以这种状况很危险。每次对这种类进行修改,从这个类扩展得到的客户类就有可能遭到破坏。这不仅仅是个理论问题。对于一个并非为了继承而设计的非final具体类,在修改了它的内部实现之后,接收到与子类化相关的错误报告也并不少见。
这个问题的最佳解决方案是,对于那些并非为了安全地进行子类化而设计和编写文档的类,要禁止子类化。有两种办法可以禁止子类化。比较容易的办法是把这个类声明为final的。另一种办法是把所有的构造器都变成私有的,或者包级私有的,并增加一些公有的静态工厂来替代构造器。后一种办法在第17条中讨论过,它为内部使用子类提供了灵活性。这两种办法都是可以接受的。
这条建议可能会引来争议,因为许多程序员已经习惯于对普通的具体类进行子类化,以便增加新的功能设施,比如仪表功能(如计数显示等)、通知机制或者同步功能,或者为了限制原有类中的功能。如果类实现了某个能够反映其本质的接口,比如 Set、List 或者Map,就不应该为了禁止子类化而感到后悔。第18条中介绍的包装类(wrapperclass)模式还提供了另一种更好的办法,让继承机制实现更多的功能。
如果具体的类没有实现标准的接口,那么禁止继承可能会给某些程序员带来不便。如果你认为必须允许从这样的类继承,一种合理的办法是确保这个类永远不会调用它的任何可覆盖的方法,并在文档中说明这一点。换句话说,完全消除这个类中可覆盖方法的自用特他任何方法的行为。
你可以机械地消除类中可覆盖方法的自用特性,而不改变它的行为。将每个可覆盖方法的代码体移到一个私有的”辅助方法”(helper method)中,并且让每个可覆盖的方法调用它的私有辅助方法。然后用”直接调用可覆盖方法的私有辅助方法”来代替”可覆盖方法的每个自用调用”。
简而言之,专门为了继承而设计类是一件很辛苦的工作。你必须建立文档说明其所有的自用模式,并且一旦建立了文档,在这个类的整个生命周期中都必须遵守。如果没有做到,子类就会依赖超类的实现细节,如果超类的实现发生了变化,它就有可能遭到破坏。为了允许其他人能编写出高效的子类,还你必须导出一个或者多个受保护的方法。除非知道真正需要子类,否则最好通过将类声明为final,或者确保没有可访问的构造器来禁止类被继承。
第20条:接口优于抽象类
Java 提供了两种机制,可以用来定义允许多个实现的类型:接口和抽象类。自从Java 8为继承引l入了缺省方法(default method),这两种机制都允许为某些实例方法提供实现。主要的区别在于,为了实现由抽象类定义的类型,类必须成为抽象类的一个子类。因为Java只允许单继承,所以用抽象类作为类型定义受到了限制。任何定义了所有必要的方法并遵守通用约定的类,都允许实现一个接口,无论这个类是处在类层次结构中的什么位置。
现有的类可以很容易被更新,以实现新的接口。如果这些方法尚不存在,你所需要做的就只是增加必要的方法,然后在类的声明中增加一个 implements子句。例如,当Comparable、Iterable 和 Autocloseable 接口被引l人 Java 平台时,更新了许多现有的类,以实现这些接口。一般来说,无法更新现有的类来扩展新的抽象类。如果你希望两个类扩展同一个抽象类,就必须把抽象类放到类型层次(type hierarchy)的高处,这样它就成了那两个类的一个祖先。遗憾的是,这样做会间接地伤害到类层次,迫使这个公共祖先的所有后代类都扩展这个新的抽象类,无论它对于这些后代类是否合适。
接口是定义 mixin(混合类型)的理想选择。不严格地讲,mixin 类型是指:类除了实现它的”基本类型”之外,还可以实现这个 mixin 类型,以表明它提供了某些可供选择的行为。例如,Comparable是一个mixin接口,它允许类表明它的实例可以与其他的可相互比较的对象进行排序。这样的接口之所以被称为 mixin,是因为它允许任选的功能可被混合到类型的主要功能中。抽象类不能被用于定义mixin,同样也是因为它们不能被更新到现有的类中:类不可能有一个以上的父类,类层次结构中也没有适当的地方来插入mixin。
接口允许构造非层次结构的类型框架。类型层次对于组织某些事物是非常合适的,但是其他事物并不能被整齐地组织成一个严格的层次结构。例如,假设我们有一个接口代表一个singer(歌唱家),另一个接口代表一个songwriter(作曲家):
1 | public interface Singer{ |
也许并非总是需要这种灵活性,但是一旦这样做了,接口可就成了救世主。另外一种做法是编写一个臃肿(bloated)的类层次,对于每一种要被支持的属性组合,都包含一个单独的类。如果在整个类型系统中有n个属性,那么就必须支持2”种可能的组合。这种现象被称为”组合爆炸”(combinatorial explosion)。类层次臃肿会导致类也臃肿,这些类包含许多方法,并且这些方法只是在参数的类型上有所不同而已,因为类层次中没有任何类型体现了公共的行为特征。
通过第18条中介绍的包装类(wrapper class)模式,接口使得安全地增强类的功能成为可能。如果使用抽象类来定义类型,那么程序员除了使用继承的手段来增加功能,再没有其他的选择了。这样得到的类与包装类相比,功能更差,也更加脆弱。
当一个接口方法根据其他接口方法有了明显的实现时,可以考虑以缺省方法的形式为程序员提供实现协助。关于这种方法的范例,请参考第 21条中的 removeIf 方法。如果提供了缺省方法,要确保利用Javadoc 标签@implSpec 建立文档(详见第 19条)。
通过缺省方法可以提供的实现协助是有限的。虽然许多接口都定义了object方法的行为,如équals 和hashCode,但是不允许给它们提供缺省方法。而且接口中不允许包含实例域或者非公有的静态成员(私有的静态方法除外)。最后一点,无法给不受你控制的接口添加缺省方法。
但是,通过对接口提供一个抽象的骨架实现(skeletalimplementation)类,可以把接口和抽象类的优点结合起来。接口负责定义类型,或许还提供一些缺省方法,而骨架实现类则负责实现除基本类型接口方法之外,剩下的非基本类型接口方法。扩展骨架实现占了实现接口之外的大部分工作。这就是模板方法(TemplateMethod)模式[Gamma95]。
按照惯例,骨架实现类被称为 AbstractInterface,这里的Interface 是指所实现的接口的名字。例如,Collections Framework 为每个重要的集合接口都提供了一个骨架实现,包括AbstractCollection、AbstractSet、AbstractList 和 AbstractMap。将 它们称作 SkeletalCollection、 SkeletalSet、SkeletalList 和 SkeletalMap 也是有道理的,但是现在Abstract 的用法已经根深蒂固。 如果设计得当,骨架实现(无论是单独一个抽象类,还是接口中唯一包含的缺省方法)可以使程序员非常容易地提供他们自已的接口实现。例如,下面是一个静态工厂方法,除AbstractList之外,它还包含了一个完整的、功能全面的 List实现:
1 | //Concreteimplementationbuiltatopskeletalimplementation |
如果想知道一个List实现应该为你完成哪些工作,这个例子就充分演示了骨架实现的强大功能。顺便提一下,这个例子是个Adapter[Gamma95],它允许将 int数组看作Integer 实例的列表。由于在 int 值和 Integer 实例之间来回转换需要开销,它的性能不会很好。注意,这个实现采用了匿名类(anonymous class)的形式(详见第 24条)。
骨架实现类的美妙之处在于,它们为抽象类提供了实现上的帮助,但又不强加”抽象类被用作类型定义时”所特有的严格限制。对于接口的大多数实现来讲,扩展骨架实现类是个很显然的选择,但并不是必须的。如果预置的类无法扩展骨架实现类,这个类始终能手工实现这个接口。同时,这个类本身仍然受益于接口中出现的任何缺省方法。此外,骨架实现类仍然有助于接口的实现。实现了这个接口的类可以把对于接口方法的调用转发到一个内部私有类的实例上,这个内部私有类扩展了骨架实现类。这种方法被称作模拟多重继承(simulated multiple inheritance),它与第18 条中讨论过的包装类模式密切相关。这项技术具有多重继承的绝大多数优点,同时又避免了相应的缺陷。
编写骨架实现类相对比较简单,只是过程有点乏味。首先,必须认真研究接口,并确定哪些方法是最为基本的,其他的方法则可以根据它们来实现。这些基本方法将成为骨架实现类中的抽象方法。接下来,在接口中为所有可以在基本方法之上直接实现的方法提供缺省方法,但要记住,不能为 Object 方法(如 equals 和 hashCode)提供缺省方法。如果基本方法和缺省方法覆盖了接口,你的任务就完成了,不需要骨架实现类了。否则,就要编写一个类,声明实现接口,并实现所有剩下的接口方法。这个类中可以包含任何非公有的域,以及适合该任务的任何方法。
以 Map.Entry接口为例,举个简单的例子。明显的基本方法是 getKey、getValue和(可选的)setValue。接口定义了equals 和hashCode 的行为,并且有一个明显的toString 实现。由于不允许给 Object 方法提供缺省实现,因此所有实现都放在骨架实现类中:
1 | //Skeletal implementation |
注意,这个骨架实现不能在Map.Entry接口中实现,也不能作为子接口,因为不允许缺省方法覆盖 Object 方法,如 equals、hashCode 和toString。
因为骨架实现类是为了继承的目的而设计的,所以应该遵从第19条中介绍的所有关于设计和文档的指导原则。为了简洁起见,上面例子中的文档注释部分被省略掉了,但是对于骨架实现类而言,好的文档绝对是非常必要的,无论它是否在接口或者单独的抽象类中包含了缺省方法。
骨架实现上有个小小的不同,就是简单实现(simple implementation),AbstractMap,SimpleEntry就是个例子。简单实现就像骨架实现一样,这是因为它实现了接口,并且是为了继承而设计的,但是区别在于它不是抽象的:它是最简单的可能的有效实现。你可以原封不动地使用,也可以看情况将它子类化。
总而言之,接口通常是定义允许多个实现的类型的最佳途径。如果你导出了一个重要的接口,就应该坚决考虑同时提供骨架实现类。而且,还应该尽可能地通过缺省方法在接口中提供骨架实现,以便接口的所有实现类都能使用。也就是说,对于接口的限制,通常也限制了骨架实现会采用的抽象类的形式。
第21条:为后代设计接口
在Java 8发行之前,如果不破坏现有的实现,是不可能给接口添加方法的。如果给某个接口添加了一个新的方法,一般来说,现有的实现中是没有这个方法的,因此就会导致编译错误。在Java 8中,增加了缺省方法(default method)构造[JLS9.4],目的就是允许给现有的接口添加方法。但是给现有接口添加新方法还是充满风险的。
没有实现默认方法的所有类使用的。虽然Java中增加了缺省方法之后,可以给现有接口添加方法了,但是并不能确保这些方法在之前存在的实现中都能良好运行。因为这些默认的方法是被”注人”到现有实现中的,它们的实现者并不知道,也没有许可。在Java8之前,编写这些实现时,是默认它们的接口永远不需要任何新方法的。
Java8在核心集合接口中增加了许多新的缺省方法,主要是为了便于使用lambda(详见第6章)。Java类库的缺省方法是高品质的通用实现,它们在大多数情况下都能正常使用。但是, 并非每一个可能的实现的所有变体,始终都可以编写出一个缺省方法。
比如,以 removeIf 方法为例,它在 Java 8 中被添加到了 Collection 接口。这个方法用来移除所有元素,并用一个 boolean 函数(或者断言)返回true。缺省实现指定用其迭代器来遍历集合,在每个元素上调用断言(predicate),并利用迭代器的remove 方法移除断言返回值为 true 的元素。其声明大致如下:
1 | //Default method added to the Collection interface in Java 8 |
这是适用于 removeIf方法的最佳通用实现,但遗憾的是,它在某些现实的Collection实现中会出错。比如,以 org.apache.commons.collections4.Collection.Synch-ronizedCollection 为例,这个类来自 Apache Commons 类库,类似于 java.util 中的静态工厂Collections.SynchronizedCollection。Apache版本额外提供了利用客户端提供的对象(而不是用集合)进行锁定的功能。换句话说,它是一个包装类(详见第18条),它的所有方法在委托给包装集合之前,都在锁定对象上进行了同步。
Apache 版本的 SynchronizedCollection 类依然有人维护,但是到编写本书之时,它也没有取代 removeIf 方法。如果这个类与Java 8结合使用,将会继承 removeIf 的缺省实现,它不会(实际上也无法)保持这个类的基本承诺:围绕着每一个方法调用执行自动同步。缺省实现压根不知道同步这回事,也无权访问包含该锁定对象的域。如果客户在SynchronizedCollection 实例上调用removeIf方法,同时另一个线程对该集合进行修改,就会导致ConcurrentModificationException 或者其他异常行为。
为了避免在类似的 Java平台类库实现中发生这种异常,如 Collections.synchronizedCollection返回的包私有类,JDK维护人员必须覆盖默认的 removeIf实现,以及像它一样的其他方法,以便在调用缺省实现之前执行必要的同步。不属于Java平台组成部分的预先存在的集合实现,过去无法做出与接口变化相类似的改变,现在有些已经可以做到了。
有了缺省方法,接口的现有实现就不会出现编译时没有报错或警告,运行时却失败的情况。这个问题虽然并非普遍,但也不是孤立的意外事件。Java8在集合接口中添加的许多方法是极易受影响的,有些现有实现已知将会受到影响。
建议尽量避免利用缺省方法在现有接口上添加新的方法,除非有特殊需要,但就算在那样的情况下也应该慎重考虑:缺省的方法实现是否会破坏现有的接口实现。然而,在创建接口的时候,用缺省方法提供标准的方法实现是非常方便的,它简化了实现接口的任务(详见第20条)。
还要注意的是,缺省方法不支持从接口中删除方法,也不支持修改现有方法的签名。对接口进行这些修改肯定会破坏现有的客户端代码。
结论很明显:尽管缺省方法现在已经是Java平台的组成部分,但谨慎设计接口仍然是至关重要的。虽然缺省方法可以在现有接口上添加方法,但这么做还是存在着很大的风险。就算接口中只有细微的缺陷都可能永远给用户带来不愉快;假如接口有严重的缺陷,则可能摧毁包含它的API。
因此,在发布程序之前,测试每一个新的接口就显得尤其重要。程序员应该以不同的方法实现每一个接口。最起码不应少于三种实现。编写多个客户端程序,利用每个新接口的实例来执行不同的任务,这一点也同样重要。这些步骤对确保每个接口都能满足其既定的所有用途起到了很大的帮助。它还有助于在接口发布之前及时发现其中的缺陷,使你依然能够轻松地把它们纠正过来。或许接口程序发布之后也能纠正,但是千万别指望它啦!
第22条:接口只用于定义类型
当类实现接口时,接口就充当可以引I用这个类的实例的类型(type)。因此,类实现了接口,就表明客户端可以对这个类的实例实施某些动作。为了任何其他目的而定义接口是不恰当的。
有一种接口被称为常量接口(constant interface),它不满足上面的条件。这种接口不包含任何方法,它只包含静态的final域,每个域都导出一个常量。使用这些常量的类实现这个接口,以避免用类名来修饰常量名。下面举个例子:
1 | // Constant interface antipattern -do not use! |
常量接口模式是对接口的不良使用。类在内部使用某些常量,这纯粹是实现细节。实现常量接口会导致把这样的实现细节泄露到该类的导出 API中。类实现常量接口对于该类的用户而言并没有什么价值。实际上,这样做反而会使他们更加糊涂。更糟糕的是,它代表了一种承诺:如果在将来的发行版本中,这个类被修改了,它不再需要使用这些常量了,它依然必须实现这个接口,以确保二进制兼容性。如果非final类实现了常量接口,它的所有子类的命名空间也会被接口中的常量所”污染”。
在Java平台类库中有几个常量接口,例如java.io.ObjectStreamConstants。这些接口应该被认为是反面的典型,不值得效仿。
如果要导出常量,可以有几种合理的选择方案。如果这些常量与某个现有的类或者接口紧密相关,就应该把这些常量添加到这个类或者接口中。例如,在Java平台类库中所有的数值包装类,如Integer 和 Double,都导出了 MIN_VALUE 和 MAX_VALUE 常量。如果这些常量最好被看作枚举类型的成员,就应该用枚举类型(enum type)(详见第 34条)来导出这些常量。否则,应该使用不可实例化的工具类(utility class)(详见第 4条)来导出这些常量。下面的例子是前面的 PhysicalConstants 例子的工具类翻版:
1 | //Constantutilityclass |
注意,有时候会在数字的字面量中使用下划线(_)。从Java 7开始,下划线的使用已经合法了,它对数字字面量的值没有影响,如果使用得当,还可以极大地提升它们的可读性。如果其中包含五个或五个以上连续的数字,无论是浮点还是定点,都要考虑在数字的字面量中添加下划线。对于基数为10的字面量,无论是整数还是浮点,都应该用下划线把数字隔成每三位一组,表示一千的正负倍数。
工具类通常要求客户端要用类名来修饰这些常量名,例如 PhysicalConstants.AVO-GADROS_NUMBER。如果大量利用工具类导出的常量,可以通过利用静态导入(static import)机制,避免用类名来修饰常量名:
1 | // Use of static import to avoid qualifying constants |
简而言之,接口应该只被用来定义类型,它们不应该被用来导出常量。
第23条:类层次优于标签类
有时可能会遇到带有两种甚至更多种风格的实例的类,并包含表示实例风格的标签(tag)域。例如,以下面这个类为例,它能够表示圆形或者矩形:
1 | //Tagged class-vastly inferior to a class hierarchy! |
这种标签类(tagged class)有许多缺点。它们中充斥着样板代码,包括枚举声明、标签域以及条件语句。由于多个实现乱七八糟地挤在单个类中,破坏了可读性。由于实例承担着属于其他风格的不相关的域,因此内存占用也增加了。域不能做成final的,除非构造器初始化了不相关的域,产生了更多的样板代码。构造器必须不借助编译器来设置标签域,并初始化正确的数据域:如果初始化了错误的域,程序就会在运行时失败。无法给标签类添加风格,除非可以修改它的源文件。如果一定要添加风格,就必须记得给每个条件语句都添加一个条件,否则类就会在运行时失败。最后,实例的数据类型没有提供任何关于其风格的线索。一句话,标签类过于长、容易出错,并且效率低下。
幸运的是,面向对象的语言(如Java)提供了其他更好的方法来定义能表示多种风格对象的单个数据类型:子类型化(subtyping)。标签类正是对类层次的一种简单的仿效。
为了将标签类转变成类层次,首先要为标签类中的每个方法都定义一个包含抽象方法的抽象类,标签类的行为依赖于标签值。在 Figure 类中,只有一个这样的方法:area。这个抽象类是类层次的根(root)。如果还有其他的方法其行为不依赖于标签的值,就把这样的方法放在这个类中。同样地,如果所有的方法都用到了某些数据域,就应该把它们放在这个类中。在Figure 类中,不存在这种类型独立的方法或者数据域。
接下来,为每种原始标签类都定义根类的具体子类。在前面的例子中,这样的类型有两个:圆形(circle)和矩形(rectangle)。在每个子类中都包含特定于该类型的数据域。在我们的示例中,radius 是特定于圆形的,length 和 width 是特定于矩形的。同时在每个子类中还包括针对根类中每个抽象方法的相应实现。以下是与原始的Figure类相对应的类层次:
1 | //Class hierarchy replacement for a tagged class |
这个类层次纠正了前面提到过的标签类的所有缺点。这段代码简单且清楚,不包含在原来的版本中见到的所有样板代码。每个类型的实现都配有自己的类,这些类都没有受到不相关数据域的拖累。所有的域都是final的。编译器确保每个类的构造器都初始化它的数据域,对于根类中声明的每个抽象方法都确保有一个实现。这样就杜绝了由于遗漏 switch case而导致运行时失败的可能性。多名程序员可以独立地扩展层次结构,并且不用访问根类的源代码就能相互操作。每种类型都有一种相关的独立的数据类型,允许程序员指明变量的类型,限制变量,并将参数输人到特殊的类型。
类层次的另一个好处在于,它们可以用来反映类型之间本质上的层次关系,有助于增强灵活性,并有助于更好地进行编译时类型检查。假设上述例子中的标签类也允许表达正方形。类层次可以反映出正方形是一种特殊的矩形这一事实(假设两者都是不可变的):
1 | class Square extends Rectangle { |
注意,上述层次中的域被直接访问,而不是通过访问方法访问。这是为了简洁起见,如果层次结构是公有的(详见第16条),则不允许这样做。
简而言之,标签类很少有适用的时候。当你想要编写一个包含显式标签域的类时,应该考虑一下,这个标签是否可以取消,这个类是否可以用类层次来代替。当你遇到一个包含标签域的现有类时,就要考虑将它重构到一个层次结构中去。
第24条:静态成员类优于非静态成员类
嵌套类(nested class)是指定义在另一个类的内部的类。嵌套类存在的目的应该只是为它的外围类(enclosing class)提供服务。如果嵌套类将来可能会用于其他的某个环境中,它就应该是顶层类(top-levelclass)。嵌套类有四种:静态成员类(static member class)、非静态成员类(nonstatic member class)、匿名类(anonymous class)和局部类(local class)。除了第一种之外,其他三种都称为内部类(inner class)。本条目将告诉你什么时候应该使用哪种嵌套类,以及这样做的原因。
静态成员类是最简单的一种嵌套类。最好把它看作是普通的类,只是碰巧被声明在另一个类的内部而已,它可以访问外围类的所有成员,包括那些声明为私有的成员。静态成员类是外围类的一个静态成员,与其他的静态成员一样,也遵守同样的可访问性规则。如果它被声明为私有的,它就只能在外围类的内部才可以被访问,等等。
静态成员类的一种常见用法是作为公有的辅助类,只有与它的外部类一起使用才有意义。例如,以枚举为例,它描述了计算器支持的各种操作(详见第 34条)。Operation 枚举应该是Calculator类的公有静态成员类,之后Calculator类的客户端就可以用诸如 Calculator.Operation.PLUS 和 Calculator.Operation.MINUS 这样的名称来引引用这些操作。
从语法上讲,静态成员类和非静态成员类之间唯一的区别是,静态成员类的声明中包含修饰符 static。尽管它们的语法非常相似,但是这两种嵌套类有很大的不同。非静态成员类的每个实例都隐含地与外围类的一个外围实例(enclosing instance)相关联。在非静态成员类的实例方法内部,可以调用外围实例上的方法,或者利用修饰过的 this(qualifiedthis)构造获得外围实例的引I用[JLS,15.8.4]。如果嵌套类的实例可以在它外围类的实例之外独立存在,这个嵌套类就必须是静态成员类:在没有外围实例的情况下,要想创建非静态成员类的实例是不可能的。
当非静态成员类的实例被创建的时候,它和外围实例之间的关联关系也随之被建立起来;而且,这种关联关系以后不能被修改。通常情况下,当在外围类的某个实例方法的内部调用非静态成员类的构造器时,这种关联关系被自动建立起来。使用表达式énclosing-Instance.newMemberClass(args)来手工建立这种关联关系也是有可能的,但是很少使用。正如你所预料的那样,这种关联关系需要消耗非静态成员类实例的空间,并且会增加构造的时间开销。
非静态成员类的一种常见用法是定义一个Adapter[Gamma95],它允许外部类的实例被看作是另一个不相关的类的实例。例如,Map 接口的实现往往使用非静态成员类来实现它们的集合视图(collection view),这些集合视图是由 Map 的keySet、entrySet 和values方法返回的。同样地,诸如 Set 和List这种集合接口的实现往往也使用非静态成员类来实现它们的迭代器(iterator):
1 | //Typical use of a nonstatic member class |
**如果声明成员类不要求访问外围实例,就要始终把修饰符static放在它的声明中,**使它成为静态成员类,而不是非静态成员类。如果省略了static 修饰符,则每个实例都将包含一个额外的指向外围对象的引用。如前所述,保存这份引用要消耗时间和空间,并且会导致外围实例在符合垃圾回收(详见第7条)时却仍然得以保留。由此造成的内存泄漏可能是灾难性的。但是常常难以发现,因为这个引用是不可见的。
私有静态成员类的一种常见用法是代表外围类所代表的对象的组件。以Map实例为例,它把键(key)和值(value)关联起来。许多Map 实现的内部都有一个Entry对象,对应于Map 中的每个键-值对。虽然每个entry都与一个Map关联,但是éntrY上的方法(getKeY、getValue 和 setValue)并不需要访问该Map。因此,使用非静态成员类来表示éntry是很浪费的:私有的静态成员类是最佳的选择。如果不小心漏掉了éntry声明中的 static 修饰符,该Map 仍然可以工作,但是每个 éntry中将会包含一个指向该Map 的引用,这样就浪费了空间和时间。
如果相关的类是导出类的公有或受保护的成员,毫无疑问,在静态和非静态成员类之间做出正确的选择是非常重要的。在这种情况下,该成员类就是导出的API元素,在后续的发行版本中,如果不违反向后兼容性,就无法从非静态成员类变为静态成员类。
顾名思义,匿名类是没有名字的。它不是外围类的一个成员。它并不与其他的成员一起被声明,而是在使用的同时被声明和实例化。匿名类可以出现在代码中任何允许存在表达式的地方。当且仅当匿名类出现在非静态的环境中时,它才有外围实例。但是即使它们出现在静态的环境中,也不可能拥有任何静态成员,而是拥有常数变量(constant variable),常数变量是final基本类型,或者被初始化成常量表达式[JLS,4.12.4]的字符串域。
匿名类的运用受到诸多的限制。除了在它们被声明的时候之外,是无法将它们实例化的。不能执行instanceof测试,或者做任何需要命名类的其他事情。无法声明一个匿名类来实现多个接口,或者扩展一个类,并同时扩展类和实现接口。除了从超类型中继承得到之外,匿名类的客户端无法调用任何成员。由于匿名类出现在表达式中,它们必须保持简短(大约10行或者更少),否则会影响程序的可读性。
在Java 中增加lambda(详见第6章)之前,匿名类是动态地创建小型函数对象(functionobject)和过程对象(process object)的最佳方式,但是现在会优先选择 lambda(详见第 42条)。匿名类的另一种常见用法是在静态工厂方法的内部(参见第 20条中的intArrayAsList方法)。
局部类是四种嵌套类中使用最少的类。在任何”可以声明局部变量”的地方,都可以声明局部类,并且局部类也遵守同样的作用域规则。局部类与其他三种嵌套类中的每一种都有一些共同的属性。与成员类一样,局部类有名字,可以被重复使用。与匿名类一样,只有当局部类是在非静态环境中定义的时候,才有外围实例,它们也不能包含静态成员。与匿名类一样,它们必须非常简短,以便不会影响可读性。
总而言之,共有四种不同的嵌套类,每一种都有自己的用途。如果一个嵌套类需要在单个方法之外仍然是可见的,或者它太长了,不适合放在方法内部,就应该使用成员类。如果成员类的每个实例都需要一个指向其外围实例的引用,就要把成员类做成非静态的;否则,就做成静态的。假设这个嵌套类属于一个方法的内部,如果你只需要在一个地方创建实例,并且已经有了一个预置的类型可以说明这个类的特征,就要把它做成匿名类;否则,就做成局部类。
第25条:限制源文件为单个顶级类
虽然Java 编译器允许在一个源文件中定义多个顶级类,但这么做并没有什么好处,只会带来巨大的风险。因为在一个源文件中定义多个顶级类,可能导致给一个类提供多个定义。哪一个定义会被用到,取决于源文件被传给编译器的顺序。
为了更具体地说明,下面举个例子,这个源文件中只包含一个Main类,它将引用另外两个顶级类(Utensil 和 Dessert)的成员:
1 | public class Main { |
现在假设你在一个名为 Utensil.java 的源文件中同时定义了 Utensil 和 Dessert:
1 | // Two classes defined in one file.Don't ever do this! |
当然,主程序会打印出”pancake”。现在假设你不小心在另一个名为 Dessert.java 的源文件中也定义了同样的两个类:
1 | //Twoclassesdefinedinonefile.Don'teverdothis! |
如果你侥幸是用命令javac Main.javaDessert.java 来编译程序,那么编译就会失败,此时编译器会提醒你定义了多个Utensil 和 Dessert类。这是因为编译器会先编译Main.java,当它看到Utensil的引I用(在 Dessert引用之前),就会在Utensil.javajava 时,也会去查找该文件,结果会遇到Utensil 和 Dessert这两个定义。
如果用命令 javac Main.java 或者 javac Main.java Utensil.java 编译程序,结果将如同你还没有编写 Dessert.java 文件一样,输出 pancake。但如果是用命令javacDessert.java Main.java 编译程序,就会输出 potpie。程序的行为受源文件被传给编译器的顺序影响,这显然是让人无法接受的。
这个问题的修正方法很简单,只要把顶级类(在本例中是指Utensil 和 Dessert)分别放入独立的源文件即可。如果一定要把多个顶级类放进一个源文件中,就要考虑使用静态成员类(详见第 24条),以此代替将这两个类分到独立源文件中去。如果这些类服从于另一个类,那么将它们做成静态成员类通常比较好,因为这样增强了代码的可读性,如果将这些类声明为私有的(详见第15条),还可以使它们减少被读取的概率。以下就是做成静态成员类的范例:
1 | // Static member classes instead of multiple top-level classes |
结论显而易见:永远不要把多个顶级类或者接口放在一个源文件中。遵循这个规则可以确保编译时一个类不会有多个定义。这么做反过来也能确保编译产生的类文件,以及程序结果的行为,都不会受到源文件被传给编译器时的顺序的影响。
第五章 泛型
从Java 5开始,泛型(generic)已经成了Java 编程语言的一部分。在没有泛型之前,从集合中读取到的每一个对象都必须进行转换。如果有人不小心插入了类型错误的对象,在运行时的转换处理就会出错。有了泛型之后,你可以告诉编译器每个集合中接受哪些对象类型。编译器自动为你的插人进行转换,并在编译时告知是否插入了类型错误的对象。这样可以使程序更加安全,也更加清楚,但是要享有这些优势(不限于集合)有一定的难度。本章就是教你如何最大限度地享有这些优势,又能使整个过程尽可能简单化。
第26条:请不要使用原生态类型
首先介绍一些术语。声明中具有一个或者多个类型参数(type parameter)的类或者接口,就是泛型(generic)类或者接口[JLS,8.1.2,9.1.2]。例如,List 接口就只有单个类型参数E,表示列表的元素类型。这个接口的全称是List<E>
(读作”E的列表”),但是人们经常把它简称为 List。泛型类和接口统称为泛型(generic type)。
每一种泛型定义一组参数化的类型(parameterized type),构成格式为:先是类或者接口的名称,接着用尖括号(<>)
把对应于泛型形式类型参数的实际类型参数(actual type paramter)列表 [ JLS, 4.4, 4.5 ] 括起来。 例如, List<String>
(读作 “字符串列表”)是一个参数化的类型,表示元素类型为 String 的列表。( String 是与形式的类型参数 E 相对应的实际类型参数。)
最后一点,每一种泛型都定义一个原生态类型(raw type),即不带任何实际类型参数的泛型名称[ JLS,4.8]。例如,与List<E>
相对应的原生态类型是List。原生态类型就像从类型声明中删除了所有泛型信息一样。它们的存在主要是为了与泛型出现之前的代码相兼容。
在 Java 增加泛型之前,下面这个集合声明是值得参考的。从 Java 9开始,它依然合法,但是已经没什么参考价值了:
1 | //Rawcollectiontype-don't dothis! |
如果现在使用这条声明,并且不小心将一个 coin 放进了 stamp 集合中,这一错误的插入照样得以编译和运行,不会出错(不过编译器确实会发出一条模糊的警告信息):
1 | //Erroneous insertion of coininto stamp collections |
直到从 stamp 集合中获取coin 时才会收到一条错误提示:
1 | // Raw iterator type - don't do this! |
如本书中经常提到的,出错之后应该尽快发现,最好是编译时就发现。在本例中,直到运行时才发现错误,已经出错很久了,而且它在代码中所处的位置,距离包含错误的这部分代码已经很远了。一旦发现ClassCastException,就必须搜索代码,查找将coin 放进 stamp 集合的方法调用。此时编译器帮不上忙,因为它无法理解这种注释:”Contains only Stamp instances”(只包含 Stamp 实例)。
有了泛型之后,类型声明中可以包含以下信息,而不是注释:
1 | // Parameterized collection type - typesafe |
通过这条声明,编译器知道 stamps 应该只包含 Stamp 实例,并给予保证(guarantee),假设整个代码库在编译过程中都没有发出(或者隐瞒,详见第27条)任何警告。当 stamps利用一个参数化的类型进行声明时,错误的插入会产生一条编译时的错误消息,告诉你具体是哪里出错了:
1 | Test.java:9:error:incompatible types:Coin cannot be converted |
从集合中检索元素时,编译器会替你插入隐式的转换,并确保它们不会失败(依然假设所有代码都没有产生或者隐瞒任何编译警告)。假设不小心将coin插人stamp 集合,这显得有点牵强,但这类问题却是真实的。例如,很容易想象有人会不小心将一个BigInteger实例放进一个原本只包含BigDecimal实例的集合中。
如上所述,使用原生态类型(没有类型参数的泛型)是合法的,但是永远不应该这么做。如果使用原生态类型,就失掉了泛型在安全性和描述性方面的所有优势。既然不应该使用原生态类型,为什么Java语言的设计者还要允许使用它们呢?这是为了提供兼容性。因为泛型出现的时候,Java平台即将进人它的第二个十年,已经存在大量没有使用泛型的Java代码。人们认为让所有这些代码保持合法,并且能够与使用泛型的新代码互用,这一点很重要。它必须合法才能将参数化类型的实例传递给那些被设计成使用普通类型的方法,反之亦然。这种需求被称作移植兼容性(MigrationCompatibility),促成了支持原生态类型,以及利用擦除(erasure)(详见第28条)实现泛型的决定。
虽然不应该在新代码中使用像List这样的原生态类型,使用参数化的类型以允许插人任意对象(比如List<Object>
)是可行的。原生态类型List和参数化的类型List<Object>
之间到底有什么区别呢?不严格地说,前者逃避了泛型检查,后者则明确告知编译器,它能够持有任意类型的对象。虽然可以将 List<String>
传递给类型List 的参数,但是不能将它传给类型List<Object>
的参数。泛型有子类型化(subtyping)的规则,List<String>
是原生态类型List 的一个子类型,而不是参数化类型List<Object>
的子类型(详见第28条)。因此,如果使用像List这样的原生态类型,就会失掉类型安全性,但是如果使用像List<Object>
这样的参数化类型,则不会。
为了更具体地进行说明,请参考下面的程序:
1 | // Fails at runtime - unsafeAdd method uses a raw type (List)! |
这段程序可以进行编译,但是因为它使用了原生态类型List,你会收到一条警告:
1 | Test.java:10: warning: [unchecked] unchecked call to add(E) as a member of the raw type List |
实际上,如果运行这段程序,在程序试图将 strings.get(O)
的调用结果 Integer转换成 String 时,你会收到一个ClassCastException异常。这是一个编译器生成的转换,因此一般保证会成功,但是我们在这个例子中忽略了一条编译器警告,为此付出了代价。
如果在 unsafeAdd 声明中用参数化类型List<Object>
代替原生态类型List,并试着重新编译这段程序,会发现它无法再进行编译了,并发出以下错误消息:
1 | Test.java:5: error: incompatible types: List<String> cannot be |
在不确定或者不在乎集合中的元素类型的情况下,你也许会使用原生态类型。例如,假设想要编写一个方法,它有两个集合,并从中返回它们共有元素的数量。如果你对泛型还不熟悉,可以参考以下方式来编写这种方法:
1 | //Useofraw typeforunknown elementtype-don'tdothis! |
这个方法可行,但它使用了原生态类型,这是很危险的。安全的替代做法是使用无限类型参数,就可以用一个问号代替。例如,泛型 Set<E>
的无限制通配符类型为 Set<?>numElementsInCommon
方法使用了无限制通配符类型时的情形:
1 | //Usesunboundedwildcard type-typesafeandflexible |
用了吗?这一点不需要赘述,但通配符类型是安全的,原生态类型则不安全。由于可以将任何元素放进使用原生态类型的集合中,因此很容易破坏该集合的类型约束条件(如之前范例中所示的unsafeAdd方法);但不能将任何元素(除了null之外)放到collection<?>
中。如果尝试这么做,将会产生一条像这样的编译时错误消息:
1 | WildCard.java:13: error: incompatible types: String cannot be |
这样的错误消息显然无法令人满意,但是编译器已经尽到了它的职责,防止你破坏集合的类型约束条件。你不仅无法将任何元素(除了null之外)放进Collection<?>
中,而且根本无法猜测你会得到哪种类型的对象。要是无法接受这些限制,就可以使用泛型方法(详见第30条)或者有限制的通配符类型(详见第31条)。
不要使用原生态类型,这条规则有几个小小的例外。必须在类文字(class literal)中使用原生态类型。规范不允许使用参数化类型(虽然允许数组类型和基本类型)[ JLS,15.8.2]。换句话说,List.class
、String[].class
和 int.class
都合法,但是List<String>.class
和List<?>.class
则不合法。
这条规则的第二个例外与 instanceof 操作符有关。由于泛型信息可以在运行时被擦除,因此在参数化类型而非无限制通配符类型上使用 instanceof 操作符是非法的。用无限制通配符类型代替原生态类型,对 instanceof 操作符的行为不会产生任何影响。在这种情况下,尖括号(<>)
和问号(?)
就显得多余了。下面是利用泛型来使用 instanceof操作符的首选方法:
1 | // Legitimate use of raw type instanceof operator |
注意,一旦确定这个是个 Set,就必须将它转换成通配符类型 Set<?>
,而不是转换成原生态类型 Set。这是个受检的(checked)转换,因此不会导致编译时警告。
总而言之,使用原生态类型会在运行时导致异常,因此不要使用。原生态类型只是为了与引入泛型之前的遗留代码进行兼容和互用而提供的。让我们做个快速的回顾:Set<Object>
是个参数化类型,表示可以包含任何对象类型的一个集合;Set<?>
则是一个通配符类型,表示只能包含某种未知对象类型的一个集合;Set是一个原生态类型,它脱离了泛型系统。前两种是安全的,最后一种不安全。
为便于参考,在下表中概括了本条目中所介绍的术语(及本章后续条目中将要介绍的一些术语):
第27条:消除非受检的警告
用泛型编程时会遇到许多编译器警告:非受检转换警告(unchecked cast warning)、非受检方法调用警告、非受检参数化可变参数类型警告(unchecked parameterized vararg type warning),以及非受检转换警告(unchecked conversion warning)。 当你越来越熟悉泛型之后,遇到的警告也会越来越少,但是不要期待一开始用泛型编写代码就可以正确地进行编译。
有许多非受检警告很容易消除。例如,假设意外地编写了这样一个声明:
1 | Set<Lark> exaltation = new HashSet(); |
编译器会细致地提醒你哪里出错了:
1 | Venery.java:4: warning: [unchecked] unchecked conversion |
你就可以纠正所显示的错误,消除警告。注意,不必真正去指定类型参数,只需要用在Java7中开始引入的菱形操作符(diamondoperator)(<>)
将它括起来即可。随后编译器就会推测出正确的实际类型参数(在本例中是Lark):
1 | Set<Lark> exaltation = new HashSet<>(); |
有些警告非常难以消除。本章主要介绍这种警告示例。当你遇到需要进行一番思考的警告时,要坚持住!要尽可能地消除每一个非受检警告。如果消除了所有警告,就可以确保代码是类型安全的,这是一件很好的事情。这意味着不会在运行时出现Class-Cast-Exception 异常,你会更加自信自己的程序可以实现预期的功能。
如果无法消除警告,同时可以证明引起警告的代码是类型安全的,(只有在这种情况下)才可以用一个@SuppressWarnings(“unchecked”)注解来禁止这条警告。如果在禁止警告之前没有先证实代码是类型安全的,那就只是给你自己一种错误的安全感而已。代码在编译的时候可能没有出现任何警告,但它在运行时仍然会抛出 ClassCastException异常。但是如果忽略(而不是禁止)明知道是安全的非受检警告,那么当新出现一条真正有问题的警告时,你也不会注意到。新出现的警告就会淹没在所有的错误警告声当中。
SuppressWarnings 注解可以用在任何粒度的级别中,从单独的局部变量声明到整个类都可以。应该始终在尽可能小的范围内使用SuppressWarnings 注解。它通常是个变量声明,或是非常简短的方法或构造器。永远不要在整个类上使用 SuppressWarnings,这么做可能会掩盖重要的警告。
如果你发现自已在长度不止一行的方法或者构造器中使用了 SuppressWarnings 注解,可以将它移到一个局部变量的声明中。虽然你必须声明一个新的局部变量,不过这么做还是值得的。例如,看看 ArrayList 类当中的toArray方法:
1 | public <T>T[]toArray(T[]a){ |
如果编译ArrayList,该方法就会产生成这条警告:
1 | ArrayList.java:305: warning: [unchecked] unchecked cast |
将 SuppressWarnings 注解放在 return 语句中是合法的,因为它不是声明[ JLS,9.7]。你可以试着将注解放在整个方法上,但是在实践中千万不要这么做,而是应该声明一个局部变量来保存返回值,并注解其声明,像这样:
1 | // Adding local variable to reduce scope of @SuppressWarnings |
这个方法可以正确地编译,禁止非受检警告的范围也会减到最小。
每当使用 SuppressWarnings("unchecked")
注解时,都要添加一条注释,说明为什么这么做是安全的。这样可以帮助其他人理解代码,更重要的是,可以尽量减少其他人修改代码后导致计算不安全的概率。如果你觉得这种注释很难编写,就要多加思考。最终你会发现非受检操作是非常不安全的。
总而言之,非受检警告很重要,不要忽略它们。每一条警告都表示可能在运行时抛出ClassCastException 异常。要尽最大的努力消除这些警告。如果无法消除非受检警告,同时可以证明引起警告的代码是类型安全的,就可以在尽可能小的范围内使用@Suppress-
第28条:列表优于数组
数组与泛型相比,有两个重要的不同点。首先,数组是协变的(covariant)。这个词听起来有点吓人,其实只是表示如果 Sub 为 Super 的子类型,那么数组类型 Sub[]就是Super[]的子类型。相反,泛型则是可变的(invariant):对于任意两个不同的类型Type1类型[JLS,4.10;Naftalin07,2.5]
。你可能认为,这意味着泛型是有缺陷的,但实际上可以说数组才是有缺陷的。下面的代码片段是合法的:
1 | // Fails at runtime! |
但下面这段代码则不合法:
1 | //Won'tcompile! |
这其中无论哪一种方法,都不能将 String放进Long容器中,但是利用数组,你会在运行时才发现所犯的错误;而利用列表,则可以在编译时就发现错误。 我们当然希望在编译时就发现错误。
数组与泛型之间的第二大区别在于,数组是具体化的(reified)[JLS,4.7]。因此数组会在运行时知道和强化它们的元素类型。如上所述,如果企图将 String 保存到Long数组中,就会得到一个ArrayStoreException异常。相比之下,泛型则是通过擦除(erasure)[JLS,4.6]来实现的。这意味着,泛型只在编译时强化它们的类型信息,并在运行时丢弃(或者擦除)它们的元素类型信息。擦除就是使泛型可以与没有使用泛型的代码随意进行互用(详见第 26 条),以确保在Java5中平滑过渡到泛型。
由于上述这些根本的区别,因此数组和泛型不能很好地混合使用。例如,创建泛型、参数化类型或者类型参数的数组是非法的。这些数组创建表达式没有一个是合法的:newList<E>[]、new List<String>[] 和 new E[]
。这些在编译时都会导致一个泛型数组创建(generic array creation)错误。
为什么创建泛型数组是非法的?因为它不是类型安全的。要是它合法,编译器在其他正确的程序中发生的转换就会在运行时失败,并出现一个ClassCastException 异常。这就违背了泛型系统提供的基本保证,
为了更具体地对此进行说明,以下面的代码片段为例:
1 | // Why generic array creation is illegal - won't compile! |
我们假设第1行是合法的,它创建了一个泛型数组。 第2行创建并初始化了一个包含单个元素的List<Integer>
。第3行将List<String>
数组保存到一个Object 数组变量中,这是合法的,因为数组是协变的。第 4行将 List<Integer>
保存到 Object 数组里唯一的元素中,这是可以的,因为泛型是通过擦除实现的:List<Integer>
实例的运行时类型只是List,List<String>[]
实例的运行时类型则是List[]
,因此这种安排不会产生 ArrayStoreException异常。但现在我们有麻烦了。我们将一个List<Integer>
实例保存到了原本声明只包含List<String>
实例的数组中。在第5行中,我们从这个数组里唯一的列表中获取了唯一的元素。编译器自动地将获取到的元素转换成 String,但它是一个Integer,因此,我们在运行时得到了一个ClassCastException 异常。为了防止出现这种情况,(创建泛型数组的)第1行必须产生一条编译时错误。
从技术的角度来说,像E、List<E>
和List<String>
这样的类型应称作不可具体化的(nonreifiable)类型[JLS,4.7]。直观地说,不可具体化的(non-reifiable)类型是指其运行时表示法包含的信息比它的编译时表示法包含的信息更少的类型。唯一可具体化的(reifiable)参数化类型是无限制的通配符类型,如List<?>
和Map<?,?>
(详见第 26条)。虽然不常用,但是创建无限制通配类型的数组是合法的。
禁止创建泛型数组可能有点讨厌。例如,这表明泛型一般不可能返回它的元素类型数组(部分解决方案请见第33条)。这也意味着在结合使用可变参数(varargs)方法(详见第53条)和泛型时会出现令人费解的警告。这是由于每当调用可变参数方法时,就会创建一个数组来存放varargs 参数。如果这个数组的元素类型不是可具体化的(reifialbe),就会得到一条警告。利用 SafeVarargs 注解可以解决这个问题(详见第 32 条)。
当你得到泛型数组创建错误时,最好的解决办法通常是优先使用集合类型List<E>
,而不是数组类型E[]
。这样可能会损失一些性能或者简洁性,但是换回的却是更高的类型安全性和互用性。
例如,假设要通过构造器编写一个带有集合的Chooser类和一个方法,并用该方法返回在集合中随机选择的一个元素。根据传给构造器的集合类型,可以用chooser充当游戏用的色子、魔术8球(一种卡片棋牌类游戏),或者一个蒙特卡罗模拟的数据源。下面是一个没有使用泛型的简单实现:
1 | //Chooser-a class badly in need of generics! |
要使用这个类,必须将 choose 方法的返回值,从 Object 转换成每次调用该方法时想要的类型,如果搞错类型,转换就会在运行时失败。牢记第29条的建议,努力将Chooser 修改成泛型,修改部分如粗体所示:
1 | //A first cut at making Chooser generic- won't compile |
如果试着编译这个类,将会得到以下错误消息:
1 | Chooser.java:9: error: incompatible types: Object[] cannot be |
你可能会说:这没什么大不了的,我可以把 Object 数组转换成数组:
1 | choiceArray = (T[]) choices.toArray(); |
这样做的确消除了错误消息,但是现在得到了一条警告:
1 | Chooser.java:9: warning: [unchecked] unchecked cast |
编译器告诉你,它无法在运行时检查转换的安全性,因为程序在运行时还不知道T是什么一记住,元素类型信息会在运行时从泛型中被擦除。这段程序可以运行吗?可以,但是编译器无法证明这一点。你可以亲自证明,只要将证据放在注释中,用一条注解禁止警告,但是最好能消除造成警告的根源(详见第 27条)。
要消除未受检的转换警告,必须选择用列表代替数组。下面是编译时没有出错或者警告的Chooser类版本:
1 | // List-based Chooser - typesafe |
这个版本的代码稍微冗长一点,运行速度可能也会慢一点,但是在运行时不会得到ClassCastException 异常,为此也值了。
总而言之,数组和泛型有着截然不同的类型规则。数组是协变且可以具体化的;泛型是不可变的且可以被擦除的。因此,数组提供了运行时的类型安全,但是没有编译时的类型安全,反之,对于泛型也一样。一般来说,数组和泛型不能很好地混合使用。如果你发现自已将它们混合起来使用,并且得到了编译时错误或者警告,你的第一反应就应该是用列表代替数组。
第29条:优先考虑泛型
一般来说,将集合声明参数化,以及使用JDK所提供的泛型方法,这些都不太困难。编写自己的泛型会比较困难一些,但是值得花些时间去学习如何编写。
以第7条中简单的(玩具)堆栈实现为例:
1 | // Object-based collection - a prime candidate for generics |
这个类应该先被参数化,但是它没有,我们可以在后面将它泛型化(generify)。换句话说,可以将它参数化,而又不破坏原来非参数化版本的客户端代码。也就是说,客户端必须转换从堆栈里弹出的对象,以及可能在运行时失败的那些转换。将类泛型化的第一步是在它的声明中添加一个或者多个类型参数。在这个例子中有一个类型参数,它表示堆栈的元素类型,这个参数的名称通常为E(详见第68条)。
下一步是用相应的类型参数替换所有的 Object类型,然后试着编译最终的程序:
1 | //Initialattempt togenerifyStack-won'tcompile! |
通常,你将至少得到一个错误提示或警告,这个类也不例外。幸运的是,这个类只产生一个错误,内容如下:
1 | Stack.java:8: generic array creation |
如第 28 条中所述,你不能创建不可具体化的(non-reifiable)类型的数组,如E。每当编写用数组支持的泛型时,都会出现这个问题。解决这个问题有两种方法。第一种,直接绕过创建泛型数组的禁令:创建一个Objéct的数组,并将它转换成泛型数组类型。现在错误是消除了,但是编译器会产生一条警告。这种用法是合法的,但(整体上而言)不是类型安全的:
1 | Stack.java:8: warning: [unchecked] unchecked cast |
编译器不可能证明你的程序是类型安全的,但是你可以。你自己必须确保未受检的转换不会危及程序的类型安全性。相关的数组(即elements 变量)保存在一个私有的域中,永远不会被返回到客户端,或者传给任何其他方法。这个数组中保存的唯一元素,是传给push 方法的那些元素,它们的类型为E,因此未受检的转换不会有任何危害。
一旦你证明了未受检的转换是安全的,就要在尽可能小的范围中禁止警告(详见第27条)。在这种情况下,构造器只包含未受检的数组创建,因此可以在整个构造器中禁止这条警告。通过增加一条注解 @SuppressWarnings
来完成禁止,Stack 能够正确无误地进行编译,你就可以使用它了,无须显式的转换,也无须担心会出现ClassCastException 异常:
1 | // The elements array will contain only E instances from push(E). |
消除 Stack 中泛型数组创建错误的第二种方法是,将 elements 域的类型从 E[]
改为 Object[]
。这么做会得到一条不同的错误:
1 | Stack.java:19: incompatible types |
通过把从数组中获取到的元素由Object转换成E,可以将这条错误变成一条警告:
1 | Stack.java:19: warning:[unchecked] unchecked cast |
由于E是一个不可具体化的(non-reifiable)类型,编译器无法在运行时检验转换。你还是可以自己证实未受检的转换是安全的,因此可以禁止该警告。根据第 27条的建议,我们只要在包含未受检转换的任务上禁止警告,而不是在整个pop方法上禁止就可以了,方法如下:
1 | //Appropriate suppression ofuncheckedwarning |
这两种消除泛型数组创建的方法,各有所长。第一种方法的可读性更强:数组被声明为E[]类型清楚地表明它只包含E实例。它也更加简洁:在一个典型的泛型类中,可以在代码中的多个地方读取到该数组;第一种方法只需要转换一次(创建数组的时候),而第二种方法则是每次读取一个数组元素时都需要转换一次。因此,第一种方法优先,在实践中也更常用。但是,它会导致堆污染(heap pollution),详见第 32 条:数组的运行时类型与它的编译时类型不匹配(除非E正好是Object)。这使得有些程序员会觉得很不舒服,因而选择第二种方案,虽然堆污染在这种情况下并没有什么危害。
下面的程序示范了泛型 Stack类的使用方法。程序以倒序的方式打印出它的命令行参数,并转换成大写字母。如果要在从堆栈中弹出的元素上调用 String 的toUpperCase方法,并不需要显式的转换,并且确保自动生成的转换会成功:
1 | // Little program to exercise our generic Stack |
看来上述的示例与第 28 条相矛盾了,第 28 条鼓励优先使用列表而非数组。实际上不可能总是或者总想在泛型中使用列表。Java 并不是生来就支持列表,因此有些泛型如 ArrayList,必须在数组上实现。为了提升性能,其他泛型如 HashMap 也在数组上实现。
绝大多数泛型就像我们的 Stack示例一样,因为它们的类型参数没有限制:你可以创建 Stack<Object>、Stack<int[]>、Stack<List<String>>
,或者任何其他对象引用类型的 Stack。注意不能创建基本类型的 Stack:企图创建 Stack<int>
或者 Stack<double>
会产生一个编译时错误。这是Java 泛型系统的一个基本局限性。你可以通过使用基本包装类型(boxed primitive type)来避开这条限制(详见第61条)。
有一些泛型限制了可允许的类型参数值。例如,以java.util.concurrent.Delay-Queue 为例,其声明内容如下:
1 | class DelayQueue<E extends Delayed>implements BlockingQueue<E> |
类型参数列表(<EextendsDelayed>)
要求实际的类型参数E 必须是java.util.con-的元素上利用 Delayed 方法,无须显式的转换,也没有出现ClassCastException 的风险。类型参数E被称作有限制的类型参数(bounded type parameter)。注意,子类型关系确定了,每个类型都是它自身的子类型[JLS,4.10],因此创建 DelayQueue<Delayed>
是合法的。
总而言之,使用泛型比使用需要在客户端代码中进行转换的类型来得更加安全,也更加容易。在设计新类型的时候,要确保它们不需要这种转换就可以使用。这通常意味着要把类做成是泛型的。只要时间允许,就把现有的类型都泛型化。这对于这些类型的新用户来说会变得更加轻松,又不会破坏现有的客户端(详见第26条)。
第30条:优先考虑泛型方法
正如类可以从泛型中受益一般,方法也一样。静态工具方法尤其适合于泛型化。Co1le-
编写泛型方法与编写泛型类型相类似。例如下面这个方法,它返回两个集合的联合:
1 | //Uses raw types-unacceptable! (Item 26) |
这个方法可以编译,但是有两条警告:
1 | Union.java:5: warning: [unchecked] unchecked call to |
为了修正这些警告,使方法变成是类型安全的,要将方法声明修改为声明一个类型参数(type parameter),表示这三个集合的元素类型(两个参数和一个返回值),并在方法中使用类型参数。声明类型参数的类型参数列表,处在方法的修饰符及其返回值之间。在这个示例中,类型参数列表为<E>
,返回类型为 Set<E>
。类型参数的命名惯例与泛型方法以及泛型的相同(详见第 29条和第68条):
1 | //Genericmethod |
至少对于简单的泛型方法而言,就是这么回事了。现在该方法编译时不会产生任何警告,并提供了类型安全性,也更容易使用。以下是一个执行该方法的简单程序。程序中不包含转换,编译时不会有错误或者警告:
1 | //Simpleprogramtoexercisegenericmethod |
运行这段程序时,会打印出[Moe,Harry,Tom,Curly,Larry,Dick]。(元素的输出顺序是独立于实现的。)
union 方法的局限性在于三个集合的类型(两个输入参数和一个返回值)必须完全相同。利用有限制的通配符类型(bounded wildcard type)可以使方法变得更加灵活(详见第 31条)。
有时可能需要创建一个不可变但又适用于许多不同类型的对象。由于泛型是通过擦除(详见第28条)实现的,可以给所有必要的类型参数使用单个对象,但是需要编写一个静态工厂方法,让它重复地给每个必要的类型参数分发对象。这种模式称作泛型单例工厂(generic singleton factory),常用于函数对象(详见第 42 条),如Collections.reverse-Order,有时也用于像 Collections.emptySet 这样的集合。
假设要编写一个恒等函数(identity function)分发器。类库中提供了Function.identity,因此不需要自己编写(详见第59条),但是自己编写也很有意义。如果在每次需要的时候都重新创建一个,这样会很浪费,因为它是无状态的(stateless)。如果 Java 泛型被具体化了,每个类型都需要一个恒等函数,但是它们被擦除后,就只需要一个泛型单例。请看以下示例:
1 | // Generic singleton factory pattern |
IDENTITY_FN 转换成(UnaryFunction<T>)
,产生了一条未受检的转换警告,因为UnaryFunction<Object>
对于每个T来说并非都是个UnaryFunction<T>
。但是恒等函数很特殊:它返回未被修改的参数,因此我们知道无论T的值是什么,用它作为 Unary-Function<T>
都是类型安全的。因此,我们可以放心地禁止由这个转换所产生的未受检转换警告。一旦禁止,代码在编译时就不会出现任何错误或者警告。
下面是一个范例程序,它利用泛型单例作为 UnaryFunction<String>
和Unary-Function<Number>
。像往常一样,它不包含转换,编译时没有出现错误或者警告:
1 | //Sampleprogramto exercisegenericsingleton |
虽然相对少见,但是通过某个包含该类型参数本身的表达式来限制类型参数是允许的。这就是递归类型限制(recursive type bound)。递归类型限制最普遍的用途与Comparable接口有关,它定义类型的自然顺序(详见第14条)。这个接口的内容如下:
1 | public interface Comparable<T> { |
类型参数 T定义的类型,可以与实现 Comparable<T>
的类型的元素进行比较。实际上,几乎所有的类型都只能与它们自身的类型的元素相比较。例如 String 实现Comparable<String>,Integer
实现 Comparable<Integer>
等等。
有许多方法都带有一个实现Comparable 接口的元素列表,为了对列表进行排序,并在其中进行搜索,计算出它的最小值或者最大值,等等。要完成这其中的任何一项操作,都要求列表中的每个元素能够与列表中的每个其他元素相比较,换句话说,列表的元素可以互相比较(mutually comparable)。下面是如何表达这种约束条件的一个示例:
1 | //Using arecursive typebound to express mutualcomparability |
类型限制<E extends Comparable<?>>
,可以读作”针对可以与自身进行比较的每个类型E”,这与互比性的概念或多或少有些一致。
下面的方法就带有上述声明。它根据元素的自然顺序计算列表的最大值,编译时没有出现错误或者警告:
1 | // Returns max value ina collection-uses recursive type bound |
注意,如果列表为空,这个方法就会抛出 IllegalArgumentException 异常。更好的替代做法是返回一个Optional<E>
(详见第55条)。
递归类型限制可能比这个要复杂得多,但幸运的是,这种情况并不经常发生。如果你理解了这种习惯用法和它的通配符变量(详见第31 条),以及模拟自类型(simulated self-type)习惯用法(详见第 2条),就能够处理在实践中遇到的许多递归类型限制了。
总而言之,泛型方法就像泛型一样,使用起来比要求客户端转换输人参数并返回值的方法来得更加安全,也更加容易。就像类型一样,你应该确保方法不用转换就能使用,这通常意味着要将它们泛型化。并且就像类型一样,还应该将现有的方法泛型化,使新用户使用起来更加轻松,且不会破坏现有的客户端(详见第26条)。
第31条:利用有限制通配符来提升API的灵活性
如第 28 条所述,参数化类型是不变的(invariant)。换句话说,对于任何两个截然不同的类虽然List<String>
不是List<Object>
的子类型,这与直觉相悖,但是实际上很有意义。你可以将任何对象放进一个List<Object>
中,却只能将字符串放进List<String>
中。由于List<String>
不能像List<Object>
能做任何事情,它不是一个子类型(详见第10条)。
有时候,我们需要的灵活性要比不变类型所能提供的更多。比如第 29条中的堆栈。提醒一下,下面就是它的公共API:
1 | public class Stack<E>{ |
假设我们想要增加一个方法,让它按顺序将一系列的元素全部放到堆栈中。第一次尝试如下:
1 | // pushAll method without wildcard type - deficient! |
这个方法编译时正确无误,但是并非尽如人意。如果 Iterable 的 src 元素类型与堆栈的完全匹配,就没有问题。但是假如有一个Stack<Number>
,并且调用了push(intVal),这里的intVal就是Integer类型。这是可以的,因为 Integer是Number 的一个子类型。因此从逻辑上来说,下面这个方法应该可行:
1 | Stack<Number> numberStack = new Stack<>(); |
但是,如果尝试这么做,就会得到下面的错误消息,因为参数化类型是不可变的:
1 | StackTest.java:7: error: incompatible types: Iterable<Integer> |
幸运的是,有一种解决办法。Java提供了一种特殊的参数化类型,称作有限制的通配符类型(bounded wildcard type),它可以处理类似的情况。pushAll 的输人参数类型不应该,Iterable<?extends E>
正是这个意思。(使用关键字 extends 有些误导:回忆一下第29条中的说法,确定了子类型(subtype)后,每个类型便都是自身的子类型,即便它没有将自身扩展。)我们修改一下pushAll来使用这个类型:
1 | //wildcard type for a parameter that serves as an E producer |
修改之后,不仅 Stack 可以正确无误地编译,没有通过初始的 pushAll 声明进行编译的客户端代码也一样可以。因为 Stack及其客户端正确无误地进行了编译,你就知道一切都是类型安全的了。
现在假设想要编写一个 pushAll 方法,使之与 popAll 方法相呼应。popAll方法从堆栈中弹出每个元素,并将这些元素添加到指定的集合中。初次尝试编写的 popAll方法可能像下面这样:
1 | //popAll method without wildcard type-deficient! |
此外,如果目标集合的元素类型与堆栈的完全匹配,这段代码编译时还是会正确无误,并且运行良好。但是,也并不意味着尽如人意。假设你有一个 Stack<Number>和Object
类型的变量。如果从堆栈中弹出一个元素,并将它保存在该变量中,它的编译和运行都不会出错,那你为何不能也这么做呢?
1 | Stack<Number> numberStack = new Stack<Number>(); |
如果试着用上述的 popA1l版本编译这段客户端代码,就会得到一个非常类似于第一次用pushAll时所得到的错误:Collection<Object>
不是Collection<Number>
的子类型。这一次通配符类型同样提供了一种解决办法。POpA11的输人参数类型不应该为”E的集合”,而应该为”E的某种超类的集合”(这里的超类是确定的,因此E是它自身的一个超类型[JLS,4.10])。仍有一个通配符类型正符合此意:Collection<?superE>
。让我们修改 popAll来使用它:
1 | //Wildcard type for parameter that serves as an E consumer |
做了这个变动之后,Stack 和客户端代码就都可以正确无误地编译了。
结论很明显:为了获得最大限度的灵活性,要在表示生产者或者消费者的输入参数上使用通配符类型。如果某个输入参数既是生产者,又是消费者,那么通配符类型对你就没有什么好处了:因为你需要的是严格的类型匹配,这是不用任何通配符而得到的。
下面的助记符便于让你记住要使用哪种通配符类型:
PECS 表示 producer-extends,consumer-super。
换句话说,如果参数化类型表示一个生产者T,就使用<?extends T>
;如果它表示一个消费者T,就使用<?super T>
。在我们的 Stack 示例中,pushAll 的 src 参数产生 E实例供 Stack使用,因此 src 相应的类型为Iterable<? extends E>;popAll
的 dst 参数通过 Stack 消费E 实例,因此 dst 相应的类型为 Collection<? super E>。PECS
这个助记符突出了使用通配符类型的基本原则。Naftalin 和 Wadler 称之为 Get and PutPrinciple [Naftalin07, 2.4]。
记住这个助记符,下面我们来看一些之前的条目中提到过的方法声明。第 28条中的reduce方法就有这条声明:
1 | public Chooser(Collection<T> choices) |
这个构造器只用 choices 集合来生成类型T的值(并把它们保存起来供后续使用),因此它的声明应该使用一个éxtends T的通配符类型。得到的构造器声明如下:
1 | // Wildcard type for parameter that serves as an T producer |
这一变化实际上有什么区别吗?事实上,的确有区别。假设你有一个List<Integer>
,想通过Function<Number>
把它简化。它不能通过初始声明进行编译,但是一旦添加了有限制的通配符类型,就可以进行编译了。
现在让我们看看第 30 条中的 union 方法。声明如下:
1 | public static<E> Set<E>union(Set<E> sl,Set<E> s2) |
s1 和 s2 这两个参数都是生产者E,因此根据 PECS 助记符,这个声明应该是:
1 | public static <E> Set<E> union(Set<? extends E> sl,Set<? extends E> s2) |
注意返回类型仍然是 Set<E>
。不要用通配符类型作为返回类型。除了为用户提供额外的灵活性之外,它还会强制用户在客户端代码中使用通配符类型。修改了声明之后,这段代码就能正确编译了:
1 | Set<Integer> integers = Set.of(1,3,5); |
如果使用得当,通配符类型对于类的用户来说几乎是无形的。它们使方法能够接受它们应该接受的参数,并拒绝那些应该拒绝的参数。如果类的用户必须考虑通配符类型,类的API或许就会出错。
在Java8之前,类型推导(type inference)规则还不够智能,它无法处理上述代码片段,还需要编译器使用通过上下文指定的返回类型(或者目标类型)来推断E的类型。前面出现过的 union 调用的目标类型是 Set<Number>
。如果试着在较早的 Java 版本中编译这个代码片段(使用 Set.of 工厂相应的替代方法),将会得到一条像下面这样冗长、繁复的错误消息:
1 | Union.java:14: error: incompatible types |
幸运的是,有一种办法可以处理这种错误。如果编译器不能推断出正确的类型,始终可以通过一个显式的类型参数(explicit type parameter)[JLS,15.12]来告诉它要使用哪种类型。甚至在Java 8中引人目标类型之前,这种情况不经常发生,这是好事,因为显式的类型参数不太优雅。增加了这个显式的类型参数之后,这个代码片段在Java 8之前的版本中也能正确无误地进行编译了:
1 | // Explicit type parameter - required prior to Java 8 |
接下来,我们把注意力转向第 30条中的 max方法。以下是初始的声明:
1 | public static <T extends Comparable<T>> T max(List<T> list) |
下面是修改过的使用通配符类型的声明:
1 | public static <T extends Comparable<? super T>> T max(List<? extends T> list) |
为了从初始声明中得到修改后的版本,要应用PECS转换两次。最直接的是运用到参数list。它产生 T实例,因此将类型从 List<T>
改成List<?extends T>
。更灵活的是运用到类型参数T。这是我们第一次见到将通配符运用到类型参数。最初T被指定用来扩展Comparable<T>
,但是 T的 comparable 消费T实例(并产生表示顺序关系的整值)。因comparable 始终是消费者,因此使用时始终应该是 Comparable<? super T>
优先于Comparable<T>
。对于comparator 接口也一样,因此使用时始终应该是Comparator<?super T>
优先于 Comparator<T>
。
修改过的 max声明可能是整本书中最复杂的方法声明了。所增加的复杂代码真的起作用了么?是的,起作用了。下面是一个简单的列表示例,在初始的声明中不允许这样,修改过的版本则可以:
1 | List<ScheduledFuture<?>> scheduledFutures = ...; |
不能将初始方法声明运用到这个列表的原因在于,java.util.concurrent.Scheduled-Future 没有实现Comparable<ScheduledFuture>
接口。相反,它是扩展 Comparable<Delayed>
接口的 Delayed接口的子接口。换句话说,ScheduleFuture实例并非只能与其他 ScheduledFuture实例相比较;它可以与任何 Delayed实例相比较,这就足以导致初始声明时就会被拒绝。更通俗地说,需要用通配符支持那些不直接实现Comparable(或者Comparator)而是扩展实现了该接口的类型。
还有一个与通配符有关的话题值得探讨。类型参数和通配符之间具有双重性,许多方法都可以利用其中一个或者另一个进行声明。例如,下面是可能的两种静态方法声明,来交换列表中的两个被索引的项目。第一个使用无限制的类型参数(详见第30条),第二个使用无限制的通配符:
1 | //Twopossibledeclarationsfortheswapmethod |
你更喜欢这两种声明中的哪一种呢?为什么?在公共API中,第二种更好一些,因为它更简单。将它传到一个列表中(任何列表)方法就会交换被索引的元素。不用担心类型参数。一般来说,如果类型参数只在方法声明中出现一次,就可以用通配符取代它。如果是无限制的类型参数,就用无限制的通配符取代它;如果是有限制的类型参数,就用有限制的通配符取代它。
将第二种声明用于swap方法会有一个问题。下面这个简单的实现不能编译:
1 | public static void swap(List<?> list,int i,int j) { |
试着编译时会产生这条没有什么用处的错误消息:
1 | Swap.java:5: error: incompatible types: Object cannot be |
不能将元素放回到刚刚从中取出的列表中,这似乎不太对劲。问题在于List的类型为List<?>
,你不能把 null 之外的任何值放到List<?>
中。幸运的是,有一种方式可以实现这个方法,无须求助于不安全的转换或者原生态类型(raw type)。这种想法就是编写一个私有的辅助方法来捕捉通配符类型。为了捕捉类型,辅助方法必须是一个泛型方法,像下面这样:
1 | public static void swap(List<?> list,int i,int j){ |
swapHelper方法知道 list是一个List<E>
。因此,它知道从这个列表中取出的任何值均为 E 类型,并且知道将 E类型的任何值放进列表都是安全的。Swap 这个有些费解的实现编译起来却是正确无误的。它允许我们导出 swap这个比较好的基于通配符的声明,同时在内部利用更加复杂的泛型方法。Swap方法的客户端不一定要面对更加复杂的swapHelper声明,但是它们的确从中受益。值得一提的是,辅助方法中拥有的签名,正是我们在公有方法中因为它过于复杂而抛弃的。
总而言之,在API中使用通配符类型虽然比较需要技巧,但是会使API变得灵活得多。如果编写的是将被广泛使用的类库,则一定要适当地利用通配符类型。记住基本的原则:producer-extends,consumer-super(PECS)。还要记住所有的 comparable 和comparator都是消费者。
第32条:谨慎并用泛型和可变参数
可变参数(vararg)方法(详见第53条)和泛型都是在Java5中就有了,因此你可能会期待它们可以良好地相互作用;遗憾的是,它们不能。可变参数的作用在于让客户端能够将可变数量的参数传给方法,但这是个技术露底(leaky abstration):当调用一个可变参数方法时,会创建一个数组用来存放可变参数;这个数组应该是一个实现细节,它是可见的。因此,当可变参数有泛型或者参数化类型时,编译警告信息就会产生混乱。
回顾一下第28条,非具体化(non-reifiable)类型是指其运行时代码信息比编译时少,并且显然所有的泛型和参数类型都是非具体化的。如果一个方法声明其可变参数为non-reifiable类型,编译器就会在声明中产生一条警告。如果方法是在类型为non-reifiable的可变参数上调用,编译器也会在调用时发出一条警告信息。这个警告信息类似于:
1 | warning: [unchecked] Possible heap pollution from |
当一个参数化类型的变量指向一个不是该类型的对象时,会产生堆污染(heap pollution)[JLS,4.12.2]。它导致编辑器的自动生成转换失败,破坏了泛型系统的基本保证。
举个例子。下面的代码是对第28条中的代码片段稍加修改而得:
1 | //Mixing generics andvarargscanviolate type safety! |
这个方法没有可见的转换,但是在调用一个或者多个参数时会抛出 ClassCast Except工on 异常。 上述最后一行代码中有一个不可见的转换,这是由编译器生成的。这个转换失败证明类型安全已经受到了危及,因此将值保存在泛型可变参数数组参数中是不安全的。
这个例子引出了一个有趣的问题:为什么显式创建泛型数组是非法的,用泛型可变参数声明方法却是合法的呢?换句话说,为什么之前展示的方法只产生一条警告,而第28条中的代码片段却产生一个错误呢?答案在于,带有泛型可变参数或者参数化类型的方法在实践中用处很大,因此Java语言的设计者选择容忍这一矛盾的存在。事实上,Java类库导出了好几个这样的方法,包括Arrays.asList(T...a)、Collections.addAll(Collection<? super T> C, T...elements), 以及EnumSet.of(E first,E.·.rest)
。与前面提到的危险方法不一样,这些类库方法是类型安全的。
在Java 7之前,带泛型可变参数的方法的设计者,对于在调用处出错的警告信息一点办法也没有。这使得这些API使用起来非常不愉快。用户必须忍受这些警告,要么最好在每处调用点都通过@SuppressWarnings("unchecked")
注解来消除警告(详见第27条)。这么做过于烦琐,而且影响可读性,并且掩盖了反映实际问题的警告。
在Java 7中,增加了 SafeVarargs 注解,它让带泛型 vararg 参数的方法的设计者能够自动禁止客户端的警告。本质上,SafeVarargs 注解是通过方法的设计者做出承诺,声明这是类型安全的。作为对于该承诺的交换,编译器同意不再向该方法的用户发出警告说这些调用可能不安全。
重要的是,不要随意用@SafeVarargs 对方法进行注解,除非它真正是安全的。那么它凭什么确保安全呢?回顾一下,泛型数组是在调用方法的时候创建的,用来保存可变参数。如果该方法没有在数组中保存任何值,也不允许对数组的引用转义(这可能导致不被信任的代码访问数组),那么它就是安全的。换句话说,如果可变参数数组只用来将数量可变的参数从调用程序传到方法(毕竟这才是可变参数的目的),那么该方法就是安全的。
值得注意的是,从来不在可变参数的数组中保存任何值,这可能破坏类型安全性。以下面的泛型可变参数方法为例,它返回了一个包含其参数的数组。乍看之下,这似乎是一个方便的小工具:
1 | //UNSAFE - Exposes a reference to its generic parameter array! |
这个方法只是返回其可变参数数组,看起来没什么危险,但它实际上很危险!这个数组的类型,是由传到方法的参数的编译时类型来决定的,编译器没有足够的信息去做准确的决定。因为该方法返回其可变参数数组,它会将堆污染传到调用堆栈上。
下面举个具体的例子。这是一个泛型方法,它带有三个类型为T的参数,并返回一个包含两个(随机选择的)参数的数组:
1 | static <T> T[] pickTwo(T a,T b,T c){ |
这个方法本身并没有危险,也不会产生警告,除非它调用了带有泛型可变参数的toArray方法。
在编译这个方法时,编译器会产生代码,创建一个可变参数数组,并将两个T实例传到toArray。这些代码配置了一个类型为 Object[]的数组,这是确保能够保存这些实例的最具体的类型,无论在调用时给 pickTwo传递什么类型的对象都没问题。toArray方法只是将这个数组返回给 pickTwo,反过来也将它返回给其调用程序,因此 pickTwo 始终都会返回一个类型为 Object[] 的数组。
现在以下面的 main 方法为例,练习一下 pickTwo 的用法:
1 | public static void main(String[] args) { |
这个方法压根没有任何问题,因此编译时不会产生任何警告。但是在运行的时候,它会抛出一个ClassCastException,虽然它看起来并没有包括任何的可见的转换。你看不到的是,编译器在pickTwo 返回的值上产生了一个隐藏的 String[] 转换。但转换失败了,这是因为从实际导致堆污染(toArray)的方法处移除了两个级别,可变参数数组在实际的参数存入之后没有进行修改。
这个范例是为了告诉大家,**允许另一个方法访问一个泛型可变参数数组是不安全的,**有两种情况例外:将数组传给另一个用 @SafeVarargs
正确注解过的可变参数方法是安全的,将数组传给只计算数组内容部分函数的非可变参数方法也是安全的。
这里有一个安全使用泛型可变参数的典型范例。这个方法中带有一个任意数量参数的列表,并按顺序返回包含输人清单中所有元素的唯一列表。由于该方法用@SafeVarargs
注解过,因此在声明处或者调用处都不会产生任何警告:
1 | //Safe method with a generic, varargs parameter |
确定何时应该使用 SafeVarargs 注解的规则很简单:对于每一个带有泛型可变参数或者参数化类型的方法,都要用@SafeVarargs 进行注解,这样它的用户就不用承受那些无谓的、令人困惑的编译警报了。这意味着应该永远都不要编写像dangerous或者toArray这类不安全的可变参数方法。每当编译器警告你控制的某个带泛型可变参数的方法可能形成堆污染,就应该检查该方法是否安全。这里先提个醒,泛型可变参数方法在下列条件下是安全的:
- 它没有在可变参数数组中保存任何值。
- 它没有对不被信任的代码开放该数组(或者其克隆程序)。
以上两个条件只要有任何一条被破坏,就要立即修正它。
注意,SafeVarargs 注解只能用在无法被覆盖的方法上,因为它不能确保每个可能的覆盖方法都是安全的。在Java 8 中,该注解只在静态方法和 final实例方法中才是合法的;在Java9中,它在私有的实例方法上也合法了。
如果不想使用 SafeVarargs 注解,也可以采用第 28 条的建议,用一个List参数代替可变参数(这是一个伪装数组)。下面举例说明这个办法在flatten 方法上的运用。注意,此处只对参数声明做了修改:
1 | // List as a typesafe alternative to a generic varargs parameter |
随后,这个方法就可以结合静态工厂方法List.of
一起使用了,允许使用数量可变的参数。注意,使用该方法的前提是用 @SafeVarargs
对List.of 声明进行了注解:
1 | audience = flatten(List.of(friends, romans, countrymen)); |
这种做法的优势在于编译器可以证明该方法是类型安全的。你不必再通过Safe-Varargs 注解来证明它的安全性,也不必担心自己是否错误地认定它是安全的。其缺点在于客户端代码有点烦琐,运行起来速度会慢一些。
这一技巧也适用于无法编写出安全的可变参数方法的情况,比如本条之前提到的toArray方法。其List 对应的是 List.of
方法,因此我们不必编写;Java 类库的设计者已经替我们完成了。因此pickTwo 方法就变成了下面这样:
1 | static <T> List<T> pickTwo(T a,T b, T c) { |
main方法变成了下面这样:
1 | public static void main(String[] args) { |
这样得到的代码就是类型安全的,因为它只使用泛型,没有用到数组。
总而言之,可变参数和泛型不能良好地合作,这是因为可变参数设施是构建在顶级数组之上的一个技术露底,泛型数组有不同的类型规则。虽然泛型可变参数不是类型安全的,但它们是合法的。如果选择编写带有泛型(或者参数化)可变参数的方法,首先要确保该方法是类型安全的,然后用@SafeVarargs
对它进行注解,这样使用起来就不会出现不愉快的情况了。
第33条:优先考虑类型安全的异构容器
泛型最常用于集合,如 Set<E>
和 Map<K,V>
,以及单个元素的容器,如 Thread-Local<T>
和 AtomicReference<T>
。在所有这些用法中,它都充当被参数化了的容器。这样就限制每个容器只能有固定数目的类型参数。一般来说,这种情况正是你想要的。一个Set只有一个类型参数,表示它的元素类型;一个Map 有两个类型参数,表示它的键和值类型。
但是,有时候你会需要更多的灵活性。例如,数据库的行可以有任意数量的列,如果能以类型安全的方式访问所有列就好了。幸运的是,有一种方法可以很容易地做到这一点。这种方法就是将键(key)进行参数化而不是将容器(container)参数化。然后将参数化的键提交给容器来插人或者获取值。用泛型系统来确保值的类型与它的键相符。
下面简单地示范一下这种方法:以 Favorites 类为例,它允许其客户端从任意数量的其他类中,保存并获取一个”最喜爱”的实例。Class对象充当参数化键的部分。之所以可以这样,是因为类Class被泛型化了。类的类型从字面上来看不再只是简单的Class,而是Class<T>
。例如,String.class 属于 Class<String>
类型,Integer.class
属于Class<Integer>
类型。当一个类的字面被用在方法中,来传达编译时和运行时的类型信息时,就被称作类型令牌(type token)[Brancha04]。
Favorites类的API很简单。它看起来就像一个简单的映射,除了键(而不是映射)被参数化之外。客户端在设置和获取最喜爱的实例时提交Class 对象。下面就是这个API:
1 | // Typesafe heterogeneous container pattern - API |
下面是一个示例程序,检验一下 Favorites 类,它将保存、获取并打印一个最喜爱的 String、Integer 和 Class 实例:
1 | // Typesafe heterogeneous container pattern - client |
正如所料,这段程序打印出的是Java Cafebabe Favorites。注意,有时Java 的printf方法与C语言中的不同,C语言中使用\n
的地方,在Java 中应该使用%n。这个n 会产生适用于特定平台的行分隔符,在许多平台上是\n
,但是并非所有平台都是如此。
Favorites 实例是类型安全(typesafe)的:当你向它请求 String 的时候,它从来不会返回一个Integer 给你。同时它也是异构的(heterogeneous):不像普通的映射,它的所有键都是不同类型的。因此,我们将 Favorites 称作类型安全的异构容器(typesafeheterogeneous container) 。
Favorites 的实现小得出奇。它的完整实现如下:
1 | //Typesafe heterogeneous container pattern-implementation |
这里面发生了一些微妙的事情。每个 Favorites 实例都得到一个称作 favorites 的私有 Map<Class<?>,Object>
的支持。你可能认为由于无限制通配符类型的关系,将不能把任何东西放进这个Map 中,但事实正好相反。要注意的是通配符类型是嵌套的:它不是属于通配符类型的Map 的类型,而是它的键的类型。由此可见,每个键都可以有一个不同的参数化类型:一个可以是Class<String>
,接下来是Class<Integer>
等。异构就是从这里来的。
第二件要注意的事情是,favorites Map 的值类型只是 Object。换句话说,Map 并不能保证键和值之间的类型关系,即不能保证每个值都为它的健所表示的类型(通俗地说,就是指键与值的类型并不相同一译者注)。事实上,Java 的类型系统还没有强大到足以表达这一点。但我们知道这是事实,并在获取 faVorite 的时候利用了这一点。
putFavorite 方法的实现很简单:它只是把(从指定的 Class 对象到指定的 favo-rite实例)一个映射放到favorites 中。如前所述,这是放弃了键和值之间的”类型联系”,因此无法知道这个值是键的一个实例。但是没关系,因为 getFavorites 方法能够并且的确重新建立了这种联系。
getFavorite方法的实现比 putFavorite 的更难一些。它先从 favorites 映射中获得与指定Class对象相对应的值。这正是要返回的对象引l用,但它的编译时类型是错误的。它的类型只是 Object(favorites 映射的值类型),我们需要返回一个 T。因此,getFavorite 方法的实现利用 Class 的 cast 方法,将对象引l用动态地转换(dynamicallycast)成了Class对象所表示的类型。
cast方法是Java 的转换操作符的动态模拟。它只检验它的参数是否为 Class 对象所表示的类型的实例。如果是,就返回参数;否则就抛出 ClassCastException 异常。我们知道 getFavorite 中的 cast调用永远不会抛出 ClassCastException 异常,并假设客户端代码正确无误地进行了编译。也就是说,我们知道 faVorites 映射中的值会始终与键的类型相匹配。
假设cast方法只返回它的参数,那它能为我们做什么呢?cast方法的签名充分利用了Class 类被泛型化的这个事实。它的返回类型是 Class 对象的类型参数:
1 | public class Class<T>{ |
这正是 getFavorite 方法所需要的,也正是让我们不必借助于未受检地转换成 T 就能确保 Favorites 类型安全的东西。
Favorites类有两种局限性值得注意。首先,恶意的客户端可以很轻松地破坏Favorites 实例的类型安全,只要以它的原生态形式(raw form)使用 Class对象。但会造成客户端代码在编译时产生未受检的警告。这与一般的集合实现,如 HashSet 和 HashMap并没有什么区别。你可以很容易地利用原生态类型HashSet(详见第 26 条)将 String 放进HashSet<Integer>
中。也就是说,如果愿意付出一点点代价,就可以拥有运行时的类型安全。确保 Favorites 永远不违背它的类型约束条件的方式是,让putFavorite 方法检验 instance 是否真的是type 所表示的类型的实例。只需使用一个动态的转换,如下代码所示:
1 | //Achieving runtime typesafetywithadynamic cast |
java.util.Collections 中有一些集合包装类采用了同样的技巧。它们称作 checked-Set、checkedList、checkedMap,诸如此类。除了一个集合(或者映射)之外,它们的静态工厂还采用一个(或者两个)Class 对象。静态工厂属于泛型方法,确保 Class对象和集合的编译时类型相匹配。包装类给它们所封装的集合增加了具体化。例如,如果有人试图将 Coin 放进你的 Collection<Stamp>
,包装类就会在运行时抛出 ClassCast-Exception异常。用这些包装类在混有泛型和原生态类型的应用程序中追溯”是谁把错误的类型元素添加到了集合中”很有帮助。
Favorites 类的第二种局限性在于它不能用在不可具体化的(non-reifiable)类型中(详见第 28 条)。换句话说,你可以保存最喜爱的 String 或者 String[],但不能保存最喜爱的List<String>
。如果试图保存最喜爱的List<String>
,程序就不能进行编译。原因在于你无法为List<String>
获得一个Class 对象:List<String>.Class
是个语法错误,这也是件好事。List<String>
和List<Integer>
共用一个 Class 对象,即List.class
。如果从”类型的字面”(type literal)上来看,List<String>.class
和List<Integer>.class
是合法的,并返回了相同的对象引l用,这会破坏 Favorites 对象的内部结构。对于这种局限性,还没有完全令人满意的解决办法。
Favorites 使用的类型令牌(type token)是无限制的:getFavorite 和putFavorite接受任何Class对象。有时可能需要限制那些可以传给方法的类型。这可以通过有限制的类型令牌(bounded type token)来实现,它只是一个类型令牌,利用有限制类型参数(详见第 30条)或者有限制通配符(详见第31条),来限制可以表示的类型。
注解API(详见第39条)广泛利用了有限制的类型令牌。例如,这是一个在运行时读取注解的方法。这个方法来自 AnnotatedElement 接口,它通过表示类、方法、域及其他程序元素的反射类型来实现:
1 | public <T extends Annotation>T getAnnotation(Class<T> annotationType); |
参数annotationType是一个表示注解类型的有限制的类型令牌。如果元素有这种类型的注解,该方法就将它返回;如果没有,则返回 null。被注解的元素本质上是个类型安全的异构容器,容器的键属于注解类型。
的方法,例如 getAnnotation。你可以将对象转换成 Class<?extends Annotation>
,但是这种转换是非受检的,因此会产生一条编译时警告(详见第27条)。幸运的是,类Class 提供了一个安全(且动态)地执行这种转换的实例方法。该方法称作 asSubclass,它将调用它的Class 对象转换成用其参数表示的类的一个子类。如果转换成功,该方法返回它的参数;如果失败,则抛出 ClassCastException 异常。
下面示范如何利用 as Subclass 方法在编译时读取类型未知的注解。这个方法编译时没有出现错误或者警告:
1 | //UseofasSubclasstosafelycasttoaboundedtypetoken |
总而言之,集合API说明了泛型的一般用法,限制每个容器只能有固定数目的类型参数。你可以通过将类型参数放在键上而不是容器上来避开这一限制。对于这种类型安全的异构容器,可以用Class 对象作为键。以这种方式使用的Class 对象称作类型令牌。你也可以使用定制的键类型。例如,用一个 DatabaseRow 类型表示一个数据库行(容器),用泛型 Column <T>
作为它的键。
第六章 枚举和注解
Java 支持两种特殊用途的引引用类型:一种是类,称作枚举类型(enum type);一种是接口,称作注解类型(annotation type)。本章将讨论这两个新类型的最佳使用实践。
第34条:用enum代替int常量
枚举类型(enum type)是指由一组固定的常量组成合法值的类型,例如一年中的季节、太阳系中的行星或者一副牌中的花色。在Java 编程语言引人枚举类型之前,通常是用一组int 常量来表示枚举类型,其中每一个int 常量表示枚举类型的一个成员:
1 | //Theintenumpattern-severelydeficient! |
这种方法称作int枚举模式(int enum pattern),它存在着很多不足。int枚举模式不具有类型安全性,也几乎没有描述性可言。例如你将 apple 传到想要 orange 的方法中,编译器也不会发出警告,还会用 ==操作符对 apple 与 orange 进行比较,甚至更糟糕:
1 | // Tasty citrus flavored applesauce! |
注意每个 apple 常量的名称都以 APPLE_作为前缀,每个 orange 常量的名称则都以ORANGE_作为前缀。这是因为 Java 没有为 int 枚举组提供命名空间。当两个int 枚举组具有相同的命名常量时,前缀可以防止名称发生冲突,如使用ELEMENT_MERCURY和PLANET_MERCURY避免名称冲突。
采用int枚举模式的程序是十分脆弱的。因为 int枚举是编译时常量(constantvariable)的值发生了变化,客户端必须重新编译。如果没有重新编译,客户端程序还是可以运行,不过其行为已经不再准确。
很难将int 枚举常量转换成可打印的字符串。就算将这种常量打印出来,或者从调试器中将它显示出来,你所见到的也只是一个数字,这几乎没有什么用处。当需要遍历一个int枚举模式中的所有常量,以及获得int 枚举数组的大小时,在 int 枚举模式中,几乎不存在可靠的方式。
这种模式还有一种变体,它使用的是 String 常量,而不是 int 常量。这样的变体被称作 String 枚举模式(String enum pattern),同样也不是我们期望的。它虽然为这些常量提供了可打印的字符串,但是会导致初级用户直接把字符串常量硬编码到客户端代码中,而不是使用对应的常量字段(field)名。一旦这样的硬编码字符串常量中包含书写错误,在编译时不会被检测到,但是在运行的时候却会报错。而且它会导致性能问题,因为它依赖于字符串的比较操作。
幸运的是,Java 提供了另一种替代的解决方案,可以避免 int 和 String 枚举模式的缺点,并提供更多的好处。这就是枚举类型(enum type)[JLS,8.9]。下面以最简单的形式演示了这种模式:
1 | public enum Apple{ FUJI,PIPPIN,GRANNY_SMITH } |
表面上看来,这些枚举类型与其他语言中的没有什么两样,例如C、C++和C#,但是实际上并非如此。Java的枚举类型是功能十分齐全的类,其功能比其他语言中的对应类强大得多,Java的枚举本质上是int值。
出一个实例。枚举类型没有可以访问的构造器,所以它是真正的final类。客户端不能创建枚举类型的实例,也不能对它进行扩展,因此不存在实例,而只存在声明过的枚举常量。换句话说,枚举类型是实例受控的(详见第6页)。它们是单例(Singleton)(详见第3条)的泛型化,本质上是单元素的枚举。
枚举类型保证了编译时的类型安全。例如声明参数的类型为Apple,它就能保证传到该参数上的任何非空的对象引用一定属于三个有效的Apple值之一,而其他任何试图传递类型错误的值都会导致编译时错误,就像试图将某种枚举类型的表达式赋给另一种枚举类型的变量,或者试图利用==操作符比较不同枚举类型的值都会导致编译时错误。
包含同名常量的多个枚举类型可以在一个系统中和平共处,因为每个类型都有自己的命名空间。你可以增加或者重新排列枚举类型中的常量,而无须重新编译它的客户端代码,因为导出常量的域在枚举类型和它的客户端之间提供了一个隔离层:常量值并没有被编译到客户端代码中,而是在 int 枚举模式之中。最终,可以通过调用toString 方法,将枚举转换成可打印的字符串。
除了完善 int 枚举模式的不足之外,枚举类型还允许添加任意的方法和域,并实现任意的接口。它们提供了所有 Object方法(详见第3章)的高级实现,实现了Comparable(详见第14条)和 Serializable接口(详见第 12章),并针对枚举类型的可任意改变性设计了序列化方式。
那么我们为什么要向枚举类型中添加方法或者域呢?首先,可能是想将数据与它的常量关联起来。例如,一个能够返回水果颜色或者返回水果图片的方法,对于我们的 Apple和range类型就很有必要。你可以利用任何适当的方法来增强枚举类型。枚举类型可以先作为枚举常量的一个简单集合,随着时间的推移再演变成为全功能的抽象。
举个有关枚举类型的例子,比如太阳系中的8颗行星。每颗行星都有质量和半径,通过这两个属性可以计算出它的表面重力。从而给定物体的质量,进而计算出一个物体在行星表面上的重量。下面就是这个枚举。每个枚举常量后面括号中的数值就是传递给构造器的参数。在这个例子中,它们就是行星的质量和半径:
1 | //Enum typewithdataand behavior |
编写一个像 Planet 这样的枚举类型并不难。为了将数据与枚举常量关联起来,得声明实例域,并编写一个带有数据并将数据保存在域中的构造器。枚举天生就是不可变的,因此所有的域都应该为 final的(详见第17条)。它们可以是公有的,但最好将它们做成私有的,并提供公有的访问方法(详见第16 条)。在 Planet这个示例中,构造器还计算和保存表面重力,但这正是一种优化。每当 surfaceWeight 方法用到重力时,都会根据质量和半径重新计算,并返回它在该常量所表示的行星上的重量。
虽然 Planet 枚举很简单,但它的功能强大得出奇。下面是一个简短的程序,根据某个物体在地球上的重量(以任何单位),打印出一张很棒的表格,显示出该物体在所有8颗行星上的重量 (用相同的单位):
1 | public class WeightTable { |
注意就像所有的枚举一样,Planet有一个静态的values方法,按照声明顺序返回它的值数组。toString 方法返回每个枚举值的声明名称,使得 println 和 printf 的打印变得更加容易。如果你不满意这种字符串表示法,可以通过覆盖toString方法对它进行修改。下面就是带命令行参数为 185来运行这个小小的 WeightTable程序(没有覆盖toString方法)时的结果:
1 | Weight on MERCURY is 69.912739 |
直到 2006 年,即Java 中增加了枚举的两年之后,当时冥王星Pluto 还属于行星。这引发出一个问题:当把一个元素从一个枚举类型中移除时,会发生什么情况呢?答案是:没有引l用该元素的任何客户端程序都会继续正常工作。因此,我们的 WeightTable程序只会打印出一个少了一行的表格而已。对于引l用了被删除元素(如本例中是指 Planet.Pluto)的客户端程序又如何呢?如果重新编译客户端程序,就会失败,并在引用被删除行星的那一条出现一条错误消息;如果没有重新编译客户端代码,在运行时就会在这一行抛出一个异常。这是你能期待的最佳行为了,远比使用int枚举模式时要好得多。
有些与枚举常量相关的行为,可能只会用在枚举类型的定义类或者所在的包中,那么这些方法最好被实现成私有的或者包级私有的。于是每个枚举常量都带有一组隐藏的行为,这使得枚举类型的类或者所在的包能够运作得很好,像其他的类一样,除非要将枚举方法导出至它的客户端,否则都应该声明为私有的,或者声明为包级私有的(详见第 15条)。
如果一个枚举具有普遍适用性,它就应该成为一个顶层类(top-level class);如果它只是被用在一个特定的顶层类中,它就应该成为该顶层类的一个成员类(详见第24条)。例如,java.math.RoundingMode 枚举表示十进制小数的舍人模式(rounding mode)。这些舍人模式被用于BigDecimal类,但是它们却不属于BigDecimal类的一个抽象。通过使 RoundingMode 变成一个顶层类,库的设计者鼓励任何需要舍人模式的程序员重用这个枚举,从而增强API之间的一致性。
Planet示例中所示的方法对于大多数枚举类型来说就足够了,但有时候我们会需要更多的方法。每个Planet常量关联了不同的数据,但你有时需要将不同的行为(behavior)与每个常量关联起来。例如,假设你在编写一个枚举类型,来表示计算器的四大基本操作(即加减乘除),你想要提供一个方法来执行每个常量所表示的算术运算。有一种方法是通过启用枚举的值来实现:
1 | // Enum type that switches on its own value- questionable |
这段代码能用,但是不太好看。如果没有throw语句,它就不能进行编译,虽然从技术角度来看代码的结束部分是可以执行到的,但是实际上是不可能执行到这行代码的[JLS,14.21]。更糟糕的是,这段代码很脆弱。如果你添加了新的枚举常量,却忘记给switch 添加相应的条件,枚举仍然可以编译,但是当你试图运用新的运算时,就会运行失败。
幸运的是,有一种更好的方法可以将不同的行为与每个枚举常量关联起来:在枚举类型中声明一个抽象的 apply方法,并在特定于常量的类主体(constant-specific class body)中,用具体的方法覆盖每个常量的抽象 apply方法。这种方法被称作特定于常量的方法实现 (constant-specific method implementation):
1 | // Enum type with constant-specific method implementations |
如果给Operation 的第二种版本添加新的常量,你就不可能会忘记提供apply方法,因为该方法紧跟在每个常量声明之后。即使你真的忘记了,编译器也会提醒你,因为枚举类型中的抽象方法必须被它的所有常量中的具体方法所覆盖。
特定于常量的方法实现可以与特定于常量的数据结合起来。例如,下面的 Operation覆盖了toString方法以返回通常与该操作关联的符号:
1 | // Enum type with constant-specific class bodies and data |
上述的toString 实现使得打印算术表达式变得非常容易,如下小程序所示:
1 | public static void main(String[] args){ |
用2和4作为命令行参数来运行这段程序,会输出:
1 | 2.000000 + 4.000000 = 6.000000 |
枚举类型有一个自动产生的valueOf(String)方法,它将常量的名字转变成常量本身。如果在枚举类型中覆盖toString,要考虑编写一个 fromString 方法,将定制的字符串表示法变回相应的枚举。下列代码(适当地改变了类型名称)可以为任何枚举完成这一技巧,只要每个常量都有一个独特的字符串表示法:
1 | //Implementing a fromString methodonanenumtype |
注意,在枚举常量被创建之后,Operation 常量从静态代码块中被放人到了 string-ToEnum 的映射中。前面的代码在values()方法返回的数组上使用流(见第 7章);在Java 8之前,我们将创建一个空的散列映射并遍历 values 数组,将字符串到枚举的映射插人到映射中,当然,如果你愿意,现在仍然可以这么做。但是,试图使每个常量都从自己的构造器将自身放入到映射中是不起作用的。它会导致编译时错误,这是好事,因为如果这是合法的,可能会引发NullPointerException 异常。除了编译时常量域(见第34 条)之外,枚举构造器不可以访问枚举的静态域。这一限制是有必要的,因为构造器运行的时候,这些静态域还没有被初始化。这条限制有一个特例:枚举常量无法通过其构造器访问另一个构造器。
还要注意返回 Optional
特定于常量的方法实现有一个美中不足的地方,它们使得在枚举常量中共享代码变得更加困难了。例如,考虑用一个枚举表示薪资包中的工作天数。这个枚举有一个方法,根据给定的某工人的基本工资(按小时)以及当天的工作时间,来计算他当天的报酬。在五个工作日中,超过正常八小时的工作时间都会产生加班工资;在节假日中,所有工作都产生加班工资。利用 switch 语句,很容易通过将多个case标签分别应用到两个代码片段中,来完成这一计算:
1 | //Enum that switches on itsvalue to sharecode-questionable |
不可否认,这段代码十分简洁,但是从维护的角度来看,它非常危险。假设将一个元素添加到该枚举中,或许是一个表示假期天数的特殊值,但是忘记给 switch 语句添加相应的
为了利用特定于常量的方法实现安全地执行工资计算,你可能必须重复计算每个常量的加班工资,或者将计算移到两个辅助方法中(一个用来计算工作日,一个用来计算节假日),并从每个常量调用相应的辅助方法。任何一种方法都会产生相当数量的样板代码,这会降低可读性,并增加了出错的概率。
通过用计算工作日加班工资的具体方法来代替 PayrollDay 中抽象的overtimePaY方法,可以减少样板代码。这样,就只有节假日必须覆盖该方法了。但是这样也有着与switch 语句一样的不足:如果又增加了一天而没有覆盖 overtimePay方法,就会悄悄地延续工作日的计算。
我们真正想要的就是每当添加一个枚举常量时,就强制选择一种加班报酬策略。幸运的是,有一种很好的方法可以实现这一点。这种想法就是将加班工资计算移到一个私有的嵌套枚举中,将这个策略枚举(strategyenum)的实例传到PayrollDay枚举的构造器中。之后 PayrollDay 枚举将加班工资计算委托给策略枚举,PayrollDay 中就不需要 switch语句或者特定于常量的方法实现了。虽然这种模式没有 switch 语句那么简洁,但更加安全,也更加灵活:
1 | // The strategy enum pattern |
如果枚举中的switch语句不是在枚举中实现特定于常量的行为的一种很好的选择,那么它们还有什么用处呢?枚举中的switch语句适合于给外部的枚举类型增加特定于常量的行为。 例如,假设Operation 枚举不受你的控制,你希望它有一个实例方法来返回每个运算的反运算。你可以用下列静态方法模拟这种效果:
1 | //Switch on an enumto simulateamissing method |
如果一个方法不属于枚举类型,也应该在你所能控制的枚举类型上使用这种方法。这种方法有点用处,但是通常还不值得将它包含到枚举类型中去。
一般来说,枚举通常在性能上与 int 常量相当。与 int 常量相比,枚举有个小小的性能缺点,即装载和初始化枚举时会需要空间和时间的成本,但在实践中几乎注意不到这个问题。
那么什么时候应该使用枚举呢?每当需要一组固定常量,并且在编译时就知道其成员的时候,就应该使用枚举。当然,这包括”天然的枚举类型”,例如行星、一周的天数以及棋子的数目等。但它也包括你在编译时就知道其所有可能值的其他集合,例如菜单的选项、操作代码以及命令行标记等。枚举类型中的常量集并不一定要始终保持不变。专门设计枚举特性是考虑到枚举类型的二进制兼容演变。
总而言之,与int常量相比,枚举类型的优势是不言而喻的。枚举的可读性更好,也更加安全,功能更加强大。许多枚举都不需要显式的构造器或者成员,但许多其他枚举则受益于属性与每个常量的关联以及其行为受该属性影响的方法。只有极少数的枚举受益于将多种行为与单个方法关联。在这种相对少见的情况下,特定于常量的方法要优先于启用自有值的枚举。如果多个(但非所有)枚举常量同时共享相同的行为,则要考虑策略枚举。
第35条:用实例域代替序数
许多枚举天生就与一个单独的 int值相关联。所有的枚举都有一个ordinal方法,它返回每个枚举常量在类型中的数字位置。你可以试着从序数中得到关联的int值:
1 | //Abuseofordinaltoderiveanassociatedvalue-DoN'TDoTHIS |
虽然这个枚举工作得不错,但是维护起来就像一场噩梦。如果常量进行重新排序,number-OfMusicians 方法就会遭到破坏。如果要再添加一个与已经用过的 int 值关联的枚举常量,就没那么走运了。例如,给双四重奏(double quartet)添加一个常量,它就像个八重奏一样,是由8位演奏家组成,但是没有办法做到。
而且,要是没有给所有这些 int 值添加常量,也无法给某个 int 值添加常量。例如,假设想要添加一个常量表示三四重奏(triple quartet),它由 12 位演奏家组成。对于由 11 位演奏家组成的合奏曲并没有标准的术语,因此只好给没有用过的int 值(11)添加一个虚拟(dummy)常量。这么做顶多就是不太好看。如果有许多 int 值都是从未用过的,可就不切实际了。
幸运的是,有一种很简单的方法可以解决这些问题。永远不要根据枚举的序数导出与它关联的值,而是要将它保存在一个实例域中:
1 | public enum Ensemble { |
Enum 规范中谈及ordinal方法时写道:”大多数程序员都不需要这个方法。它是设计用于像EnumSet和EnumMap这种基于枚举的通用数据结构的。”除非你在编写的是这种数据结构,否则最好完全避免使用 ordinal 方法。
第36条:用EnumSet代替位域
如果一个枚举类型的元素主要用在集合中,一般就使用int枚举模式(详见第 34条),比如将2的不同倍数赋予每个常量:
1 | //Bit field enumerationconstants-OBSOLETE! |
这种表示法让你用OR 位运算将几个常量合并到一个集合中,称作位域(bit field):
1 | text.applyStyles(STYLE_BOLD I STYLE_ITALIC); |
位域表示法也允许利用位操作,有效地执行像union(联合)和intersection(交集)这样的集合操作。但位域具有int 枚举常量的所有缺点,甚至更多。当位域以数字形式打印时,翻译位域比翻译简单的int枚举常量要困难得多。要遍历位域表示的所有元素也没有很容易的方法。最后一点,在编写API的时候,就必须先预测最多需要多少位,同时还要给位域选择对应的类型(一般是 int 或者 1ong)。一旦选择好类型,在没有修改 API的情况下,将不能超出其位宽度(如 32位或者64位)。
有些程序员虽然更倾向于使用枚举而非 int 常量,但是他们在需要传递多组常量集时,仍然倾向于使用位域。其实没有理由这么做,因为还有更好的替代方法。jaVa.util包提Set接口,提供了丰富的功能、类型安全性,以及可以从任何其他 Set实现中得到的互用性。但是在内部具体的实现上,每个EnumSet内容都表示为位矢量。如果底层的枚举类型有64个或者更少的元素(大多如此)整个EnumSet就使用单个long 来表示,因此它的性能比得上位域的性能。批处理操作,如 removeAll 和 retainAll,都是利用位算法来实现的,就像手工替位域实现的那样。但是可以避免手工位操作时容易出现的错误以及丑陋的代码,因为EnumSet替你完成了这项艰巨的工作。
下面是前一个范例改成用枚举代替位域之后的代码,它更加简短、更加清楚,也更加安全:
1 | //EnumSet-amodernreplacementforbitfields |
下面是将EnumSet 实例传递给 applyStyles 方法的客户端代码。 EnumSet 提供了丰富的静态工厂来轻松创建集合,其中一个如下代码所示:
1 | text.applyStyles(EnumSet.of(Style.BOLD,Style.ITALIC)); |
虽然看起来好像所有的客户端都可以将EnumSet传到这个方法,但是最好还是接受接口类型而非接受实现类型(详见第64条)。这是考虑到可能会有特殊的客户端需要传递一些其他的 Set实现。
总而言之,正是因为枚举类型要用在集合中,所以没有理由用位域来表示它。EnumSet类集位域的简洁和性能优势及第34条中所述的枚举类型的所有优点于一身。实际上EnumSet有个缺点,即截止 Java 9发行版本,它都无法创建不可变的 EnumSet,但是这一点很可能在即将发布的版本中得到修正。同时,可以用Collections.unmodifiableSet将EnumSet封装起来,但是简洁性和性能会受到影响。
第37条:用EnumMap代替序数索引
有时你可能会见到利用ordinal方法(详见第 35条)来索引数组或列表的代码。例如下面这个超级简化的类,用来表示一种烹饪用的香草:
1 | class Plant{ |
现在假设有一个香草的数组,表示一座花园中的植物,你想要按照类型(一年生、多年生或者两年生植物)进行组织之后将这些植物列出来。如果要这么做,需要构建三个集合,每种类型一个,并且遍历整座花园,将每种香草放到相应的集合中。有些程序员会将这些集合放到一个按照类型的序数进行索引的数组中来实现这一点:
1 | //Using ordinal() to index into an array- DON’T DO THIS! |
这种方法的确可行,但是隐藏着许多问题。因为数组不能与泛型(详见第28条)兼容,程序需要进行未受检的转换,并且不能正确无误地进行编译。因为数组不知道它的索引代表着什么,你必须手工标注(label)这些索引的输出。但是这种方法最严重的问题在于,当你访问一个按照枚举的序数进行索引的数组时,使用正确的int值就是你的职责了;int不能提供枚举的类型安全。你如果使用了错误的值,程序就会悄悄地完成错误的工作,或者幸运的话,会抛出 ArrayIndexOutOfBoundException 异常。
有一种更好的方法可以达到同样的效果。数组实际上充当着从枚举到值的映射,因此可能还要用到Map。更具体地说,有一种非常快速的Map 实现专门用于枚举键,称作java.util.EnumMap。以下就是用EnumMap改写后的程序:
1 | //UsinganEnumMaptoassociatedatawithanenum |
这段程序更简短、更清楚,也更加安全,运行速度方面可以与使用序数的程序相媲美。它没有不安全的转换;不必手工标注这些索引的输出,因为映射键知道如何将自身翻译成可打印字符串的枚举;计算数组索引时也不可能出错。EnumMap 在运行速度方面之所以能与通过序数索引的数组相媲美,正是因为EnuMap 在内部使用了这种数组。但是它对程序员隐藏了这种实现细节,集 Map 的丰富功能和类型安全与数组的快速于一身。注意 EnumMap构造器采用键类型的Class对象:这是一个有限制的类型令牌(bounded type token),它提供了运行时的泛型信息(详见第33条)。
的最简单的代码,大量复制了上一个示例的行为:
1 | //Naivestream-basedapproach-unlikelytoproduceanEnumMap! |
这段代码的问题在于它选择自己的映射实现,实际上不会是一个EnumMap,因此与显式EnumMap版本的空间及时间性能并不吻合。为了解决这个问题,要使用有三种参数形式的Collectors.groupingBy方法,它允许调用者利用 mapFactory参数定义映射实现:
1 | //UsingastreamandanEnumMaptoassociatedatawithanenum |
在这样一个玩具程序中不值得进行这种优化,但是在大量使用映射的程序中就很重要了。
一个植物生命周期都设计一个嵌套映射,基于 stream 的版本则仅当花园中包含了一种或多种植物带有该生命周期时才会设计一个嵌套映射。因此,假如花园中包含了一年生和多年生植物,但没有两年生植物,plantByLifeCycle 的数量在 EnumMap 版本中应该是三种,在基于stream 的两个版本中则都是两种。
你还可能见到按照序数进行索引(两次)的数组的数组,该序数表示两个枚举值的映射。例如,下面这个程序就是使用这样一个数组将两个阶段映射到一个阶段过渡中(从液体到固体称作凝固,从液体到气体称作沸腾,诸如此类):
1 | //Using ordinal() to index array of arrays- DON ’T DO THIS! |
这段程序可行,看起来也比较优雅,但是事实并非如此。就像上面那个比较简单的香草花园的示例一样,编译器无法知道序数和数组索引之间的关系。如果在过渡表中出了错,或者在修改 Phase 或者 Phase.Transition 枚举类型的时候忘记将它更新,程序就会在运行时失败。这种失败的形式可能为ArrayIndexOutOfBoundsException、NullPointerException 或者(更糟糕的是)没有任何提示的错误行为。这张表的大小是阶段数的平方,即使非空项的数量比较少。
同样,利用 EnumMap 依然可以做得更好一些。因为每个阶段过渡都是通过一对阶段枚举进行索引的,最好将这种关系表示为一个映射,这个映射的键是一个枚举(起始阶段),值为另一个映射,这第二个映射的键为第二个枚举(目标阶段),它的值为结果(阶段过渡),即形成了Map(起始阶段,Map(目标阶段,阶段过渡))这种形式。一个阶段过渡所关联的两个阶段,最好通过”数据与阶段过渡枚举之间的关系”来获取,之后用该阶段过渡枚举来初始化嵌套的EnumMap:
1 | //UsinganestedEnumMaptoassociatedatawithenumpairs |
初始化阶段过渡映射的代码看起来可能有点复杂。映射的类型为 Map<Phasé,Map<Phase,Transition>>,其中组成值的 Map 是由键值对目标 Phase(即第二个阶段)和Transition 组成的。这个映射的映射是利用两个集合的级联顺序进行初始化的。第一个集合按源 Phase 对过渡进行分组,第二个集合利用从目标 Phase 到过渡之间的映射创建一个 EnumMap。第二个集合中的 merge 函数((α,Y)->y)没有用到;只有当我们因为想要获得一个EnumMap而定义映射工厂时才需要用到它,同时Collectors 提供了重叠工厂。本书第 2版是利用显式迭代来初始化阶段过渡映射的。其代码更加烦琐,但是的确更易于理解。
现在假设想要给系统添加一个新的阶段:plasma(离子)或者电离气体。只有两个过渡与这个阶段关联:电离化(ionization),它将气体变成离子;以及消电离化(deionization),将离子变成气体。为了更新基于数组的程序,必须给 Phase 添加一种新常量,给 Phase.Transition 添加两种新常量,用一种新的16个元素的版本取代原来9个元素的数组的数组。如果给数组添加的元素过多或者过少,或者元素放置不妥当,可就麻烦了:程序可以编译,但是会在运行时失败。为了更新基于 EnumMap 的版本,所要做的就是必须将 PLASMA添加到PhaSe 列表,并将IONIZE(GAS,PLASMA)和 DEIONIZE(PLASMA,GAS)添加到Phase.Transition 的列表中:
1 | //Adding a new phase using the nested EnumMap implementation |
程序会自行处理所有其他的事情,这样就几乎没有出错的可能。从内部来看,映射的映射被实现成了数组的数组,因此在提升了清晰性、安全性和易维护性的同时,在空间或者时间上也几乎没有多余的开销。
为了简洁起见,上述范例是用 null 表明状态没有变化(这里的to 和 from 是相等的)。这并不是好的实践,可能在运行时导致NullPointerException 异常。要给这个问题设计一个整洁、优雅的解决方案,需要高超的技巧,得到的程序会很长,贬损了本条目的主要精神。
总而言之,最好不要用序数来索引数组,而要使用EnumMap。如果你所表示的这种关系是多维的,就使用EnumMap<.··,EnumMap<。..>>。应用程序的程序员在一般情况下都不使用Enum.ordinal方法,仅仅在极少数情况下才会使用,因此这是一种特殊情况(详见第35条)。
第38条:用接口模拟可扩展的枚举
几乎从所有方面来看,枚举类型都优越于本书第1版中所述的类型安全枚举模式[Bloch01]。言构造的支持。换句话说,使用第1版所述的模式能够实现让一个枚举类型去扩展另一个枚举类型;利用这种语言特性,则不可能做到。这绝非偶然。枚举的可伸缩性最后证明基本上都不是什么好点子。扩展类型的元素为基本类型的实例,基本类型的实例却不是扩展类型的元素,这样很混乱。目前还没有很好的方法来枚举基本类型的所有元素及其扩展。最终,可伸缩性会导致设计和实现的许多方面变得复杂起来。
也就是说,对于可伸缩的枚举类型而言,至少有一种具有说服力的用例,这就是操作码(operation code),也称作 opcode。操作码是指这样的枚举类型:它的元素表示在某种机器上的那些操作,例如第34条中的Operation 类型,它表示一个简单的计算器中的某些函数。有时要尽可能地让API的用户提供它们自己的操作,这样可以有效地扩展API所提供的操作集。
幸运的是,有一种很好的方法可以利用枚举类型来实现这种效果。由于枚举类型可以通过给操作码类型和(属于接口的标准实现的)枚举定义接口来实现任意接口,基本的想法就是利用这一事实。例如,以下是第 34条中的Operation 类型的扩展版本:
1 | // Emulated extensible enum using an interface |
虽然枚举类型(BasicOperation)不是可扩展的,但接口类型(Operation)却是可扩展的,它是用来表示API中的操作的接口类型。你可以定义另一个枚举类型,它实现这个接口,并用这个新类型的实例代替基本类型。例如,假设你想要定义一个上述操作类型的扩展,由求幂(exponentiation)和求余(remainder)操作组成。你所要做的就是编写一个枚举类型,让它实现Operation 接口:
1 | //Emulatedextensionenum |
在可以使用基础操作的任何地方,现在都可以使用新的操作,只要API是写成采用接口类型(Operation)而非实现(BasicOperation)。注意,在枚举中,不必像在不可扩展的枚举中所做的那样,利用特定于实例的方法实现(见第 34 条)来声明抽象的 apply方法。因为抽象的方法(apply)是接口(Operation)的一部分。
不仅可以在任何需要”基本枚举”的地方单独传递一个”扩展枚举”的实例,而且除了那些基本类型的元素之外,还可以传递完整的扩展枚举类型,并使用它的元素。例如,通过第34条的测试程序版本,体验一下上面定义过的所有扩展过的操作:
1 | public static void main(String[] args) { |
注意扩展过的操作类型的类的字面文字(ExtendedOperation.class)从 main 被传递给了七est方法,来描述被扩展操作的集合。这个类的字面文字充当有限制的类型令牌(bounded type token)(详见第 33条)。OpEnumType参数中公认很复杂的声明(<T extends Enum<T>&Operation>Class<T>)
确保了Class 对象既表示枚举又表示 Operation的子类型,这正是遍历元素和执行与每个元素相关联的操作时所需要的。
第二种方法是传人一个Collection<?Extends Operation>,这是个有限制的通配符类型(boundedwildcard type)(详见第31条),而不是传递一个类对象:
1 | public static void main(String[] args) { |
这样得到的代码没有那么复杂,test方法也比较灵活一些:它允许调用者将多个实现类型的操作合并到一起。另一方面,也放弃了在指定操作上使用 EnumSet(详见第 36条)和EnumMap(详见第37条)的功能。
上面这两段程序运行时带上命令行参数4和2,都会产生如下输出:
4.000000 ^ 2.000000 = 16.000000
4.000000 % 2.000000 = 0.000000
用接口模拟可伸缩枚举有个小小的不足,即无法将实现从一个枚举类型继承到另一个枚举类型。如果实现代码不依赖于任何状态,就可以将缺省实现(详见第20条)放在接口中。在上述Operation 的示例中,保存和获取与某项操作相关联的符号的逻辑代码,必须复制到 BasicOperation 和 ExtendedOperation 中。在这个例子中是可以的,因为复制的代码非常少。如果共享功能比较多,则可以将它封装在一个辅助类或者静态辅助方法中,来避免代码的复制工作。
本条目所述的模式也在 Java 类库中得到了应用。例如,java.nio.file.LinkOption枚举类型,它同时实现了 CopyOption 和 OpenOption 接口。
总而言之,虽然无法编写可扩展的枚举类型,却可以通过编写接口以及实现该接口的基础枚举类型来对它进行模拟。这样允许客户端编写自己的枚举(或者其他类型)来实现接口。如果API是根据接口编写的,那么在可以使用基础枚举类型的任何地方,也都可以使用这些枚举。
第39条:注解优先于命名模式
根据经验,一般使用命名模式(naming pattern)表明有些程序元素需要通过某种工具或者框架进行特殊处理。例如,在 Java 4 发行版本之前,JUnit 测试框架原本要求其用户一定要用test 作为测试方法名称的开头[Beck04]。这种方法可行,但是有几个很严重的缺点。首先,文字拼写错误会导致失败,且没有任何提示。例如,假设不小心将一个测试方法命名为tsetSafetyOverride 而不是testSafetyOverride。JUnit 3 不会提示,但也不会执行测试,造成错误的安全感。
命名模式的第二个缺点是,无法确保它们只用于相应的程序元素上。例如,假设将某个类称作TestSafetyMechanisms,是希望JUnit3会自动地测试它所有的方法,而不管它们叫什么名称。JUnit3还是不会提示,但也同样不会执行测试。
命名模式的第三个缺点是,它们没有提供将参数值与程序元素关联起来的好方法。例如,假设想要支持一种测试类别,它只在抛出特殊异常时才会成功。异常类型本质上是测试的一个参数。你可以利用某种具体的命名模式,将异常类型名称编码到测试方法名称中,但是这样的代码很不雅观,也很脆弱(见第62条)。编译器不知道要去检验准备命名异常的字符串是否真正命名成功。如果命名的类不存在,或者不是一个异常,你也要到试着运行测试时才会发现。
注解[JLS,9.7]很好地解决了所有这些问题,JUnit 从 Java4开始使用。在本条目中,我们要编写自己的试验测试框架,展示一下注解的使用方法。假设想要定义一个注解类型来指定简单的测试,它们自动运行,并在抛出异常时失败。以下就是这样的一个注解类型,命名为Test:
1 | //Markerannotationtypedeclaration |
Test 注解类型的声明就是它自身通过 Retention 和 Target 注解进行了注解。注解类型声明中的这种注解被称作元注解(meta-annotation)。@Retention(RetentionPolicy·RUNTIME)元注解表明 Test 注解在运行时也应该存在,否则测试工具就无法知道 Test 注解。@Target(ElementType.METHOD)元注解表明,Test注解只在方法声明中才是合法的:它不能运用到类声明、域声明或者其他程序元素上。
注意Test注解声明上方的注释:”Use only on parameterless static method”(只用于无参的静态方法)。如果编译器能够强制这一限制最好,但是它做不到,除非编写一个注解处理器(annotation processor),让它来完成。关于这个主题的更多信息,请参阅javax。annotation·processing 的文档。在没有这类注解处理器的情况下,如果将 Test 注解放在实例方法的声明中,或者放在带有一个或者多个参数的方法中,测试程序还是可以编译,让测试工具在运行时来处理这个问题。
下面就是现实应用中的Test注解,称作标记注解(marker annotation),因为它没有参数,只是”标注”被注解的元素。如果程序员拼错了Test,或者将Test 注解应用到程序元素而非方法声明,程序就无法编译:
1 | //Program containing marker annotations |
Sample类有7个静态方法,其中 4个被注解为测试。这4个中有2个抛出了异常:m3 和m7,另外两个则没有:m1和m5。但是其中一个没有抛出异常的被注解方法:m5,是一个实例方法,因此不属于注解的有效使用。总之,Sample 包含 4项测试:一项会通过,两项会失败,另一项无效。没有用 Test 注解进行标注的另外4个方法会被测试工具忽略。
Test 注解对 Sample 类的语义没有直接的影响。它们只负责提供信息供相关的程序使用。更一般地讲,注解永远不会改变被注解代码的语义,但是使它可以通过工具进行特殊的处理,例如像这种简单的测试运行类:
1 | //Programto process marker annotations |
测试运行工具在命令行上使用完全匹配的类名,并通过调用 Method.invoke 反射式地运行类中所有标注了 Test 注解的方法。isAnnotationPresent 方法告知该工具要运行哪些方法。如果测试方法抛出异常,反射机制就会将它封装在 InvocationTargetException中。该工具捕捉到这个异常,并打印失败报告,包含测试方法抛出的原始异常,这些信息是通过 getCause 方法从 InvocationTargetException 中提取出来的。
如果尝试通过反射调用测试方法时抛出InvocationTargetException之外的任何异常,表明编译时没有捕捉到Test注解的无效用法。这种用法包括实例方法的注解,或者带有一个或多个参数的方法的注解,或者不可访问的方法的注解。测试运行类中的第二个catch 块捕捉到这些 Test用法错误,并打印出相应的错误消息。下面就是 RunTests 在Sample上运行时打印的输出:
1 | public static void Sample.m3() failed: RuntimeException: Boom |
现在我们要针对只在抛出特殊异常时才成功的测试添加支持。为此需要一个新的注解类型:
1 | //Annotation type with a parameter |
这个注解的参数类型是 Class<?extends Throwable>。这个通配符类型有些绕口。它在英语中的意思是:某个扩展 Throwable 的类的Class对象,它允许注解的用户指定任何异常(或错误)类型。这种用法是有限制的类型令牌(bounded type token)(详见第33条)的一个示例。下面就是实际应用中的这个注解。注意类名称被用作了注解参数的值:
1 | //Program containing annotationswith aparameter |
现在我们要修改一下测试运行工具来处理新的注解。这其中包括将以下代码添加到main 方法中:
1 | if (m.isAnnotationPresent(ExceptionTest.class)) { |
这段代码类似于用来处理Test注解的代码,但有一处不同:这段代码提取了注解参数的值,并用它检验该测试抛出的异常是否为正确的类型。没有显式的转换,因此没有出现 ClassCastException 的危险。编译过的测试程序确保它的注解参数表示的是有效的异常类型,需要提醒一点:有可能注解参数在编译时是有效的,但是表示特定异常类型的类文件在运行时却不存在。在这种希望很少出现的情况下,测试运行类会抛出 TypeNot-PresentException 异常。
将上面的异常测试示例再深人一点,想象测试可以在抛出任何一种指定异常时都能够通过。注解机制有一种工具,使得支持这种用法变得十分容易。假设我们将 ExceptionTest注解的参数类型改成 Class 对象的一个数组:
1 | // Annotation type with an array parameter |
注解中数组参数的语法十分灵活。它是进行过优化的单元素数组。使用了 Exception-Test 新版的数组参数之后,之前的所有 ExceptionTest 注解仍然有效,并产生单元素的数组。为了指定多元素的数组,要用花括号将元素包围起来,并用逗号将它们隔开:
1 | // Code containing an annotation with an array parameter |
修改测试运行工具来处理新的 ExceptionTest 相当简单。下面的代码代替了原来的代码:
1 | if (m.isAnnotationPresent(ExceptionTest.class)) { |
从Java8开始,还有另一种方法可以进行多值注解。它不是用一个数组参数声明一个注解类型,而是用 @Repeatable 元注解对注解的声明进行注解,表示该注解可以被重复地应用给单个元素。这个元注解只有一个参数,就是包含注解类型(containing annotationtype)的类对象,它唯一的参数是一个注解类型数组[JLS,9.6.3]。下面的注解声明就是把ExceptionTest 注解改成使用这个方法之后的版本。注意包含的注解类型必须利用适当的保留策略和目标进行注解,否则声明将无法编译:
1 | //Repeatableannotationtype |
下面是 doublyBad 测试方法用重复注解代替数组值注解之后的代码:
1 | // Code containing a repeated annotation |
处理可重复的注解要非常小心。重复的注解会产生一个包含注解类型的合成注解。get-AnnotationsByType方法掩盖了这个事实,可以用于访问可重复注解类型的重复和非重复的注解。但isAnnotationPresent使它变成了显式的,即重复的注解不是注解类型(而是所包含的注解类型)的一部分。如果一个元素具有某种类型的重复注解,并且用isAnnotationPresent方法检验该元素是否具有该类型的注解,会发现它没有。用这种方法检验是否存在注解类型,会导致程序默默地忽略掉重复的注解。同样地,用这种方法检验是否存在包含的注解类型,会导致程序默默地忽略掉非重复的注解。为了利用isAnnotationPresent 检测重复和非重复的注解,必须检查注解类型及其包含的注解类型。下面是 Runtests 程序改成使用 ExceptionTest 注解时有关部分的代码:
1 | // Processing repeatable annotations |
加人可重复的注解,提升了源代码的可读性,逻辑上是将同一个注解类型的多个实例应用到了一个指定的程序元素。如果你觉得它们增强了源代码的可读性就使用它们,但是记住在声明和处理可重复注解的代码中会有更多的样板代码,并且处理可重复的注解容易出错。
本条目中的测试框架只是一个试验,但它清楚地示范了注解之于命名模式的优越性。这只是揭开了注解功能的冰山一角。如果是在编写一个需要程序员给源文件添加信息的工具,就要定义一组适当的注解类型。既然有了注解,就完全没有理由再使用命名模式了。
也就是说,除了”工具铁匠”(toolsmiths,即平台框架程序员)之外,大多数程序员都不必定义注解类型。但是所有的程序员都应该使用Java平台所提供的预定义的注解类型(详见第 40 条和第 27条)。还要考虑使用IDE 或者静态分析工具所提供的任何注解。这种注解可以提升由这些工具所提供的诊断信息的质量。但是要注意这些注解还没有标准化,因此如果变换工具或者形成标准,就有很多工作要做了。
第40条:坚持使用Override注解
Java类库中包含了几种注解类型。对于传统的程序员而言,这里面最重要的就是@Override 注解。这个注解只能用在方法声明中,它表示被注解的方法声明覆盖了超类型中的一个方法声明。如果坚持使用这个注解,可以防止一大类的非法错误。以下面的程序为例,这里的 Bigram 类表示一个双字母组或者有序的字母对:
1 | //Canyouspotthebug? |
主程序反复地将26个双字母组添加到集合中,每个双字母组都由两个相同的小写字母组成。随后它打印出集合的大小。你可能以为程序打印出的大小为26,因为集合不能包含重复。如果你试着运行程序,会发现它打印的不是26而是260。哪里出错了呢?
很显然,Bigram 类的创建者原本想要覆盖equals 方法(详见第 10条),同时还记得覆盖了hashCode(详见第 11 章)。遗憾的是,不幸的程序员没能覆盖equals 方法,而是将它重载了(详见第52条)。为了覆盖Object.equals 必须定义一个参数为 Object 类型的equals 方法,但是 Bigram 类的 equals 方法的参数并不是 Object 类型,因此 Bigram类从 Object继承了equals方法。这个equals方法测试对象的同一性(identity),就像==操作符一样。每个 bigram 的 10 个备份中,每一个都与其余的9个不同,因此 Object.equals认为它们不相等,这正是程序会打印出 260的原因。
幸运的是,编译器可以帮助你发现这个错误,但是只有当你告知编译器你想要覆盖Object.equals时才行。为了做到这一点,要用@Override 标注Bigram.euqals,如下所示:
1 | public boolean equals(Bigram b){ |
如果插人这个注解,并试着重新编译程序,编译器就会产生如下的错误消息:
1 | Bigram.java:10:method does not override or implement a method |
你会立即意识到哪里错了,拍拍自己的头,恍然大悟,马上用正确的来取代出错的equals 实现(详见第10条):
1 | public boolean equals(object o){ |
因此,应该在你想要覆盖超类声明的每个方法声明中使用Override注解。这一规则有个小小的例外。如果你在编写一个没有标注为抽象的类,并且确信它覆盖了超类的抽象方法,在这种情况下,就不必将 Override 注解放在该方法上了。在没有声明为抽象的类中,如果没有覆盖抽象的超类方法,编译器就会发出一条错误消息。但是,你可能希望关注类中所有覆盖超类方法的方法,在这种情况下,也可以放心地标注这些方法。大多数IDE可以设置为在需要覆盖一个方法时自动插人Override 注解。
大多数IDE 都提供了使用Override 注解的另一种理由。如果启用相应的代码检验功能,当有一个方法没有Override 注解,却覆盖了超类方法时,IDE 就会产生一条警告。如果使用了Override注解,这些警告就会提醒你警惕无意识的覆盖。这些警告补充了编译器的错误消息,后者会提醒你警惕无意识的覆盖失败。IDE和编译器可以确保你无一遗漏地覆盖任何你想要覆盖的方法。
Override 注解可以用在方法声明中,覆盖来自接口以及类的声明。由于缺省方法的出现,在接口方法的具体实现上使用Override,可以确保签名正确,这是一个很好的实践。如果知道接口没有缺省方法,可以选择省略接口方法的具体实现上的 Override 注解,以减少混乱。
但是在抽象类或者接口中,还是值得标注所有你想要的方法,来覆盖超类或者超接口方法,无论它们是具体的还是抽象的。例如,Set接口没有给Collection 接口添加新方法,因此它应该在它的所有方法声明中包括Override 注解,以确保它不会意外地给Collection接口添加任何新方法。
总而言之,如果在你想要的每个方法声明中使用Override 注解来覆盖超类声明,编译器就可以替你防止大量的错误,但有一个例外。在具体的类中,不必标注你确信覆盖了抽象方法声明的方法(虽然这么做也没有什么坏处)。
第41条:用标记接口定义类型
标记接口(marker interface)是不包含方法声明的接口,它只是指明(或者”标明”)一个类实现了具有某种属性的接口。例如,考虑 Serializable接口(详见第12章)。通过实现这个接口,类表明它的实例可以被写到ObjectOutputStream 中(或者”被序列化”)。
你可能听说过标记注解(详见第39条)使得标记接口过时了。这种断言是不正确的。标记接口有两点胜过标记注解。首先,也是最重要的一点是,标记接口定义的类型是由被标记类的实例实现的;标记注解则没有定义这样的类型。标记接口类型的存在,允许你在编译时就能捕捉到在使用标记注解的情况下要到运行时才能捕捉到的错误。
序列化的。ObjectoutputStream.writeObject方法将传人的对象序列化,其参数必须是可序列化的。该方法的参数类型应该为 Serializable,如果试着序列化一个不恰当的对象,(通过类型检查)在编译时就会被发现。编译时的错误侦测是标记接口的目的,但遗憾的是,ObjectOutputStream.write API并没有利用 Serializable接口的优势:其参数声明为Object类型,因此,如果尝试序列化一个不可序列化的对象,将直到程序运行时才会失败。
标记接口胜过标记注解的另一个优点是,它们可以被更加精确地进行锁定。如果注解类型用目标 ElementType.TYPE声明,它就可以被应用于任何类或者接口。假设有一个标记它适用的接口,确保所有被标记的类型也都是该唯一接口的子类型。
Set接口可以说就是这种有限制的标记接口(restricted marker interface)。它只适用于 Collection 子类型,但是它不会添加除了Collection 定义之外的方法。一般情况下,不把它当作是标记接口,因为它改进了几个Collection 方法的合约,包括 add、equals 和hashCode。但是很容易想象只适用于某种特殊接口的子类型的标记接口,它没有改进接口的任何方法的合约。这种标记接口可以描述整个对象的某个约束条件,或者表ObjectOutputStream 进行处理一样)。
标记注解胜过标记接口的最大优点在于,它们是更大的注解机制的一部分。因此,标记注解在那些支持注解作为编程元素之一的框架中同样具有一致性。
那么什么时候应该使用标记注解,什么时候应该使用标记接口呢?很显然,如果标记是应用于任何程序元素而不是类或者接口,就必须使用注解,因为只有类和接口可以用来实现或者扩展接口。如果标记只应用于类和接口,就要问问自己:我要编写一个还是多个只接受有这种标记的方法呢?如果是这种情况,就应该优先使用标记接口而非注解。这样你就可以用接口作为相关方法的参数类型,它可以真正为你提供编译时进行类型检查的好处。如果外,如果标记是广泛使用注解的框架的一个组成部分,则显然应该选择标记注解。
总而言之,标记接口和标记注解都各有用处。如果想要定义一个任何新方法都不会与之关联的类型,标记接口就是最好的选择。如果想要标记程序元素而非类和接口,或者标记要适合于已经广泛使用了注解类型的框架,那么标记注解就是正确的选择。 如果你发现自己在编写的是目标为ElementType.TYPE的标记注解类型,就要花点时间考虑清楚,它是否真的应该为注解类型,想想标记接口是否会更加合适。
从某种意义上说,本条目与第22条中”如果不想定义类型就不要使用接口”的说法相反。本条目最接近的意思是说:”如果想要定义类型,一定要使用接口。”
- 本文标题:读《Effective Java》前六章
- 本文作者:形而上
- 创建时间:2021-12-01 05:56:00
- 本文链接:https://deepter.gitee.io/2021_12_01_effective_java/
- 版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!