EffectiveJava(v3) - chapter2: Methods Common to All Objects

Methods Common to All Objects

虽然Object类是一个具体的类, 但是主要设计出来还是用来进行拓展的. 对于Object类中的一些非不变(nofinal)的方法, 如equals, hashCode, toString, clone和finalize方法, 都是设计来进行覆盖的. 但是在覆盖的同时, 子类也是需要遵循这些方法的约定. 如果 不遵守的话可能导致一些别的类(依赖这些约定的类)使用时出现问题, 如HashMap, HashSet.

本章主要讲解如何正确地覆盖这些方法, 其中finalize方法在Item8中讲解了, 不推荐使用. 另外添加了一个新的, 不是Object的方法: Comparable.compareTo方法.

Introduce

EffectiveJava 第三版读书笔记,如果各位觉得翻译的不对或者内容有误,请及时联系我,敬请斧正。原文链接.

Item 10: Obey the general contract when override equals

覆盖equals方法看起来非常简单, 但是特别容易出错, 导致的后果也是非常严重的. 最简单的方法就是不要覆盖, 这种情况下下就是单纯的比较实例对象是否相同(比较内存地址), 在以下这些情况中, 是推荐不覆盖的.

  • 每个类的实力对象都是独一无二的. 如每一个Thread对象的实例都是代表一个独一无二的对象, 代表着自己的特性, 因此Object默认的实现正是所需要的.
  • 当一个类没有需要提供逻辑的等的需求时. 如java.util.regex.Pattern本来可以覆盖该方法来提供验证两个Pattern对象是否代表同一个表达式, 但是设计者认为用户端是没有这个需求的, 也就不进行覆盖, 使用Object默认的也是可以的.
  • 如果父类已经覆盖了equals方法, 而父类覆盖的行为也适合当前类, 那么也没有必要进行覆盖. 如, Set中equals的实现方法就是继承自父类AbstractSet中的实现, List中的equals方法也是继承自父类AbstractList.
  • 如果一个类是私有的, 你可以百分百保证这个类不会被调用equals方法, 那么也是没有必要覆盖的. 甚至, 如果你为了防止别人碰巧调用了, 可以覆盖equals方法, 在内部抛出一个Error.

什么情况下需要覆盖equals方法呢? 这个类对象需要的更多的是逻辑上的等操作, 而不是实例对象的等操作(是否引用到同一个对象), 并且父类并没有覆盖equals方法. 这种类一般称为值类型. 值类型一般代表着值对象, 如Integer, String等. 程序员比较两个对象更加倾向于比较内部的值, 而不是外部的引用, 并且希望在Map中, Set中也是按照值比较来区分的话, 覆盖equals方法是一个很好的选择. 但是有一种值类型却不用遵守这个规则, 那就是枚举类型(Enum), 枚举类型通过控制实例引用, 保证一个值只有一个引用对象实例来完成这个需求, 对于这种类, Object中引用等操作和逻辑上的等操作是等价的, 所以也就没有必要覆盖equals方法.

当你覆盖equals方法时, 需要遵守如下约定:

  • Reflexive(自反性): 对于非null的对象引用x, x.equals(x)必须为true.
  • Symmetric(对称性): 对于非null的对象引用x和y, 如果 x.equals(y) 为true, 那么y.equals(x)也必须为true.
  • Transitive(传递性): 对于非null的对象引用x, y和z, 如果x.equals(y)为true, y.equals(z)为true, 那么x.equals(z)也必须为true.
  • Consistent(一致性): 对于非null的对象引用x和y, 多次调用x.equals(y)必须返回相同的值, 要么全为true, 要么全为false, 即调用equals方法的过程中不能修改x和y内部的信息.
  • null判断: 对于非null的对象引用x, x.equals(null) 必须返回 false.

上面这些要求看起来有些繁琐和吓人, 但是千万不要忽视它们. 一旦违背了其中某条, 你会发现你的程序行为会变得不正确和容易崩溃, 同时这也非常难定位到问题所在. 正如John Donne所说, 没有类是孤岛. 所有的类都会传递给别的类, 而许多类(包括所有的集合类)都依靠传递过来的类需要遵循equals的约定.

虽然这些约定看起来有点吓人, 但是当你真正理解它们的时候, 你会发现遵守它们并不难. 那么什么是等价关系呢? 简单来说, 就是将一组元素划分为许多小组, 每个小组内的元素都必须要相等, 这些小组就是等价类对象. 那如何区分呢, 则是需要依靠equals方法. 下面就详细讲解一下equals的内部约定.

Reflexive(自反性)

一个对象必须等于它自己, 很难想象如果你违背了这条准则, 会发生什么后果: 如果你将一个实例放到一个集合中去, 然而集合告诉你集合中没有这个元素.这是非常严重的.

Symmetric(对称性)

