Effective-java-读书笔记之类和接口

第15条 使类和成员的可访问性最小化

尽可能地使每个类或者成员不被外界访问.

对于顶层(非嵌套)的类和接口, 只有两种可能的访问级别: 包级私有(package private)和公有(public).

如果一个包级私有的顶层类(或接口)只是在某一个类的内部被用到, 就应该考虑使它成为那个类的私有嵌套类.

对于成员(域, 方法, 嵌套类和嵌套接口), 有四种可能的访问级别(可访问性递增):

  • 私有的(private).
  • 包级私有的(package-private): 缺省(default)访问级别, 声明该成员的包内部的任何类都可以访问这个成员.
  • 受保护的(protected): 声明该成员的类的子类和包内部的任何类可以访问这个成员.
  • 公有的(public).

如果覆盖了超类中的一个方法, 子类中的访问级别就不允许低于超类中的访问级别. 这样可以确保任何使用超类的地方都可以使用子类的实例.

实例域很少是公有的.

包含公有可变域的类并不是线程安全的.

同样的建议也适用于静态域. 只有一种例外: 公有静态final域来暴露常量(名称由大写字母单词下划线隔开). 这些域要么包含基本类型的值, 要么包含指向不可变对象的引用.

长度非零的数组总是可变的, 所以, 类具有公有的静态final数组域, 或者返回这种域的访问方法, 这几乎总是错误的.

-> 改进: 让数组域private, 暴露一个immutable的list或者提供一个返回数组copy的公有方法.

Java 9的module在package之上的组. module可以在module-info.java中声明要export那些packages, 没有exported的packages在module外不可访问.

第16条 在公有类中使用访问方法而非公有域

一些退化类(degenerate classes)只是用来集中实例域, 因为其中的字段都是可直接访问的, 所以这些类没有提供封装的好处.

对公有类, 应该用包含私有域和公有访问方法(getter)的类来代替, 对可变的类, 加上公有设值方法(setter).

-> 保留了改变内部表现的灵活性.

如果类是包级私有的, 或者是私有的嵌套类, 直接暴露它的数据域并没有本质的错误.

Java平台中有几个类违反了”公有类不应该直接暴露数据域”的告诫. 如java.awt包中的PointDimension.

让公有类直接暴露域虽然从来都不是种好办法, 但是如果域是不可变的, 这种做法的危害就比较小一些(但是仍然questionable).

第17条 使可变性最小化

不可变类: 其实例不能被修改的类. 每个实例中包含的所有信息都必须在创建该实例的时候就提供, 并在对象的整个生命周期内固定不变.

为了使类成为不可变, 要遵循下面五条规则:

  • 不要提供任何会修改对象状态的方法.
  • 保证类不会被扩展. (一般做法: 声明为final.)
  • 使所有的域都是final的.
  • 使所有的域都成为私有的.
  • 确保对于任何可变组件的互斥访问.

不可变对象本质上是线程安全的, 它们不要求同步.

不可变对象可以被自由地共享.

不可变对象永远也不需要保护性拷贝.

不可变类唯一真正的缺点是, 对于每个不同的值都需要一个单独的对象. (特定情况下的性能问题.)

可以为类提供公有的可变配套类. Java类库中的String的可变配套类是StringBuilderStringBuffer.

为了让类不能被继承, 除了使类成为final的外, 还有一种方法: 让类的所有构造器都变成私有的或者包级私有的, 并添加公有的静态工厂.

优点: 提供了缓存能力, 可以提供多个不同名字的静态方法, 使相同参数类型可以构造出不同的对象(用构造器就不行).

尽量缩小可变性:

  • 除非有很好的理由要让类成为可变的, 否则就应该是不可变的.
  • 如果类不能被做成是不可变的, 仍然应该尽可能地限制它的可变性. (降低状态数, 尽量让域为private final的.)
  • 构造器应该创建完全初始化的对象, 并建立起所有的约束关系. 不要在构造器或者静态工厂之外再提供公有的初始化方法, 也不应该提供重新初始化方法.

第18条 组合优先于继承

这里说的继承是类的继承, 不是接口的实现.

