第四章 类和接口
15. 尽量减少类和成员的可访问性
信息隐藏的作用
- 将API与实现完全分离。组件之间只能通过它们的API进行通信,而不知道彼此的内部工作方式。
- 解耦系统的组件,允许它们被独立开发、测试、优化、使用、理解和修改。
- 增加了软件的复用性,降低了构建大型系统的风险,即使系统没有成功,单个组件也可能被证明是成功的
访问控制机制
访问控制机制指定了类、接口和成员的可访问性。
-
对于顶级(非嵌套)类和接口,只有两个可能的访问级别:包私有和公共。如果用 public 修饰符声明,它将是公共的,即API的一部分,修改会损害客户端;否则,它将是包私有的。
-
如果包私有顶级类或接口只被一个类使用,那么可以考虑变成这个类的私有静态嵌套类(Item-24)
-
对于成员(字段、方法、嵌套类和嵌套接口),有四个访问级别,这里按可访问性依次递增的顺序列出:
- 私有,成员只能从声明它的顶级类内部访问
- 包私有,成员可以从声明它的类的包中访问,包私有就是默认访问,即如果没有指定访问修饰符(接口的成员除外,默认情况下,接口的成员是公共的),就是这个访问级别
- 保护,成员可以从声明它的类的子类和声明它的类的包中访问
- 公共,成员可以从任何地方访问
设计类和成员的可访问性
总体规则:让每个类或成员尽可能不可访问。换句话说,在不影响软件正常功能时,使用尽可能低的访问级别。
-
仔细设计类的公共API,所有成员声明为私有。私有成员和包私有成员都是类实现的一部分,通常不会影响其导出的API。但是,如果类实现了Serializable(Item-86和Item-87),这些字段可能会「泄漏」到导出的 API 中
-
少用保护成员。保护成员是类导出API的一部分,必须永远支持
-
子类覆盖父类的方法,可访问性不能缩小
-
为了测试,将公共类的成员由私有变为包私有是可以接受的,但是进一步提高可访问性是不可接受的
-
带有公共可变字段的类通常不是线程安全的
-
为了得到不可变数组,不要公开或通过访问器返回长度非零的数组的引用,客户端将能够修改数组的内容。可以将公共数组设置为私有,并添加一个公共不可变 List;或者,将数组设置为私有,并添加一个返回私有数组副本的公共方法:
总之,你应该尽可能减少程序元素的可访问性(在合理的范围内)。在仔细设计了一个最小的公共 API 之后,你应该防止任何游离的类、接口或成员成为 API 的一部分。除了作为常量的公共静态 final 字段外,公共类应该没有公共字段。确保public static final 字段引用的对象是不可变的。
16. 在公共类中,使用访问器方法,而不是公共字段
访问器方法的作用
如果类是公共的,即可以在包外访问,提供访问器方法可以维持类内部数据表示形式的灵活性
一些建议
- 如果类是包私有或者是私有嵌套类,公开数据字段比提供访问器方法更清晰
- 公共类公开不可变字段的危害会小一点,但仍不建议公开字段
17. 减少可变性
不可变类是实例创建后不能被修改的类。Java库包含许多不可变的类,包括 String、基本类型的包装类、BigInteger和BigDecimal。
创建不可变类的规则
要使类不可变,请遵守以下5条规则:
- 不要提供修改对象状态的方法
- 确保类不能被继承。这可以防止可变子类损害父类的不可变。防止子类化通常用 final 修饰父类
- 所有字段用 final 修饰。
- 所有字段设为私有
- 确保对任何可变组件的独占访问。如果你的类有任何引用可变对象的字段,请确保该类的客户端无法获得对这些对象的引用。
不可变类的优点
- 不可变对象线程安全,复用程度高开销小,不需要防御性拷贝
- 不仅可以复用不可变对象,还可以复用它们的内部实现
- 不可变对象很适合作为其它对象的构建模块,例如 Map 的键和 Set 的元素
- 不可变对象自带提供故障原子性
不可变类的缺点
-
不可变类的主要缺点是每个不同的值都需要一个单独的对象。不可变类BigInteger的flipBit方法会创建一个和原来对象只有一个bit不同的新对象。可变类BigSet可以在固定时间内修改对象单个bit
-
如果执行多步操作,在每一步生成一个新对象,最终丢弃除最终结果之外的所有对象,那么性能问题就会被放大。解决方法是使用伴随类,如果能预测客户端希望在不可变类上执行哪些复杂操作就可以使用包私有可变伴随可变类(BigInteger和内部的伴随类);否则提供一个公共可变伴随类,例如String和它的伴随类StringBuilder
设计不可变类
- 为了保证类不被继承,可以用final修饰类,也可以将构造函数变为私有或包私有,通过静态工厂提供对象
- BigInteger 和 BigDecimal没有声明为final,为了确保你的类依赖是不可变的BigInteger或BigDecimal,你需要检查对象类型,如果是BigInteger或BigDecimal的子类,那必须防御性复制
- 适当放松不可变类所有字段必须是 final 的限制可以提供性能,例如,用可变字段缓存计算结果,例如 hashCode 方法在第一次调用时缓存了hash
- 如果你选择让不可变类实现 Serializable,并且该类包含一个或多个引用可变对象的字段,那么你必须提供一个显式的 readObject 或 readResolve 方法,或者使用 ObjectOutputStream.writeUnshared 或 ObjectInputStream.readUnshared 方法
18. 优先选择组合而不是继承
在同一个包中使用继承是安全的,因为子类和父类的实现都由相同程序员控制。在对专为继承而设计和有文档的类时使用继承也是安全的(Item-19)。然而,对普通的非抽象类进行跨包继承是危险的。与方法调用不同,继承破坏了封装性。换句话说,子类的功能正确与否依赖于它的父类的实现细节。父类的实现可能在版本之间发生变化,如果发生了变化,子类可能会崩溃,即使子类的代码没有被修改过。因此,子类必须与其父类同步更新,除非父类是专门为继承的目的而设计的,并具有很明确的文档说明。
继承的风险
- 子类覆盖父类的多个方法,父类的多个方法之间有调用关系,因为多态,父类方法在调用其它父类方法时会调用到子类的方法
- 父类可以添加新方法,新方法没有确保在添加的元素满足断言,子类没有覆盖这个方法,导致调用这个方法时添加了非法元素
- 父类添加了新方法,但是子类继承原来的父类时也添加了相同签名和不同返回类型的方法,这时子类不能编译,如果签名和返回类型都相同,必须声明覆盖
组合
为新类提供一个引用现有类实例的私有字段,这种设计称为组合,因为现有的类是新类的一个组件。新类中的每个实例方法调用现有类实例的对应方法,并返回结果,这称为转发。比较好的写法是包装类+转发类。
总结
只有子类确实是父类的子类型的情况下,继承才合适。换句话说,两个类 A、B 之间只有 B 满足「is-a」关系时才应该扩展 A。如果你想让 B 扩展 A,那就问问自己:每个 B 都是 A 吗?如果不能对这个问题给出肯定回答,B 不应该扩展 A;如果答案是否定的,通常情况下,B 应该包含 A 的私有实例并暴露不同的 API:A 不是 B 的基本组成部分,而仅仅是其实现的一个细节。
19. 继承要设计良好并且有文档,否则禁止使用
必须精确地在文档中描述覆盖任何方法的效果。文档必须指出方法调用了哪些可覆盖方法、调用顺序以及每次调用的结果如何影响后续处理过程。描述由 Javadoc 标签 @implSpec 生成
20. 接口优于抽象类
Java 有两种机制来定义允许多重实现的类型:接口和抽象类。
接口优势
-
可以定义 mixin(混合类型)
接口是定义 mixin(混合类型)的理想工具。粗略的说,mixin 是类除了「基本类型」之外还可以实现的类型,用于声明它提供了一些可选的行为。例如,Comparable 是一个 mixin 接口,它允许类的实例可以与其他的可相互比较的对象进行排序。这样的接口称为 mixin,因为它允许可选功能「混合」到基本类型中。抽象类不能用于定义 mixin,原因是:一个类不能有多个父类,而且在类层次结构中没有插入 mixin 的合理位置。‘ -
允许构造非层次化类型框架
如果系统中有 n 个属性(例如唱、跳、rap),如果每个属性组合都封装成一个抽象类,组成一个层次化的类型框架,总共有个类,而接口只需要 n 个
接口劣势
- 接口不能给 equals 和 hashCode 方法提供默认实现
- 接口不允许包含实例字段或者非公共静态成员(私有静态方法除外)
结合接口和抽象类的优势
模板方法模式:接口定义类型,提供默认方法,抽象类(骨架实现类)实现接口其余方法。继承骨架实现类已经完成直接实现接口的大部分工作。
按照惯例,骨架实现类称为 AbstractInterface,其中 Interface 是它们实现的接口的名称。例如 Collections Framework 提供了一个骨架实现来配合每个主要的集合接口:AbstractCollection、AbstractSet、AbstractList 和 AbstractMap。可以说,把它们叫做 SkeletalCollection、SkeletalSet、SkeletalList 和 SkeletalMap 更合理,但 Abstract 的用法现在已经根深蒂固。
编写骨架实现类的过程
研究接口有哪些方法,哪些方法可以提供默认实现,如果都可以提供默认实现就不需要骨架实现类,否则,声明一个实现接口的骨架实现类,实现所有剩余的接口方法
21. 为后代设计接口
Java 8 之前,往接口添加方法会导致实现它的类缺少方法,编译错误。Java 8 添加了默认方法,目的是允许向现有接口添加方法,但是向现有接口添加新方法有风险。
默认方法的风险
- 接口实现类需要同步调用每个方法,但是没有覆盖接口新加入的默认方法,导致调用默认方法时出现 ConcurrentModificationException
- 接口的现有实现类可以在没有错误或警告的情况下通过编译,但是运行时会出错
22. 接口只用于定义类型
接口只用于定义类型。类实现接口表明客户端可以用类的实例做什么。将接口定义为任何其他目的都是不合适的。
有一个反例是常量接口,接口内只包含 public static final 字段,它的问题在于类使用什么常量是实现细节,而实现常量接口会导致实现细节泄露到 API 中。
导出常量,有几个合理的选择。
- 如果这些常量与现有的类或接口紧密绑定,则应该将它们添加到类或接口。例如,所有数值包装类,比如 Integer 和 Double,都导出 MIN_VALUE 和 MAX_VALUE 常量。
- 枚举或者不可实例化的工具类,使用工具类的常量推荐静态导入
23. 类层次结构优于带标签的类
标签类
类的实例有两种或两种以上的样式,并且包含一个标签字段来表示实例的样式。标签类有许多缺点,可读性差、内存占用多、容易出错、添加新样式复杂
类层次结构
标签类只是类层次结构的简易模仿。要将已标签的类转换为类层次结构,
- 抽取标签类都有的方法、字段到一个抽象类中
- 继承抽象类实现都有的方法和子类特有的方法
24. 静态成员类优于非静态成员类
嵌套类是在另一个类中定义的类。嵌套类应该只为它的外部类服务。如果嵌套类在其它环境中有用,那么它应该是顶级类。有四种嵌套类:静态成员类、非静态成员类、匿名类和局部类。除了静态成员类,其它嵌套类被称为内部类。
静态成员类
静态成员类是最简单的嵌套类。最好把它看作是一个普通的类,只是碰巧在另一个类中声明而已,并且可以访问外部类的所有成员,甚至是那些声明为 private 的成员。静态成员类是其外部类的静态成员,并且遵守与其它静态成员相同的可访问性规则。如果声明为私有,则只能在外部类中访问,等等。
静态成员类作用
- 作为公共的辅助类,只有与它的外部类一起使用时才有意义,例如 Calculator 类和公有静态成员类 Operation 枚举
- 表示由其外部类表示的组件。例如,Map实现类内部的Entry类。Entry对象不需要访问 Map 。因此,使用非静态成员类来表示 entry 是浪费,私有静态成员类是最好的。
非静态成员类
从语法上讲,静态成员类和非静态成员类之间的唯一区别是静态成员类在其声明中具有修饰符 static。尽管语法相似,但这两种嵌套类有很大不同。非静态成员类的每个实例都隐式地与外部类的实例相关联,非静态成员类的实例方法可以调用外部实例上的方法,或者使用受限制的 this (父类.this)构造获得对外部实例的引用。如果嵌套类的实例可以独立于外部类的实例存在,那么嵌套类必须是静态成员类;非静态成员类的实例依赖外部类的实例
非静态成员类实例与外部类实例之间的关联是在创建成员类实例时建立的,之后无法修改。通常,关联是通过从外部类的实例方法中调用非静态成员类构造函数自动建立的。使用 enclosingInstance.new MemberClass(args) 表达式手动建立关联是可能的,尽管这种情况很少见。正如你所期望的那样,关联占用了非静态成员类实例中的空间,并增加了构造时间。
如果声明的成员类不需要访问外部类的实例,那么应始终在声明中添加 static 修饰符,如果省略这个修饰符,每个实例都有一个隐藏的对其外部实例的额外引用。存储此引用需要时间和空间,更糟糕的是,外部类可能不能被垃圾回收。
非静态成员类作用
非静态成员类的一个常见用法是定义一个 Adapter,它允许外部类的实例被视为某个不相关类的实例。例如,Map 接口的实现类通常使用非静态成员类来实现它们的集合视图, Set 和 List,通常使用非静态成员类来实现它们的迭代器
匿名类
匿名类没有名称。它不是外部类的成员。它不是与其它成员一起声明的,而是在使用时同时声明和实例化。匿名类可以在代码中用在任何一个可以用表达式的地方。当且仅当它们出现在非静态环境(没有写在静态方法里面)时,匿名类才持有外部类实例。但是,即使它们出现在静态环境中,它们也不能有除常量以外的任何静态成员。
匿名类的使用有很多限制。你只能在声明它们的时候实例化,你不能执行 instanceof 测试,也不能执行任何其它需要命名类的操作。你不能声明一个匿名类来实现多个接口或继承一个类并同时实现一个接口。匿名类的使用者除了从父类继承的成员外,不能调用任何成员。因为匿名类出现在表达式中,所以它们必须保持简短——大约10行或更短,否则会影响可读性。
匿名类作用
在 lambda 表达式被添加的 Java 之前,匿名类是动态创建小型函数对象和进程对象的首选方法,但 lambda 表达式现在是首选方法(Item-42)。匿名类的另一个常见用法是实现静态工厂方法(参见 Item-20 中的 intArrayAsList 类)
局部类
局部类是四种嵌套类中最不常用的。局部类几乎可以在任何能够声明局部变量的地方使用,并且遵守相同的作用域规则。局部类具有与其它嵌套类相同属性。与成员类一样,它们有名称,可以重复使用呢。与匿名类一样,他们只有在非静态环境中定义的情况下才具有外部类实例,而且它们不能包含静态静态成员。和匿名类一样,它们应该保持简短,以免损害可读性。
嵌套类总结
简单回顾一下,有四种不同类型的嵌套类,每一种都有自己的用途。如果嵌套的类需要在单个方法之外可见,或者太长,不适合放入方法中,则使用成员类。除非成员类的每个实例都需要引用其外部类实例,否则让它保持静态。假设嵌套类属于方法内部,如果你只需要从一个位置创建实例,并且存在一个能够描述类的现有类型,那么将其设置为匿名类;否则,将其设置为局部类。
25. 源文件仅限有单个顶层类
虽然 Java 编译器允许你在单个源文件中定义多个顶层类,但这样做没有任何好处,而且存在重大风险。这种风险源于这样一个事实:在源文件中定义多个顶层类使得为一个类提供多个定义成为可能。所使用的定义受源文件传给编译器的顺序的影响。