文章作者:Tyan
博客:noahsnail.com | CSDN | 簡書
CHAPTER3 所有對象的共通方法
雖然Object
是一個具體的類,但設計它的主要目的是為了擴展。它的所有非final
方法(equals
,hashCode
,toString
,clone
和finalize
)都有明確的通用約定,因為設計它們的目的是為了重寫。任何類都應該遵循通用約定重寫這些方法;不這樣做的話,依賴這些約定的其它類(例如HashMap
和HashSet
)將無法結合這個類正確運行。
會告本章訴你什么時候,怎樣重寫這些非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
方法。這種情況通常是對值類而言的。值類僅僅是表示值的類,例如Integer
或Date
。程序員用equals
方法比較值對象的引用,期望找出它們是否是邏輯等價的,而不管它們是否是同一對象。重寫equals
方法不僅滿足了程序員的期望;它也能使實例作為映射表的主鍵或者集合的元素,使它們表現出可預期的行為。
有一種不需要重寫equals
方法的值類,它通過實例控制(Item 1)來確保每個值至多存在一個對象。枚舉類型(Item 30)就是這種類。對于這種類而言,邏輯等價等同與對象同一性,Object
的equals
方法在功能上就如同邏輯等價方法。
當你重寫equals
方法時,你必須遵循通用約定。下面是約定內容,從Object
規范[JavaSE6]中拷貝的:
equals
實現了一種等價關系。它是:
自反性:對于任何非空引用值
x
,x.equals(x)
必須返回true
。對稱性:對于任何非空引用值
x
和y
,x.equals(y)
必須返回true
當且僅當y.equals(x)
返回true
。傳遞性:對于任何非空引用值,
x
,y
,z
,如果x.equals(y)
返回true
并且y.equals(z)
返回true
,則x.equals(z)
必須返回true
。一致性:對于任何非空引用值
x
和y
,x.equals(y)
的多次調用一致返回true
或一致返回false
,假設對象進行equals
比較時沒有修改任何信息。對于非空引用值
x
,x.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
類使用了基于getClass
的equals
方法,onUnitCircle
將會返回false
,無論CounterPoint
實例的x
值和y
值是多少。這是因為集合,例如onUnitCircle
方法中的HashSet
,使用equals
方法來測試是否包含元素,沒有CounterPoint
實例等于Point
。然而,如果你在Point
上使用合適的基于instanceof
的equals
方法,當面對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
字段。Timestamp
的equals
實現確實違反了對稱性,如果Timestamp
和Date
用在同一個集合中或混雜在一起,會引起不穩定的行為。Timestamp
類有一個免責聲明,警告程序員不要混合日期和時間戳。雖然只要你將它們分開就不會有麻煩,但是沒有任何東西阻止你混合它們,而且產生的錯誤很難調試。Timestamp
類的這個行為是一個錯誤,不應該進行模仿。
注意,你可以添加值組件到抽象類的子類而且不會違反equals
約定。對于遵循Item 20 “Prefer class hierarchies to tagged classes”的建議而得到這種類層次來說,這是非常重要的。例如,你可以有一個沒有值組件的抽象類Shape
,子類Circle
添加了radius
字段,子類Rectangle
添加了length
和width
字段。只要不能直接創建一個超類實例,上面的種種問題就不會發生。
一致性——equals
約定的第四個要求是說如果兩個對象相等,它們必須一致相等,除非其中一個(或二者)被修改了。換句話說,可變對象在不同的時間可以等于不同的對象而不可變對象不能。當你寫了一個類,仔細想想它是否應該是不可變的(Item 15)。如果你推斷它應該是不可變的,那么要確保你的equals
方法滿足這樣的約束條件:相等的對象永遠相等,不等的對象永遠不等。
無論一個類是否是不可變的,都不要寫一個依賴于不可靠資源的equals
方法。如果你違反了這個禁令,要滿足一致性要求是非常困難的。例如,java.net.URL
的equals
方法依賴于對關聯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
方法的流程:
使用
==
操作符來檢查參數是否是這個對象的一個引用,。如果是,返回true
。這只是一個性能優化,如果比較的代價有可能很昂貴,這樣做是值得的。使用
instanceof
操作符來檢查參數類型是否正確。如果不正確,返回false
。通常,正確的類型是指equals
方法所在的那個類。有時候,它是這個類實現的一些接口。如果一個類實現了一個接口,這個接口提煉了equals
約定來允許比較那些實現了這個接口類,那么就使用接口。集合接口例如Set
,List
,Map
和Map.Entry
都有這個屬性。將參數轉換成正確的類型。由于轉換測試已經被
instanceof
在之前做了,因此它保證能成功。對于類中的每一個“有效”字段,檢查參數的這個字段是否匹配這個對象的對應字段。如果所有的這些測試都成功了,返回
true
;否則返回false
。如果第二步中的類型是一個接口,你必須通過接口方法訪問參數的字段;如果類型是一個類,你可能要直接訪問字段,依賴于它們的可訪問性。
對于基本類型,如果不是float
或double
,使用==
操作符進行比較;對于對象引用字段,遞歸地調用equals
方法;對于float
自動,使用Float.compare
方法;對于double
字段,使用Double.compare
。float
和double
字段的特別對待是有必要的,因為存在Float.NaN
,-0.0f
和類似的double
常量;更多細節請看Float.equals
。對于數組字段,對每個元素應用這些指導。如果數組中的每個元素都是有意義的,你可以使用1.5版本中添加的Arrays.equals
方法。
某些對象引用字段可能合理的包含null
。為了避免產生NullPointerException
的可能性,使用下面的習慣用法來比較這些字段:
(field == null ? o.field == null : field.equals(o.field))
如果field
和o.field
經常是等價的,使用下面的可替代方式可能會更快:
(field == o.field || (field != null && field.equals(o.field)))
對于某些類而言,例如上面的CaseInsensitiveString
,字段比較比簡單的相等性檢測更復雜。如果是這種情況,你可能想存儲這個字段的標準形式,因此equals
方法可以在這些標準形式上進行低開銷的精確比較,而不是更高代碼的非精確比較。這種技術最適合不可變類(Item 15);如果對象可以改變,你必須保持最新的標準形式。
equals
方法的性能可能會受到字段比較順序的影響。為了最佳性能,你首先應該比較那些更可能不同,比較代價更小的字段,或者理想情況下二者兼具的字段。你不能比較那些不屬于對象邏輯狀態一部分的字段,例如同步操作中的Lock
字段。你也不需要比較冗余的字段,它們能從“有意義字段”中計算出來,但這樣做可能會改善equals
方法的性能。如果冗余字段相當于整個對象的概要描述,比較這個字段,如果失敗的話會節省你比較真正數據的開銷。例如,假設你有一個Polygon
類,并且你緩存這個區域。如果兩個多邊形有不同的面積,你就不需要比較它們的邊和頂點。
- 當你完成了
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) {
...
}