Effective Java笔记 :覆盖 equals 时请遵守通用约定

Effective Java笔记 :覆盖 equals 时请遵守通用约定

在《Effective Java》中,Item 10:覆盖 equals 时请遵守通用约定 是一个重要的指导原则。equals 方法是 Java 对象中最核心的方法之一,正确地覆盖它不仅可以让程序运行更可靠,还能避免由于设计错误导致的潜在问题,例如违反对称性或一致性等。

下面我们会详细讲解覆盖 equals 方法的通用约定、注意事项、以及一个示例实现。


1. 为什么需要覆盖 equals 方法?

默认行为(Object 的 equals)

equals 方法在 java.lang.Object 中默认实现如下:

public boolean equals(Object obj) {
    return this == obj;  // 默认比较的是对象的引用
}

这一默认实现检查两个对象是否是同一个对象(内存地址是否相同)。这种行为对于大部分类(例如用作某种特定唯一身份对象)是足够的,但对于某些类,比如值类型类(value classes),通常需要比较它们的内容是否相同,而不是引用相等。

什么时候需要覆盖 equals?

当一个类需要通过值相等来判断两个对象是否相同时(而不是引用相等),就应该覆盖 equals

比如:

  • String 类已经覆盖了 equals,比较内容而不是引用。
  • Integer 类也覆盖了 equals

2. 通用约定(契约)

覆盖 equals 时,必须遵守以下约定,这些约定定义了 equals 的行为:

2.1 自反性(Reflexive)

  • 对任意非空引用值 x, x.equals(x) 必须返回 true
  • 一个对象必须与自身相等。

2.2 对称性(Symmetric)

  • 对任意引用值 xy,如果 x.equals(y) 返回 true,则 y.equals(x) 也必须返回 true
  • 比较结果必须互相一致。

2.3 传递性(Transitive)

  • 对任意引用值 xyz,如果 x.equals(y)true,且 y.equals(z)true,那么 x.equals(z) 必须为 true
  • 这保证了 equals 的一致性。

2.4 一致性(Consistent)

  • 对任意引用值 xy,只要对象未改动,x.equals(y) 的比较结果就必须一致(无论调用几次都返回相同的值)。

2.5 对 null 的比较

  • 对任意非空引用值 x, x.equals(null) 必须返回 false

3. 覆盖 equals 方法的注意事项

3.1 使用 instanceof 检查类型

  • 确保传入的对象类型是正确的,避免出现 ClassCastException

3.2 比较每个字段的值

  • 对各个关键字段进行比较来判断对象是否相等。

3.3 返回 boolean 类型

  • 始终返回布尔值 truefalse,而不是抛异常或者返回其他值。

3.4 避免对浮点数直接比较

  • 对于 floatdouble,在比较其值时要考虑精度问题;
  • 可以使用 Double.compareFloat.compare 方法。

4. 示例:一个正确覆盖 equals 的实现

假设我们有一个表示二维点的类 Point,需要覆盖 equals 方法。

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

    // 构造器
    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }

    // 覆盖 equals 方法
    @Override
    public boolean equals(Object obj) {
        // 1. instanceof 检查类型
        if (!(obj instanceof Point)) {
            return false; // 非同类型返回 false
        }
        
        // 2. 进行类型转换
        Point other = (Point) obj; 
        
        // 3. 比较关键字段
        return this.x == other.x && this.y == other.y; 
    }
    
    // 最好还要覆盖 hashCode 方法(后续会讨论)
    @Override
    public int hashCode() {
        return 31 * x + y; // 简单的哈希实现,确保一致性
    }
}

测试类:验证遵守约定