任意两个对象必须满足相等的约定. 考虑如下的覆盖函数:

//Broken - violates symmetry
public final class CaseInsensitiveString {
private final String s;

public CaseInsensitiveString(String s) {
	this.s = Objects.requireNonNull(s);
}

//Broken - violates symmetry
pubic boolean equals(Object o) {
	if (o instanceof CaseInsensitiveString)
		return s.equalsIgnoreCase((CaseInsensitiveString) o).s);
	if (o instanceof String)
		return s.equalsIgnoreCase((String) o);
	return false;
	}
}
复制代码

这个方法明显违反了对称性, 如果有以下两个对象:

String str = "abolish";
CaseInsensitiveString cis = new CaseInsensitiveString("Abolish");
复制代码

当我们调用cls.equals(str)时, 肯定是返回true, 但是str.equals(cls)时明显返回false. 当你将cis放入到一个List对象中时, list.contain(str) ?, 谁也不确定. 碰巧在这个JDK中返回的是false, 但是这取决于你在List中的实现(cis.equals(str), 还是相反), 在别的实现中很容易就返回true, 并且出现运行时异常. 一旦你违反了这个等价的约定, 你不会知道别的对象和你的对象比较时, 会发生什么事.

要解决这个问题也很简单, 简单排除造成困扰的判断:

pubic boolean equals(Object o) {
	return o.instanceof CaseInsensitiveString && ((CaseInsensitiveString) o).s.equalsIgnoreCase(s);
}
复制代码

Transitivity(传递性)

如果第一个对象等于第二个对象, 第二个对象等于第三个对象, 那么第一个对象等于第三个对象. 考虑一下如下这种情况:

public class Point {
private final int x;
private final int y;

public Point(int x, int y) {
	this.x = x;
	this.y = y;
}

public boolean equals(Object o) {
	if (!(o instanceof Point))
		return false;
	Point p = (Point) o;
	return p.x == x && p.y == y;
}
}
复制代码

这是一个简单的二维点类, 假设你想拓展它, 添加一个新的属性:

publc class ColorPoint extends Point {
private final Color color;

public ColorPoint(int x, int y, Color color) {
	super(x, y);
	this.color = color;
}
...
}
复制代码

刚开始你不打算覆盖equals方法, 但是你很快发现问题, Color属性总是被忽略, 相同x,y不同的Color的ColorPoint总是返回true, 这是不能接受的. 于是你覆盖equals方法.

//Broken - violates symmetry!
public boolean equals(Object o) {
if (!(o instanceof ColorPoint))
	return false;
retrun super.equals(o) && ((ColorPoint) o).color == color;
}
复制代码

新的方法满足你的要求, 会比较三个属性. 但是却违背了对称性, Point.equals(ColorPoint)总是为true, 而反过来总是为false. 这个问题也是需要解决, 为了满足对称性, 你重写了equals方法.

//Broken - violates transitivity
public boolean equals(Object o) {
if (!(o instanceof Point))
	return false;
if (!(o instanceof ColorPoint))
	return o.equals(this);
return super.equals(o) && ((ColorPoint) o).color == color;
}
复制代码

这个解决方法虽然解决了对称性: 如果是比较Point, 就单纯的比较x和y, 否则还要比较color. 但是却破坏了传递性.

ColorPoint p1 = new ColorPoint(1, 2, Color.RED);
Point p2 = new Point(1, 2);
ColorPoint p3 = new ColorPoint(1, 2, Color.BLUE);
复制代码

p1.equals(p2)为true, p2.equals(p3)为true, 但是p1.equals(p3)为false. 很明显这违反了传递性. 并且这还容易导致无限循环. 如果有两个子类ColorPointSmellPoint都是有自己的单独属性, 那么比较的时候就会抛出StackOverflowError(无限循环卡在了第二步判断).

那么有什么解决方法呢? 事实上这是一个原理上的问题: 你没有办法通过继承一个类(可实例化的)来拓展值属性, 却可以遵守equals准则, 除非你放弃继承. 你可能听过通过getClass进行校验是否相同的解决方法:

//Broken - violate Liskov substitution principle
public boolean equals(Object o) {
	if (o == null || o.getClass() != getClass())
		return false;
	Point p = (Point) o;
	return p.x == x && p.y == y;
}
复制代码

这限制了所有的对象只能是同一个具体Class对象, 这同样会带来严重的副作用: 任何Pointer的子类, 都无法进行功能性的比较. 也就没办法用接口编程(父类编程), 它的结果关联了具体的实现类. Liskov substitution principle中说一个对象任何重要的属性可以被所有的子类所包含, 因此任何基于这些属性的方法都应该保持一致在所有的子类中.

实际上还有一个迂回的解决方法, 那就是使用组合, 通过使用组合而不是继承可以很好的解决这个问题.