继承打破了封装性.

超类的实现有可能会随着发行版本的不同而有所变化, 如果真的发生了变化, 子类有可能会遭到破坏.

因此, 子类必须要跟着其超类的更新而演变, 除非超类是专门为了扩展而设计的, 并且有很好的文档说明.

例子: 覆写了HashSet中的addaddAll方法, 但其实后者调用了前者.

组合(composition): 在新的类中增加一个私有域, 它引用现有类的一个实例.

新类中的方法可以转发被包含的现有实例中的方法. 这样得到的类将会非常稳固, 它不依赖于现有类的实现细节.

因为每一个新类的实例都把另一个现有类的实例包装起来了, 所以新的类被称为包装类(wrapper class), 这也正是Decorator模式.

只有当子类真正是超类的子类型时, 才适合用继承. 即对于两个类, 只有当两者之间确实存在”is-a”关系的时候, 才应该继承.

Java类库中也有明显违反这条原则的地方, 比如Stack并不是Vector, Properties不是Hashtable, 它们却错误地使用了继承.

在决定使用继承而不是复合之前, 还应该问自己最后一组问题: 对于你正在试图扩展的类, 它的API中有没有缺陷呢? 继承机制会把超类API中的缺陷传播到子类中, 而复合则允许设计新的API来隐藏这些缺陷.

第19条 要么为继承而设计, 并提供文档说明, 要么就禁止继承

对于专门为了继承而设计的类, 需要具有良好的文档.

该类的文档必须精确地描述覆盖每个方法所带来的影响. 换句话说, 该类必须有文档说明它可覆盖的方法的自用性.

更一般地, 类必须在文档中说明, 在哪些情况下它会调用可覆盖的方法.

如果方法调用到了可覆盖的方法, 在它的文档注释末尾应该包含关于这些调用的描述信息: Implementation Requirements, @implSpec(Added in Java 8).

这段描述信息要以这样的句子开头: “This implementation…”.

关于程序文档有句格言; 好的API文档应该描述一个给定的方法做了什么工作, 而不是描述它是如何做到的. 上面这种做法违背了这句格言, 这是继承破坏了封装性所带来的不幸后果.

类必须通过某种形式提供适当的钩子(hook).

类必须通过某种形式提供适当的钩子(hook), 以便能够进入到它的内部工作流程中, 这种形式可以是精心选择的protected方法, 也可以是protected的域, 后者比较少见.

例子: java.util.AbstractList中的removeRange方法. 使子类更易提供针对子列表的快速clear方法.

对于为了继承而设计的类, 唯一的测试方法就是编写子类.

在为了继承而设计的类有可能被广泛使用时, 必须要意识到, 对于文档中所说明的自用模式, 以及对于其受保护方法和域中所隐含的实现策略, 实际上已经做出了永久的承诺.

因此必须在发布之前先编写子类对类进行测试.

为了允许继承, 类还必须遵守其他一些约束.

  • 构造器决不能调用可被覆盖的方法. 无论是直接调用还是间接调用.
    (因为超类的构造器在子类的构造器之前运行, 如果子类中覆盖版本的方法依赖于子类构造器所执行的任何初始化工作, 该方法将不会如预期般地执行.)
  • 在为了继承而设计类的时候, CloneableSerializable接口出现了特殊的困难.
    无论是clone还是readObject, 都和构造器类似, 都不可以调用可覆盖的方法, 不管是以直接还是间接的方式.
    如果该类有readResolvewriteReplace方法, 就必须使它们成为受保护的方法.

对于那些并非为了安全地进行子类化而设计和编写文档的类, 要禁止子类化.

  • 把类声明为final.
  • 把所有的构造器都变成私有的, 或者包级私有的, 并增加一些公有的静态工厂来替代构造器.

第20条 接口优于抽象类

抽象类和接口的区别:

  • 抽象类允许包含某些方法的实现, 接口则不允许. (从Java 8开始接口也可以包含默认方法了.)
  • 抽象类需要继承(Java只允许单继承), 但是可以实现多个接口.

