Effective Java (类和接口)

十三、使类和成员的可访问性最小化:

      信息隐藏是软件程序设计的基本原则之一,面向对象又为这一设计原则提供了有力的支持和保障。这里我们简要列出几项受益于该原则的优势:
      1.    更好的解除各个模块之间的耦合关系:
      由于模块间的相互调用是基于接口契约的,每个模块只是负责完成自己内部既定的功能目标和单元测试,一旦今后出现性能优化或需求变更时,我们首先需要做的便是定位需要变动的单个模块或一组模块,然后再针对各个模块提出各自的解决方案,分别予以改动和内部测试。这样便大大降低了因代码无规则交叉而带来的潜在风险,同时也缩减了开发周期。
      2.    最大化并行开发:
      由于各个模块之间保持着较好的独立性,因此可以分配更多的开发人员同时实现更多的模块,由于每个人都是将精力完全集中在自己负责和擅长的专一领域,这样不仅提高了软件的质量,也大大加快了开发的进度。
      3.    性能优化和后期维护:
      一般来说,局部优化的难度和可行性总是要好于来自整体的优化,事虽如此,然而我们首先需要做的却是如何定位需要优化的局部,在设计良好的系统中,完成这样的工作并非难事,我们只需针对每个涉及的模块做性能和压力测试,之后再针对测试的结果进行分析并拿到相对合理的解决方案。
      4.    代码的高可复用性:
      在软件开发的世界中,提出了众多的设计理论,设计原则和设计模式,之所以这样,一个非常现实的目标之一就是消除重复代码,记得《重构》中有这样的一句话:“重复代码,万恶之源”。可见提高可用代码的复用性不仅对编程效率和产品质量有着非常重要的意义,对日后产品的升级和维护也是至关重要的。说一句比较现实的话,一个设计良好的产品,即使因为某些原因导致失败,那么产品中应用到的一个个独立、可用和高效的模块也为今后的东山再起提供了一个很好的技术基础。
      让我们重新回到主题,Java通过访问控制的方式来完成信息隐藏,而我们的原则是尽可能的使每个类的域成员不被外界访问。对于包内的类而言,则尽可能少的定义公有类,遵循这样的原则可以极大的降低因包内设计或实现的改变而给该包的使用者带来的影响。当然达到这个目标的一个重要前提是定义的接口足以完成调用者的需求。
      该条目给出了一个比较重要的建议,既不要提供直接访问或通过函数返回可变域对象的实例,见下例:
      public final Thing[] values = { ... };
      即便Thing数组对象本身是final的,不能再被赋值给其他对象,然而数组内的元素是可以改变的,这样便给外部提供了一个机会来修改内部数据的状态,从而在主类未知的情况下破坏了对象内部的状态或数据的一致性。其修订方式如下:

1     private static final Thing[] PRIVATE_VALUES = { ... };
2 public static final Thing[] values() {
3 return PRIVATE_VALUES.clone();
4 }

      总而言之,你应该尽可能地降低可访问性。你在仔细地设计了一个最小的公有API之后,应该防止把任何散乱的类、接口和成员变成API的一部分。除了公有静态final域的特殊情形之外,公有类都不应该包含公有域。并且要确保公有静态final域所引用的对象都是不可变的。

十四、在公有类中使用访问方法而非公有域:

      这个条目简短的标题已经非常清晰的表达了他的含义,我们这里将只是列出几点说明:
      1.    对于公有类而言,由于存在大量的使用者,因此修改API接口将会给使用者带来极大的不便,他们的代码也需要随之改变。如果公有类直接暴露了域字段,一旦今后需要针对该域字段添加必要的约束逻辑时,唯一的方法就是为该字段添加访问器接口,而已有的使用者也将不得不更新其代码,以避免破坏该类的内部逻辑。
      2.    对于包级类和嵌套类,公有的域方法由于只能在包内可以被访问,因而修改接口不会给包的使用者带来任何影响。
      3.    对于公有类中的final域字段,提供直接访问方法也会带来负面的影响,只是和非final对象相比可能会稍微好些,如final的数组对象,即便数组对象本身不能被修改,但是他所包含的数组成员还是可以被外部改动的,针对该情况建议提供API接口,在该接口中可以添加必要的验证逻辑,以避免非法数据的插入,如:

1     public <T> boolean setXxx(int index, T value) {
2 if (index > myArray.length)
3 return false;
4 if (!(value instanceof LegalClass))
5 return false;
6 ...
7 return true;
8 }

十五、使可变性最小化:

      只在类构造的时候做初始化,构造之后类的外部没有任何方法可以修改类成员的状态,该对象在整个生命周期内都会保持固定不变的状态,如String、Integer等。不可变类比可变类更加易于设计、实现和使用,而且线程安全。
      使类成为不可变类应遵循以下五条原则:
      1.    不要提供任何会修改对象状态的方法;
      2.    保证类不会被扩展,既声明为final类,或将构造函数定义为私有;
      3.    使所有的域都是final的;
      4.    使所有的域都成为私有的;
      5.    确保在返回任何可变域时,返回该域的deep copy。
      见如下Complex类:

 1     final class Complex {
2 private final double re;
3 private final double im;
4 public Complex(double re,double im) {
5 this.re = re;
6 this.im = im;
7 }
8 public double realPart() {
9 return re;
10 }
11 public double imaginaryPart() {
12 return im;
13 }
14 public Complex add(Complex c) {
15 return new Complex(re + c.re,im + c.im);
16 }
17 public Complex substract(Complex c) {
18 return new Complex(re - c.re, im - c.im);
19 }
20 ... ...
21 }

      不可变对象还有一个对象重用的优势,这样可以避免创建多余的新对象,这样也能减轻垃圾收集器的压力,如:
      public static final Complex ZERO = new Complex(0,0);
      public static final Complex ONE = new Complex(1,0);
      这样使用者可以重复使用上面定义的两个静态final类,而不需要在每次使用时都创建新的对象。
      从Complex.add和Complex.substract两个方法可以看出,每次调用他们的时候都会有新的对象被创建,这样势必会带来一定的性能影响,特别是对于copy开销比较大的对象,如包含几万Bits的BigInteger。如果我们所作的操作仅仅是修改其中的某个Bit,如bigInteger.flipBit(0),该操作只是修改了第0位的状态,而BigInteger却为此copy了整个对象并返回。鉴于此,该条目推荐为不可变对象提供一个功能相仿的可变类,如java.util.BitSet之于java.math.BigInteger。如果我们在实际开发中确实遇到刚刚提及的场景,那么使用BitSet或许是更好的选择。
      对于不可变对象还有比较重要的优化技巧,既某些关键值的计算,如hashCode,可以在对象构造时或留待某特定方法(Lazy Initialization)第一次调用时进行计算并缓存到私有域字段中,之后再获取该值时,可以直接从该域字段获取,避免每次都重新计算。这样的优化主要是依赖于不可变对象的域字段在构造后即保持不变的特征。
    
十六、复合优先于继承:

      由于继承需要透露一部分实现细节,因此不仅需要超类本身提供良好的继承机制,同时也需要提供更好的说明文档,以便子类在覆盖超类方法时,不会引起未知破坏行为的发生。需要特别指出的是对于跨越包边界的继承,很可能超类和子类的实现者并非同一开发人员或同一开发团队,因此对于某些依赖实现细节的覆盖方法极有可能会导致预料之外的结果,还需要指出的是,这些细节对于超类的普通用户来说往往是不看见的,因此在未来的升级中,该实现细节仍然存在变化的可能,这样对于子类的实现者而言,在该细节变化时,子类的相关实现也需要做出必要的调整,见如下代码:

 1     //这里我们需要扩展HashSet类,提供新的功能用于统计当前集合中元素的数量,
2 //实现方法是新增一个私有域变量用于保存元素数量,并每次添加新元素的方法中
3 //更新该值,再提供一个公有的方法返回该值。
4 public class InstrumentedHashSet<E> extends HashSet<E> {
5 private int addCount = 0;
6 public InstrumentedHashSet() {}
7 public InstrumentedHashSet(int initCap,float loadFactor) {
8 super(initCap,loadFactor);
9 }
10 @Override public boolean add(E e) {
11 ++addCount;
12 return super.add(e);
13 }
14 @Override public boolean addAll(Collection<? extends E> c) {
15 addCount += c.size();
16 return super.addAll(c);
17 }
18 public int getAddCount() {
19 return addCount;
20 }
21 }

      该子类覆盖了HashSet中的两个方法add和addAll,而且从表面上看也非常合理,然而他却不能正常的工作,见下面的测试代码:

