Kotlin学习总结:类、对象和接口(三)
编译器生成的方法:数据类和委托类
Java平台定义了一些需要在许多类中呈现的方法,并且通常是以一种很机械的方式,譬如equals、hashCode及toString。幸运的是,Java IDE可以将这些方法的生成自动化,所以通常不需要手动写它们。但是这种情况下,你的代码库包含了样板代码。Kotlin的编译器就领先一步了:它能将这些呆板的代码生成放到幕后,并不会因为自动生成的结果导致源代码文件变得混论。
通过对象方法
就像Java中的情况一样,所有的Kotlin类也有许多也许你想要重写的方法:toString、equals和hashCode。
Client类的最初声明
class Client(val name: String, val postalCode: Int)
字符串表示:toString()
Kotlin中的所有类同Java一样,提供了一种方式来获取对象的字符串表示形式,虽然也能在其他的上下文中使用这个功能,但是这还是主要用在调试和日志输出中。默认来说,一个对象的字符串表示形如Client@5e9f23b4,这并不十分有用。要想改变它,需要重写toString方法。
为Client实现toString()
class Client(val name: String, val postalCode: Int) {
override fun toString(): String = "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)
false
这意味着必须为Client类重写equals。
= = 表示相等性
在Java中,可以使用 = = 运算符来比较基本数据类型和引用类型。如果应用在基本数据类型上,Java的 = =比较的是值,然而在引用类型上= =比较的是引用。因此,在Java中,众所周知的实践是总是调用equals,如果忘记了这样当然也会导致众所周知的问题。
在Kotlin中, = = 运算符是比较两个对象的默认方式:本质上说它就是通过调用equals来比较两个值的。因此,如果equals被重写了,能够很安全地使用= =来比较实例。要想进行引用比较,可以使用===运算符,这与Java中的= =比较对象引用的效果一模一样。
为Client类实现equals()
class Client(val name: String, val postalCode: Int) {
override fun toString(): String = "Client(name=$name, postalCode=$postalCode)"
/*
“Any”是java.lang.Object的模拟:Kotlin中所有类的父类。
可空类型“Any?”意味着“other”是可以为空的
*/
override fun equals(other: Any?): Boolean {
if (other == null || other !is Client) // 检查“other”是不是一个Client
return false
return name == other.name && // 检查对应的属性是否相等
postalCode == other.postalCode
}
}
注意,Kotlin中的is检查是Java中instanceof的模拟,用来检查一个值是否为一个指定的类型。就像!in运算符是in检查的逆运算一样,!is运算符表示is检查的非运算。
在Kotlin中override修饰符是强制使用的,会在意外书写fun equals(other: Client)时得到保护,这样会添加一个新的方法而不是重写equals。
Hash容器:hashCode()
hashCode方法通常与equals一起被重写。
创建有一个元素的set:一个名为Alice的客户。接着,创建一个新的包含相同数据的Client实例并检查它是否包含在set中。期望检查会返回true,因为这两个实例是相等的,但事实上返回的是false:
>>> val processed = hashSetOf(Client("Alice", 342562))
>>> println(processed.contains(Client("Alice", 342562)))
false
原因就是Client类缺少了hashCode方法。因此它违反了通用的hashCode契约:如果两个对象相等,它们必须有着相同的hash值。processed set是一个hash值,然后只有它们相等时才会去比较真正的值。前一个例子中Client类的两个不同的实例有着不同的hash值,所有set认为它不包含第二个对象,即使equals会返回true。因此,如果不遵循规则,HashSet不能在这样的对象上正常工作。
要修复这个问题,可以向类中添加hashCode的实现。
为Client实现hashCode():
class Client(val name: String, val postalCode: Int) {
...
override fun hashCode(): Int = name.hashCode() * 31 + postalCode
}
现在这个类在所有的情景下都可以按预期来工作了——但是注意有多少代码需要书写。
数据类:自动生成通用方法的实现
如果想要某个类时一个方便的数据容器,便需要重写这些方法:toString、equals和hashCode。通常来说这些方法的实现十分简单,冰洁像Intellij IDEA这样的IDE能够帮助用户自动生成它们,并确保它们的实现时正确且一致的。
好消息时,在Kotlin中不必再去生成这些方法了。如果为目标类添加data修饰符,必要的方法将会自动生成好。
数据类Client:
data class Client(val name: String, val postalCode: Int)
现在就得到了一个重写了所有Java方法的类:
- equals用来比较实例
- hashCode用来作为例如HashMap这种基于哈希容器的键
- toString用来为类生成按声明顺序排列的所有字段的字符串表达式
equals和hashCode方法会将所有在主构造方法中声明的属性纳入考虑。生成的equals方法会检测所有的属性的值是否相等。hashCode方法会返回一个根据所有属性生成的哈希值。请注意没有在主构造方法中声明的属性将不会加入到相等性检查和哈希值计算中去。
数据类和不可变形:copy()方法
请注意,虽然数据类的属性并没有要求val——同样可以使用var+但还是强烈推荐只使用可读属性,让数据类的实例不可变。如果想使用这样的实例作为HashMap或者类似容器的键,这会是必需的要求,因为如果不这样,被用作键的对象在加入容器后被修改了,容器可能会进入一种无效的状态。不可变对象同样更容易理解,特别是在多线程代码中:一旦一个对象被创建出来,它会一直保持初始状态,也不用担心在自己的代码工作时其他线程修改了对象的值。
为了让使用不可变对象的数据类变得更加容易,Kotlin编译器为它们多生成了一个方法:一个允许copy类的实例的方法,并在copy的同时修改某些属性的值。创建副本通常是修改实例的好选择:副本有着单独的生命周期而且不会影响代码中引用原始实例的位置。下面就是手动实现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)
类委托:使用“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 contains(element: T): Boolean = innerList.contains(element)
override fun isEmpty(): Boolean = innerList.isEmpty()
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>(
private val innerSet: MutableCollection<T> = HashSet<T>()
) : MutableCollection<T> by innerSet { // 把MutableCollection的实现委托内innerSet
var objectAdded = 0
override fun add(element: T): Boolean { // 不使用委托,提供一个不同的实现
objectAdded++
return innerSet.add(element)
}
override fun addAll(elements: Collection<T>): Boolean { // 不使用委托,提供一个不同的实现
objectAdded += elements.size
return innerSet.addAll(elements)
}
}
>>> val cset = CountingSet<Int>()
>>> cset.addAll(listOf(1, 1, 2))
>>> println("${cset.objectAdded} objects where added, ${cset.size} remain")
3 objects where added, 2 remain