public class Pointer {
	private final Point point;
	private final Color color;
	
	public ColorPoint(int x, int y, Color color) {
		point = new Pointer(x, y);
		this.color = Objects.requireNonNull(color);
	}
	
	public Point asPoint() {
		return point;
	}
	
	public boolean equals(Object o) {
		if (!(o instanceof ColorPoint))
			return false;
		ColorPoint cp = (ColorPoint) o;
		return cp.point.equals(point) && cp.color.equals(color);
	}
	
	...
}
复制代码

在Java类库中有许多类通过继承一个可实例化的类来拓展属性, 如java.sql.Timestamp拓展自java.util.Date, 然后添加了一个新的属性nanoseconds. 其中equals的方法违背了对称性, 可能导致严重的后果, 我们应该在使用时注意这一点(不要放在一起使用).

同样你可以通过继承一个不可实例化父类(抽象类)来拓展属性, 这是允许的, 且不会违背等价关系的. 因为父类没办法实例化, 也就不会违反对称性.

Consistent(一致性)

如果两个对象是否相同, 它们必须长期保持一致, 除非中间修改了对象. 这就意味着可变对象可能不一定相同(过程中可以修改), 但是不可变对象就必须保证在任何情况下都要保持等价性(无法修改).

无论一个类是否是可变的, equals方法都不能依靠不可靠的资源, 否则就很难保证维护这条要求. 如java.net.URL的equals方法依赖比较主机的IP地址, 但是获取IP地址需要网络连接, 不能保证可以一直正确获取. 所以URL的equals方法可能导致严重的后果, 应该避免使用equals方法.

Non-nullity(非空性)

任何对象都必须不为null, 即所有的对象都要不等于null. 如果我们违反了这个约定, 我们的程序可能随时抛出NullPointerException, 并且很难找到原因. 一般为了保证这个条件都是在equals方法内添加限制:

public boolean equals(Object obj) {
	if (obj == null) {
		return false;
	}
	...
}
复制代码

这是一种显式的判断, 现在也存在一些非显式的判断, 可以更加优化这个功能: 调用instanceof进行判断.

public boolean equals(Object obj) {
	if (obj instanceof XXX) {
		return false;
	}
	...
}
复制代码

在instanceof函数中会自动进行空判断, 如果为空直接返回false, 并且可以对象是否为某个对象的实例.

总结

要如何实现合理的equals方法, 这里给出一些建议:

  • 使用==操作来检查传递的对象是否指向同一个对象. 如果是, 返回true. 如果比较的代价较高的话, 可以显著提高性能.

  • 使用instanceof来检查传递的对象是否是同一个实例对象. 如果不是, 返回false. 需要注意的是instanceof同样支持实现接口. 有些类实现同一个接口, 依旧可以的.

  • 对传递的对象进行转换成正确的类型. 由于上一步的保证, 这一步是肯定可以成功的.

  • 对类对象内所有重要的的域, 进行比较. 如果全部都比较通过了, 返回true, 否则返回false. 对于这些域, 如果是int, byte等原始类型(除了flaoat和double)可以直接使用==进行值比较. 而对于对象类型, 则递归地调用该对象的equals方法. 对于float和double, 可以使用Float.compare(float, float), Double.compare(double, double)进行比较, 主要是处理flaot和double存在一些特殊值(如Float.NaN, -0.0f等). 这里不推荐使用Float.equals方法和Double的equals方法, 因为这会导致自动装箱. 对于数组类型, 推荐使用Arrays.equals方法, 进行验证. 如果有些对象类型允许空, 可以使用Objects.equals(Object, Object)进行比较. 对于有些类如果比较的代价非常高, 可以存储一个规范的标准域(CanonicalForm)(如, 对对象内所有的域取哈希码), 然后比较这个域是否相同, 而不是进行昂贵的比较. 但是这一般用于不变类.

  • 当你完成equals方法的时候, 问下你自己这个方法是否满足对称性, 是否满足传递性, 是否满足一致性. 有时候一个合理的测试将会起到更好的校验效果.

同时这里有一些equals方法的注意事项:

  • 覆盖equals方法的时候, 一定要覆盖hashCode方法.

  • 不要过分强调equals, 对于一些域的判断还是非常容易实现的, 不要添加太多标示为来进行比较.

  • 不要替代equals方法中传递的Object参数. 有的人会替代成对应的实际类, 但是这是不推荐的. 首先这不会覆盖默认的equals方法(就算添加了@Override, 也会报错), 第二这就强调了比较的对象为具体的实际类, 不推荐这么使用.

有的时候我们可以借助一些工具来帮我们进行覆盖equals方法, 如Google AutoValue, Lombok等, 这些通过简单地添加一个注释就可以完成任务. 而注释生成的方法往往就是你想要的. 通过IDEs自动生成equals方法是不推荐的, 可读性不是很好, 并且没有办法动态生成(如果你修改了某个一属性, 你就需要重新生成).

