16. 在公共类中使用访问方法而不是公共属性
有时候,你可能会试图写一些退化的类(degenerate classes),除了集中实例属性之外别无用处:
1 | // Degenerate classes like this should not be public! |
由于这些类的数据属性可以直接被访问,因此这些类不提供封装的好处(条目 15)。 如果不更改API,则无法更改其表示形式,无法强制执行不变量,并且在访问属性时无法执行辅助操作。 坚持面向对象的程序员觉得这样的类是厌恶的,应该被具有私有属性和公共访问方法的类(getter)所取代,而对于可变类来说,它们应该被替换为setter设值方法:
1 | // Encapsulation of data by accessor methods and mutators |
当然,对于公共类来说,坚持面向对象是正确的:如果一个类在其包之外是可访问的,则提供访问方法来保留更改类内部表示的灵活性。如果一个公共类暴露其数据属性,那么以后更改其表示形式基本上没有可能,因为客户端代码可以散布在很多地方。
但是,如果一个类是包级私有的,或者是一个私有的内部类,那么暴露它的数据属性就没有什么本质上的错误——假设它们提供足够描述该类提供的抽象。在类定义和使用它的客户端代码中,这种方法比访问方法产生更少的视觉混乱。 虽然客户端代码绑定到类的内部表示,但是这些代码仅限于包含该类的包。 如果类的内部表示是可取的,可以在不触碰包外的任何代码的情况下进行更改。 在私有内部类的情况下,更改作用范围进一步限制在封闭类中。
Java平台类库中的几个类违反了公共类不应直接暴露属性的建议。 着名的例子包括java.awt包中的Point
和Dimension
类。 这些类别应该被视为警示性的示例,而不是模仿的例子。 如条目 67所述,暴露Dimension
的内部结构的决定是一个严重的性能问题,这个问题在今天仍然存在。
虽然公共类直接暴露属性并不是一个好主意,但是如果属性是不可变的,那么危害就不那么大了。当一个属性是只读的时候,除了更改类的API外,你不能改变类的内部表示形式,也不能采取一些辅助的行为,但是可以加强不变性。例如,下面的例子中保证每个实例表示一个有效的时间:
1 | // Public class with exposed immutable fields - questionable |
总之,公共类不应该暴露可变属性。 公共累暴露不可变属性的危害虽然仍然存在问题,但其危害较小。 然而,有时需要包级私有或私有内部类来暴露属性,无论此类是否是可变的。
17. 最小化可变性
不可变类简单来说是它的实例不能被修改的类。 包含在每个实例中的所有信息在对象的生命周期中是固定的,因此不会观察到任何变化。 Java平台类库包含许多不可变的类,包括String类,基本类型包装类以及BigInteger类和BigDecimal类。 有很多很好的理由:不可变类比可变类更容易设计,实现和使用。 他们不太容易出错,更安全。
要使一个类不可变,请遵循以下五条规则:
- 不要提供修改对象状态的方法(也称为mutators)。
- 确保这个类不能被继承。 这可以防止粗心的或恶意的子类,假设对象的状态已经改变,从而破坏类的不可变行为。 防止子类化通常是通过final修饰类,但是我们稍后将讨论另一种方法。
- 把所有属性设置为final。通过系统强制执行,清楚地表达了你的意图。 另外,如果一个新创建的实例的引用从一个线程传递到另一个线程而没有同步,就必须保证正确的行为,正如内存模型[JLS,17.5; Goetz06,16]所述。
- 把所有的属性设置为private。 这可以防止客户端获得对属性引用的可变对象的访问权限并直接修改这些对象。 虽然技术上允许不可变类具有包含基本类型数值的公共final属性或对不可变对象的引用,但不建议这样做,因为它不允许在以后的版本中更改内部表示(项目15和16)。
- 确保对任何可变组件的互斥访问。 如果你的类有任何引用可变对象的属性,请确保该类的客户端无法获得对这些对象的引用。 切勿将这样的属性初始化为客户端提供的对象引用,或从访问方法返回属性。 在构造方法,访问方法和
readObject方法
(条目 88)中进行防御性拷贝(条目 50)。
以前条目中的许多示例类都是不可变的。 其中这样的类是条目 11中的PhoneNumber
类,它具有每个属性的访问方法(accessors),但没有相应的设值方法(mutators)。 这是一个稍微复杂一点的例子:
1 | // Immutable complex number class |
这个类代表了一个复数(包含实部和虚部的数字)。 除了标准的Object方法之外,它还为实部和虚部提供访问方法,并提供四个基本的算术运算:加法,减法,乘法和除法。 注意算术运算如何创建并返回一个新的Complex
实例,而不是修改这个实例。 这种模式被称为函数式方法,因为方法返回将操作数应用于函数的结果,而不修改它们。 与其对应的过程(procedural)或命令(imperative)的方法相对比,在这种方法中,将一个过程作用在操作数上,导致其状态改变。 请注意,方法名称是介词(如plus)而不是动词(如add)。 这强调了方法不会改变对象的值的事实。 BigInteger
和BigDecimal
类没有遵守这个命名约定,并导致许多使用错误。
如果你不熟悉函数式方法,可能会显得不自然,但它具有不变性,具有许多优点。 不可变对象很简单。 一个不可变的对象可以完全处于一种状态,也就是被创建时的状态。 如果确保所有的构造方法都建立了类不变量,那么就保证这些不变量在任何时候都保持不变,使用此类的程序员无需再做额外的工作。 另一方面,可变对象可以具有任意复杂的状态空间。 如果文档没有提供由设置(mutator)方法执行的状态转换的精确描述,那么可靠地使用可变类可能是困难的或不可能的。
不可变对象本质上是线程安全的; 它们不需要同步。 被多个线程同时访问它们时并不会被破坏。 这是实现线程安全的最简单方法。 由于没有线程可以观察到另一个线程对不可变对象的影响,所以不可变对象可以被自由地共享。 因此,不可变类应鼓励客户端尽可能重用现有的实例。 一个简单的方法是为常用的值提供公共的静态 final常量。 例如,Complex
类可能提供这些常量:
1 | public static final Complex ZERO = new Complex(0, 0); |
这种方法可以更进一步。 一个不可变的类可以提供静态的工厂(条目 1)来缓存经常被请求的实例,以避免在现有的实例中创建新的实例。 所有基本类型的包装类和BigInteger
类都是这样做的。 使用这样的静态工厂会使客户端共享实例而不是创建新实例,从而减少内存占用和垃圾回收成本。 在设计新类时,选择静态工厂代替公共构造方法,可以在以后增加缓存的灵活性,而不需要修改客户端。
不可变对象可以自由分享的结果是,你永远不需要做出防御性拷贝( defensive copies)(条目 50)。 事实上,永远不需要做任何拷贝,因为这些拷贝永远等于原始对象。 因此,你不需要也不应该在一个不可变的类上提供一个clone方法或拷贝构造方法(copy constructor)(条目 13)。 这一点在Java平台的早期阶段还不是很好理解,所以String类有一个拷贝构造方法,但是它应该尽量很少使用(条目 6)。
不仅可以共享不可变的对象,而且可以共享内部信息。 例如,BigInteger
类在内部使用符号数值表示法。 符号用int值表示,数值用int数组表示。 negate
方法生成了一个数值相同但符号相反的新BigInteger
实例。 即使它是可变的,也不需要复制数组;新创建的BigInteger
指向与原始相同的内部数组。
不可变对象为其他对象提供了很好的构件(building blocks),无论是可变的还是不可变的。 如果知道一个复杂组件的内部对象不会发生改变,那么维护复杂对象的不变量就容易多了。这一原则的特例是,不可变对象可以构成Map
对象的键和Set
的元素,一旦不可变对象作为Map
的键或Set
里的元素,即使破坏了Map
和Set
的不可变性,但不用担心它们的值会发生变化。
不可变对象提供了免费的原子失败机制(条目 76)。它们的状态永远不会改变,所以不可能出现临时的不一致。
不可变类的主要缺点是对于每个不同的值都需要一个单独的对象。 创建这些对象可能代价很高,特别是如果是大型的对象下。 例如,假设你有一个百万位的BigInteger
,你想改变它的低位:
1 | BigInteger moby = ...; |
flipBit
方法创建一个新的BigInteger
实例,也是一百万位长,与原始位置只有一位不同。 该操作需要与BigInteger
大小成比例的时间和空间。 将其与java.util.BitSet
对比。 像BigIntege
r一样,BitSet
表示一个任意长度的位序列,但与BigInteger
不同,BitSe
t是可变的。 BitSet
类提供了一种方法,允许你在固定时间内更改百万位实例中单个位的状态:
1 | BitSet moby = ...; |
如果执行一个多步操作,在每一步生成一个新对象,除最终结果之外丢弃所有对象,则性能问题会被放大。这里有两种方式来处理这个问题。第一种办法,先猜测一下会经常用到哪些多步的操作,然后讲它们作为基本类型提供。如果一个多步操作是作为一个基本类型提供的,那么不可变类就不必在每一步创建一个独立的对象。在内部,不可变的类可以是任意灵活的。 例如,BigInteger
有一个包级私有的可变的“伙伴类(companion class)”,它用来加速多步操作,比如模幂运算( modular exponentiation)。出于前面所述的所有原因,使用可变伙伴类比使用BigInteger要困难得多。 幸运的是,你不必使用它:BigInteger
类的实现者为你做了很多努力。
如果你可以准确预测客户端要在你的不可变类上执行哪些复杂的操作,那么包级私有可变伙伴类的方式可以正常工作。如果不是的话,那么最好的办法就是提供一个公开的可变伙伴类。 这种方法在Java平台类库中的主要例子是String类,它的可变伙伴类是StringBuilder
(及其过时的前身StringBuffer
类)。
现在你已经知道如何创建一个不可改变类,并且了解不变性的优点和缺点,下面我们来讨论几个设计方案。 回想一下,为了保证不变性,一个类不得允许子类化。 这可以通过使类用 final 修饰,但是还有另外一个更灵活的选择。 而不是使不可变类设置为 final,可以使其所有的构造方法私有或包级私有,并添加公共静态工厂,而不是公共构造方法(条目 1)。 为了具体说明这种方法,下面以Complex
为例,看看如何使用这种方法:
1 | // Immutable class with static factories instead of constructors |
这种方法往往是最好的选择。 这是最灵活的,因为它允许使用多个包级私有实现类。 对于驻留在包之外的客户端,不可变类实际上是final的,因为不可能继承来自另一个包的类,并且缺少公共或受保护的构造方法。 除了允许多个实现类的灵活性以外,这种方法还可以通过改进静态工厂的对象缓存功能来调整后续版本中类的性能。
当BigInteger
和BigDecimal
被写入时,不可变类必须是有效的final,因此它们的所有方法都可能被重写。不幸的是,在保持向后兼容性的同时,这一事实无法纠正。如果你编写一个安全性取决于来自不受信任的客户端的BigIntege
r或BigDecimal
参数的不变类时,则必须检查该参数是“真实的”BigInteger
还是BigDecimal
,而不应该是不受信任的子类的实例。如果是后者,则必须在假设可能是可变的情况下保护性拷贝(defensively copy)(条目 50):
1 | public static BigInteger safeInstance(BigInteger val) { |
在本条目开头关于不可变类的规则说明,没有方法可以修改对象,并且它的所有属性必须是final的。事实上,这些规则比实际需要的要强硬一些,其实可以有所放松来提高性能。 事实上,任何方法都不能在对象的状态中产生外部可见的变化。 然而,一些不可变类具有一个或多个非final属性,在第一次需要时将开销昂贵的计算结果缓存在这些属性中。 如果再次请求相同的值,则返回缓存的值,从而节省了重新计算的成本。 这个技巧的作用恰恰是因为对象是不可变的,这保证了如果重复的话,计算会得到相同的结果。
例如,PhoneNumber
类的hashCode
方法(第53页的条目 11)在第一次调用改方法时计算哈希码,并在再次调用时对其进行缓存。 这种延迟初始化(条目 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的。
构造方法应该创建完全初始化的对象,并建立所有的不变性。 除非有令人信服的理由,否则不要提供独立于构造方法或静态工厂的公共初始化方法。 同样,不要提供一个“reinitialize”方法,使对象可以被重用,就好像它是用不同的初始状态构建的。 这样的方法通常以增加的复杂度为代价,仅仅提供很少的性能优势。
CountDownLatch
类是这些原理的例证。 它是可变的,但它的状态空间有意保持最小范围内。 创建一个实例,使用它一次,并完成:一旦countdown锁的计数器已经达到零,不能再重用它。
在这个条目中,应该添加关于Complex
类的最后一个注释。 这个例子只是为了说明不变性。 这不是一个工业强度复杂的复数实现。 它对复数使用了乘法和除法的标准公式,这些公式不正确会进行不正确的四舍五入,没有为复数的NaN和无穷大提供良好的语义[Kahan91,Smith62,Thomas94]。
18. 组合优于继承
继承是实现代码重用的有效方式,但并不总是最好的工具。使用不当,会导致脆弱的软件。 在包中使用继承是安全的,其中子类和父类的实现都在同一个程序员的控制之下。对应专门为了继承而设计的,并且有文档说明的类来说(条目 19),使用继承也是安全的。 然而,从普通的具体类跨越包级边界继承,是危险的。 提醒一下,本书使用“继承”一词来表示实现继承(当一个类继承另一个类时)。 在这个项目中讨论的问题不适用于接口继承(当类实现接口或当接口继承另一个接口时)。
与方法调用不同,继承打破了封装[Snyder86]。 换句话说,一个子类依赖于其父类的实现细节来保证其正确的功能。 父类的实现可能会从发布版本不断变化,如果是这样,子类可能会被破坏,即使它的代码没有任何改变。 因此,一个子类必须与其超类一起更新而变化,除非父类的作者为了继承的目的而专门设计它,并对应有文档的说明。
为了具体说明,假设有一个使用HashSet
的程序。 为了调整程序的性能,需要查询HashSe
,从创建它之后已经添加了多少个元素(不要和当前的元素数量混淆,当元素被删除时数量也会下降)。 为了提供这个功能,编写了一个HashSet
变体,它保留了尝试元素插入的数量,并导出了这个插入数量的一个访问方法。 HashSet
类包含两个添加元素的方法,分别是add
和addAll
,所以我们重写这两个方法:
1 | // Broken - Inappropriate use of inheritance! |
这个类看起来很合理,但是不能正常工作。 假设创建一个实例并使用addAll
方法添加三个元素。 顺便提一句,请注意,下面代码使用在Java 9中添加的静态工厂方法List.of
来创建一个列表;如果使用的是早期版本,请改为使用Arrays.asList
:
1 | InstrumentedHashSet<String> s = new InstrumentedHashSet<>(); |
我们期望getAddCount
方法返回的结果是3,但实际上返回了6。哪里出来问题?在HashSet
内部,addAll
方法是基于它的add
方法来实现的,即使HashSet
文档中没有指名其实现细节,倒也是合理的。InstrumentedHashSet
中的addAll
方法首先给addCount
属性设置为3,然后使用super.addAll
方法调用了HashSet
的addAll
实现。然后反过来又调用在InstrumentedHashSet
类中重写的add
方法,每个元素调用一次。这三次调用又分别给addCount
加1,所以,一共增加了6:通过addAll
方法每个增加的元素都被计算了两次。
我们可以通过消除addAll
方法的重写来“修复”子类。 尽管生成的类可以正常工作,但是它依赖于它的正确方法,因为HashSet
的addAll
方法是在其add
方法之上实现的。 这个“自我使用(self-use)”是一个实现细节,并不保证在Java平台的所有实现中都可以适用,并且可以随发布版本而变化。 因此,产生的InstrumentedHashSet
类是脆弱的。
稍微好一点的做法是,重写addAll
方法遍历指定集合,为每个元素调用add
方法一次。 不管HashSet
的addAll
方法是否在其add
方法上实现,都会保证正确的结果,因为HashSet
的addAll
实现将不再被调用。然而,这种技术并不能解决所有的问题。 这相当于重新实现了父类方法,这样的方法可能不能确定到底是否时自用(self-use)的,实现起来也是困难的,耗时的,容易出错的,并且可能会降低性能。 此外,这种方式并不能总是奏效,因为子类无法访问一些私有属性,所以有些方法就无法实现。
导致子类脆弱的一个相关原因是,它们的父类在后续的发布版本中可以添加新的方法。假设一个程序的安全性依赖于这样一个事实:所有被插入到集中的元素都满足一个先决条件。可以通过对集合进行子类化,然后并重写所有添加元素的方法,以确保在添加每个元素之前满足这个先决条件,来确保这一问题。如果在后续的版本中,父类没有新增添加元素的方法,那么这样做没有问题。但是,一旦父类增加了这样的新方法,则很有肯能由于调用了未被重写的新方法,将非法的元素添加到子类的实例中。这不是个纯粹的理论问题。在把Hashtable
和Vector
类加入到Collections框架中的时候,就修复了几个类似性质的安全漏洞。
这两个问题都源于重写方法。 如果仅仅添加新的方法并且不要重写现有的方法,可能会认为继承一个类是安全的。 虽然这种扩展更为安全,但这并非没有风险。 如果父类在后续版本中添加了一个新的方法,并且你不幸给了子类一个具有相同签名和不同返回类型的方法,那么你的子类编译失败[JLS,8.4.8.3]。 如果已经为子类提供了一个与新的父类方法具有相同签名和返回类型的方法,那么你现在正在重写它,因此将遇到前面所述的问题。 此外,你的方法是否会履行新的父类方法的约定,这是值得怀疑的,因为在你编写子类方法时,这个约定还没有写出来。
幸运的是,有一种方法可以避免上述所有的问题。不要继承一个现有的类,而应该给你的新类增加一个私有属性,该属性是 现有类的实例引用,这种设计被称为组合(composition),因为现有的类成为新类的组成部分。新类中的每个实例方法调用现有类的包含实例上的相应方法并返回结果。这被称为转发(forwarding),而新类中的方法被称为转发方法。由此产生的类将坚如磐石,不依赖于现有类的实现细节。即使将新的方法添加到现有的类中,也不会对新类产生影响。为了具体说用,下面代码使用组合和转发方法替代InstrumentedHashSet
类。请注意,实现分为两部分,类本身和一个可重用的转发类,其中包含所有的转发方法,没有别的方法:
1 | // Reusable forwarding class |
1 | // Wrapper class - uses composition in place of inheritance |
InstrumentedSet
类的设计是通过存在的Set接口来实现的,该接口包含HashSet
类的功能特性。除了功能强大,这个设计是非常灵活的。InstrumentedSet
类实现了Set接口,并有一个构造方法,其参数也是Set类型的。本质上,这个类把Set
转换为另一个类型Set
, 同时添加了计数的功能。与基于继承的方法不同,该方法仅适用于单个具体类,并且父类中每个需要支持构造方法,提供单独的构造方法,所以可以使用包装类来包装任何Set
实现,并且可以与任何预先存在的构造方法结合使用:
1 | Set<Instant> times = new InstrumentedSet<>(new TreeSet<>(cmp)); |
InstrumentedSet
类甚至可以用于临时替换没有计数功能下使用的集合实例:
1 | static void walk(Set<Dog> dogs) { |
InstrumentedSet
类被称为包装类,因为每个InstrumentedSet
实例都包含(“包装”)另一个Set
实例。 这也被称为装饰器模式[Gamma95],因为InstrumentedSet
类通过添加计数功能来“装饰”一个集合。 有时组合和转发的结合被不精确地地称为委托(delegation)。 从技术上讲,除非包装对象把自身传递给被包装对象,否则不是委托[Lieberman86;Gamma95]。
包装类的缺点很少。 一个警告是包装类不适合在回调框架(callback frameworks)中使用,其中对象将自我引用传递给其他对象以用于后续调用(“回调”)。 因为一个被包装的对象不知道它外面的包装对象,所以它传递一个指向自身的引用(this),回调时并不记得外面的包装对象。 这被称为SELF问题[Lieberman86]。 有些人担心转发方法调用的性能影响,以及包装对象对内存占用。 两者在实践中都没有太大的影响。 编写转发方法有些繁琐,但是只需为每个接口编写一次可重用的转发类,并且提供转发类。 例如,Guava为所有的Collection接口提供转发类[Guava]。
只有在子类真的是父类的子类型的情况下,继承才是合适的。 换句话说,只有在两个类之间存在“is-a”关系的情况下,B类才能继承A类。 如果你试图让B类继承A类时,问自己这个问题:每个B都是A吗? 如果你不能如实回答这个问题,那么B就不应该继承A。如果答案是否定的,那么B通常包含一个A的私有实例,并且暴露一个不同的API:A不是B的重要部分 ,只是其实现细节。
在Java平台类库中有一些明显的违反这个原则的情况。 例如,stacks实例并不是vector实例,所以Stack
类不应该继承Vector
类。 同样,一个属性列表不是一个哈希表,所以Properties
不应该继承Hashtable
类。 在这两种情况下,组合方式更可取。
如果在合适组合的地方使用继承,则会不必要地公开实现细节。由此产生的API将与原始实现联系在一起,永远限制类的性能。更严重的是,通过暴露其内部,客户端可以直接访问它们。至少,它可能导致混淆语义。例如,属性p指向Properties
实例,那么 p.getProperty(key)
和p.get(key)
就有可能返回不同的结果:前者考虑了默认的属性表,而后者是继承Hashtable
的,它则没有考虑默认属性列表。最严重的是,客户端可以通过直接修改超父类来破坏子类的不变性。在Properties
类,设计者希望只有字符串被允许作为键和值,但直接访问底层的Hashtable
允许违反这个不变性。一旦违反,就不能再使用属性API的其他部分(load
和store
方法)。在发现这个问题的时候,纠正这个问题为时已晚,因为客户端依赖于使用非字符串键和值了。
在决定使用继承来代替组合之前,你应该问自己最后一组问题。对于试图继承的类,它的API有没有缺陷呢? 如果有,你是否愿意将这些缺陷传播到你的类的API中?继承传播父类的API中的任何缺陷,而组合可以让你设计一个隐藏这些缺陷的新API。
总之,继承是强大的,但它是有问题的,因为它违反封装。 只有在子类和父类之间存在真正的子类型关系时才适用。 即使如此,如果子类与父类不在同一个包中,并且父类不是为继承而设计的,继承可能会导致脆弱性。 为了避免这种脆弱性,使用合成和转发代替继承,特别是如果存在一个合适的接口来实现包装类。 包装类不仅比子类更健壮,而且更强大。
19. 如果使用继承则设计,并文档说明,否则不该使用
条目 18中提醒你注意继承没有设计和文档说明的“外来”类的子类化的危险。 那么为了继承而设计和文档说明一个类是什么意思呢?
首先,这个类必须准确地描述重写这个方法带来的影响。 换句话说,该类必须文档说明可重写方法的自用性(self-use)。 对于每个公共或受保护的方法,文档必须指明方法调用哪些重写方法,以何种顺序以及每次调用的结果如何影响后续处理。 (重写方法,这里是指非final修饰的方法,无论是公开还是保护的。)更一般地说,一个类必须文档说明任何可能调用可重写方法的情况。 例如,后台线程或者静态初始化代码块可能会调用这样的方法。
调用可重写方法的方法在文档注释结束时包含对这些调用的描述。 这些描述在规范中特定部分,标记为“Implementation Requirements,”,由Javadoc标签@implSpec
生成。 本节介绍该方法的内部工作原理。 下面是从java.util.AbstractCollection
类的规范中拷贝的例子:
1 | public boolean remove(Object o) |
从该集合中删除指定元素的单个实例(如果存在,optional
实例操作)。 更正式地说,如果这个集合包含一个或多个这样的元素,删除使得Objects.equals(o, e)
的一个元素e。 如果此集合包含指定的元素(或者等同于此集合因调用而发生了更改),则返回true。
实现要求:这个实现迭代遍历集合查找指定元素。 如果找到元素,则使用迭代器的remove
方法从集合中删除元素。 请注意,如果此集合的iterator
方法返回的迭代器未实现remove
方法,并且此集合包含指定的对象,则此实现将引发UnsupportedOperationException
异常。
这个文档毫无疑问地说明,重写iterator
方法会影响remove
方法的行为。 它还描述了iterator
方法返回的Iterator行为将如何影响remove
方法的行为。 与条目 18中的情况相反,在这种情况下,程序员继承HashSet
并不能说明重写add方法是否会影响addAll方法的行为。
但是,这是否违背了一个良好的API文档应该描述给定的方法是什么,而不是它是如何做的呢? 是的,它确实!这是继承违反封装这一事实的不幸后果。要文档说明一个类以便可以安全地进行子类化,必须描述清楚那些没有详细说明的实现细节。
@implSpec
标签是在Java 8中添加的,并且在Java 9中被大量使用。这个标签应该默认启用,但是从Java 9开始,除非通过命令行开关-tag "apiNote:a:API Note:”
,否则Javadoc实用工具仍然会忽略它。
设计继承涉及的不仅仅是文档说明自用的模式。 为了让程序员能够写出有效的子类而不会带来不适当的痛苦,一个类可能以明智选择的受保护方法的形式提供内部工作,或者在罕见的情况下,提供受保护的属性。 例如,考虑java.util.AbstractList
中的removeRange
方法:
1 | protected void removeRange(int fromIndex, int toIndex) |
从此列表中删除索引介于fromIndex
(包含)和inclusive
(不含)之间的所有元素。 将任何后续元素向左移(减少索引)。 这个调用通过(toIndex - fromIndex)
元素来缩短列表。 (如果toIndex == fromIndex
,则此操作无效。)
这个方法是通过列表及其子类的clear操作来调用的。重写这个方法利用列表内部实现的优势,可以大大提高列表和子类的clear操作性能。
实现要求:这个实现获取一个列表迭代器,它位于fromIndex
之前,并重复调用ListIterator.remove
和ListIterator.next
方法,直到整个范围被删除。 注意:如果ListIterator.remove
需要线性时间,则此实现需要平方级时间。
参数:
fromIndex 要移除的第一个元素的索引
toIndex 要移除的最后一个元素之后的索引
这个方法对List实现的最终用户来说是没有意义的。 它仅仅是为了使子类很容易提供一个快速clear方法。 在没有removeRange
方法的情况下,当在子列表上调用clear方法,子类将不得不使用平方级的时间,否则,或从头重写整个subList机制——这不是一件容易的事情!
那么当你设计一个继承类的时候,你如何决定暴露哪些的受保护的成员呢? 不幸的是,没有灵丹妙药。 所能做的最好的就是努力思考,做出最好的测试,然后通过编写子类来进行测试。 应该尽可能少地暴露受保护的成员,因为每个成员都表示对实现细节的承诺。 另一方面,你不能暴露太少,因为失去了保护的成员会导致一个类几乎不能用于继承。
测试为继承而设计的类的唯一方法是编写子类。 如果你忽略了一个关键的受保护的成员,试图编写一个子类将会使得遗漏痛苦地变得明显。 相反,如果编写的几个子类,而且没有一个使用受保护的成员,那么应该将其设为私有。 经验表明,三个子类通常足以测试一个可继承的类。 这些子类应该由父类作者以外的人编写。
当你为继承设计一个可能被广泛使用的类的时候,要意识到你永远承诺你文档说明的自用模式以及隐含在其保护的方法和属性中的实现决定。 这些承诺可能会使后续版本中改善类的性能或功能变得困难或不可能。 因此,在发布它之前,你必须通过编写子类来测试你的类。
另外,请注意,继承所需的特殊文档混乱了正常的文档,这是为创建类的实例并在其上调用方法的程序员设计的。 在撰写本文时,几乎没有工具将普通的API文档从和仅仅针对子类实现的信息,分离出来。
还有一些类必须遵守允许继承的限制。 构造方法绝不能直接或间接调用可重写的方法。 如果违反这个规则,将导致程序失败。 父类构造方法在子类构造方法之前运行,所以在子类构造方法运行之前,子类中的重写方法被调用。 如果重写方法依赖于子类构造方法执行的任何初始化,则此方法将不会按预期运行。 为了具体说明,这是一个违反这个规则的类:
1 | public class Super { |
以下是一个重写overrideMe
方法的子类,Super类的唯一构造方法会错误地调用它:
1 | public final class Sub extends Super { |
你可能期望这个程序打印两次instant
实例,但是它第一次打印出null,因为在Sub构造方法有机会初始化instant
属性之前,overrideMe
被Super构造方法调用。 请注意,这个程序观察两个不同状态的final属性! 还要注意的是,如果overrideMe
方法调用了instant
实例中任何方法,那么当父类构造方法调用overrideMe
时,它将抛出一个NullPointerException
异常。 这个程序不会抛出NullPointerException
的唯一原因是println
方法容忍null参数。
请注意,从构造方法中调用私有方法,其中任何一个方法都不可重写的,那么final方法和静态方法是安全的。
Cloneable
和Serializable
接口在设计继承时会带来特殊的困难。 对于为继承而设计的类来说,实现这些接口通常不是一个好主意,因为这会给继承类的程序员带来很大的负担。 然而,可以采取特殊的行动来允许子类实现这些接口,而不需要强制这样做。 这些操作在条目 13和条目 86中有描述。
如果你决定在为继承而设计的类中实现Cloneable
或Serializable
接口,那么应该知道,由于clone
和readObjec
t方法与构造方法相似,所以也有类似的限制:clone和readObject都不会直接或间接调用可重写的方法。在readObject
的情况下,重写方法将在子类的状态被反序列化之前运行。 在clone
的情况下,重写方法将在子类的clone
方法有机会修复克隆的状态之前运行。 在任何一种情况下,都可能会出现程序故障。 在clone
的情况下,故障可能会损坏原始对象以及被克隆对象本身。 例如,如果重写方法假定它正在修改对象的深层结构的拷贝,但是尚未创建拷贝,则可能发生这种情况。
最后,如果你决定在为继承设计的类中实现Serializable
接口,并且该类有一个readResolve
或writeReplace
方法,则必须使readResolve
或writeReplace
方法设置为受保护而不是私有。 如果这些方法是私有的,它们将被子类无声地忽略。 这是另一种情况,把实现细节成为类的API的一部分,以允许继承。
到目前为止,设计一个继承类需要很大的努力,并且对这个类有很大的限制。 这不是一个轻率的决定。 有些情况显然是正确的,比如抽象类,包括接口的骨架实现(skeletal implementations)(条目 20)。 还有其他的情况显然是错误的,比如不可变的类(条目 17)。
但是普通的具体类呢? 传统上,它们既不是final的,也不是为了子类化而设计和文档说明的,但是这种情况是危险的。每次修改这样的类,则继承此类的子类将被破坏。 这不仅仅是一个理论问题。 在修改非final的具体类的内部之后,接收与子类相关的错误报告并不少见,这些类没有为继承而设计和文档说明。
解决这个问题的最好办法是,在没有想要安全地子类化的设计和文档说明的类中禁止子类化。 有两种方法禁止子类化。 两者中较容易的是声明类为final。 另一种方法是使所有的构造方法都是私有的或包级私有的,并且添加公共静态工厂来代替构造方法。 这个方案在内部提供了使用子类的灵活性,在条目 17中讨论过。两种方法都是可以接受的。
这个建议可能有些争议,因为许多程序员已经习惯于继承普通的具体类来增加功能,例如通知和同步等功能,或限制原有类的功能。 如果一个类实现了捕获其本质的一些接口,比如Set,List或Map,那么不应该为了禁止子类化而感到愧疚。 在条目 18中描述的包装类模式为增强功能提供了继承的优越选择。
如果一个具体的类没有实现一个标准的接口,那么你可能会通过禁止继承来给一些程序员带来不便。 如果你觉得你必须允许从这样的类继承,一个合理的方法是确保类从不调用任何可重写的方法,并文档说明这个事实。 换句话说,完全消除类的自用(self-use)的可重写的方法。 这样做,你将创建一个合理安全的子类。 重写一个方法不会影响任何其他方法的行为。
你可以机械地消除类的自我使用的重写方法,而不会改变其行为。 将每个可重写的方法的主体移动到一个私有的“帮助器方法”,并让每个可重写的方法调用其私有的帮助器方法。 然后用直接调用可重写方法的专用帮助器方法来替换每个自用的可重写方法。
你可以机械地消除类的自用的重写方法,而不会改变其行为。 将每个可重写的方法的主体移到一个私有的“辅助方法(helper method)”,并让每个可重写的方法调用其私有的辅助方法。 然后用直接调用可重写方法的专用辅助方法来替换每个自用的可重写方法。
总之,设计一个继承类是一件很辛苦的事情。 你必须文档说明所有的自用模式,一旦你文档说明了它们,必须承诺为他们的整个生命周期。 如果你不这样做,子类可能会依赖于父类的实现细节,并且如果父类的实现发生改变,子类可能会损坏。 为了允许其他人编写高效的子类,可能还需要导出一个或多个受保护的方法。 除非你知道有一个真正的子类需要,否则你可能最好是通过声明你的类为final禁止继承,或者确保没有可访问的构造方法。
20. 接口优于抽象类
Java有两种机制来定义允许多个实现的类型:接口和抽象类。 由于在Java 8 [JLS 9.4.3]中引入了接口的默认方法(default methods ),因此这两种机制都允许为某些实例方法提供实现。 一个主要的区别是要实现由抽象类定义的类型,类必须是抽象类的子类。 因为Java只允许单一继承,所以对抽象类的这种限制严格限制了它们作为类型定义的使用。 任何定义所有必需方法并服从通用约定的类都可以实现一个接口,而不管类在类层次结构中的位置。
现有的类可以很容易地进行改进来实现一个新的接口。 你只需添加所需的方法(如果尚不存在的话),并向类声明中添加一个implements
子句。 例如,当Comparable
, Iterable
, 和Autocloseable
接口添加到Java平台时,很多现有类需要实现它们来加以改进。 一般来说,现有的类不能改进以继承一个新的抽象类。 如果你想让两个类继承相同的抽象类,你必须把它放在类型层级结构中的上面位置,它是两个类的祖先。 不幸的是,这会对类型层级结构造成很大的附带损害,迫使新的抽象类的所有后代对它进行子类化,无论这些后代类是否合适。
接口是定义混合类型(mixin)的理想选择。 一般来说,mixin是一个类,除了它的“主类型”之外,还可以声明它提供了一些可选的行为。 例如,Comparable
是一个类型接口,它允许一个类声明它的实例相对于其他可相互比较的对象是有序的。 这样的接口被称为类型,因为它允许可选功能被“混合”到类型的主要功能。 抽象类不能用于定义混合类,这是因为它们不能被加载到现有的类中:一个类不能有多个父类,并且在类层次结构中没有合理的位置来插入一个类型。
接口允许构建非层级类型的框架。 类型层级对于组织某些事物来说是很好的,但是其他的事物并不是整齐地落入严格的层级结构中。 例如,假设我们有一个代表歌手的接口,另一个代表作曲家的接口:
1 | public interface Singer { |
在现实生活中,一些歌手也是作曲家。 因为我们使用接口而不是抽象类来定义这些类型,所以单个类实现歌手和作曲家两个接口是完全允许的。 事实上,我们可以定义一个继承歌手和作曲家的第三个接口,并添加适合于这个组合的新方法:
1 | public interface SingerSongwriter extends Singer, Songwriter { |
你并不总是需要这种灵活性,但是当你这样做的时候,接口是一个救星。 另一种方法是对于每个受支持的属性组合,包含一个单独的类的臃肿类层级结构。 如果类型系统中有n个属性,则可能需要支持2n种可能的组合。 这就是所谓的组合爆炸(combinatorial explosion)。 臃肿的类层级结构可能会导致具有许多方法的臃肿类,这些方法仅在参数类型上有所不同,因为类层级结构中没有类型来捕获通用行为。
接口通过包装类模式确保安全的,强大的功能增强成为可能(条目 18)。 如果使用抽象类来定义类型,那么就让程序员想要添加功能,只能继承。 生成的类比包装类更弱,更脆弱。
当其他接口方法有明显的接口方法实现时,可以考虑向程序员提供默认形式的方法实现帮助。 有关此技术的示例,请参阅第104页的removeIf
方法。如果提供默认方法,请确保使用@implSpec
Javadoc标记(条目19)将它们文档说明为继承。
使用默认方法可以提供实现帮助多多少少是有些限制的。 尽管许多接口指定了Object
类中方法(如equals
和hashCode
)的行为,但不允许为它们提供默认方法。 此外,接口不允许包含实例属性或非公共静态成员(私有静态方法除外)。 最后,不能将默认方法添加到不受控制的接口中。
但是,你可以通过提供一个抽象的骨架实现类(abstract skeletal implementation class)来与接口一起使用,将接口和抽象类的优点结合起来。 接口定义了类型,可能提供了一些默认的方法,而骨架实现类在原始接口方法的顶层实现了剩余的非原始接口方法。 继承骨架实现需要大部分的工作来实现一个接口。 这就是模板方法设计模式[Gamma95]。
按照惯例,骨架实现类被称为AbstractInterface,其中Interface是它们实现的接口的名称。 例如,集合框架( Collections Framework)提供了一个框架实现以配合每个主要集合接口:AbstractCollection
,AbstractSet
,AbstractList
和AbstractMap
。 可以说,将它们称为SkeletalCollection
,SkeletalSet
,SkeletalList
和SkeletalMap
是有道理的,但是现在已经确立了抽象约定。 如果设计得当,骨架实现(无论是单独的抽象类还是仅由接口上的默认方法组成)可以使程序员非常容易地提供他们自己的接口实现。 例如,下面是一个静态工厂方法,在AbstractList
的顶层包含一个完整的功能齐全的List实现:
1 | // Concrete implementation built atop skeletal implementation |
当你考虑一个List实现为你做的所有事情时,这个例子是一个骨架实现的强大的演示。 顺便说一句,这个例子是一个适配器(Adapter )[Gamma95],它允许一个int数组被看作Integer
实例列表。 由于int值和整数实例(装箱和拆箱)之间的来回转换,其性能并不是非常好。 请注意,实现采用匿名类的形式(条目 24)。
骨架实现类的优点在于,它们提供抽象类的所有实现的帮助,而不会强加抽象类作为类型定义时的严格约束。对于具有骨架实现类的接口的大多数实现者来说,继承这个类是显而易见的选择,但它不是必需的。如果一个类不能继承骨架的实现,这个类可以直接实现接口。该类仍然受益于接口本身的任何默认方法。此外,骨架实现类仍然可以协助接口的实现。实现接口的类可以将接口方法的调用转发给继承骨架实现的私有内部类的包含实例。这种被称为模拟多重继承的技术与条目 18讨论的包装类模式密切相关。它提供了多重继承的许多好处,同时避免了缺陷。
编写一个骨架的实现是一个相对简单的过程,虽然有些乏味。 首先,研究接口,并确定哪些方法是基本的,其他方法可以根据它们来实现。 这些基本方法是你的骨架实现类中的抽象方法。 接下来,为所有可以直接在基本方法之上实现的方法提供接口中的默认方法,回想一下,你可能不会为诸如Object
类中equals
和hashCode
等方法提供默认方法。 如果基本方法和默认方法涵盖了接口,那么就完成了,并且不需要骨架实现类。 否则,编写一个声明实现接口的类,并实现所有剩下的接口方法。 为了适合于该任务,此类可能包含任何的非公共属性和方法。
作为一个简单的例子,考虑一下Map.Entry接口。 显而易见的基本方法是
getKey,getValue
和(可选的)setValue
。 接口指定了equals
和hashCode
的行为,并且在基本方面方面有一个toString
的明显的实现。 由于不允许为Object类方法提供默认实现,因此所有实现均放置在骨架实现类中:
1 | // Skeletal implementation class |
请注意,这个骨架实现不能在Map.Entry
接口中实现,也不能作为子接口实现,因为默认方法不允许重写诸如equals
,hashCode
和toString
等Object
类方法。
由于骨架实现类是为了继承而设计的,所以你应该遵循条目 19中的所有设计和文档说明。为了简洁起见,前面的例子中省略了文档注释,但是好的文档在骨架实现中是绝对必要的,无论它是否包含 一个接口或一个单独的抽象类的默认方法。
与骨架实现有稍许不同的是简单实现,以AbstractMap.SimpleEntry
为例。 一个简单的实现就像一个骨架实现,它实现了一个接口,并且是为了继承而设计的,但是它的不同之处在于它不是抽象的:它是最简单的工作实现。 你可以按照情况使用它,也可以根据情况进行子类化。
总而言之,一个接口通常是定义允许多个实现的类型的最佳方式。 如果你导出一个重要的接口,应该强烈考虑提供一个骨架的实现类。 在可能的情况下,应该通过接口上的默认方法提供骨架实现,以便接口的所有实现者都可以使用它。 也就是说,对接口的限制通常要求骨架实现类采用抽象类的形式。