1     public static void main(String[] args) {
2 InstrumentedHashSet<String> s = new InstrumentedHashSet<String>();
3 s.addAll(Arrays.asList("Snap","Crackle","Pop"));
4 System.out.println("The count of InstrumentedHashSet is " + s.getAddCount());
5 }
6 //The count of InstrumentedHashSet is 6

      从输出结果中可以非常清楚的看出,我们得到的结果并不是我们期望的3,而是6。这是什么原因所致呢?在HashSet的内部,addAll方法是基于add方法来实现的,而HashSet的文档中也并未列出这样的细节说明。了解了原因之后,我们应该取消addAll方法的覆盖,以保证得到正确的结果。然而仍然需要指出的是,这样的细节既然未在API文档中予以说明,那么也就间接的表示这种未承诺的实现逻辑是不可依赖的,因为在未来的某个版本中他们有可能会发生悄无声息的发生变化,而我们也无法通过API文档获悉这些。还有一种情况是超类在未来的版本中新增了添加新元素的接口方法,因此我们在子类中也必须覆盖这些方法,同时也要注意一些新的超类实现细节。由此可见,类似的继承是非常脆弱的,那么该如何修订我们的设计呢?答案很简单,复合优先于继承,见如下代码:

 1     //转发类
2 class ForwardingSet<E> implements Set<E> {
3 private final Set<E> s;
4 public ForwardingSet(Set<E> s) {
5 this.s = s;
6 }
7 @Override public int size() {
8 return s.size();
9 }
10 @Override public void clear() {
11 s.clear();
12 }
13 @Override public boolean add(E e) {
14 return s.add(e);
15 }
16 @Override public boolean addAll(Collection<? extends E> c) {
17 return s.addAll(c);
18 }
19 ... ...
20 }
21 //包装类
22 class InstrumentedHashSet<E> extends ForwardingSet<E> {
23 private int addCount = 0;
24 public InstrumentedHashSet(int initCap,float loadFactor) {
25 super(initCap,loadFactor);
26 }
27 @Override public boolean add(E e) {
28 ++addCount;
29 return super.add(e);
30 }
31 @Override public boolean addAll(Collection<? extends E> c) {
32 addCount += c.size();
33 return super.addAll(c);
34 }
35 public int getAddCount() {
36 return addCount;
37 }
38 }

      由上面的代码可以看出,这种设计最大的问题就是比较琐碎,需要将接口中的方法基于委托类重新实现。
      在决定使用继承而不是复合之间,还应该问自己最后一组问题。对于你试图扩展的类,它的API中有没有缺陷呢?如果有,你是否愿意把这些缺陷传播到类的API中?继承机制会把超类API中的所有缺陷传播到子类中,而复合则允许设计新的API来隐藏这些缺陷。
    
十七、要么为继承而设计,并提供文档说明,要么就禁止继承:

      上一条目针对继承将会引发的潜在问题给出了很好的解释,本条目将继续深化这一个设计理念,并提出一些好的建议,以便在确实需要基于继承来设计时,避免这些潜在问题的发生。
      1)    为公有方法提供更为详细的说明文档,这其中不仅包扩必要的功能说明和参数描述,还要包含关键的实现细节说明,比如对其他公有方法的依赖和调用。
      在上一条目的代码示例中,子类同时覆盖了HashSet的addAll和add方法,由于二者之间存在内部的调用关系,而API文档中并没有给出详细的说明,因而子类的覆盖方法并没有得到期望的结果。
      2)    在超类中尽可能避免公有方法之间的相互调用。
      HashSet.addAll和HashSet.add给我们提供了一个很好的案例,然而这并不表示HashSet的设计和实现是有问题的,我们只能说HashSet不是为了继承而设计的类。在实际的开发中,如果确实有这样的需要又该如何呢?很简单,将公用的代码提取(extract)到一个私有的帮助方法中,再在其他的公有方法中调用该帮助方法。
      3)    可以采用设计模式中模板模式的设计技巧,在超类中将需要被覆盖的方法设定为protected级别。
      在采用这种方式设计超类时,还需要额外考虑的是哪些域字段也同时需要被设定为protected级别,以保证子类在覆盖protected方法时,可以得到必要的状态信息。
      4)    不要在超类的构造函数中调用可能被子类覆盖的方法,如public和protected级别的域方法。
      由于超类的初始化早于子类的初始化,如果此时调用的方法被子类覆盖,而覆盖的方法中又引用了子类中的域字段,这将很容易导致NullPointerException异常被抛出,见下例:

 1     public class SuperClass {
2 public SuperClass() {
3 overrideMe();
4 }
5 public void overrideMe() {}
6 }
7 public final class SubClass extends SuperClass {
8 private final Date d;
9 SubClass() {
10 d = new Date();
11 }
12 @Override public void overrideMe() {
13 System.out.println(dd.getDay());
14 }
15 }
16 public static void main(String[] args) {
17 SubClass sub = new SubClass();
18 sub.overrideMe();
19 }

      5)    如果超类实现了Cloneable和Serializable接口,由于clone和readObject也有构造的能力,因此在实现这两个接口方法时也需要注意,不能调用子类的覆盖方法。