总而言之, 不要覆盖equals方法, 一般来说默认的实现就是你要的. 如果你要覆盖的话, 一定要比较类中所有的域, 并且比较的时候要满足5大要求.

Item 11: Always override hashCode when you override equals.

覆盖了equals方法但是却不覆盖hashCode可能导致严重的后果. 因为这违背了hashCode方法的基本准则, 而hashCode的准则为:

  • 保持一致性, 即多次调用hashCode方法, 只要通过equals方法保持为true, 就一定要返回一样的值.

  • 如果两个对象调用equals为true, 那么调用hashCode就一定要返回一样的值.

  • 如果两个对象不相同, 不要求hashCode返回的值一定相同, 但是尽量保持.

而我们覆盖equals方法但是却不覆盖hashCode的做法, 违背了第二条要求. 如新建两个对象, 赋予一样的成员变量(满足equals方法为true), 但是调用hashCode时是调用Object的hashCode方法(比较引用地址), 肯定是返回false的. 这里用一个例子来说明危害:

Map datas = new HashMap();
datas.put(new PhoneNumber(172, 168, 32), "Jerry");
String name = datas.get(new PhoneNumber(172, 168, 32)); //null, 并不是 Jerry.
复制代码

其中PhoneNumber就是覆盖了equals方法, 没有覆盖hashCode方法. 当我们查询的时候, 传递的是一个不同的hashCode值, 内部查询时从不同的桶进行查询(有可能一样).

解决方法也很简单, 那就是覆盖一个合适的HashCode方法. 什么样的HashCode方法是合适的呢? 这里有一个简单的HashCode方法:

public int hashCode() {
	return 42;
}
复制代码

这是非常野蛮的, 但是可以使用. 因为这个方法满足了hashCode的所有限制. 但是这也可能导致严重的性能后果, 如在HashMap中根据对象的hashCode进行分桶存储, 但是由于hashCode全部相同, 就放到同一个桶里面. 导致HashMap原先的设计特性: 线性查询时间无法实现. 特别是对于包含大量数据的HashMap或者taable就可能导致无法进行查询的后果.

一个好的hashCode函数应该为每一个unequal的对象返回同一个hashCode值. 这是非常完美实现的, 但是可以近乎完美的实现, 这里提供一个实现方法:

  • int result = first.hashCode(), 初始化一个int对象result, 取第一个对象的hashCode值.

  • 然后对于剩余的每一个对象(域)取hashCode(c)进行组合.

    • result = 31 * result + c;
    • 其中一个域f是原始类型, 通过Type.hashCode(f)进行计算, Type是对应的封装型. 如: Integer.hashCode(f).
    • 如果这个域f是一个对象引用, 并且在equals中进行调用比较了, 调用对象的hashCode方法. 如果为null, 就使用0.
    • 如果域f是一个数组, 并且数组中每个元素都非常重要, 就分离出来(分别调用对应hashCode方法)使用Arrays.hashCode进行生成hashCode值, 如果数组中没有成员变量, 使用0来代替.
  • 返回result.

Item 12: Always override toString

Object提供了原始的toString方法: 对象的类名 + @ + 十六进制的哈希码(如:PhoneNumber@163b91). 这是非常令人困惑的, 也是很难理解的. toString方法本意是让我们返回一个简单而精确的描述字符串, 但这显然没有满足要求. 在Object的toString方法也显示告诉我们去重写这个方法: It is recommended that all subclasses override this method.;

虽然不像equalhashCode的限制一样, 但也是非常推荐进行重写该方法的. 不仅可以让类更加容易使用, 在调试的时候也能起到很好的作用. 如707-867-5309PhoneNumber@163b91更加直接明了, 让人理解. 并且很多时候, 都会不自觉地调用toString方法: 如传递对象给print,printf, string的级联操作(如+), assert或者debuger中输出. 这些时候都会输出对应toString()的结果, 如果我们重写了该方法, 可以带来很好的帮助.

当重写toString方法的时候, 推荐包含所有对象内重要的信息. 有些特殊情况可以不满足这个: 如果一个对象太大了, 属性过多而无法实现. 或者就是一个对象内部的信息不适合用String来进行描述. 在这些特殊情况下推荐使用一些总结的话语, 如之前的PhoneNumer可以返回Manhattan residential phone directory(1487536 listings). 一般这个总结应该是容易让人理解的.

同时在重写toString方法的时候, 你可以在toString的文档中显示表明返回的String的样式. 一旦你规定返回的样式, 那么返回的结果将会是唯一的, 没有异议的, 容易读取的. 同时一旦规定了返回的样式, 那么实现一个静态转换方法或者构造函数来接收这个样式的String进行转换也是非常好的. 就如同大多数原始类型封装类, BigInteger, BidDecimal等.

