天天看點

用了這麼久的equals,你知道還要遵守約定麼

用了這麼久的equals,你知道還要遵守約定麼

避免重寫 equals 方法

重寫equals 方法看起來很簡單,但是還會有多種方式導緻出錯,後果可能是嚴重的。最簡單,最容易避免出錯的方式是 避免重寫equals方法 ,采用這種方式的每個類隻需要和自己對比即可,這樣永遠不會出錯。如果滿足了以下任何一個約定,也能産生正确的結果:

1. 該類的每個執行個體本質上都是唯一的

即使對于像Thread 這種代表活動狀态的實體而不是值的類來說也是如此。Object提供的equals方法也能確定這個類展現出正确的行為。

2. 類沒有必要提供邏輯相等的測試

例如:java.util.regex.Pattern能夠重寫equals檢查是否兩個Pattern 執行個體是否代表了同一個正規表達式。但是設計者并不認為客戶需要或者期望這樣的功能。在這種情況下,從Object繼承的equals方法的實作就已經足夠了。

3. 超類已經重寫了equals方法,并且超類的行為對此類也适用

例如:大部分Set實作從AbstractSet那裡繼承了equals方法,List實作從AbstractList那裡繼承了equals 方法,Map實作從AbstractMap那裡繼承了equals 方法。

4. 這個類是私有的或者包級私有的,可以确定equals方法永遠不會調用

如果你非常想要規避風險,那就確定equals方法不會突然調用

@Override public boolean equals(Object o){
  throw new AssertionError();
}           

複制

那麼何時重寫equals方法呢?

當一個類具有邏輯相等的概念時,它不僅僅是對象身份,而超類還沒有覆寫equals,這通常屬于值類的情形。一個值類僅僅是一個代表了值的類,例如Integer 或者String。程式員用equals來比較對象的時候,往往想要知道的是兩個對象在邏輯上是否相等,而不是想了解他們是否指向同一個對象。為了滿足程式員的要求,不僅必須覆寫equals方法,而且這樣做也使得這個類的執行個體可以用作映射表(map)的鍵(key),或者集合(set)的元素,使映射或者集合表現出正确的行為。

一種不需要重寫equals方法的值類是一個使用單例實作類,以確定每個值最多隻有一個對象。枚舉類型就屬于此類别。對于這些類,邏輯相等就是對象相等,是以對象的equals方法判斷的相等也表示邏輯相等。

重寫equals 遵循的約定

如果你非要重寫equals 方法,請遵循以下約定:

  • 自反性:對于任何非 null 的引用值 x,x.equals(x),必須傳回true,null equals (null) 會有空指針。
  • 對稱性:對于任何非 null 的引用值 x 和 y,當且僅當 x.equals(y) 為true時,y.equals(x) 時也必須傳回true。
  • 傳遞性:對于任何非 null 的引用值 x 、y和 z ,如果 x.equals(y) 為 true 時,y.equals(z) 也是 true 時,那麼x.equals(z) 也必須傳回 true。
  • 一緻性:對于任何非 null 的引用值 x 和 y,隻要 equals 比較在對象中資訊沒有修改,多次調用 x.equals(y) 就會一緻傳回 true,或者一緻傳回 false。
  • 對于任何非 null 的引用值x, x.equals(null) 必須傳回false。

解釋

現在你已經知道了違反 equals 約定是多麼可怕,下面将更細緻的讨論,下面我們逐一檢視這五個要求

自反性

自反性:第一個要求僅僅說明對象必須等于它自身,假如違背了這一條,然後把該類添加到集合中,該集合的 contains 方法會告訴你,該集合不包含你剛剛添加的執行個體。

對稱性

對稱性:這個要求是說,任何兩個對象在對于"它們是否相等" 的問題上都必須保持一緻。例如如下這個例子

public class CaseInsensitiveString {

    private final String s;

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

    @Override
    public 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;
    }

    public static void main(String[] args) {
        CaseInsensitiveString cis = new CaseInsensitiveString("Polish");
        String s = "Polish";
        System.out.println(cis.equals(s));
        System.out.println(s.equals(cis));
    }
}           

複制

不出所料,cris.equals(s) 傳回true。問題在于,雖然 CaseInsensitiveString 類的 equals 方法知道普通的字元串對象,但是, String 類中的 equals 方法卻并不知道不區分大小寫的字元串,是以,s.equals(cris) 傳回false,顯然違反了對稱性。

如果你用下面的示例來進行操作

List<CaseInsensitiveString> list = new ArrayList<>();
list.add(cis);
System.out.println(list.contains(s));           

複制

會傳回什麼呢?

沒人知道,可能在 OpenJDK 實作中會傳回 false,但這隻是特定實作的結果而已,在其他的實作中,也有可能傳回true,或者抛出運作時異常,是以我們能總結出一點:一旦違反了equals 約定,當面對其他對象時,你完全不知道這些對象的行為會怎麼樣

為了解決這個問題,那麼就需要去掉與 String 互操作的這段代碼去掉,變成下面這樣

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

複制

傳遞性: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;
    }
}           

複制

假如你想擴充這個類,添加一些顔色資訊:

public class ColorPoint extends Point{

