4.3 編譯器生成的方法:資料類和類委託
你只依賴你的是Java平臺定義了大量的需要在多個類中出現但往往以一種呆板的方式實現的方法。例如, equals(),hashCode() 和 toString() 。所幸的是,Java IDE可以自動生成這些方法。所以你通常不需要手動編寫這一類程式碼。但是這樣的話,你的程式碼庫就會包含許多的八股程式碼。Kotlin編譯器(在這個問題上) 向前了一步:它可以在後臺執行機械程式碼的生成,而不會使用生成的結果打亂你的程式碼。
你已經見識過對於重要的類建構函式和屬性訪問器,它是如何工作的了。讓我們來看看更多關於Kotlin編譯器生成對簡單的資料類非常有用而且極大的簡化了類委託模式的典型方法的例子。
4.3.1 通用的物件方法
像Java那樣,所有的Kotlin類有多個你可能想要覆蓋的方法: toString(),equals() 和 hashCoce() 。讓我們來看看這些方法長什麼樣。還有Kotlin如何能夠幫助你自動生成它們的實現。作為一個切入點,你將會使用一個儲存客戶端名稱和郵編的簡單的 Client 類:
class Client(val name: String, val postalCode: Int)
讓我們來看看類例項是如何表現為一個字串的。
字串表示:toString()
Kotlin中的所有類,跟Java中的一樣,都提供了獲取類物件的字串表示的一種途徑。儘管你也可以在其他環節中使用這個功能,但這一特性主要是用來除錯和輸出日誌的。預設情況下,物件的字串表示看起來像
class Client(val name: String, val postalCode: Int) {
override fun toString() = "Client(name=$name, postalCode=$postalCode)"
}
現在現在客戶端(物件) 的表示長這樣:
>>> val client1 = Client("Alice", 342562) >>> println(client1) Client(name=Alice, postalCode=342562)
物件等價性: equals()
Client 類所有的計算都在它的外部進行。這個類只儲存資料。它註定是簡單、透明的。不過,對於這個類的行為,你可能會有一些要求。例如,假設你希望如果它們包含同樣的資料,兩個物件被認為是相等的:
>>> val client1 = Client("Alice", 342562)
>>> val client2 = Client("Alice", 342562)
>>> println(client1 == client2) // Kotlin中,==檢查兩個物件是否相等,並非它們的引用。它在底層呼叫的是"equals"。
false
你看到物件是不相等的。這意味著你必須為 Client 類覆蓋 equals 方法。
Java中,你可以使用 == 來比較原始和引用型別。如果用於原始型別,Java的 == 操作符比較兩者的值。然而 == 操作符用於引用型別時,比較的是引用。所以,在Java中,呼叫 eauals 是最佳實踐。忘了這樣做會導致出名的問題。
Kotlin中, == 是比較兩個物件的預設方式:它通過在底層呼叫 equals 來比較兩者的值。因此,如果 equals 方法在你的類中被覆蓋的話,你可以使用 == 來安全的比較它的例項。對於引用比較,你可以使用 === 操作符。它跟Java中的 == 完全一樣。
讓我們來看看修改後的 Client 類:
class Client(val name: String, val postalCode: Int) {
override fun equals(other: Any?): Boolean { // "Any"類似於java.lang.Object:它是Kot lin中所有類的超類。可為空的型別"Any?"指的是other變數可以為null
if (other == null || other !is Client) // 檢查other變數是否為Client
return false
return name == other.name && // 檢查相應的屬性是否相等
postalCode == other.postalCode
}
override fun toString() = "Client(name=$name, postalCode=$postalCode)"
}
這裡想提醒一下你,Kotlin中的 is 是Java中 instanceof 的對應物。它檢查一個值是否擁有指定的型別。正如 in 檢查的否定形式(我們在2.4.4一節討論過的) : !in 操作符, !is 操作符表示 is 檢查的否定形式。這樣的操作符讓你的程式碼更容易閱讀。在第6章,我們將會詳細討論可為空的型別,以及為什麼 other == null || other !is Client 條件可以被簡化為 other!is Client 。
由於Kotlin中 override 修飾符是強制使用的,(這能) 保護你不經意的的寫成 fun equals(other: Client) 。這種寫法將會新增一個新的方法,而不是覆蓋 equals 。在你覆蓋了 equals 方法之後,你可能希望擁有相同屬性的客戶端相等。事實上,前一個例子中的等價性檢查 client1 == client2 現在返回 true 。但是如果你想對客戶端做更多複雜的事情,那不會有用。你可能會說問題是 hashCode 缺失了。那是真實的案例。我們即將討論為什麼這是重要的(在你閱讀下一個節之前,確保你理解了上面的解釋)
雜湊容器:hashCode()
hashCode 方法應該總是跟 eauals 一同被覆蓋。這一章節解釋為什麼(要這樣做) 。
讓我們建立一個只有一個元素的集合:一個叫做Alice的客戶端。當你檢查這個集合是否包含擁有相同名字和郵政編碼的客戶端時,你會認為這樣的物件是相等的,但是集合並不包含:
>>> val processed = setOf(Client("Alice", 342562))
>>> println(processed.contains(Client("Alice", 342562)))
false
原因是 Client 類缺失了 hashCode 方法。因此,它跟常見的 hashCode 約定衝突了:如果兩個物件是相等的,他們必須擁有相同的雜湊值。 processed 集合是一個雜湊集合。雜湊集合中的值以一種優化後的方式進行比較:首先比較兩者的雜湊值,然後,當且僅當它們相等時,比較真實值。再之前的例子中,兩個不同的 Client 類例項的雜湊值是不同的。所以集合判定不包含第二個物件,儘管 equals 返回 true 。因此,如果不遵循(這個) 規則,對於這個物件雜湊集合無法正確的工作。
為了解決這個問題,你可以新增 hashCode() 實現到類中:
class Client(val name: String, val postalCode: Int) {
...
override fun hashCode(): Int = name.hashCode() * 31 + postalCode
}
現在你有一個可以在所有場景下按預期工作的類,但是請注意你必須寫多少程式碼。幸運的是,Kotlin編譯器可以通過自動的生成所有的這些方法來幫助你。讓我們來看看你可以如何要求編譯器去做這件事。
4.3.3 資料類:自動生成的通用方法的實現
如果你想要你的類成為你的資料的便捷容器,別忘了覆蓋這些方法: toString,equals 和 hashCode 。通常,這些方法的實現是直接的。像IntelliJ IDEA一類的IDE可以幫助你自動生成並驗證這些方法是正確的並且一致的實現。
好訊息是,在Kotlin中,你不必生成所有這些方法。如果你添加了 data 修飾符到你的類,所有必要的方法會為你自動生成:
data class Client(val name: String, val postalCode: Int)
很簡單,對吧?你有一個覆蓋了所有Java標準方法的類:
- 用於比較例項的 equals()
- 在基於雜湊的容器(例如 HashMap ) 中作為鍵的 hashCode()
- 以宣告的順序所有欄位的生成字元表示的 toString()
equals 和 hashCode 方法考慮了主建構函式中宣告的所有屬性。生成的 equals() 方法檢查所有值相等的屬性。 hashCode() 方法返回一個值。而這個值依賴於所有屬性的雜湊值。注意,沒有在主建構函式中宣告的屬性沒有參與等價性檢查和雜湊值計算。
這並不是為 data 類生成的有用方法的完整列表。以後將會展示更多 。
資料類和不可變性:COPY()方法
注意,儘管資料類的屬性並不要求為 val ,你也可以使用 var ,但是強烈推薦你使用只讀的屬性,讓資料類的例項不可變。如果你想使用這個例項作為 HashMap 或者一個類似的容器的鍵,這一點是必要的。因為否則的話,容器會進入一個失效的狀態,如果物件使用一個新增到容器之後被修改的鍵。不可變的物件也更容易推斷,特別是在多執行緒程式碼中:一旦一個物件被建立了,它會保持原始的狀態。當你的程式碼使用它時,你不需要擔心其他的執行緒修改這個物件。
為了讓資料類用作不可變物件更容易,Kotlin編譯器為它們生成了一個方法:一個允許你複製類例項、改變某些屬性的值的方法。在適當的位置建立一個副本通常是修改例項的以個好的可選方案:副本有一個獨立的生命週期,不會影響程式碼中指向原始例項的地方。如果你手動實現 copy() 方法,以下是它可能的一個模樣:
class Client(val name: String, val postalCode: Int) {
...
fun copy(name: String = this.name,
postalCode: Int = this.postalCode) =
Client(name, postalCode)
}
下面是 copy() 方法如何使用:
>>> val bob = Client("Bob", 973293)
>>> println(bob.copy(postalCode = 382555))
Client(name=Bob, postalCode=382555)
你已經見識了 data 修飾符如何使得值-物件類更加容易使用。現在讓我們討論其他的Kotlin特性來讓你避開IDE生成的八股程式碼:類委託。
4.3.3 類委託:使用 by 關鍵字
在設計大型面向物件系統時的一個常見問題是繼承實現導致的脆弱性。當你擴充套件一個類並覆蓋它當中的方法時,你的程式碼變得依賴於你所擴充套件的類的實現細節。當系統進化以及基類的實現改變或者新增新的方法到類基類中時,你所做的有關你的類的行為將變得無效。所以你的程式碼可能以異常的表現而告終。
Kotlin的設計承認了這個問題並將類預設視為 final 。這確保了只有為擴充套件性而設計的類才能被繼承。當使用這樣的類時,你看到它是開放的,你記住的是修飾需要相容派生的類。但是,你經常需要新增行為到另一個類,即使它並不是設計用來擴充套件的。一個常見的實現方法是著名的裝飾器模式。這個模式的本質是:建立一個新的類,實現跟原始類中相同的方法並將原始類的例項儲存未一個欄位。原始類的行為不需要被修改的方法將被轉發給原始類例項。 這個方法的一個缺點是,它需要大量的模板程式碼(很多像IntelliJ IDEA的IDE都有專門的特性來為你生成這些程式碼) 。舉個例子,這是你為實現一個跟 Collection 一樣簡單的裝飾器所需的程式碼量,即便你沒有修改任何的行為:
class DelegatingCollection<T> : Collection<T> {
private val innerList = arrayListOf<T>()
override val size: Int get() = innerList.size
override fun isEmpty(): Boolean = innerList.isEmpty()
override fun contains(element: T): Boolean = innerList.contains(element)
override fun iterator(): Iterator<T> = innerList.iterator()
override fun containsAll(elements: Collection<T>): Boolean =
innerList.containsAll(elements)
}
好訊息是Kotlin為作為語言特性的委託包含了一流的支援。每當你實現一個介面時,你可以說你通過 by 關鍵字把介面的實現委託給另一個物件。以下是你可以如何使用這個方法來重寫前一個例子:
class DelegatingCollection<T>(
innerList: Collection<T> = ArrayList<T>()
) : Collection<T> by innerList {}
如你所見,這個類中所有方法的實現都消失了。編譯器會生成這些實現。它們跟 DelegatingCollection 例子中的實現相似。由於程式碼中沒有有趣的內容,當編譯器可以自動的為你做同樣的工作時手動編寫是沒有意義的。 現在,當你需要改變一些方法的行為時,你可以覆蓋它們。你的程式碼將會被呼叫而不是(編譯器自動) 生成的方法。你可以忽略你滿意的委託給底層示例的預設實現。 讓我們來看看你可以如何使用這個技術來實現一個統計嘗試的次數集合,並向其新增元素。舉個例子,如果你執行某種去重操作,通過比較嘗試次數來新增一個帶有結果集合大小的元素,你可以使用這樣的一個集合來測量這個操作的效率如何:
class CountingSet<T>(
val innerSet: MutableCollection<T> = HashSet<T>()
) : MutableCollection<T> by innerSet { // 委託MutableCollection的實現給innerSet
var objectsAdded = 0
override fun add(element: T): Boolean { // 沒有委託,提供了一個不同的實現
objectsAdded++
return innerSet.add(element)
}
override
fun addAll(c: Collection<T>): Boolean {
objectsAdded += c.size
return innerSet.addAll(c)
}
}
>>> val cset = CountingSet<Int>()
>>> cset.addAll(listOf(1, 1, 2))
>>> println("${cset.objectsAdded} objects were added, ${cset.size} remain")
3 objects were added, 2 remain
如你所見,你覆蓋了 add 和 addAll 方法來增加計數。同時你把 MutableCollection 介面其餘的實現委託給了你所包裝的容器。 重要的部分是你沒有引入任何有關底層集合是如何實現的依賴。舉個例子,你不會在意實現了 addAll() 方法的集合是通過在一個迴圈中呼叫 add() 方法,還是它使用了為一個特定的場景而優化的一種不同的實現。你完全控制了當客戶程式呼叫你的類時會發生什麼。你只依賴於有相關文件的底層集合的API來實現你的操作。所以你可以依賴於它繼續工作。 我們已經討論完了有關Kotlin編譯器如何為類生成有用的方法。讓我們開始Kotlin類故事的最後、最大塊的部分: object 關鍵字和適合使用的情形。