使用接口的好处:

  • 现有的类可以很容易被更新, 以实现新的接口.
  • 接口是定义混合类型(mixin)的理想选择.
  • 接口允许我们构造非层次结构的类型框架.
  • 接口可以更安全有力地增强功能. -> 组合优于继承.

通过对你导出的每个重要接口都提供一个抽象的骨架实现(skeletal implementation)类, 把接口和抽象类的优点结合起来.

按照惯例, 骨架实现被称为AbstractInterface, 这里Interface是指所实现的接口的名字.

比如AbstractCollection, AbstractSet, AbstractListAbstractMap.

骨架实现的美妙之处在于, 它们为抽象提供了实现上的帮助, 但又不强加抽象类被用作类型定义时所特有的严格限制.

对于接口的大多数实现来讲, 扩展骨架实现类是个很显然的选择, 但并不是必需的. 如果类无法扩展骨架实现类, 这个类始终可以手工实现这个接口.

此外, 骨架实现类仍然能够有助于接口的实现.

实现了这个接口的类可以把对于接口方法的调用, 转发到一个内部私有类的实例上, 这个内部私有类扩展了骨架实现类. 这种方法被称作模拟多重继承(simulated multiple inheritance).

编写骨架实现类, 必须认真研究接口, 并确定哪些方法是最为基本的, 其他的方法则可以根据它们来实现.

这些基本方法将成为骨架实现类中的抽象方法, 然后, 必须为接口中的其他方法提供具体的实现.

骨架类的简单实现: 最简单的可能的有效实现, 不是抽象的. 你可以原封不动地使用, 也可以将它子类化.

使用抽象类有一个优势: 抽象类的演变比接口的演变要容易得多.

如果在后续的发行版本中, 你希望在抽象类中增加新的具体方法, 始终可以增加, 它包含合理的默认实现. 然后, 该抽象类的所有实现都将提供这个新的方法.

接口一旦被公开发行, 并且已被广泛实现, 再想改变这个接口几乎是不可能的.

第21条 为了后代设计接口

从Java 8开始, 可以给接口加上方法, 而不破坏现有的实现. (有风险).

声明包含默认实现的默认方法, 可以让之前实现这个接口的子类用这个默认实现.

Java 8开始, 有很多默认方法被加在了collection中, 主要是为了lambda.

Java库的默认方法是高质量, 通用的实现, 大多数情况都能工作得很好.

但并不是永远都能写一个在任何情形下都适用的默认方法实现. -> 比如removeIf, Apache的SynchronizedCollection实现允许一个外部对象作为锁,

如果使用removeIf可能会抛出异常或者其他未知行为.

在有默认方法出现的时候, 接口之前存在的实现可能可以通过编译, 但是可能在运行时失败.

第22条 接口只用于定义类型

常量接口(constant interface): 没有包含任何方法, 只包含静态的final域, 每个域都导出一个常量. 使用这些常量的类实现这个接口, 以避免用类名来修饰常量名.

常量接口模式是对接口的不良使用:

  • 暴露了实现细节到该类的导出API中;
  • 实现常量接口对于类的用户来说没有价值;
  • 如果以后的发行版本中不需要其中的常量了, 依然必须实现这个接口;
  • 所有子类的命名空间也会被接口中的常量污染.

如果要导出常量, 可以有几种合理的选择方案:

  • 如果这些常量与某个现有的类或者接口紧密相关, 就应该把这些常量添加到这个类或者接口中.
  • 如果这些常量最好被看作枚举类型的成员, 就应该用枚举类型来导出这些常量.
  • 使用不可实例化的工具类来导出这些常量.

总结: 接口应该只被用来定义类型, 它们不应该被用来导出常量.

第23条 类层次优于标签类

有时候, 可能会遇到带有两种甚至更多种风格的实例的类, 并包含表示实例风格的标签域. 例子: Figure类内含Shape枚举, 包含圆形和方形.

这种标签类有着许多缺点:

  • 它们中充斥着样板代码, 包括枚举声明, 标签域以及条件语句, 可读性不好.
  • 内存占用也增加了, 因为实例承担着属于其他风格的不相关的域.
  • 域不能是final的, 构造器必须不借助编译器来设置标签域.
  • 添加风格必须要修改源文件.
  • 不容易扩展, 添加风格必须记得给每个条件语句都添加条件.
  • 实例的数据类型没有提供任何关于其风格的线索.

