Chapter 4 类和接口
第15条 使类和成员的可访问性最小化
尽可能地使每个类或者成员不被外界访问.
对于顶层(非嵌套)的类和接口, 只有两种可能的访问级别: 包级私有(package private)和公有(public).
对于成员(域, 方法, 嵌套类和嵌套接口), 有四种可能的访问级别(可访问性递增):
- 私有的(private).
- 包级私有的(package-private): 缺省(default)访问级别, 声明该成员的包内部的任何类都可以访问这个成员.
- 受保护的(protected): 声明该成员的类的子类和包内部的任何类可以访问这个成员.
- 公有的(public).
第16条 在公有类中使用访问方法而非公有域
对公有类, 应该用包含私有域和公有访问方法(getter)的类来代替, 对可变的类, 加上公有设值方法(setter).
-> 保留了改变内部表现的灵活性.
比如一个实体类,下边的定义导致该类的数据域被直接访问,没有提供封装的功能。
class People{ public static String name; public static int age; }
封装之后,我们不直接对外暴露数据域,而是提供一个公有的访问方法。
class People { private static String name; private static int age; public People(String name, int age) { this.name = name; this.age = age; } public static String getName() { return name; } public static void setName(String name) { People.name = name; } public static int getAge() { return age; } public static void setAge(int age) { People.age = age; } }
第17条 使可变性最小化
不可变类: 其实例不能被修改的类. 每个实例中包含的所有信息都必须在创建该实例的时候就提供, 并在对象的整个生命周期内固定不变.
为了使类成为不可变, 要遵循下面五条规则:
- 不要提供任何会修改对象状态的方法.
- 保证类不会被扩展. (一般做法: 声明为final.)
- 使所有的域都是final的.
- 使所有的域都成为私有的.
- 确保对于任何可变组件的互斥访问.
不可变对象本质上是线程安全的, 它们不要求同步.
不可变对象可以被自由地共享.
不可变对象永远也不需要保护性拷贝.
不可变类唯一真正的缺点是, 对于每个不同的值都需要一个单独的对象. (特定情况下的性能问题.)
可以为类提供公有的可变配套类. Java类库中的String
的可变配套类是StringBuilder
和StringBuffer
.
为了让类不能被继承, 除了使类成为final的外, 还有一种方法: 让类的所有构造器都变成私有的或者包级私有的, 并添加公有的静态工厂.
优点: 提供了缓存能力, 可以提供多个不同名字的静态方法, 使相同参数类型可以构造出不同的对象(用构造器就不行).
尽量缩小可变性:
- 除非有很好的理由要让类成为可变的, 否则就应该是不可变的.
- 如果类不能被做成是不可变的, 仍然应该尽可能地限制它的可变性. (降低状态数, 尽量让域为
private final
的.) - 构造器应该创建完全初始化的对象, 并建立起所有的约束关系. 不要在构造器或者静态工厂之外再提供公有的初始化方法, 也不应该提供重新初始化方法.
第18条 组合优先于继承
这里说的继承是类的继承, 不是接口的实现.
继承打破了封装性.
超类的实现有可能会随着发行版本的不同而有所变化, 如果真的发生了变化, 子类有可能会遭到破坏.
因此, 子类必须要跟着其超类的更新而演变, 除非超类是专门为了扩展而设计的, 并且有很好的文档说明.
例子: 覆写了HashSet
中的add
和addAll
方法, 但其实后者调用了前者.
案例如下,我们创建一个自定义的HashSet,并且统计一共被添加过多少个元素。
package util; import java.util.Collection; import java.util.HashSet; public class MyHashSet<E> extends HashSet<E> { private int count = 0; public MyHashSet() { } public MyHashSet(int initCap, float loadFactor) { super(initCap, loadFactor); } public boolean add(E e) { count++; return super.add(e); } public boolean addAll(Collection<? extends E> c) { count += c.size(); return super.addAll(c); } public int getCount() { return count; } }
public class MyHashSetMain { public static void main(String[] args) { MyHashSet<String> set = new MyHashSet<>(); set.addAll(Arrays.asList("lisi","zhangsan","wangliu")); System.out.println(set.getCount()); } }
由于子类覆盖了add和addAll方法,我们期待返回的是3,结果却是6,该程序没有正常执行。因为addAll方法内部调用了add方法,也就是我们的每一个元素都被添加计数了两次。我们应该去掉addAll方法。
接着都调用了add方法
注释调覆盖addAll方法
说完了继承的缺点,我们再来看看何为复合?
复合就是在新的类中增加一个私有域,它可以引用现有类的一个实例,现有的类变成了新类的一个组件。新类中的每个实例方法都可以调用被包含的现有类实例中对应的方法。这样得到的新类,即使现有类中增加了新的方法也不会影响到新的类。
组合(composition): 在新的类中增加一个私有域, 它引用现有类的一个实例.
新类中的方法可以转发被包含的现有实例中的方法. 这样得到的类将会非常稳固, 它不依赖于现有类的实现细节.
因为每一个新类的实例都把另一个现有类的实例包装起来了, 所以新的类被称为包装类(wrapper class), 这也正是Decorator模式.
只有当子类真正是超类的子类型时, 才适合用继承. 即对于两个类, 只有当两者之间确实存在"is-a"关系的时候, 才应该继承.
Java类库中也有明显违反这条原则的地方, 比如Stack
并不是Vector
, Properties
不是Hashtable
, 它们却错误地使用了继承.
Stack
并不是Vector
Properties
不是Hashtable
在决定使用继承而不是复合之前, 还应该问自己最后一组问题: 对于你正在试图扩展的类, 它的API中有没有缺陷呢? 继承机制会把超类API中的缺陷传播到子类中, 而复合则允许设计新的API来隐藏这些缺陷.
第19条 要么为继承而设计, 并提供文档说明, 要么就禁止继承
对于专门为了继承而设计的类, 需要具有良好的文档.
该类的文档必须精确地描述覆盖每个方法所带来的影响. 换句话说, 该类必须有文档说明它可覆盖的方法的自用性.
更一般地, 类必须在文档中说明, 在哪些情况下它会调用可覆盖的方法.
第20条 接口优于抽象类
抽象类和接口的区别:
- 抽象类允许包含某些方法的实现, 接口则不允许. (从Java 8开始接口也可以包含默认方法了.)
- 抽象类需要继承(Java只允许单继承), 但是可以实现多个接口.
使用接口的好处:
- 现有的类可以很容易被更新, 以实现新的接口.
- 接口是定义混合类型(mixin)的理想选择.
- 接口允许我们构造非层次结构的类型框架.
- 接口可以更安全有力地增强功能. -> 组合优于继承.
通过对你导出的每个重要接口都提供一个抽象的骨架实现(skeletal implementation)类, 把接口和抽象类的优点结合起来.
按照惯例, 骨架实现被称为AbstractInterface
, 这里Interface
是指所实现的接口的名字.
比如AbstractCollection
, AbstractSet
, AbstractList
和AbstractMap
.
骨架实现的美妙之处在于, 它们为抽象提供了实现上的帮助, 但又不强加抽象类被用作类型定义时所特有的严格限制.
对于接口的大多数实现来讲, 扩展骨架实现类是个很显然的选择, 但并不是必需的. 如果类无法扩展骨架实现类, 这个类始终可以手工实现这个接口.
此外, 骨架实现类仍然能够有助于接口的实现.
实现了这个接口的类可以把对于接口方法的调用, 转发到一个内部私有类的实例上, 这个内部私有类扩展了骨架实现类. 这种方法被称作模拟多重继承(simulated multiple inheritance).
使用抽象类有一个优势: 抽象类的演变比接口的演变要容易得多.
如果在后续的发行版本中, 你希望在抽象类中增加新的具体方法, 始终可以增加, 它包含合理的默认实现. 然后, 该抽象类的所有实现都将提供这个新的方法.
接口一旦被公开发行, 并且已被广泛实现, 再想改变这个接口几乎是不可能的.
第21条 为了后代设计接口
从Java 8开始, 可以给接口加上方法, 而不破坏现有的实现. (有风险).
声明包含默认实现的默认方法, 可以让之前实现这个接口的子类用这个默认实现.
第22条 接口只用于定义类型
常量接口(constant interface): 没有包含任何方法, 只包含静态的final域, 每个域都导出一个常量. 使用这些常量的类实现这个接口, 以避免用类名来修饰常量名.
常量接口模式是对接口的不良使用:
- 暴露了实现细节到该类的导出API中;
- 实现常量接口对于类的用户来说没有价值;
- 如果以后的发行版本中不需要其中的常量了, 依然必须实现这个接口;
- 所有子类的命名空间也会被接口中的常量污染.
总结: 接口应该只被用来定义类型, 它们不应该被用来导出常量.
第23条 类层次优于标签类
有时候, 可能会遇到带有两种甚至更多种风格的实例的类, 并包含表示实例风格的标签域. 例子: Figure类内含Shape枚举, 包含圆形和方形.
标签类过于冗长, 容易出错, 效率低下.
用子类型修正:
- 定义抽象基类, 方法行为若依赖于标签值, 则定义为抽象方法. 方法行为若不依赖于标签值, 就把方法放在抽象类中.
- 所有方法都用到的数据域放在抽象类中, 特定于某个类型的数据域放在对应的子类中.
这个类层次纠正了前面所提到的标签类的所有缺点.
第24条 优先考虑静态成员类
嵌套类(nested class)是指被定义在另一个类的内部的类. 嵌套类存在的目的应该只是为它的外围类提供服务.
嵌套类有四种:
- 静态成员类(static member class).
- 非静态成员类(nonstatic member class).
- 匿名类(anonymous class).
- 局部类(local class).
除了第一种之外, 其他三种都被称为内部类(inner class).
小结:
如果一个嵌套类需要在单个方法之外仍然可见的,或者它太长了,不适合于放在方法内部,就应该使用成员类。
如果成员类的每个示例都需要一个指向外围实例的引用,就要把成员类做成非静态的;否则,就做成静态的。
假设这个嵌套类属于一个方法的内部,如果你只需要在一个地方创建实例,并且已经有了一个预置的类型可以说明这个类的特征,就要把它做成匿名类;否则,就做成局部类。