读书-Effective Java系列(三、四)
三、四
10. 覆盖quals时请遵守通用规定
尽管Object是一个具体类,但设计它主要是为了扩展。它所有的非final方法(equals、hashCode、toString、clone和finalize)都有明确的通用约定(general contract),因为它们设计成是要被覆盖(override)的。任何任何一个类,它在覆盖这些方法的时候,都有责任遵守这些通用约定;如果不能做到这一点,其他依赖于这些约定的类(例如HashMap和HashSet)就无法结合该类一起正常运作。
本章将讲述何时以及如何覆盖这些非final的Object方法。本章不再讨论finalize方法,因为第8条已经讨论过这个方法了。而Comparable.compareTo虽然不是Object方法,但是本章也将对其进行讨论,因为它具有类似的特点。
覆盖 equals 方法看起来似乎很简单,但是有许多覆盖方式会导致错误,并且后果非常严重。最容易避免这类问题的办法就是不覆盖 equals 方法,在这种情况下,类的每个实例都只与它自身相等。如果满足了以下任何一个条件,就可以不覆盖 equals 方法:
- 类的每个实例本质上都是唯一的。对于代表活动实体而不是值(value)的类来说确实如此,例如 Thread。Object 提供的 equals 实现对于这些类来说正是正确的行为。
- 类没有必要提供“逻辑相等”的测试功能。例如,java.util.regex.Pattern 可以覆盖 equals,以检查两个 Pattern 实例是否代表同一个正则表达式,但是设计者并不认为客户需要或者期望这样的功能。在这类情况之下,从 Object 继承得到的 equals 实现已经足够了。
超类已经覆盖了 equals,超类的行为对于这个类也是合适的。例如,大多数的 Set 实现从 AbstractSet 继承 equals 实现,List 实现从 AbstractList 继承 equals 实现,Map 实现从 AbstractMap 继承 equals 实现。
类是私有的,或者是包级私有的,可以确定它的 equals 方法永远不会被调用。如果你非常想规避风险,可以覆盖 equals 方法,确保它不会被意外调用:
1 | public boolean equals(Object o) { |
那么什么时候应该覆盖 equals 方法呢?
如果类具有自己特有的“逻辑相等”概念(不同于对象等同的概念),而且超类还没有覆盖 equals。这通常属于“值类”(value class)的情形。值类仅仅是一个表示值的类,例如 Integer 或者 String。程序员在利用 equals 方法来比较值对象的引用时,希望知道它们在逻辑上是否相等,而不是了解它们是否指向同一个对象。为了满足程序员的要求,不仅必须覆盖 equals 方法,而且这样做也使得这个类的实例可以用作映射表(map)的键(key),或者集合(set)的元素,使映射或者集合表现出预期的行为。
在覆盖 equals 方法的时候,必须要遵守它的通用约定。下面是约定的内容,来自 Object 的规范。
equals 方法实现了等价关系,其属性如下:
- 自反性(reflexive):对于任何非 null 的引用值 x,x.equals(x) 必须返回 true。
- 对称性(symmetric):对于任何非 null 的引用值 x 和 y,当且仅当 y.equals(x) 返回 true 时,x.equals(y) 必须返回 true。
- 传递性(transitive):对于任何非 null 的引用值 x、y 和 z,如果 x.equals(y) 返回 true,并且 y.equals(z) 也返回 true,那么 x.equals(z) 也必须返回 true。
- 一致性(consistent):对于任何非 null 的引用值 x 和 y,只要 equals 的比较操作在对象中所用的信息没有被修改,多次调用 x.equals(y) 就会一致地返回 true,或者一致地返回 false。
- 对于任何非 null 的引用值 x,x.equals(null) 必须返回 false。
实现高质量 equals 方法的诀窍:
- 使用 == 操作符检查“参数是否为这个对象的引用”。 如果是,则返回 true。 这只不过是一种性能优化,如果比较操作有可能很昂贵,就值得这么做。
- 使用 instanceof 操作符检查“参数是否为正确的类型”。 如果不是,则返回 false 一般说来,所谓“正确的类型”是指equals 方法所在的那个类。 某些情况下,是指该类所实现的某个接口。 如果类实现的接口改进了 equals 约定,允许在实现了该接口的类之间进行比较,那么就使用接口。 集合接口如Set、List、Map和Map.E口try具有这样的特性。
- 把参数转换成正确的类型。 因为转换之前进行过且stanceof测试,所以确保会成功。
- 对于该类中的每个“关键”( significant )域,检查参数中的域是否与该对象中对应的域相匹配。 如果这些测试全部成功,则返回 true;否则返回 false。 如果第2步中的类型是个接口,就必须通过接口方法访问参数中的域;如果该类型是个类,也许就能够直接访问参数中的域,这要取决于它们的可访问性。
- 在编写完 equals 方法之后,应该问自己三个问题:它是否是对称的、传递的、一致的? 并且不要只是自问,还要编写单元测试来检验这些特性
下面是最后的一些告诫:
-
覆盖equals 时总要覆盖hashCode
-
不要企图让equals 方法过于智能。 如果只是简单地测试域中的值是否相等,则不难做到遵守 equals 约定。 如果想过度地去寻求各种等价关系,则很容易陷入麻烦之中。把任何一种别名形式考虑到等价的范围内,往往不会是个好主意。 例如,File类不应该试图把指向同一个文件的符号链接(symbolic link)当作相等的对象来看待。 所幸File 类没有这样做
-
不要将equals 声明中的 Object 对象替换为其他的类型。 比如:
// Broken - parameter type must be Object!
public boolean equals(MyClass o) {
…
}问题在于,这个方法并没有覆盖 Object. equals,因为它的参数应该 是Object 类型,相反,它重载了 Object.equals。 在正常 equals 方法的基础上,再提供一个“强类型”的 equals 方法。
增加@Override注解可以在编译前发现问题
// Still broken, but won’t compile
@Override
public boolean equals(MyClass o) {
…
} -
编写和测试 equals(及hashCode)方法都是十分繁琐的,得到的代码也很琐碎。 代替手工编写和测试这些方法的最佳途径,是使用Google开源的
AutoValue框架
,它会自动替你生成这些方法,通过类中的单个注解就能触发。 在大多数情况下,AutoValue生成的方法本质上与你亲自编写的方法是一样的。
总之,除非必须,否则不要覆盖 equals 方法:在许多情况下,从 Object 继承而来的实现正是你想要的。如果你确实覆盖了 equals,那么一定要比较类的所有重要字段,并以保留 equals 约定的所有 5 项规定的方式进行比较。
11. 覆盖equals时总要覆盖hashCode
在每一个覆盖了equals方法的类中,总要覆盖hashCode方法。如果不这样做的话,就会违反hashCode的通用约定,
Object规范:
- 在应用程序的执行期间,只要对象的equals方法的比较操作所用到的信息没有被修改,那么对同一个对象的多次调用,hashCode方法都必须始终返回同一个值。在一个应用程序与另一个程序的执行过程中,执行hashCode方法所返回的值可以不一致。
- 如果两个对象根据equals(Object)方法比较是相等的,那么调用这两个对象中的hashCode方法都必须产生同样的整数结果。
- 如果两个对象根据equals(Object)方法比较是不相等的,那么调用这两个对象中的hashCode方法,则不一定要求hashCode方法必须产生不同的结果。 但是程序员应该知道,给不相等的对象产生截然不同的整数结果,有可能提高散列表(hashtable)的性能。
因没有覆盖hashCode 而违反的关键约定是第二条:相等的对象必须具有相等的散到码( hashcode )。
比如:
1 | Map<PhoneNumber, String> m = new HashMap<>(); |
此时,你可能期望 m.get(new PhoneNumber(707, 867,5309))
返回「Jenny」,但是它返回 null。
注意,这里涉及两个PhoneNumber实例:第一个被插入HashMap中,第二个实例与第一个相等,用于从Map中根据PhoneNumber去获取用户名字。
**由于PhoneNumber类没有覆盖hashCode方法,从而导致两个相等的实例具有不相等的散列码,违反了hashCode的约定。**因此,put方法把电话号码对象存放在一个散列桶(hash bucket)中,get方法却在另一个散列桶中查找这个电话号码。即使这两个实例正好被放到同一个散列桶中,get方法也必定会返回null,因为HashMap有一项优化,可以将与每个项相关的散列码缓存起来,如果散列码不匹配,也就不再去检验对象的等同性。
修正这个问题十分简单,只需要给PhoneNumber类提供一个适当的hashCode方法即可。
不要试图从散列码计算中排除掉一个对象的关键域来提高性能。 虽然这样得到的散列函数运行起来可能更快,但是它的效果不见得会好,可能会导致散列表慢到根本无法使用。特别是在实践中,散列函数可能面临大量的实例,在你选择忽略的区域之中,这些实例仍然区别非常大。 如果是这样,散列函数就会把所有这些实例映射到极少数的散列码上,原本应 该以线性级时间运行的程序,将会以平方级的时间运行。
**不要对hashCode方法的返回值做出具体的规定,因此客户端无法理所当然地依赖它;**这样可以为修改提供灵活性。 Java类库中的许多类,比如 String 和 Integer,都可以把 它们的 hashCode 方法返回的确切值规定为该实例值的一个函数。一般来说,这并不是个好主意,因为这样做严格地限制了在未来的版本中改进散列函数的能力。 如果没有规定散列函数的细节,那么当你发现了它的内部缺陷时,或者发现了更好的散列函数时,就可以在后面的发行版本中修正它。
12. 始终要覆盖toString
虽然Object提供了 toString 方法的一个实现,但它返回的字符串通常并不是类的用户所期望看到的。它包含类的名称,以及一个“@”符号,接着是散列码的无符号十六进制表示法,例如PhoneNumber@163b91。 toString的通用约定指出,被返回的字符串应该是一个“简洁的但信息丰富,并且易于阅读的表达形式”。
- 遵守toString约定并不像遵守 equals 和 hashCode 的约定那么重要,但是,提供好的toString实现可以便类用起来更加舒适,使用了这个类的系统也更易于调试。
- 在实际应用中, toString 方法应该返回对象中包含的所有值得关注的信息。
- 无论是否指定返回值(字符串)格式,都应该在文档注释中明确地表明你的意图。
- 无论是否指定返回值(字符串)格式,都为toString返回值中包含的所有信息提供一种可以通过编程访问到的途径。 即提供方法获取toString中的某一部分数据,而不需要去解析toString。
13. 谨慎地覆盖clone
Cloneable接口的目的是作为对象的UI个mixin接口,表明这样的对象允许克隆。但是这个接口没有定义clone方法,并且Object的clone方法是受保护的。所以,如果不借助反射,就不能仅仅因为一个对象实现了Cloneable接口,就调用clone方法。
Cloneable接口并没有包含任何方法,那么这个接口有什么作用呢?
这个接口是一个标记接口(空接口),他决定了Object中受保护的clone方法实现的行为。如果一个类实现了Cloneable接口,Object的clone方法就返回该对象的浅拷贝;如果没有实现Cloneable接口就会抛出CloneNotSupportedException异常。
【Object的clone方法被 protected 和 native 修饰 (见相关博客)】
事实上,实现Cloneable接口的类都是为了提供一个功能适当的公有的clone方法。
注意事项:
-
**不可变的类永远都不应该提供clone方法。**它只会激发不必要的克隆。
-
clone默认是浅拷贝,如果对象的域引用了可变的对象(数组),需要重写clone方法深拷贝。
-
**Cloneable架构与引用可变对象的final域的正常用法是不兼容的。**clone方法被禁止给final域对象赋新值。
-
**公有的clone方法应该省略throws声明。**因为不会抛出受检异常的方法使用起来更加轻松。
-
对象拷贝的更好的办法是提供一个拷贝构造器或者拷贝工厂
1
2
3
4// Copy constructor
public Yum(Yum yum) { ... };
// Copy factory
public static Yum newInstance(Yum yum) { ... }; -
使用clone方法克隆复杂对象的一种方法:首先,调用super.clone方法(归根结底是Object的clone方法,浅拷贝);然后把对象中的所有域(属性)设置成初始状态,然后调用子类的方法给域(属性)重新赋值。
相关博客:java实现简单的克隆-CSDN博客
14. 考虑实现Comparable接口
实现Comparable接口主要是重写compareTo方法。该方法的目的不但允许进行简单的等同性比较,而且允许执行顺序比较。
- 如果一个对象没有实现Comparable接口,或者需要使用一个非标准的排序关系,就可以使用一个显示的Comparator来代替,或者编写自己的比较器,或者使用已有的比较器。
- 在compareTo方法中使用关系操作符<、>(大于号、小于号)是非常繁琐的,并且容易出错,所以不建议使用。应该在装箱基本类型的类中使用静态的compare方法,或者Comparator接口中使用比较器构造方法。
15. 使类和成员的可访问性最小化
区分一个组件设计的好不好,唯一重要的因素是:它对于外部的其他组件而言,是否隐藏了其内部数据和其他细节实现。好的组件会隐藏所有的实现细节,把API与实现清晰地隔离出来,组件之间只通过API进行通信,一个模块不需要知道其他模块的内部工作情况。这种设计也被称为信息隐藏或封装。
Java提供了许多机制来协助信息隐藏。访问控制机制通过访问修饰符决定类、接口和成员的可访问性。
规则:
-
尽可能地使每个类或者成员不被外界访问
-
类和接口:只有两种访问级别,包级私有(无修饰)和公有(public修饰)。如果能把类/接口做成包级私有,就需要定义为包级私有。
如果一个包级私有的顶层类/接口只在某一个类的内部被用到,就应该考虑使它成为唯一使用的类的私有嵌套类
-
成员(域、方法、嵌套类、嵌套接口):私有(private)、包级私有(无修饰)、受保护的(protected修饰)、公有(public修饰)
-
-
公有类的实例域决不能是公有的。如果公有类的实例域一旦公有,你就等于放弃了对存储在这个域中的值进行限制的能力;并且包含公有可变域的类通常并不是线程安全的。
让类具有公有的静态final数据域,或者返回这种域的访问方法,是错误的。
16. 要在公有类而非公有域中使用访问方法
对于公有类,他的域尽可能的私有。所以,如果需要访问域时,需要共有类提供访问方法,比如getXXX()。
不过,如果类是包级私有的,或者是私有的嵌套类,直接暴露它的数据域并没有本质的错误。
17. 使可变性最小化
不可变类是指其实例不能被修改的类,每个实例中包含的所有信息都必须在创建该实例的时候就提供,并且在对象的整个生命周期内固定不变。
如果要使类成为不可变类,必须要遵循的规则:
- 不要提供任何会修改对象状态的方法
- 保证类不会被拓展(一般是用final声明)
- 声明所有域都是final的
- 声明所有域都是私有的
- 确保对任何可变组件的互斥访问。如果类具有指向可变对象的域,则必须确保该类的客户端无法获得指向这些对象的引用。
不可变类的优点:
- 不可变类比较简单,只有一种状态,即被创建时的状态。
- 不可变对象本质是线程安全的,他们不要求同步。
- 不可变对象可以被自由的共享,甚至可以共享类的内部信息。
- 不可变对象为其他对象提供了大量的构件。
- 不可变对象无偿的提供了失败的原子性,不存在临时不一致的可能性。
**不可变类的缺点:**真正唯一的缺点是对于每个不同的值,都需要一个单独的对象。
类创建原则:
- 除非有很好的理由要让类成为可变的类,否则他就应该是不可变的。
- 如果类不能被做成不可变的,仍然应该尽可能地限制他的可变性。
- 除非有令人信服的理由要使域变成是非final的,否则每个域都应该是private final的。
- 构造器应该创建完全初始化的对象,并建立起所有的约束关系。
18. 复合优先于继承
本小节的继承指:一个类拓展另一个类的时候。(不考虑一个类实现一个接口或者一个接口扩展另一个接口的时候)
继承是实现代码重用的有利手段,但并非永远是最佳工具。
**因为和方法调用不同,继承打破了封装性。**子类依赖于其超类中特定功能的实现细节。超类的实现有可能会随着发行版本的不同而有所变化,如果真的发生了变化,子类可能会遭到破坏,即使它的代码完全没有改变。 因而,子类必须要跟着其 超类的更新而演变,除非超类是专门为了扩展而设计的,并且具有很好的文挡说明。
只有当子类真正是超类的子类型时,才适合用继承。
**复合:**不扩展现有类,而是在新的类中增加一个私有域,引用现有类的一个实例。新类中的每个实例方法都可以调用被包含的先有类中对应的方法,并返回它的结果。这被称为转发,新类中的方法被称为转发方法。
19. 要么设计继承并提供文档说明,要么禁止继承
对于专门为了继承而设计并且具有良好文档说明的类而言:
- 该类必须有文档说明它可覆盖的方法的自用型
好的API文档应该描述一个给定的方法做了什么工作,而不是描述它是如何做到的。
- 类必须以精心挑选的受保护的方法的形式,提供适当的钩子(hook),以便进入其内部工作中。
钩子(hook):允许程序员在程序运行的不同阶段插入额外的代码,以实现对程序行为的控制和定制化。JAVA中的hook通常通过回调函数或者监听器的方式实现。当程序到达某个特定的状态或者事件发生时,钩子会触发相应的回调函数或者事件处理方法,从而执行额外的逻辑。
- 对于为了继承而设计的类,唯一的测试方式就是编写子类。换句话说,必须在发布类之前先编写子类对类进行测试。
- 构造器决不能调用可被覆盖的方法。
- 如果类实现了Cloneable或者Serializable接口,无论是clone还是readObject方法,都不可以调用可覆盖的方法,不管是以直接还是间接的方式。
- 对于那些并非为了安全地进行子类化而设计和编写文档的类,要禁止子类化。
20. 接口优于抽象类
Java提供了两种机制,可以用来定义允许多个实现的类型:接口和抽象类。并且Java 8以后,接口引入了缺省方法。这两种机制都允许为某些实例方法提供实现。
-
现有的类可以很容易被更新,以实现新的接口
-
接口是定义mixin(混合类型)的理想选择
混合类型:类除了实现它的“基本类型”以外,还可以实现mixin类型,以表明它提供了某些可选择的行为。
-
接口允许构造非层次结构的类型框架
-
接口使得安全地增强类的功能成为可能(包装类模式)
-
通过对接口提供一个抽象的骨架实现类(即抽象类实现接口,使用类继承抽象类),可以把接口和抽象类的优点结合起来。接口负责定义类型,也可以提供一些缺省方法,抽象类则负责实现除基本类型接口方法以外的方法。(模板方法模式)
-
对于骨架实现类而言,好的文档绝对是非常必要的
21. 为后代设计接口
在Java 8发行之前,如果不破坏现有的实现,是不可能给接口添加方法的。如果给某个接口添加了一个新的方法,就会导致编译错误。Java 8以后,增加了缺省方法构造,目的就是允许给现有的接口添加方法。
注意:
- 并非每一个可能实现的所有变体,始终都可以编写出一个缺省方法
- 有了缺省方法,接口的现有实现就不会出现编译时没有报错或警告,运行时却失败的情况。
- 谨慎设计接口仍然是至关重要的
22. 接口只用于定义类型
当类实现接口时,接口就充当可以引用这个类的实例的类型。因此,类实现了接口,就表明客户端可以对这个类的实例实施某些动作。
注意:
-
常量接口模式是对接口的不良使用
有一种接口被称为常量接口(constant interface),它不满足上面的条件。这种接口不包含任何方法,它只包含静态的final域,每个域都导出一个常量。使用这些常量的类实现这个接口,以避免用类名来修饰常量名。
-
如果要导出常量类,可以把这些常量添加到这个类或者接口中,比如Integer的MAX_VALUE;如果这些常量最好被看做枚举类型的成员,就应该用枚举类型;如果不行,应该使用不可实例化的工具类来导出这些常量;如果大量利用工具类导出的常量,可以通过利用静态导入机制,避免使用类名修饰常量名。
23. 类层次优于标签类
标签类过于冗长、容易出错,并且效率低下。
标签类属于类层次的一种
24. 静态成员类优于非静态成员类
嵌套类(nested class)是指指定在另一个类的内部的类。嵌套类存在的目的应该只是为了它的外围(enclosing class)提供服务。如果嵌套类将来可能会用于其他的某个环境中,它就应该顶层类(top-level class)。嵌套类有四种:静态成员类(static member class)、非静态成员类(nonstatic member class)、匿名名(anonymous class)和局部类(local class)。除了第一种之外,其他三种都称为内部类(inner class)。
静态成员类是最简单的一种嵌套类。最好把它看作是普通类,只是碰巧被声明在另一个类的内部而已,它可以访问外围的所有成员,包括那些声明为私有的成员。静态成员类是外围的一个静态成员,与其他的静态成员一样,也遵守同样的可访问性规则。如果它被声明为私有的,它就只能在外围类的内部才能被访问,等等。静态成员类的一种常见用法是作为公有的辅助类,只有与它的外部类一起使用才有意义。
非静态成员类的一种常见用法是定义一个Adapter,它允许外部类的实例被看作是另一个不相关的类的实例。 如果声明成员类不要求访问外围实例,就要始终把修饰符 static 放在它的声明中, 使它应当把嵌套类声明成静态成员类,而不是非静态成员类。如果省略了 static 修饰符,则每个实例都将包含一个额外的指向外围对象的引用。如前所述,保存这份引用要消耗时间和空间,并且会导致外围实例在符合垃圾回收时仍然得以保留。由此造成的内存泄漏可能是灾难性的。但是常常难以发现,因为这个引用是不可见的。
如果相关的类是导出类的公有或受保护的成员,毫无疑问,在静态和非静态成员类之间做出正确的选择是非常重要的。在这种情况下,该成员类就是导出的 API 元素,在后续的发行版本中,如果不违背向后兼容性,就无法从非静态成员类变为静态成员类。
匿名类是没有名字的。它不是外围类的一个成员。它并非与其他的成员一起被声明,而是在使用的同时被声明和实例化。匿名类可以出现在代码中任何允许存在表达式的地方。当且仅当匿名类出现在非静态的环境中时,它才拥有外围实例。但是即使它们出现在静态的环境中,也不可能拥有任何静态成员,而是拥有常数变量(constant variable),常数变量是 final 基本类型,或者是被初始化为常量表达式[JLS, 4.12.4]的字符串字面量。
匿名类的运用受到诸多的限制。除了在它们被声明的时候之外,是无法将它们实例化的。不能执行 instanceof 测试,或者做任何需要命名类的其他事情。无法声明一个匿名类来实现多个接口,或者扩展一个类,并同时扩展类和实现接口。除了从超类型继承得到之外,匿名类的客户端无法调用任何成员。由于匿名类出现在表达式中,它们必须保持简短(大约 10 行或者更少),否则会影响程序的可读性。
局部类是四种嵌套类中使用最少的类。在任何“可以声明局部变量”的地方,都可以声明局部类,并且局部类也遵守同样的作用域规则。局部类与其他三种嵌套类中的每一种都有一些共同的属性。与成员类一样,局部类有名字,可以被重复使用。与匿名类一样,只有当局部类是在非静态态环境中定义的时候,才有外围实例,它们也不能包含静态成员。与匿名类一样,它们必须非常简短以便不会影响可读性。
总而言之,共有四种不同的嵌套类,每一种都有自己的用途。如果一个嵌套类需要在单个方法之外仍然是可见的,或者它太长了,不适合放在方法内部,就应该使用成员类。如果成员类的每个实例都需要一个指向其外围实例的引用,就把成员类做成非静态的;否则,就做成静态的。假设这个嵌套类属于一个方法的内部,如果你只需要在一个地方创建实例,并且已经有了一个预设的类型可以说明这个类的特征,就把它做成匿名类;否则,就做成分部类。
25. 限制源文件为单个顶级类
永远不要把多个顶级类或者接口放在一个源文件中。遵循这个规则可以确保编译时一个类不会有多重定义。这么做反过来也能确保编译产生的类文件,以及程序的结果的行为,都不会受到源文件被传送给编译器时的顺序的影响。