十八、接口优先于抽象类:

      众所周知,Java是不支持多重继承但是可以实现多个接口的,而这也恰恰成为了接口优于抽象类的一个重要因素。现将他们的主要差异列举如下:
      1)    现有的类可以很容易被更新,以实现新的接口。
      如果现存的类并不具备某些功能,如比较和序列化,那么我们可以直接修改该类的定义分别实现Comparable和Serializable接口。倘若Comparable和Serializable不是接口而是抽象类,那么同时继承两个抽象类是Java语法规则所不允许的,如果当前类已经继承自某个超类了,那么他将无法再扩展任何新的超类。
      2)    接口是定义mixin(混合类型)的理想选择。
      Comparable是一个典型的mixin接口,他允许类表明他的实例可以与其他的可相互比较的对象进行排序。这样的接口之所以被称为mixin,是因为他允许任选的功能可被混合到类型的主要功能中。抽象类不能被用于定义mixin,同样也是因为他们不能被更新到现有的类中:类不可能有一个以上的超类,类层次结构中也没有适当的地方来插入mixin。
      3)    接口允许我们构造非层次结构的类型框架。
      由于我们可以为任何已有类添加新的接口,而无需考虑他当前所在框架中的类层次关系,这样便给功能的扩展带来了极大的灵活性,也减少了对已有类层次的冲击。如:

1     public interface Singer {  //歌唱家
2 AudioClip sing(Song s);
3 }
4 public interface SongWriter { //作曲家
5 Song compose(boolean hit);
6 }

      在现实生活中,有些歌唱家本身也是作曲家。因为我们这里是通过接口来定义这两个角色的,所有同时实现他们是完全可能的。甚至可以再提供一个接口扩展自这两个接口,并提供新的方法,如:

1     public interface SingerWriter extends Singer, SongWriter {
2 AudioClip strum();
3 void actSensitive();
4 }

      试想一下,如果将Singer和SongWriter定义为抽象类,那么完成这一扩展就会是非常浩大的工程,甚至可能造成"组合爆炸"的现象。
      我们已经列举出了一些接口和抽象类之间的重要差异,下面我们还可以了解一下如何组合使用接口和抽象类,以便他们能为我们设计的框架带来更好的扩展性和层级结构。在Java的Collections Framework中存在一组被称为"骨架实现"(skeletal implementation)的抽象类,如AbstractCollection、AbstractSet和AbstractList等。如果设计得当,骨架实现可以使程序员很容易的提供他们自己的接口实现。这种组合还可以让我们在设计自己的类时,根据实际情况选择是直接实现接口,还是扩展该抽象类。和接口相比,骨架实现类还存在一个非常明显的优势,既如果今后为该骨架实现类提供新的方法,并提供了默认的实现,那么他的所有子类均不会受到影响,而接口则不同,由于接口不能提供任何方法实现,因此他所有的实现类必须进行修改,为接口中新增的方法提供自己的实现,否则将无法通过编译。
      简而言之,接口通常是定义允许多个实现的类型的最佳途径。这条规则有个例外,即当演变的容易性比灵活性更为重要的时候。在这种情况下,应该使用抽象类来定义类型,但前提是必须理解并且可以接受这些局限性。如果你导出了一个重要的接口,就应该坚决考虑同时提供骨架实现类。
    
