Effective Java 2.0_中文版_Item 8

文章作者:Tyan
博客:noahsnail.com | CSDN | 简书

CHAPTER3 所有对象的共通方法

虽然Object是一个具体的类,但设计它的主要目的是为了扩展。它的所有非final方法(equalshashCodetoStringclonefinalize)都有明确的通用约定,因为设计它们的目的是为了重写。任何类都应该遵循通用约定重写这些方法;不这样做的话,依赖这些约定的其它类(例如HashMapHashSet)将无法结合这个类正确运行。

会告本章诉你什么时候,怎样重写这些非final的Object方法。本章会忽略finalize方法,因为它在Item 7中已经讨论过了。虽然不是一个Object方法,但是这章仍会讨论Comparable.compareTo,因为它有一个类似的特性。

Item 8:当重写equals时要遵循通用约定

重写equals方法看似简单,但许多方式都会导致错误,结果是非常可怕的。避免这些问题的最简单方式是不要重写equals方法,在这种情况下类的每个实例只等价于它本身。如果符合以下任何条件,这样做就是正确的:

  • 类的每个实例本质上都是唯一的。对于表示活动实体而不是表示值的类确实如此,例如Thread。对于这些类,Object提供的equals实现具有完全正确的行为。

  • 不关心类是否提供“逻辑等价”的测试。例如,java.util.Random可以重写equals方法来检查两个Random实例是否会产生相同的随机数序列,但设计者认为客户不需要或者不想要这个功能。在这种情况下,从Object继承的equals实现就足够了。

  • 超类已经重写了equals,超类的行为对于子类是合适的。例如,大多数Set实现从AbstractSet继承了equals实现,List实现从AbstractList继承了equals实现,Map实现从AbstractMap继承了equals实现。

  • 类是私有的或包私有的,可以确定它的equals方法从不会被调用。可以说,在这些情况下equals方法应该重写,以防它被偶然调用:

@Override public boolean equals(Object o) {
    throw new AssertionError(); // Method is never called
}

什么时候重写Object.equals方法是合适的?如果类具有逻辑等的概念,不同于对象同一性,并且超类没有重写equals方法来实现要求的行为,这时候就需要重写equals方法。这种情况通常是对值类而言的。值类仅仅是表示值的类,例如IntegerDate。程序员用equals方法比较值对象的引用,期望找出它们是否是逻辑等价的,而不管它们是否是同一对象。重写equals方法不仅满足了程序员的期望;它也能使实例作为映射表的主键或者集合的元素,使它们表现出可预期的行为。

有一种不需要重写equals方法的值类,它通过实例控制(Item 1)来确保每个值至多存在一个对象。枚举类型(Item 30)就是这种类。对于这种类而言,逻辑等价等同与对象同一性,Objectequals方法在功能上就如同逻辑等价方法。

当你重写equals方法时,你必须遵循通用约定。下面是约定内容,从Object规范[JavaSE6]中拷贝的:

equals实现了一种等价关系。它是:

  • 自反性:对于任何非空引用值xx.equals(x)必须返回true

  • 对称性:对于任何非空引用值xyx.equals(y)必须返回true当且仅当y.equals(x)返回true

  • 传递性:对于任何非空引用值,xyz,如果x.equals(y)返回true并且y.equals(z)返回true,则x.equals(z)必须返回true

  • 一致性:对于任何非空引用值xyx.equals(y)的多次调用一致返回true或一致返回false,假设对象进行equals比较时没有修改任何信息。

  • 对于非空引用值xx.equals(null)必须返回false

除非你擅长数学,否则这可能看起来有点可怕,但不要忽视它!如果你违反了它,你可能会发现你的程序表现不正常或程序崩溃,并且很难确定失败的来源。用John Donne的话来说,没有类是孤立的。一个类的实例频繁传递给另一个类。许多类,包括所有的集合类,都依赖于传递给它们的对象遵循equals约定。

