1. 程式人生 > >第10項:重寫equals時請遵守通用約定

第10項:重寫equals時請遵守通用約定

  重寫equals方法看起來似乎很簡單,但是有許多重寫方式會導致錯誤,而且後果非常嚴重。最容易避免這類問題的辦法就是不覆蓋equals方法,在這種情況下,類的每個例項都只能與它自身相等。如果滿足了以下任何一個條件,那就是正確的做法:

  • 類的每個例項都是唯一的。 對於代表活動實體而不是值(value)的類來說確實如此,例如Thread。Object提供的equals實現對這些類具有完全正確的行為(The equals implementation provided by Object has exactly the right behavior for these classes)。

  • 不關心類是否提供了“邏輯相等(logical equality)”的測試功能。 例如,java.util.regex.Pattern可以重寫equals檢查兩個Pattern例項是否表示完全相同的正則表示式,但設計者並不認為客戶端需要或想要此功能。 在這種情況下,從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
}

  那麼什麼時候重寫equals方法才是合適的呢?當一個類具有邏輯相等的概念時(不同於物件本身相同的概念),而超類還沒有重寫equals。 這通常是“值類(value class)”的情況。 值類指的是隻表示值的類,例如Integer或String。程式猿在利用equals方法來比較物件的引用時,希望知道它們在邏輯上是否相等,而不是像瞭解它們是否引用了相同的物件。為了滿足程式猿的需求,不僅必須重寫equals方法,而且這樣做也使得這個類的例項可以被用作對映表(map)的鍵(key),或者集合(set)的元素,使對映或者集合表現出預期的行為。

  有一種“值類”不需要重寫equals方法,即用例項受控(第1項)確保“每個值之多隻存在一個物件”的類【單例模式】。列舉型別(第34項)就屬於這種類。對於這樣的類而言,邏輯相同與物件等同是一回事,因此Object的equals方法等同於邏輯意義上的equals方法。

  當你重寫equals方法的時候,你一定要遵守它的通用約定。下面是約定的內容,來自Object的規範:

  • 自反性(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。

  除非你對數學特別感興趣,否則這些規定看起來可能有點讓人感到恐懼,但是絕對不要忽視這些規定!如果你違反了它們,就會發現你的程式表現不正常,甚至崩潰,而且很難阻止失敗的根源(and it can be very difficult to pin down the source of the failure)。引用John Donne的話說,沒有哪個類是孤立的。一個類的例項通常會被頻繁地傳遞給另一個類的例項。有許多類,包括所有的集合類(collections classes)在內,都依賴於傳遞給它們的物件是否遵守了equals約定。

  現在你已經知道了違反equals約定有多麼可怕,現在我們就來更細緻地討論這些約定。值得欣慰的是,這些約定雖然看起來很嚇人,實際上並不複雜。一旦你理解了這些約定,要遵守它們並不困難。

  那麼什麼是等價關係呢?大致來說,它是一個運算子,它將一組元素分成子集,這些子集的元素被認為是彼此相等的。這些子集稱為等價類。 要使equals方法有用,每個等價類中的所有元素必須可以從使用者的角度進行互換。現在我們依次檢查一下5個要求:

  自反性(Reflexivity)————第一個要求僅僅說明物件必須等於其自身。很難想象會無意識地違反這一條。假如違背了這一條,然後把該類的例項新增到集合(collection)中,該集合的contains方法將會告訴你,該集合不包含你剛才新增的例項。

  對稱性(Symmetry)————第二個要求是說,任何兩個物件對於“他們是否相等”的問題都必須保持一致。與第一個要求不同,若無意中違反第一條,這種情形倒是不難想象。例如,考慮下面的類,它實現了一個區分大小寫的字串。字串由toString儲存,但在比較操作中被忽略:

// Broken - violates symmetry!
public final class CaseInsensitiveString {
    private final String s;
    public CaseInsensitiveString(String s) {
        this.s = Objects.requireNonNull(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方法的意圖非常好,它企圖與普通的字串(String)物件進行互操作。假設我們有一個不區分大小寫的字串和一個普通的字串:

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

正如所料,cis.equals(s)返回true。問題在於,雖然CaseInsensitiveString類中的equals方法知道普通的字串(String)物件,但是String類中的equals方法卻並不知道不區分大小寫的字串。因此,s.equals(cis)返回false,顯然違反了對稱性。假設你把不區分大小寫的字串物件放到一個集合中:

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

此時list.contains(s)會返回什麼結果呢?誰能知道?在當前OpenJDK的實現中,它碰巧返回的是false,但這只是這個特定實現得出的結果而已。在其他的視線中,它有可能返回true,或者丟擲一個執行時異常。一旦你違反了equals約定,當其他物件面對你的物件的時候,你完全不知道這些物件的行為會怎麼樣。

  為了解決這個問題,只需要把企圖與String互操作的這段程式碼從equals中去掉就可以了。這樣做以後,就可以重構該方法,使它變成一條單獨的返回語句:

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

  傳遞性————equals約定的第三個要求是,如果第一個物件等於第二個物件,並且第二個物件等於第三個物件,則第一個物件一定等於第三個物件。同樣地,無意識地違反這條規則的情形也不難想象。考慮子類的情形,它將一個新的值元件(value component)新增到了超類中。換句話說,子類增加的資訊會影響到equals的比較結果。我們首先以一個簡單的不可變的二維整數型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 (!(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方法會怎麼樣呢?如果完全不提供equals方法,而是直接從Point繼承過來,在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)pe.equals(p3)都是返回true,但是p1.equals(p3)則返回false,很顯然違反了傳遞性。前兩種比較不考慮顏色資訊(“色盲”),而第三種比較則考慮了顏色資訊。

  同樣,這種方法可以導致無限遞迴:假設有兩個Point的子類,叫做ColorPoint和SmellPoint,每一個子類都使用這種equals方法,那麼呼叫myColorPoint。equals(mySmellPonit)將會丟擲一個堆疊溢位的異常(StackOverflowError)。

  那麼怎麼解決呢?這是面嚮物件語言中關於等價關係的一個基本問題。我們無法在擴充套件可例項化的類的同事,既增加新的值元件,同時又保留equals約定,除非願意放棄面向物件的抽象所帶來的優勢。

  你可能聽說,在equals方法中用getClass測試代替instanceof測試,可以擴充套件可例項化的類和增加新的值元件,同時保留equals約定:

// Broken - violates Liskov substitution principle (page 43)
@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;
}

  這段程式只有當物件具有相同的實現時,才能使物件等同。雖然這樣也不算太糟糕,但是結果卻是無法接受的:Point子類的一個例項仍然是一個Point,它仍然需要作為一個函式執行,但是如果採用這種方法它就不能這樣做! 讓我們假設我們想要寫一個方法來判斷一個點是否在單位圓上。 這是我們可以做到的一種方式:

// Initialize unitCircle to contain all Points on the unit circle
private static final Set<Point> unitCircle = Set.of(
    new Point( 1, 0), new Point( 0, 1),
    new Point(-1, 0), 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 static int numberCreated() { return counter.get(); }
}

  里氏替換原則(Liskov substitution principle)認為,一個型別的任何重要屬性也將適用於它的子型別,因此為該型別編寫的任何方法,在它的子型別上也應該同樣執行得很好[Liskov87]。這是我們早期的正式宣告,即Ponit(如CounterPoint)的子類仍然是Point,並且必須作為一個Point來工作。但是假設我們將CounterPoint傳遞給onUnitCircle方法。 如果Point類使用基於getClass的equals方法,則無論CounterPoint例項的x和y座標如何,onUnitCircle方法都將返回false。之所以如此,是因為像onUnitCircle方法所用的是HashSet這樣的集合,利用equals方法檢驗包含條件【即在Set集合中插入物件的時候會先檢查該物件是否已存在】,沒有任何CounterPonit例項與任何Point例項對應。但是,如果在Point上使用適當的基於instanceof的equals方法,當遇到CounterPoint時,相同的onUnitCircle方法就可以正常工作。

  雖然沒有一種令人滿意的辦法可以即擴充套件不可例項化的類,又增加值元件,但還是有一種不錯的權宜之計(workaround):根據第18項的建議:組合優先於繼承。我們不再讓ColorPoint擴充套件Point,而是在ColorPoint中加入一個私有的Point欄位,以及一個公有的檢視(view)方法(第6項),此方法返回一個與該有色點處在相同位置的普通Point物件:

// 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) {
        point = new Point(x, y);
        this.color = Objects.requireNonNull(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欄位。Timestamp的equals實現卻是違反了對稱性,如果Timestamp和Date物件被用於同一個集合中,或者以其他方式被混合在一起,則會引起不正確的行為。Timestamp類有一個免責宣告,告誡程式猿不要混合使用Date和Timestamp物件。只要你不把它們混合在一起,就不會有麻煩,除此之外沒有其他的措施可以防止你這麼做,而且結果導致的錯誤將很難除錯。Timestamp類的這種行為是個錯誤,不值得效仿。

  注意,你可以在一個抽象(abstrace)類的子類中增加新的值元件,而不會違反equals約定。這對於類層次結構很重要,您可以通過遵循第23項中的建議“用類層次(class hierarchies)代替標籤類(tagged classes)”來獲得類層次結構。例如,你可能有一個抽象的Shap類,它沒有任何值元件,Circle子類添加了一個radius欄位,Rectangle子類添加了length和width欄位。只要不可能直接建立超類的例項,前面所述的種種問題就都不會發生。

  一致性(Consistency)————equals約定的第四個要求是,如果兩個物件相等,他們就必須始終保持相等,除非它們中有一個(或者兩個都)被修改了。換句話說,可變物件在不同的時候可以與不同的物件相等,而不可變物件則不會這樣。當你在寫一個類的時候,應該仔細考慮清楚它是否應該是不可變的(第17項)。如果認為它應該是不可變的,就必須保證equals方法滿足這樣的限制條件:相等的物件永遠相等,不相等的物件永遠不相等。

  無論類是否是不可變的,都不要使equals方法依賴於不可靠的資源。如果你違反了這個禁令,就很難滿足一致性要求。例如,java.net.URL的equals方法依賴於對URL中主機IP地址的比較。將一個主機名轉變成IP地址可能需要訪問網路,隨著時間的推移,不確保會產生相同的結果。這樣會導致URL的equals方法違反equals約定,在實踐中有可能引發一些問題。URL中equals方法的這種行為是一個很大的錯誤,而且不應該被效仿。遺憾的是,因為相容性的要求,這一行為無法被改變。為了避免這種問題,equals方法應該只對駐留在記憶體的物件執行確定性計算。

  非空性(Non-nullity)————最後一個要求沒有名稱,我姑且稱它為“非空性(Non-nullity)”。意思是指所有物件都必須不等於null。雖然很難想象在呼叫o.equals(null)時偶然返回true,但不難想象會不小心丟擲NullPointerException。通用約定是禁止這樣做的。許多類的equals方法都通過一個顯示的null測試來防止這種情況:

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

  這項測試是不必要的。為了測試其引數的等同性,equals方法必須先把引數轉換成適當的型別,以便可以呼叫它的訪問方法(accessor),或者訪問它的欄位。在進行轉換之前,equals方法必須使用instanceof操作符,檢查其引數是否為正確的型別:

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

  如果漏掉了這一步的型別檢查,並且傳遞給equals方法的引數又是錯誤的型別,那麼equals方法就會丟擲ClassCastException異常,這就違反了equals的約定。但是,如果instanceof的第一個運算物件(operand)是null,那麼,不管第二個操作物件(operand)是哪種型別,instanceof操作符都會返回false[JLS, 15.20.2],因此,如果把null傳遞給equals方法,型別檢查就會返回false,所以不需要單獨的null檢查。

  結合所有這些要求,得出了以下實現高質量equals方法的訣竅:

  1. 使用==操作符檢查“引數是否為這個物件的引用”。 如果是,則返回true。這只不過是一種效能優化,如果比較操作有可能很昂貴,就值得這麼做。
  2. 使用instanceof操作符檢查“引數是否為正確的型別”。 如果不是,則返回false,一般來說,所謂的“正確的型別”是指equals方法所在的那個類。有些情況下,它是指該類所實現的某個介面。如果類實現的介面改進了equals約定,允許在實現了該介面的類之間進行比較,那麼就使用介面。集合介面(collection interfaces)如Set、List、Map和Map.Entry具有這樣的特性。
  3. 把引數轉換成正確的型別。 因為轉換之前進行過instanceof測試,所以確保會成功。
  4. 對於該類中的每個“關鍵(significant)”欄位,檢查引數中的欄位是否與該物件中對應的欄位相匹配。 如果這些測試全部成功,則返回true,否則返回false。如果第2步中的型別是個藉口,就必須通過介面方法訪問引數中的欄位;如果該型別是類,也許就能夠直接訪問引數中的欄位,這就要取決於它們的可訪問性。

  對於既不是float型別也不是double型別的基本型別欄位,可以使用==操作符進行比較;對於物件引用型別的欄位,可以遞迴地呼叫equals方法;對於float欄位,可以使用靜態方法Float.compare(float, float)方法進行比較;對於double欄位,使用Double.compare(double,double)。對float和double欄位進行特殊的處理是有必要的,因為存在著Float.NaN、-0.0f以及類似的double常量;詳細資訊請參考JLS 15.21.1或者Float.equals的文件。雖然你可以使用靜態方法Float.equals和Double.equals來比較float和double欄位,但是這會在每次比較時產生自動裝箱,這會導致效能不佳。 對於陣列欄位,請將這些指導原則應用於每個元素。 如果陣列欄位中的每個元素都很重要,請使用Arrays.equals方法中的其中一個方法進行比較。

  有些物件引用型別的欄位包含null可能是合法的,所有,為了避免可能導致的NullPointerException異常,使用靜態方法Objects.equals(Object, Object)來檢查這些欄位是否相等。

  對於有些類,例如CaseInsensitiveString類,欄位的比較要比簡單的相等性測試複雜得多。如果是這種情況,可能會希望儲存該欄位的一個“正規化(canonical form)”,這樣equals方法就可以根據這些正規化進行低開銷的精確比較,而不是高開銷的非精確比較。這種方法對於不可變類(第17項)是最為合適的;如果物件可能發生變化,就必須保證其正規化保持最新。

  欄位的比較順序可能會影響到equals方法的效能。為了獲得最佳的效能,你應該最先比較最有可能不一致的欄位,或者是開銷最低的欄位,最理想的情況是兩個條件同時滿足的欄位。你不應該去比較那些不屬於物件邏輯狀態的欄位,例如用於同步操作的Lock欄位。您不需要去比較那些可以從關鍵欄位計算出來的派生欄位(derived fields),但是這樣做【比較派生欄位】有可能提高equals方法的效能。如果派生欄位代表了整個物件的綜合描述,比較這個欄位可以節省當比較失敗時去比較實際資料所需要的開銷【也就是說當(條件一)派生物件可以代表兩個物件是否相等的時候,同時(條件二)比較這個派生欄位的開銷比比較實際資料的開銷小的情況下,我們可以使用派生欄位進行物件是否相等的比較】。例如,假設有一個Polygon類,並快取了該區域。如果兩個多邊形【Polygon的例項】有著不同的區域,就沒有必要去比較他們的邊和至高點。

  1. 當你編寫完equals方法之後,應該問你自己三個問題:它是否具有對稱性?是否具有傳遞性?是否具有一致性? 並且不要只是自問,還要編寫單元測試來檢驗這些特性,除非你使用AutoValue【谷歌的一個開源框架,下面有提到】(原文49頁)自動生成equals方法,在這種情況下,您可以安全地忽略測試。如果屬性無法保持,找出原因,並相應地修改equals方法。當然,你的equals方法也必須滿足另外兩個屬性(自反性和非空性),但這兩個一般是相通的(If the properties fail to hold, figure out why, and modify the equals method accordingly. Of course your equals method must also satisfy the other two properties (reflexivity and non-nullity), but these two usually take care of themselves)。

  在這個簡單的PhoneNumber類中顯示了根據前面的訣竅構造的equals方法:

// Class with a typical equals method
public final class PhoneNumber {
    private final short areaCode, prefix, lineNum;
    public PhoneNumber(int areaCode, int prefix, int lineNum) {
        this.areaCode = rangeCheck(areaCode, 999, "area code");
        this.prefix = rangeCheck(prefix, 999, "prefix");
        this.lineNum = rangeCheck(lineNum, 9999, "line num");
    }
    private static short rangeCheck(int val, int max, String arg) {
        if (val < 0 || val > max)
            throw new IllegalArgumentException(arg + ": " + val);
        return (short) val;
    }
    @Override public boolean equals(Object o) {
        if (o == this)
            return true;
        if (!(o instanceof PhoneNumber))
            return false;
        PhoneNumber pn = (PhoneNumber)o;
        return pn.lineNum == lineNum && pn.prefix == prefix && pn.areaCode == areaCode;
    }
    ... // Remainder omitted
}

  下面是最後的一些說明:

  • 重寫equals方法時總要重寫hashCode方法(第11項)。
  • 不要企圖讓equals方法過於智慧。 如果只是簡單地測試域中的值是否相等,則不難做到遵守equals約定。如果想過度地去尋求各種等價關係,則很容易陷入麻煩之中。把任何任何一種別名形式考慮到等價的範圍內,往往是一個壞主意。例如,File類不應該試圖把指向同一個檔案的符號連結(symbolic link)當做相等的物件來看待。所幸File類沒有這樣做。
  • 不要將equals宣告中的Object物件替換為其他的型別。 程式猿編寫出下面這樣的equals方法並不少見,這會使程式猿花上數小時都搞不清為什麼它不能正常工作:
// Broken - parameter type must be Object!
public boolean equals(MyClass o) {
    ...
}

  問題在於,這個方法並沒有重寫Object.equals方法,因為它的引數應該是Object型別,相反,它過載了Object.equals(第52項)。在原有equals方法的基礎上,再提供一個“強型別(strongly typed)”的equals方法,這是無法接受的,因為它可能導致子類中的Override註釋產生誤報並提供錯誤的安全感(because it can cause Override annotations in subclasses to generate false positives and provide a false sense of security)【可能會導致子類中的Override註釋在編譯的時候報錯】。

  Override註釋的用法一致,就如本項中所示,可以防止犯這種錯誤(第40項)。這個equals方法不能編譯,錯誤訊息將會告訴你到底哪裡出了問題:

// Still broken, but won’t compile
@Override public boolean equals(MyClass o) {
    ...
}

  編寫和測試equals(和hashCode)方法很繁瑣,結果程式碼很平常。 手動編寫和測試這些方法的一個很好的替代方法是使用Google的開源AutoValue框架,該框架會自動為您生成這些方法,由類中的單個註釋觸發。 在大多數情況下,AutoValue生成的方法與您自己編寫的方法基本相同。

  IDE也有生成equals和hashCode方法的工具,但結果原始碼比使用AutoValue的程式碼更冗長,更不易讀,不會自動跟蹤類中的更改,因此需要測試。 也就是說,讓IDE生成equals(和hashCode)方法通常比手動實現它們更可取,因為IDE不會造成粗心的錯誤,人類也會這樣做。

  總之,不要重寫equals方法,除非您不得不這麼做:在許多情況下,從Object繼承的實現完全符合您的要求。 如果你確實重寫了equals,請確保比較所有類的關鍵欄位,並使用之前提到的五個訣竅對它進行測試(If you do override equals, make sure to compare all of the class’s significant fields and to compare them in a manner that preserves all five provisions of the equals contract)