十九、接口只用于定义类型:

      当类实现接口时,接口就充当可以引用这个类的实例的类型。因此,类实现了接口,就表明客户端可以对这个类的实例实施某些动作。为了任何其他目的定义接口是不恰当的。如实现Comparable接口的类,表明他可以存放在排序的集合中,之后再从集合中将存入的对象有序的读出,而实现Serializable接口的类,表明该类的对象具有序列化的能力。类似的接口在JDK中大量存在。
    
二十、类层次优于标签类:

      这里先给出标签类的示例代码:

 1     class Figure {
2 enum Shape { RECT,CIRCLE };
3 final Shape s; //标签域字段,标识当前Figure对象的实际类型RECT或CIRCLE。
4 double length; //length和width均为RECT形状的专有域字段
5 double width;
6 double radius; //radius是CIRCLE的专有域字段
7 Figure(double radius) { //专为生成CIRCLE对象的构造函数
8 s = Shape.CIRCLE;
9 this.radius = radius;
10 }
11 Figure(double length,double width) { //专为生成RECT对象的构造函数
12 s = Shape.RECT;
13 this.length = length;
14 this.width = width;
15 }
16 double area() {
17 switch (s) { //存在大量的case判断来确定实际的对象类型。
18 case RECT:
19 return length * width;
20 case CIRCLE:
21 return Math.PI * (radius * radius);
22 default:
23 throw new AssertionError();
24 }
25 }
26 }

      像Figure这样的类通常被我们定义为标签类,他实际包含多个不同类的逻辑,其中每个类都有自己专有的域字段和类型标识,然而他们又都同属于一个标签类,因此被混乱的定义在一起。在执行真正的功能逻辑时,如area(),他们又不得不通过case语句再重新进行划分。现在我们总结一下标签类将会给我们的程序带来哪些负面影响。
      1.    不同类型实例要求的域字段被定义在同一个类中,不仅显得混乱,而且在构造新对象实例时,也会加大内存的开销。
      2.    初始化不统一,从上面的代码中已经可以看出,在专为创建CIRCLE对象的构造函数中,并没有提供length和width的初始化功能,而是借助了JVM的缺省初始化。这样会给程序今后的运行带来潜在的失败风险。
      3.    由于没有在构造函数中初始化所有的域字段,因此不能将所有的域字段定义为final的,这样该类将有可能成为可变类。
      4.    大量的swtich--case语句,在今后添加新类型的时候,不得不修改area方法,这样便会引发因误修改而造成错误的风险。顺便说一下,这一点可以被看做《敏捷软件开发》中OCP原则的反面典型。
      那么我们需要通过什么方法来解决这样的问题呢?该条目给出了明确的答案:利用Java语句提供的继承功能。见下面的代码:

 1     abstract class Figure {
2 abstract double area();
3 }
4 class Circle extends Figure {
5 final double radius;
6 Circle(double radius) {
7 this.radius = radius;
8 }
9 double area() {
10 return Math.PI * (radius * radius);
11 }
12 }
13 class Rectangle extends Figure {
14 final double length;
15 final double width;
16 Rectangle(double length,double width) {
17 this.length = length;
18 this.width = width;
19 }
20 double area() {
21 return length * width;
22 }
23 }

      现在我们为每种标签类型都定义了不同的子类,可以明显看出,这种基于类层次的设计规避了标签类的所有问题,同时也大大提供了程序的可读性和可扩展性,如:

1     class Square extends Rectangle {
2 Square(double side) {
3 super(side,side);
4 }
5 }

      现在我们新增了正方形类,而我们所需要做的仅仅是继承Rectangle类。
      简而言之,标签类很少有适用的场景。当你想要编写一个包含显式标签域的类时,应该考虑一下,这个标签是否可以被取消,这个类是否可以用类层次来代替。当你遇到一个包含标签域的现有类时,就要考虑将它重构到一个层次结构中去。
    