但是规定返回的样式也有一个缺点: 如果规定了样式, 那就就需要永久维护它. 如果这个类被广泛的使用, 别的程序员将会频繁使用这个方法, 甚至将String对象写入持久化层(如数据库)中去, 一旦你修改了返回的样式或者不兼容之前的格式, 那别人依赖这个方法的代码就会全部崩溃, 后果非常严重. 如果不规定返回的样式, 你会保留了修改的灵活性(自由添加修改属性).

无论你是否确定规定返回的格式, 都应该显示的在描述中说明返回的样式. 如果你规定了返回的样式, 那么这个需要更加的精确. 如Integer的toString实现方法:

/**
 * Returns a string representation of the first argument in the
 * radix specified by the second argument.
 *
 * 

If the radix is smaller than {@code Character.MIN_RADIX} * or larger than {@code Character.MAX_RADIX}, then the radix * {@code 10} is used instead. * *

If the first argument is negative, the first element of the * result is the ASCII minus character {@code '-'} * ({@code '\u005Cu002D'}). If the first argument is not * negative, no sign character appears in the result. * *

The remaining characters of the result represent the magnitude * of the first argument. If the magnitude is zero, it is * represented by a single zero character {@code '0'} * ({@code '\u005Cu0030'}); otherwise, the first character of * the representation of the magnitude will not be the zero * character. The following ASCII characters are used as digits: * *

* {@code 0123456789abcdefghijklmnopqrstuvwxyz} *
* * These are {@code '\u005Cu0030'} through * {@code '\u005Cu0039'} and {@code '\u005Cu0061'} through * {@code '\u005Cu007A'}. If {@code radix} is * N, then the first N of these characters * are used as radix-N digits in the order shown. Thus, * the digits for hexadecimal (radix 16) are * {@code 0123456789abcdef}. If uppercase letters are * desired, the {@link java.lang.String#toUpperCase()} method may * be called on the result: * *
* {@code Integer.toString(n, 16).toUpperCase()} *
**/
复制代码

如果没有规定特定的返回样式, 那也应该清楚表达你的意思:

/**
 * Returns a brief description of this potion. the exact details of 
 * the representation are unspecified and subject to change,
 * but the following may be regarded as typical:
 *
 * "[Potion #9: type=love, smell=turpentine, look=india ink]"
 **/
复制代码

当使用者看到这些注释的时候, 就知道这个格式可能会改变, 就不会进行格式的持久性的存储或转换.

无论你有没有规定格式, 在toString中返回的信息, 都应该提供一个显示的获取途径(Accessor). 不然的话, 程序员需要使用的时候, 需要自己手动去构造String对象, 这是非常容易出错的, 甚至导致系统崩溃. 同时不推荐为静态工具类重写toString方法, 同样也不推荐为Enum枚举类型重写该方法(Java库中已经为你准备一个很好的实现), 同时你应该在一个抽象类中去实现这个方法, 那么它的子类就可以进行共享. 如很多集合类的toString实现就是继承自父抽象类的实现.

在Google的AutoValue, lombok等自动化生成的toString方法, 一般不是完美的. 它们更加趋向于告诉别人内部的值. 而不是这个对象代表的含义. 推荐自己进行重写该方法.

总而言之, 为你每一个可实例化的对象重写toString方法, 除非父类已经重写了合适的格式. 这会让类更加容易使用和调试. 同时toString方法应该返回一个简洁漂亮的String格式.

Item 13: Override clone judiciously

Cloneable接口被设计成一个简单的声明式接口, 没有任何方法. 只是用来决定Object中的clone()方法的可用性. 如果一个对象实现了Cloneable接口, 那就说明这个对象支持克隆功能, Object's的clone()方法应该返回一个域拷贝的新对象, 否则的话调用该方法则会抛出一个CloneNotSupportedException异常.

理论上来说实际上只要一个对象实现了Cloneable接口, 那么就应该重写clone()方法来提供一个合适public的clone()方法. 这个机制是非常脆弱的, 危险的, 超语言的(创建一个对象却不调用对象的构造函数).

clone()方法的一般约定为:

x.clone() != x;							//Must be true, clone object is not the same
x.clone().getClass() == x.getClass();	//Must be true, class is the same 
x.clone().equals(x);					//Not absolute require.
复制代码

一般约定调用clone()方法时, 首先调用super.clone()进行clone(有点类似构造函数). 通过这个约定, 可以保证上述的第二个约定一定可以完成. 当然你可以直接调用构造函数进行直接创建对象, 这样的话可能存在问题: 如果子类调用super.clone()方法, 返回的class和当前的克隆对象的class就不相同. 子类就不能满足第二条约定, 除非将clone()方法声明为final, 这样就不用担心(但是子类就无法重写了). 不推荐使用构造函数, 而是推荐使用super.clone(). 另外不可变的类不应该重写这个方法(防止无用拷贝). 这是一个标准的clone()函数.