public class Main {
    public static void main(String[] args) {
        Point p1 = new Point(1, 2);
        Point p2 = new Point(1, 2);
        Point p3 = new Point(3, 4);

        // 自反性
        System.out.println("自反性: " + p1.equals(p1)); // true

        // 对称性
        System.out.println("对称性: " + p1.equals(p2)); // true
        System.out.println("对称性: " + p2.equals(p1)); // true

        // 传递性
        Point p4 = new Point(1, 2);
        System.out.println("传递性: " + p1.equals(p2)); // true
        System.out.println("传递性: " + p2.equals(p4)); // true
        System.out.println("传递性: " + p1.equals(p4)); // true

        // 一致性
        System.out.println("一致性: " + p1.equals(p3)); // false
        System.out.println("一致性: " + p1.equals(p3)); // false

        // 对 null 的检查
        System.out.println("null 检查: " + p1.equals(null)); // false
    }
}

输出:

自反性: true
对称性: true
对称性: true
传递性: true
传递性: true
传递性: true
一致性: false
一致性: false
null 检查: false

5. 需要同时覆盖 hashCode

约定:如果覆盖了 equals,必须同时覆盖 hashCode,并确保逻辑一致性。

equalshashCode 的一致性要求:

  • 如果两个对象的 equals 方法返回 true,它们的 hashCode 方法也必须返回相同的值。
  • 如果两个对象的 equals 方法返回 false,它们的 hashCode 方法不强制要求不同。
为什么需要一致性?
  1. 在集合中使用(例如 HashMapHashSet)时,hashCode 会影响哈希表的存储和查找行为。如果 equalshashCode 不一致,集合可能出现较大的性能问题,甚至逻辑错误。
  2. equalshashCode 共同确保对象的一致性。

6. equals 方法的设计建议

6.1 不要覆盖 equals(默认实现足够时)

如果类的实例不需要比较内容(即始终只需要比较引用),无需覆盖 equals,默认实现已经足够。

6.2 避免滥用 instanceof

如果对象应该实现严格的相等检查(包括类的子类比较),可以使用 getClass() 而不是 instanceof

  • getClass() 是 java.lang.Object 中的方法,用于返回运行时对象的具体类(即实际类型)的 Class 实例。
  • 用 getClass() 检查类型时,它只会返回对象的声明类,不会考虑继承链。
  • 如果两个对象属于不同的具体类(即使其中一个是另一个的子类),getClass() 判断它们为不同类型。
  • instanceof 是一种用于检查一个对象是某个类或其子类的实例的运算符。使用 instanceof 时,会考虑继承关系,可以用来判断类的子类是否属于同一类型。
    这种行为更松散,因为它允许子类在类型检查时被认作父类。

示例:

@Override
public boolean equals(Object obj) {
    if (obj == null || obj.getClass() != getClass()) {
        return false;
    }
    // 自定义逻辑
}
6.3 谨慎处理浮点数比较

浮点数可能涉及精度问题,建议使用 Double.compareFloat.compare 方法。

7.子类新增比较字段的问题

如果你扩展了一个具体类(一个不是 abstract 的类),并且想在子类中引入新字段(值组件),则很难在保留这些约定的同时正确实现 equals。原因在于父类与子类之间的继承关系导致设计出了矛盾的逻辑。

具体举例:颜色点(Point 和 ColorPoint)问题

假设我们有一个简单的 Point 类,表示二维坐标系中的点。

父类 Point:
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 (this == o) {
            return true;
        }
        if (!(o instanceof Point)) {
            return false;
        }
        Point point = (Point) o;
        return x == point.x && y == point.y;
    }
}

Pointequals 方法比较其 xy 坐标,判断两个点是否相等。

子类 ColorPoint:

现在我们扩展 Point 类,增加颜色字段:color

public class ColorPoint extends Point {
    private final String color; // 新的值组件:颜色

    public ColorPoint(int x, int y, String color) {
        super(x, y);
        this.color = color;
    }

    @Override
    public boolean equals(Object o) {
        if (!(o instanceof ColorPoint)) {
            return false;
        }
        ColorPoint that = (ColorPoint) o;
        return super.equals(o) && this.color.equals(that.color);
    }
}

出现的问题:打破 equals 的对称性

我们来看这个例子会发生什么:

public static void main(String[] args) {
    Point p = new Point(1, 2);
    ColorPoint cp = new ColorPoint(1, 2, "red");

    System.out.println(p.equals(cp));  // true (从 Point 的 perspective 只检查 x 和 y)
    System.out.println(cp.equals(p));  // false(从 ColorPoint 的 perspective 检查 x, y, color)
}

违反了对称性!

  • p.equals(cp) 返回 true,因为 Pointequals 实现只比较了 xy,并不关心是否是 ColorPoint 的实例。
  • cp.equals(p) 返回 false,因为 ColorPointequals 要求不仅 xy 相同,还需要 color 相同(p 没有颜色数据)。

进一步问题:违反传递性

现在,我们再引入另一个颜色点:

ColorPoint cp_red = new ColorPoint(1, 2, "red");
ColorPoint cp_blue = new ColorPoint(1, 2, "blue");
Point p = new Point(1, 2);

如果按默认实现,这样的行为会违反传递性:

System.out.println(cp_red.equals(p)); // true (ColorPoint 和 Point 只比较 x 和 y)
System.out.println(p.equals(cp_blue)); // true (Point 和 ColorPoint 也只比较 x 和 y)
System.out.println(cp_red.equals(cp_blue)); // false (ColorPoint 额外比较 color)

违反了传递性:

  • cp_red.equals(p) 返回 true
  • p.equals(cp_blue) 返回 true
  • cp_red.equals(cp_blue) 返回 false

** 为什么无法解决这个问题?**

这个问题的根本原因是继承 PointColorPoint 之间对 equals 的要求不可兼容

  1. Pointequals 定义只比较 xy
  2. ColorPointequals 不仅要比较 xy,还要比较 color

在继承关系中,子类通常需要扩展父类,而不是减少功能。然而,equals 的传递性要求比较的逻辑必须是固定的,不能根据子类的扩展而改变。

总之,这种扩展导致逻辑矛盾:

  • 如果 Point.equals 忽略 color,它会打破对称性
  • 如果 ColorPoint.equals 强制要求所有对象都包含 color,就和父类逻辑不兼容。

如何应对这个问题?
方案 1:限制继承(违反里氏替换原则)

最简单的办法是,不允许子类为 Point 添加新的值组件。换言之:

  • 如果设计了一个支持 equals 方法的类(比如 Point),让这个类不允许被继承

可以通过将 Point 声明为 final 来避免继承。

public final 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 (this == o) {
            return true;
        }
        if (!(o instanceof Point)) {
            return false;
        }
        Point point = (Point) o;
        return x == point.x && y == point.y;
    }
}

如果 Point 是不可扩展的,类的等价性逻辑也会变得清晰:它永远只需要比较 xy


方案 2:使用组合而不是继承

另一种解决方法是,避免直接继承,而通过组合来表达扩展的需求。

public class ColorPoint {
    private final Point point; // 组合关系
    private final String color;

    public ColorPoint(int x, int y, String color) {
        this.point = new Point(x, y);
        this.color = color;
    }

    @Override
    public boolean equals(Object o) {
        if (!(o instanceof ColorPoint)) {
            return false;
        }
        ColorPoint cp = (ColorPoint) o;
        return point.equals(cp.point) && color.equals(cp.color);
    }

    @Override
    public int hashCode() {
        return point.hashCode() * 31 + color.hashCode();
    }
}

通过组合,ColorPoint 的等价性检查不再打破 Point 的逻辑,因为 Point 的等价逻辑与子类(ColorPoint)之间没有直接耦合关系。


方案 3:用不同类型的方式比较

如果确实需要支持父类和子类之间带值组件的比较,可以提供额外的比较逻辑(如提供一个自定义比较器),而不是直接扩展 equals 方法。


8. equals 的测试工具

  1. JUnit:
    使用 JUnit 可以对 equals 方法进行系统性测试。
  2. Apache Commons EqualsBuilder:
    Apache Commons Lang 提供了一个 EqualsBuilder 工具类,可以快速实现 equals 方法。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值