现在你已经意识到了违反了equals约定的危险,让我们详细回顾一下这个约定。好消息是实际上这个约定并不复杂,尽管从表面上来看不是这样。一旦你理解了它,遵循它并不难。让我们依次检查这五个要求:

自反性——第一个要求仅仅是说一个对象必须等价于它本身。很难想象会无意的违反这个要求。如果你违反了它并将你的类实例添加到一个集合中,集合的contains方法可能会说这个集合中不包含你刚刚添加的实例。

对称性——第二个要求是说任何两个对象必须对它们是否相等达成一致。不像第一个要求,不难想象会无意的违反这个要求。例如,考虑下面的类,它实现了大小写敏感的字符串。字符串保存在toString中,但在比较时被忽略了:

// Broken - violates symmetry!
public final class CaseInsensitiveString {
    private final String s;
    public CaseInsensitiveString(String s) {
        if (s == null)
            throw new NullPointerException();
        this.s = s;
    }
    
    // Broken - violates symmetry!
    @Override public boolean equals(Object o) {
        if (o instanceof CaseInsensitiveString)
            return s.equalsIgnoreCase(((CaseInsensitiveString) o).s);
            if (o instanceof String)  // One-way interoperability!
                return s.equalsIgnoreCase((String) o);
            return false;
        }
        ...  // Remainder omitted
    }

这个类中,equals方法的意图很好,单纯的想要与普通的字符串进行互操作。假设我们有一个区分大小写的字符串和一个普通的字符串:

CaseInsensitiveString cis = new CaseInsensitiveString("Polish");
String s = "polish";

正如预料的那样,cis.equals(s)返回true。问题是虽然CaseInsensitiveString中的equals知道普通的字符串,但是String中的equals方法不注意不区分大小写的字符串。因此s.equals(cis)返回false,这明显违反了对称性。假设你将一个不区分大小写的字符串放到一个集合中:

List<CaseInsensitiveString> list = new ArrayList<CaseInsensitiveString>();
list.add(cis);

这时list.contains(s)会返回什么?谁知道呢?在Sun当前的实现中,它碰巧会返回false,但那仅是一种实现方案。在另一种实现中,它也可能很容易的返回true或抛出一个运行时异常。一旦你违反了equals约定,当面对你的对象时,你根本不指定其它的对象行为会怎样。

为了消除这个问题,只要从equals方法中移除与String进行交互的,考虑不周的尝试即可。一旦你这样做了,你可以重构这个方法给它一个返回即可:

 @Override 
 public boolean equals(Object o) {
    return o instanceof CaseInsensitiveString && ((CaseInsensitiveString) o).s.equalsIgnoreCase(s);
}

传递性——equals约定的第三个要求是说如果一个对象等价于第二个对象,而第二个对象等价于第三个对象,则第一个对象等价于第三个对象。同样的,不难想象会无意中违反这个要求。考虑这样一种情况,子类添加一个新的值组件到它的超类中。换句话说,子类添加的信息会影响equals比较。以一个简单的不可变的二维整数点类作为开始:

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

    @Override 
    public boolean equals(Object o) {
        if (!(o instanceof Point))
            return false;
        Point p = (Point)o;
        return p.x == x && p.y == y;
    }
    ...  // Remainder omitted
}

假设你想扩展这个类,给点添加颜色的概念:

public class ColorPoint extends Point {
    private final Color color;
    public ColorPoint(int x, int y, Color color) {
        super(x, y);
        this.color = color;
    }
    ...  // Remainder omitted
}

equals方法应该看起来是怎样的?如果一点也不修改,直接从Point继承equals方法,在进行equals比较时颜色信息会被忽略。虽然这没有违反equals约定,但很明显这是不可接受的。假设你写了一个equals方法,只有在它的参数是另一个有色点,且它们具有相同的位置和颜色时才返回true

// Broken - violates symmetry!
@Override 
public boolean equals(Object o) {
    if (!(o instanceof ColorPoint))
        return false;
    return super.equals(o) && ((ColorPoint) o).color == color;
}