//Clone method for class with no references to mutable state
@Override
public PhoneNumber clone() {
	try {
		return (PhoneNumber) super.clone();
	} catch (CloneNotSupportedException e) {
		throw new AssertionError();	//Can't happen
	}
}
复制代码

为了让clone()函数正确执行, PhoneNumber必须实现Cloneable接口. 来保证方法调用不会抛出异常. 这里的返回的是PhoneNumber利用了Java的covariant return types, 返回的是Object的子类, 是允许的. 并且放在try语句中, 保证如果类没有实现接口的话, 报出异常. 这适用于类里面所有的变量都是原始数据类型或不变的(即final的).

对于那些存在可变变量的对象, 直接调用super.clone()将会导致严重的错误:

public class Stack {
	private Object[] elements;
	private int size = 0;
	private static final int DEFAULT_INITIAL_CAPACITY = 16;
	
	public Stack() {
		this.elements = new Object[DEFAULT_INITIAL_CAPACITY];
	}
	
	public void push(Object e) {
		ensureCapacity();
		elements[size++] = e;
	}
	
	public Object pop() {
		if (size == 0) 
			throw new EmptyStackException();
		Object result = elements[--size];
		elements[size] = null;	//Eliminate obsolete reference
		return result;
	}
	
	//Ensure space for at least one more element
	private void ensureCapacity() {
		if (elements.length == size)
			elements = Arrays.copyOf(elements, 2 * size + 1);
	}
}
复制代码

如果在Stack类的clone方法中直接返回super.clone()方法, 那么返回的对象拥有正确的size值, 但是在elements上却是指向同一个数组, 并没有进行拷贝. 修改原数组中对象时, 克隆的数组中也进行了变化. 这破坏了克隆的不变性.

最简单解决方法就是在clone()实现构造函数的功能, 返回一个全新的对象(为可变对象进行克隆). 因为你必须保证克隆出来的对象不会影响原来的对象.

//Clone method for class with references to mutable state
@Override
public Stack clone() {
	try {
		Stack result = (Stack) super.clone();
		result.elements = elements.clone();
		return result;
	} catch (CloneNotSupportedException e) {
		throw new AssertionError();
	}
}
复制代码

注意这里的result.elements = elements.clone();, 中数组没有进行类型转换, 因为数组的clone函数会根据实际情况进行返回, 不需要进行转换(数组的拷贝特别适合克隆). 注意这里也可能存在一个问题, 那就是如果将elements设置为final的, 那这个解决方法就不能生效了(因为你无法重新赋值elements). 并且这是设计的Bug(一直存在的): Cloneable与final引用(指向可变对象)不兼容. 除非这个可变对象可以安全的分享给所有克隆对象.

并且有时候单纯对可变成员对象进行clone也不能很好的解决问题. 如当你克隆HashTable时, HashTable使用Entry[] buckets来存储对象, 而Entry为一个单链表形式.

public class HashTable  implements Cloneable {
	private Entry[] buckets = ...;
	private static class Entry {
		final Object key;
		Object value;
		Entry next;
		
		Entry(Object key, Object value, Entry next) {
			this.key = key;
			this.value = value;
			this.next = next;
		}
	}
	...
}
复制代码

当你简单的使用数组的拷贝:

//Broken clone method - result in shared mutable state!
@Override
public HashTable clone() {
	try {
		HashTable result = (HashTable) super.clone();
		result.buckets = buckets.clone();
		return result;
	} catch (CloneNotSupportedException e) {
		throw new AssertionError();
	}
}
复制代码

看起来很完美, buckets变成一个新的buckets存储新的对象列表. 但是内部存在一个问题, 虽然buckets中对象是新的对象. 但是对象内的链表却是指向原来的(即只修改了链表头的对象, 剩余部分并没有修改). 这样就破坏了clone的不变性, 使用的时候可能造成不确定行为.

为了解决这个问题, 那就必须处理内部的所有的链表对象. 其中一个解决方法如下:

public class HashTable  implements Cloneable {
	private Entry[] buckets = ...;
	private static class Entry {
		final Object key;
		Object value;
		Entry next;
		
		Entry(Object key, Object value, Entry next) {
			this.key = key;
			this.value = value;
			this.next = next;
		}
		
		Entry deepCopy() {
			return new Entry(key, value, next == null ? null : next.deepCopy());
		}
	}
	@Override
	public HashTable clone() {
		try {
			HashTable result = (HashTable) super.clone();
			result.buckets = buckets.clone();
			for (Entry entry: result.buckets)
				if (entry != null) 
					entry = entry.deepCopy();
			return result;
		} catch (CloneNotSupportedException e) {
			throw new AssertionError();
		}
	}
	...
}
复制代码