二十一、用函数对象表示策略:

      函数对象可以简单的理解为C语言中的回调函数,但是我想他更加类似于C++中的仿函数对象。仿函数对象在C++的标准库中(STL)有着广泛的应用,如std::less等。在Java中并未提供这样的语法规则,因此他们在实现技巧上确实存在一定的差异,然而设计理念却是完全一致的。下面是该条目中对函数对象的描述:
      Java没有提供函数指针,但是可以用对象引用实现统一的功能。调用对象上的方法通常是执行该对象(that Object)上的某项操作。然而,我们也可能定义这样一种对象,它的方法执行其他对象(other Objects)上的操作。如果一个类仅仅导出这样的一个方法,它的实例实际上就等同于一个指向该方法的指针。这样的实例被称为函数对象(Function Object),如JDK中Comparator,我们可以将该对象看做是实现两个对象之间进行比较的"具体策略对象",如:

1     class StringLengthComparator {
2 public int compare(String s1,String s2) {
3 return s1.length() - s2.length();
4 }
5 }

      这种对象自身并不包含任何域字段,其所有实例在功能上都是等价的,因此可以看作为无状态的对象。这样为了提供系统的性能,避免不必要的对象创建开销,我们可以将该类定义为Singleton对象,如:

1     class StringLengthComparator {
2 private StringLengthComparator() {} //禁止外部实例化该类
3 public static final StringLengthComparator INSTANCE = new StringLengthComparator();
4 public int compare(String s1,String s2) {
5 return s1.length() - s2.length();
6 }
7 }

      StringLengthComparator类的定义极大的限制了参数的类型,这样客户端也无法再传递任何其他的比较策略。为了修正这一问题,我们需要让该类成为Comparator<T>接口的实现类,由于Comparator<T>是泛型类,因此我们可以随时替换策略对象的参数类型,如:

1     class StringLengthComparator implements Comparator<String> {
2 public int compare(String s1,String s2) {
3 return s1.length() - s2.length();
4 }
5 }

      简而言之,函数指针的主要用途就是实现策略模式。为了在Java中实现这种模式,要声明一个接口来表示策略,并且为每个具体策略声明一个实现了该接口的类。当一个具体策略只被使用一次时,可以考虑使用匿名类来声明和实例化这个具体的策略类。当一个具体策略是设计用来重复使用的时候,他的类通常就要被实现为私有的静态成员类,并通过公有的静态final域被导出,其类型为该策略接口。
    
二十二、优先考虑静态成员类:

      在Java中嵌套类主要分为四种类型,下面给出这四种类型的应用场景。
      1.    静态成员类:        
      静态成员类可以看做外部类的公有辅助类,仅当与它的外部类一起使用时才有意义。例如,考虑一个枚举,它描述了计算器支持的各种操作。Operation枚举应该是Calculator类的公有静态成员类,然后,Calculator类的客户端就可以用诸如Calculator.Operation.PLUS和Calculator.Operation.MINUS这样的名称来引用这些操作。
      2.    非静态成员类:
      一种常见的用法是定义一个Adapter,它允许外部类的实例被看做是另一个不相关的类的实例。如Map接口的实现往往使用非静态成员类来实现它们的集合视图,这些集合视图是由Map的keySet、entrySet和Values方法返回的。
      从语法上讲,静态成员类和非静态成员类之间唯一的区别是,静态成员类的声明中包含了static修饰符,尽管语法相似,但实际应用却是大相径庭。每个非静态成员类的实例中都隐含一个外部类的对象实例,在非静态成员类的实例方法内部,可以调用外围实例的方法。如果嵌套类的实例可以在它的外围类的实例之外独立存在,这个嵌套类就必须是静态成员类。由于静态成员类中并不包含外部类实例的对象引用,因此在创建时减少了内存开销。
      3.    匿名类:
      匿名类没有自己的类名称,也不是外围类的一个成员。匿名类可以出现在代码中任何允许存在表达式的地方。然而匿名类的适用性受到诸多限制,如不能执行instanceof测试,或者任何需要类名称的其他事情。我们也无法让匿名类实现多个接口,当然也不能直接访问其任何成员。最后需要说的是,建议匿名类的代码尽量短小,否则会影响程序的可读性。
      匿名类在很多时候可以用作函数对象。
      4.    局部类:
      是四种嵌套类中最少使用的类,在任何"可以声明局部变量"的地方,都可以声明局部类,并且局部类也遵守同样的作用域规则。

原文地址:https://www.cnblogs.com/orangeform/p/2228349.html