这个方法的问题在于:当你比较一个普通点和一个有色点或相反的情况时,你可能会得到不同的结果。前者的比较忽略了颜色,而后者总是返回false,因为参数类型不正确。为了使这个更具体一点,我们创建一个普通点和一个有色点:

Point p = new Point(1, 2);
ColorPoint cp = new ColorPoint(1, 2, Color.RED);

p.equals(cp)返回true,而cp.equals(p)返回false。你可能想让ColorPoint.equals进行比较混合比较时忽略颜色来修正这个问题:

// Broken - violates transitivity!
@Override 
public boolean equals(Object o) {
    if (!(o instanceof Point))
        return false;
    // If o is a normal Point, do a color-blind comparison
    if (!(o instanceof ColorPoint))
        return o.equals(this);
    // o is a ColorPoint; do a full comparison
    return super.equals(o) && ((ColorPoint)o).color == 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)p2.equals(p3)返回true,而p1.equals(p3)返回false,很明显这违反了传递性。前两个比较忽略了颜色,而第三个比较考虑了颜色。

因此解决方案是什么?事实证明:在面向对象语言中,等价关系问题是一个基本的问题。无法在扩展一个实例化的类并添加值组件的同时,还保留equals约定,除非你愿意放弃面向对象抽象的优势。

你可能听说过你可以在equals方法中通过使用getClass测试代替instanceof测试,从而在扩展一个可实例化的类并添加值组件的同时,保留equals约定:

// Broken - violates Liskov substitution principle (page 40)
@Override 
public boolean equals(Object o) {
    if (o == null || o.getClass() != getClass())
        return false;
    Point p = (Point) o;
    return p.x == x && p.y == y;
}

当且仅当它们具有相同的实现类时,上面的代码在比较对象时才会有效。虽然这不是很糟糕,但结果是不可接受的。

假设我们想写一个方法来判断一个整数点是否在单位圆上。下面是一种写法:

// Initialize UnitCircle to contain all Points on the unit circle private static final Set<Point> unitCircle;
static {
    unitCircle = new HashSet<Point>();
    unitCircle.add(new Point( 1,  0));
    unitCircle.add(new Point( 0,  1));
    unitCircle.add(new Point(-1,  0));
    unitCircle.add(new Point( 0, -1));
}
public static boolean onUnitCircle(Point p) {
    return unitCircle.contains(p);
}

虽然这可能不是实现这个功能的最快方式,但它确实有效。但假设你以某种不添加值组件的方式扩展了Point,例如通过它的构造函数来追踪创建了多少实例:

public class CounterPoint extends Point {
    private static final AtomicInteger counter = new AtomicInteger();

    public CounterPoint(int x, int y) {
        super(x, y);
        counter.incrementAndGet();
    }
    
    public int numberCreated() { 
        return counter.get(); 
    }
}

里氏替换原则认为,一个类型的任何重要属性也适用于它的子类型,因此该类型编写的任何方法在它的子类型中也都应该工作良好[Liskov87]。但假设我们给onUnitCircle传递了一个CounterPoint实例。如果Point类使用了基于getClassequals方法,onUnitCircle将会返回false,无论CounterPoint实例的x值和y值是多少。这是因为集合,例如onUnitCircle方法中的HashSet,使用equals方法来测试是否包含元素,没有CounterPoint实例等于Point。然而,如果你在Point上使用合适的基于instanceofequals方法,当面对CounterPoint时,同样的onUnitCircle方法会工作的很好。

尽管没有令人满意的方式来扩展一个可实例化的类并添加值组件,但有一个很好的解决方案。遵循Item 16 “Favor composition over inheritance”的建议,不再让ColorPoint继承Point,而是通过在ColorPoint中添加一个私有的Point字段和一个公有的视图方法(Item 5),此方法返回一个与有色点具有相同位置的普通点:

// Adds a value component without violating the equals contract
public class ColorPoint {
    private final Point point;
    private final Color color;
    public ColorPoint(int x, int y, Color color) {
        if (color == null)
            throw new NullPointerException();
        point = new Point(x, y);
        this.color = color;
    }

    /**
     * Returns the point-view of this color point.
     */
    public Point asPoint() { 
        return point;
    }
    @Override 
    public boolean equals(Object o) {
        if (!(o instanceof ColorPoint))
            return false;
        ColorPoint cp = (ColorPoint) o;
        return cp.point.equals(point) && cp.color.equals(color);
    }
    ...  // Remainder omitted
}

在Java平台库中有一些类扩展了一个可实例化的类并添加了一个值组件。例如,java.sql.Timestamp扩展了java.util.Date并添加了一个nanoseconds字段。Timestampequals实现确实违反了对称性,如果TimestampDate用在同一个集合中或混杂在一起,会引起不稳定的行为。Timestamp类有一个免责声明,警告程序员不要混合日期和时间戳。虽然只要你将它们分开就不会有麻烦,但是没有任何东西阻止你混合它们,而且产生的错误很难调试。Timestamp类的这个行为是一个错误,不应该进行模仿。

注意,你可以添加值组件到抽象类的子类而且不会违反equals约定。对于遵循Item 20 “Prefer class hierarchies to tagged classes”的建议而得到这种类层次来说,这是非常重要的。例如,你可以有一个没有值组件的抽象类Shape,子类Circle添加了radius字段,子类Rectangle添加了lengthwidth字段。只要不能直接创建一个超类实例,上面的种种问题就不会发生。

一致性——equals约定的第四个要求是说如果两个对象相等,它们必须一致相等,除非其中一个(或二者)被修改了。换句话说,可变对象在不同的时间可以等于不同的对象而不可变对象不能。当你写了一个类,仔细想想它是否应该是不可变的(Item 15)。如果你推断它应该是不可变的,那么要确保你的equals方法满足这样的约束条件:相等的对象永远相等,不等的对象永远不等。

无论一个类是否是不可变的,都不要写一个依赖于不可靠资源的equals方法。如果你违反了这个禁令,要满足一致性要求是非常困难的。例如,java.net.URLequals方法依赖于对关联URL主机的IP地址的比较。将主机名转换成IP地址可能需要访问网络,随时间推移它不能保证取得相同的结果。这可能会导致URL equals方法违反equals约定并在实践中产生问题。(很遗憾,由于兼容性问题,这一行为不能被修改。)除了极少数例外,equals方法应该对常驻内存对象进行确定性计算。

非空性”——最后的要求由于没有名字我称之为“非空性”,这个要求是说所有的对象都不等于null。虽然很难想象调用o.equals(null)会偶然的返回true,但不难想象会意外抛出NullPointerException的情况。通用约定不允许出现这种情况。许多类的equals方法为了防止出现这种情况都进行对null的显式测试:

@Override 
public boolean equals(Object o) {
    if (o == null)
        return false;
    ...
}

这个测试是没必要的。为了平等测试其参数,为了调用它的访问器或访问其字段,equals方法首先必须将它的参数转换成合适的类型。在进行转换之前,equals方法必须使用instanceof操作符来检查它的参数是否是正确的类型:

@Override 
public boolean equals(Object o) {
    if (!(o instanceof MyType))
        return false;
    MyType mt = (MyType) o;
    ...
}

如果缺少类型检查,equals方法传入了一个错误类型的参数,equals方法会抛出ClassCastException,这违反了equals约定。但当指定instanceof时,如果它的第一个操作数为null,无论它的第二个操作数是什么类型,它都会返回false[JLS, 15.20.2]。所以如果传入null类型检查将会返回false,因此你不必进行单独的null检查。

