你只依赖你只依赖你是的是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中的一样,都提供了获取类对象的字符串表示的一种途径。尽管你也可以在其他环节中使用这个功能,但这一特性主要是用来调试和输出日志的。默认情况下,对象的字符串表示看起来像Client@5e9f23b4
这样的形式。但这样的形式并没有多大用处。要想改变它,你需要覆盖toString()
方法。
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) // 1 Kotlin中,==检查两个对象是否相等,并非它们的引用。它在底层调用的是"equals"。
false
你看到对象是不相等的。这意味着你必须为Client
类覆盖equals
方法。
SIDEBAR 用于等价性判断的
==
Java中,你可以使用==
来比较原始和引用类型。如果用于原始类型,Java的==
操作符比较两者的值。然而==
操作符用于引用类型时,比较的是引用。所以,在Java中,调用eauals
是最佳实践。忘了这样做会导致出名的问题。
Kotlin中,==
是比较两个对象的默认方式:它通过在底层调用equals
来比较两者的值。因此,如果equals
方法在你的类中被覆盖的话,你可以使用==
来安全的比较它的实例。对于引用比较,你可以使用===
操作符。它跟Java中的==
完全一样。
让我们来看看修改后的Client
类:
class Client(val name: String, val postalCode: Int) {
override fun equals(other: Any?): Boolean { // 1 "Any"类似于java.lang.Object:它是Kotlin中所有类的超类。可为空的类型"Any?"指的是other变量可以为null
if (other == null || other !is Client) // 2 检查other变量是否为Client
return false
return name == other.name && // 3 检查相应的属性是否相等
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
类生成的有用方法的完整列表。下一个章节将会展示更多(有关内容)。7.4节会补充剩下的部分。
数据类和不可变性: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 { // 1 委托MutableCollection的实现给innerSet
var objectsAdded = 0
override fun add(element: T): Boolean { // 2 没有委托,提供了一个不同的实现
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
关键字和适合使用的情形。