这是一种解决方法, 并且运行时也可以达到我们的要求. 但是这里隐藏一个问题, 那就是deepCopy()方法使用了递归进行完成, 如果链表足够长的话, 就很有可能导致Stack over flow的问题. 因此改进的方法为修改递归为循环.

Entry deepCopy() {
	Entry result = new Entry(key, value, next);
	for (Entry p = result; p.next != null; p = p.next) 
		p.next = new Entry(p.next.key, p.next.value, p.next.next);
	return result;
}
复制代码

虽然这解决了我们的问题, 但是这个clone方法没有我们预想的跑的那么快, 也破坏了clone方法的简单和优雅的特性.

类似构造函数, clone方法内部不能调用任何可重写的方法. 一旦你这么做了, 如果子类重写了这些方法, 会给clone函数带来不可预知的风险. 另外, 在Object的clone方法中抛出了CloneNotSupportedException, 但是如果你实现Cloneable接口, 并不需要抛出这个异常(因为并不会出现), 可以进行省略来简化使用. 如果要设计一个类用来继承, 那么推荐不实现Cloneable接口, 模仿Object的方法进行抛出异常. 由子类自行决定是否实现clone方法. 因为一旦父类实现了该接口, 那么子类也必须进行维护, 以保证兼容性. 甚至有些限制方法, 禁止子类实现该接口:

@Override
protected final Object clone() throws CloneNotSupportedException {
	throw new CloneNotSupportedException();
}
复制代码

另外当你写一个线程安全的类时, 记住让clone方法进行同步, 就像别的方法一样.

总而言之, 所有实现了Cloneable接口的类都应该重写clone方法(以public的形式), 返回的类型为自己本身. 首先调用super.clone(), 然后修复需要修复的成员变量(指向可变类型的变量): 对于指向任何可变对象的引用, 对该可变变量进行深拷贝, 然后将引用指向新的拷贝. 一般的做法就是对其可变变量进行克隆, 虽然这不是最好的解决方法. 对于不可变的变量和原始类型数据, 则不需要进行修复, 但是也有一些例外, 如serial number或其他unique id, 这些虽然是不变, 仍然需要进行修复.

换句话说, 付出这么多努力来维护clone方法是必须的吗? 答案是否定的, 但是如果父类实现了Cloneable接口, 那当然没有别的选择, 自能进行维护. 否则的话, 还有一些更好的方法来实现对象拷贝. 那就是: copy constructorcopy factory. 传递一个对象, 然后拷贝发返回一个新的对象.

//Copy constructor
public Yum(Yum yum) { ... };

//Copy factory
public static Yum newInstance(Yum yum) { ... };
复制代码

相比clone方法, 这种方式有很多好处:

  • 不依赖特殊的, 充满风险的创建方式(clone不调用构造函数).
  • 不和final域使用冲突.
  • 不会抛出异常
  • 不需要显式转换.
  • 可以更加参数进行自定义返回对象. 正如conversion constructorconversion factories.

使用Cloneable接口时, 需要经常想到这个方法的带来的负面影响. 用于继承的类不推荐实现该接口, final类也不推荐实现该接口. 并且作为对象的拷贝功能, 构造函数和静态工厂类往往更加合适. 最好使用该方法对象, 那一定是数组.

Item 14: Consider implementing Comparable

不像别的方法都是定义在Obect对象内, ‘public int compareTo(T o);‘, compareTo方法是定义在Comparable接口中的一个单独的方法. 这个方法有点类似equals方法, 但是作用要更大一点, 提供次序的比较. 一般来说一个对象实现了Comparable接口, 意味着这个对象的实例默认拥有次序. 如对于这类对象的数组a, 如果需要进行排序:

Arrays.sort(a);
复制代码

如果需要对一个String数组进行去重和排序:

Set set = new TreeSet<>();
Collections.addAll(s, args);
System.out.println(s);
复制代码

上面这些数组排序和集合排序等操作都是依赖于对象中compareTO方法. 而compareTo方法的定义如下:

Compares this object with the specified object for order. Returns a negative integer, zero, or a positive integer as this object is less than, equal to, or greater than the specified object.

compareTo方法中, 使用sgn来处理返回值, 负数返回-1, 正数1, 相等0. 方法的约定为:

  • 对于所有的x和y, sign(x.compareTo(y)) == -sign(y.compareTo(x)).
  • 比较需要有传递性: if (x.compareTo(y) > 0 && y.compareTo(z) > 0) then x.compareTo(z) must >0.
  • if (x.compareTo(y) == 0), then ( sign(x.compareTo(z)) == sign(y.compareTo(z)));
  • 强烈要求 if (x.compareTo(y) == 0) then x.equals(y) == true. 如果不满足这个条件, 应该在注释中显示说明这一点. 如Note: This class has a natural ordering that is inconsistent with equals.;