将这些要求结合在一起,得出了下面的编写高质量equals方法的流程:

  1. 使用==操作符来检查参数是否是这个对象的一个引用,。如果是,返回true。这只是一个性能优化,如果比较的代价有可能很昂贵,这样做是值得的。

  2. 使用instanceof操作符来检查参数类型是否正确。如果不正确,返回false。通常,正确的类型是指equals方法所在的那个类。有时候,它是这个类实现的一些接口。如果一个类实现了一个接口,这个接口提炼了equals约定来允许比较那些实现了这个接口类,那么就使用接口。集合接口例如SetListMapMap.Entry都有这个属性。

  3. 将参数转换成正确的类型。由于转换测试已经被instanceof在之前做了,因此它保证能成功。

  4. 对于类中的每一个“有效”字段,检查参数的这个字段是否匹配这个对象的对应字段。如果所有的这些测试都成功了,返回true;否则返回false。如果第二步中的类型是一个接口,你必须通过接口方法访问参数的字段;如果类型是一个类,你可能要直接访问字段,依赖于它们的可访问性。

对于基本类型,如果不是floatdouble,使用==操作符进行比较;对于对象引用字段,递归地调用equals方法;对于float自动,使用Float.compare方法;对于double字段,使用Double.comparefloatdouble字段的特别对待是有必要的,因为存在Float.NaN-0.0f和类似的double常量;更多细节请看Float.equals。对于数组字段,对每个元素应用这些指导。如果数组中的每个元素都是有意义的,你可以使用1.5版本中添加的Arrays.equals方法。

某些对象引用字段可能合理的包含null。为了避免产生NullPointerException的可能性,使用下面的习惯用法来比较这些字段:

(field == null ? o.field == null : field.equals(o.field))

如果fieldo.field经常是等价的,使用下面的可替代方式可能会更快:

(field == o.field || (field != null && field.equals(o.field)))

对于某些类而言,例如上面的CaseInsensitiveString,字段比较比简单的相等性检测更复杂。如果是这种情况,你可能想存储这个字段的标准形式,因此equals方法可以在这些标准形式上进行低开销的精确比较,而不是更高代码的非精确比较。这种技术最适合不可变类(Item 15);如果对象可以改变,你必须保持最新的标准形式。

equals方法的性能可能会受到字段比较顺序的影响。为了最佳性能,你首先应该比较那些更可能不同,比较代价更小的字段,或者理想情况下二者兼具的字段。你不能比较那些不属于对象逻辑状态一部分的字段,例如同步操作中的Lock字段。你也不需要比较冗余的字段,它们能从“有意义字段”中计算出来,但这样做可能会改善equals方法的性能。如果冗余字段相当于整个对象的概要描述,比较这个字段,如果失败的话会节省你比较真正数据的开销。例如,假设你有一个Polygon类,并且你缓存这个区域。如果两个多边形有不同的面积,你就不需要比较它们的边和顶点。

  1. 当你完成了equals方法的编写时,问你自己三个问题:它是否是对称的?是否是可传递的?是否是一致的?并且不要只问你自己;编写单元测试来检查是否拥有这些属性!如果没有这些属性,弄清楚为什么没有,对应的修改equals方法。当然你的equals方法也必须满足其它两个属性(自反性和“非空性”),但这两个属性通常会自动满足。

根据上述规则构建的equals方法具体例子请看Item 9的PhoneNumber.equals`。下面是一些最后的警告:

  • 当你重写equals时,总是重写hashCode方法(Item9)

  • 不要将equals声明中的Object对象替换为其它对象。对于程序员来讲,写一个equals方法看起来像下面的一样是不常见的,并且花费了好几个小时都不明白它为什么不能正确工作:

public boolean equals(MyClass o) {
    ...
}

正如本条目阐述的那样,@Override注解的一致使用会阻止你犯这个错误(Item 36)。这个equals方法不能编译并且错误信息会确切告诉你错误是什么。

@Override 
public boolean equals(MyClass o) {
    ...
}
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,560评论 4 361
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,104评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,297评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,869评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,275评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,563评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,833评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,543评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,245评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,512评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,011评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,359评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,006评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,062评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,825评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,590评论 2 273
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,501评论 2 268

推荐阅读更多精彩内容