    private final Color color;

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

複制

equals 方法是什麼樣的呢?如果完全不提供equals 方法,而是直接從 Point 繼承過來,在 equals 做比較的時候顔色資訊就被忽略。雖然這樣做不會違反 equals 約定,但這很顯然是不可接受的。假設編寫了一個 equals 方法,隻有當它的參數是一個有色點,并且具有相同位置和顔色時,才會傳回true。

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

複制

這種方法的問題在于,在比較普通點和有色點時,以及相反的情形可能會得到不同的結果。前一種比較忽略了顔色資訊,而後一種比較傳回 false,因為參數類型不正确。為了直覺說明問題,我們建立一個普通點和一個有色點來進行測試

public static void main(String[] args) {
  Point p = new Point(1,2);
  ColorPoint cp = new ColorPoint(1,2,Color.RED);
  System.out.println(p.equals(cp));
  System.out.println(cp.equals(p));
}           

複制

p.equals(cp) 調用的是 Point 中的 equals 方法,而此方法中沒有關于顔色的比較,之比較了 x 和 y

cp.equals(p) 調用的是 ColorPoint 中的 equals 方法,而此方法中有關于顔色的比較,而 p 中沒有顔色資訊

你可以這樣做來修正這個問題

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;
}           

複制

這種方法确實提供了對稱性,但是卻犧牲了傳遞性

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

複制

此外,還可能會導緻無限遞歸問題,比如 Point 有兩個字類,分别是 ColorPoint 和 SmellPoint,它們各自有自己的 equals 方法,那麼對 myColorPoint.equals(mySmellPoint)的調用将會抛出 StackOverflowError 異常。

你可能聽過使用 getClass 方法替代 instanceof 測試,可以擴充可執行個體化的類和增加新的元件,同時保留 equals 約定,例如

@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;
}           

複制

裡氏替換原則認為,一個類型的任何屬性也将适用于它的字類型

一個不錯的改良措施是使用 組合優先于繼承 的原則,我們不再讓 ColorPoint 擴充 Point,而是讓 ColorPoint 持有一個 Point 的私有域,以及一個公有視圖方法,例如

public class ColorPoint {

    private final Point point;
    private final Color color;

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

    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);
    }
}           

複制

在 Java 平台類庫中,有一些類擴充了可執行個體化的類,并且添加了新的元件值。

例如:java.sql.Timestamp 對 java.util.Date 進行了擴充,并添加了 nanoseconds 域。Timestamp 類與 Date 類進行 equals 比較時會發生不可預期的行為,雖然工程師在 Timestamp 告誡不要和 Date 類一起使用,但是這種行為依舊不值得效仿。

一緻性

equals 約定的第四個要求是,如果兩個對象相等,它們就必須保證始終相等,除非它們中有一個對象(或者兩個都)被修改了。也就是說,可變對象在不同的時候可以與不同的對象相等。不可變對象不會這樣,它們會保證始終相等。

無論類是否可變,都不要使 equals 方法依賴于不可靠的資源。例如,java.net.URL 的 equals 方法依賴于對 URL中主機IP 位址的比較。将一個主機名轉變成 IP 位址可能需要通路網絡,随着時間的推移,就不能確定會産生相同的結果,即有可能 IP 位址發生了改變。這樣會導緻 URL equals 方法違反 equals 約定,在實踐中有可能引發一些問題。URL equals 方法的行為是一個大錯誤并且不應被模仿。遺憾的是,因為相容性的要求,這一行為元法被改變。為了避免發生這種問題,equals 方法應該對駐留在記憶體中的對象執行确定性的計算。

非空性

非空性的意思是所有的對象都不能為 null 。盡管很難想象什麼情況下 o.equals(null) 會傳回 true。但是意外抛出空指針異常的情形可不是很少見。通常不允許抛出 空指針異常,許多類的 equals 方法都通過對一個顯示的 null 做判斷來防止這種情況:

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

複制

這項測試是不必要的。為了測試其參數的等同性,equals 方法必須先把參數轉換成适當的類型,以便可以調用它的通路方法,或者通路它的域。

如果漏掉了類型檢查,有傳遞給 equals 方法錯誤的類型,那麼 equals 方法将會抛出 ClassCastException,這就違反了 equals 約定。如果 instanceof 的第一個操作數為 null ,那麼,不管第二個操作數是哪種類型,intanceof 操作符都指定應該傳回 false 。是以,如果把 null 傳給 equals 方法,類型檢查就會傳回 false ,是以不需要顯式的 null 檢查。

遵循如下約定,可以實作高品質的空判斷:

  • 使用 == 操作符檢查 參數是否為這個對象的引用 。如果是,傳回 true 。
  • 使用 instanceof 操作符檢查 參數是否為正确的引用類型。如果不是,則傳回 false。
  • 對于該類中的每個域,檢查參數中的域是否與該對象中對應的域相比對。

編寫完成後,你還需要問自己: 它是否是對稱的、傳遞的、一緻的?

下面是一些告誡:

  • 覆寫 equals 時總要覆寫 hashCode
  • 不要企圖讓 equals 方法過于智能
  • 不要将 equals 聲明中的 Object 對象替換為其他的類型。