标签类过于冗长, 容易出错, 效率低下.

用子类型修正:

  • 定义抽象基类, 方法行为若依赖于标签值, 则定义为抽象方法. 方法行为若不依赖于标签值, 就把方法放在抽象类中.
  • 所有方法都用到的数据域放在抽象类中, 特定于某个类型的数据域放在对应的子类中.

这个类层次纠正了前面所提到的标签类的所有缺点.

第24条 优先考虑静态成员类

嵌套类(nested class)是指被定义在另一个类的内部的类. 嵌套类存在的目的应该只是为它的外围类提供服务.

嵌套类有四种:

  • 静态成员类(static member class).
  • 非静态成员类(nonstatic member class).
  • 匿名类(anonymous class).
  • 局部类(local class).

除了第一种之外, 其他三种都被称为内部类(inner class).

静态成员类

外围类的一个静态成员, 与其他的静态成员遵守同样的可访问性规则.

如果声明成员类不要求访问外围实例, 就要始终把static修饰符放在它的声明中, 使它成为静态成员类.

常见用法: 作为公有的辅助类, 仅当与它的外部类一起使用时才有意义.

私有静态成员类的一种常见用法是用来代表外围类所代表的对象的组件.

例如: Map中的Entry.

非静态成员类

非静态成员类的每个实例都隐含着与外围类的一个实例相关联. 保存这份引用消耗时间和空间, 并且会导致外围实例在符合垃圾回收时却仍然得以保留.

如果嵌套类的实例可以在它外围类的实例之外独立存在, 这个嵌套类就必须是静态成员类; 在没有外围实例的情况下, 要想创建非静态成员类的实例是不可能的.

创建: 在外围类的某个实例方法的内部调用非静态成员类的构造器; 使用表达式enclosingInstance.new MemberClass(args)来手工创建(很少使用).

常见用法: 定义Adapter, 它允许外部类的实例被看作是另一个不相关的类的实例.

例如: Map的集合视图, Set和List的迭代器.

匿名类

匿名类没有名字, 它不是外围类的一个成员, 它是在使用的时候同时被声明和实例化. 可以出现在代码中任何允许存在表达式的地方.

当且仅当匿名类出现在非静态环境中时, 它才有外围实例. 但是即使它们出现在静态的环境中, 也不可能拥有任何静态成员.

常见用法:

  • 创建函数对象. 如匿名的Comparator实例.
  • 创建过程对象. 如Runnable, Thread或者TimerTask实例.
  • 在静态工厂方法的内部.

局部类

局部类不常用. 在任何可以声明局部变量的地方, 都可以声明局部类, 并且局部类也遵守同样的作用域规则.

局部类有名字, 可以被重复地使用. 只有当局部类在非静态环境中定义的时候, 才有外围实例. 它们也不能包含静态成员.

与匿名类一样, 它们必须非常简短, 以保证可读性.

总结:

  • 如果一个嵌套类需要在单个方法之外仍然是可见的, 或者它太长了, 不适合于放在方法内部, 就应该使用成员类.
  • 如果成员类的每个实例都需要一个指向其外围实例的引用, 就要把成员类做成非静态的; 否则, 就做成静态的.
  • 假设这个嵌套类属于一个方法的内部, 如果你只需要在一个地方创建实例, 并且已经有了一个预置的类型可以说明这个类的特征, 就要把它做成匿名类; 否则, 就做成局部类.

第25条 限制源文件为单个顶级类

虽然Java编译器允许你在一个文件中定义多个顶级类, 但是这样做没什么好处, 并且有风险.

风险缘由: 在一个源文件中定义多个顶级类, 将有机会通过多个源文件为一个类提供多个定义, 最终使用哪个定义和源文件被交给编译器的顺序有关.

永远不要把多个顶级类或接口放在同一个源文件中. 这样可以保证程序的运行结果和编译顺序无关.

正文完