compareTo方法的限制没有equals方法那么复杂, 因为equals方法面对的是所有的对象, 而compareTo一般用于相同对象之间的比较(即类相同), 一般用于内部比较. 当出现不同的类型进行比较的时候, 往往会抛出异常.

有点类似hashCode函数, 如果不遵守hashCode的约定, 就会让很多依赖hashCode的方法或者对象就会出错。 如果不遵守compareTo方法的约定, 那么很多依赖compareTo的方法和对象就会出错. 如排序的集合: TreeMap, TreeSet, 集合工具类Clollections数组工具类Arrays中的排序和搜索功能.

前三个规定有点类似equals中的限制: 对称性, 传递性, 自反性. 因此这里也存在同样的限制: 如果想通过继承一个对象来添加新的属性, 而这个对象实现了Comparable接口, 那也会破坏这三条特性(详细查看Item10), 推荐使用组合的形式完成.

最后一条限制, 强烈推荐兼容equals方法, 如果不兼容equals方法, 在一些集合类中容易出现问题. 因为默认的等价判断应该是使用equals方法, 但是有些集合类中使用的是compareTo进行替换, 如果compareTo不兼容equals方法的话, 会导致严重的后果. 如BigDecimal类中compareTo方法就不兼容equals方法, 如果往一个HashSet中添加new BigDecimal("1.0")和new BigDecimal("1.00"), 那么可以成功添加两个不同的对象. 但是如果你使用TreeSet的话, 就会只添加一个对象. 因为二者等价关系的判断是不一样的. 并且这种问题是很难发现的.

在compareTo方法中, 按照顺序比较对象内所有的成员(即递归地调用compareTo方法), 如果有一个成员对象没有实现Comparable接口或者你需要自定义排序的规则, 可以使用Comparator, 自己进行构造一个特殊的比较器进行比较.

public final class CaseInsensitiveString implements Comparable<CaseInsensitiveString> {
	public int compareTo(CaseInsensitiveString cis) {
		return String.CASE_INSENSITIVE_ORDER.compare(s, cis);
	}
	... //Remainder omitted
}
复制代码

在compareTo方法中比较原始类型数据时, 推荐使用对应装箱类中的工具方法, 如Integer.compare, Float.compare等. 而不是显式的使用<>, 可以很好的提高代码的阅读性, 减少犯错机会.

如果一个对象有多个成员变量, 那么比较时候的排序就非常重要了. 一般推荐先从最重要的成员进行比较, 轮流进行比较. 如:

public int compareTo(PhoneNumber pn) {
	int result = Short.compare(areaCode, pn.areaCode);
	if (result == 0) {
		result = Short.compare(prefix, pn.prefix);
		if (result == 0)
			result = Short.compare(lineNum, pn.lineNum);
	}
	return result;
}
复制代码

在Java8中, Comparator接口被广泛使用. 通过Comparator接口可以很快的构建一个良好的比较器, 虽然会带来一些性能上的损失. 在使用比较器的时候, 推荐预先构建好静态的对象(static), 并让命名简单明了.

private static final Comparator COMPARATOR = comparingInt((PhoneNumber pn) -> pn.areaCode)
	.thenComparingInt(pn -> pn.prefix)
	.thenComparingInt(pn -> pn.lineNum);

public int compareTo(PhoneNumber pn) {
	return COMPARATOR.compare(this, pn);
}
复制代码

注意这里使用了Lambda表达式, 并且后续的传递对象并没有进行类型转换(PhoneNumber), 因为JVM足够聪明可以识别. 需要注意的是, 有些Comparator使用hashCode进行比较:

static Comparator hashCodeOrder = new Comparator<>() {
	public int compare(Object o1, Object o2) {
		return o1.hashCode() -  o2.hashCode();
	}
}
复制代码 
   

这是非常危险的, 因为可能存在Integer的溢出或者浮点数(浮点数存储方式的不同). 正确的方法应该使用Integer.compare方法.

static Comparator hashCodeOrder = new Comparator<>() {
	public int compare(Object o1, Object o2) {
		return Integer.compare(o1.hashCode(), o2.hashCode());
	}
}
//Simply
static Comparator hashCodeOrder = Comparator.comparingInt(o -> o.hashCode());
复制代码 
   

总而言之, 当你实现一个值类型, 并且有敏感的次序的时候. 推荐实现Compreable接口. 这样在数组或者集合中时可以很容易被排序或者查找. 另外不要显式使用<>, 而是使用原始类型封装类的compare方法进行比较或者使用Comparator进行比较.

你可能感兴趣的:(EffectiveJava(v3) - chapter2: Methods Common to All Objects)