kotlin入门潜修之类和对象篇—数据类及其原理
本文收录于 kotlin入门潜修专题系列,欢迎学习交流。
创作不易,如有转载,还请备注。
数据类
我们都知道,在java中经常写纯粹性为了保存数据的类,比如我们定义一个Person类,一般会在该类中定义上姓名、年龄、性别等等,除此之外,该类基本不会再提供其他包含有业务逻辑的代码。如下所示:
//java
public class Person {
public String name;
public int age;
}
这种类在java中的写法和其他的类一模一样,没有任何特殊之处。而kotlin对于此类型的类专门定义了一个类型,即数据类,使用data 关键字修饰。示例如下:
data class Person(val name: String, val age: Int) {
}
上面是data类的标准写法,了解过kotlin构造方法的应该会知道,kotlin还有一个第二构造方法,那么data数据类能使用第二构造方法的形式来写吗?
看下以下几个例子,如下所示:
//例1
data class Person{//!!!编译错误,data类必须要有主构造方法
constructor(name: String, age: Int){
}
}
//例2
data class Person(){//!!!编译错误,data类的主构造方法至少要有一个参数
constructor(name: String, age: Int):this(){
}
}
//例3
data class Person(val name: String){//正确,在保证主构造方法满足data类要求的时候,可以运行使用第二构造方法
constructor(name: String, age: Int):this(name){
}
}
代码注释已经解释的比较详细了,最后总结一下:kotlin中的数据类必须要有主构造方法,而且主构造方法至少要有一个参数。
kotlin为我们提供data类,难道就是为了显示标明这个类就是保存数据用的吗?当然不是,data类相对于其他普通类还是有些区别的,不着急,我们慢慢来看。
数据类中的toString()
toString()方法大家都再熟悉不过,先来看个例子:
//定义一个数据类
data class Person(val name: String, val age: Int){
}
//注意,这个是个普通类
class Person2(val name: String, val age: Int) {
}
class Main {
companion object {
@JvmStatic fun main(args: Array<String>) {
println(Person("zhangsan", 20))//打印data类对象
println(Person2("lisi", 30))//打印普通类对象
}
}
}
上面代码执行完后,打印如下:
Person(name=zhangsan, age=20)//data类的打印结果
test.Person2@4e0fd2b1//普通类的打印结果
从打印日志可以明显看出,data类竟然打印出来了具体的值,而普通类则打印出了对象地址,这是为什么?
让我们先回到java世界,对于java我们都知道,在打印对象的时候会默认调用该对象的toString方法。对于String类型对象来说,java为我们重写了toString,默认打印字符串值。而对于其他普通类来说(比如我们定义的Person类)并没有重写toString方法,因此会默认调用其父类中的toString方法,如果父类没有重写就会调用父类的父类的toString方法,直到调用到java中的基类Object中的toString方法,这个方法默认打印的就是对象地址。
让我们再回到kotlin,在kotlin中,对普通类的toString的处理和java一样,默认打印对象地址。而对于data类却重写了基类Any中的toString方法,默认打印该对象成员变量的键值对。这就是数据类和普通类的区别之一。
有朋友可能对于kotlin中对于data类的实现既好奇又有所怀疑,toString确实打印出了成员,但是我怎么能知道toString到底是不是这么实现的呢?
这个简单,我们看下kotlin生成的Person类的字节码即可,找到字节码中的toString方法,如下所示(注意注释):
public toString()Ljava/lang/String;
NEW java/lang/StringBuilder
DUP
INVOKESPECIAL java/lang/StringBuilder.<init> ()V
LDC "Person(name="//注意这里,很显然使用了StringBuilder进行字符串拼接,这里先拼接key:name
INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder;
ALOAD 0
GETFIELD test/Person.name : Ljava/lang/String;//拼接name值
INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder;
LDC ", age="//这里拼接了key:age
INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder;
ALOAD 0
GETFIELD test/Person.age : I//拼接age的值
INVOKEVIRTUAL java/lang/StringBuilder.append (I)Ljava/lang/StringBuilder;
LDC ")"
INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder;
INVOKEVIRTUAL java/lang/StringBuilder.toString ()Ljava/lang/String;
ARETURN
MAXSTACK = 2
MAXLOCALS = 1
通过上面字节码显然可以看出,data类的toString是使用StringBuidler对打印对象中的成员属性进行了拼接。这就是我们前面看到数据类能打印出键值对的原因。
实际上,kotlin还会对数据类进行其他处理,总结如下:
- 数据类重写了toString方法
- 数据类重写了equals和hasCode方法
- 数据类提供了copy方法
- 数据类提供了componetN方法
需要注意的是,对于上面几个特性,只能作用于主构造方法中的属性成员,即只有主构造方法中的属性会生成data数据类专有的上面几个方法,下文会有专门分析。
我们先继续来看数据类的特性,上面的toString我们已经讲到过,接下来分析下剩下的三个特性。
数据类中的equals和hashCode方法
让我们先谈谈java世界中纠缠不清、剪不断理还乱的equals和hashCode方法。
equals和hashCode两个方法都是定义于java最顶层基类——Object类中,也就是默认是由Object类实现的。先看下Object类中的hashCode方法:
//Object类中的equals方法
public boolean equals(Object obj) {
return (this == obj);
}
由上面代码可知,Object只是简单的返回了this == obj的比较值,这条语句只是简单的将当前对象和目标对象的地址进行对比,所以当我们比较同一个对象的时候会返回true,而不同对象会返回false。
再来看看Object中的hashCode实现:
public native int hashCode();
很抱歉,jdk并没有为我们提供java层面的hasCode实现,而是使用了jni的方式进行实现,也就是我们在java层无法看到Object类中的源码,如果有朋友想看源码可以去找jni源码,这里姑且看下java文档对此方法的描述,这里为了消除本文对英文翻译可能产生的误解,先粘贴java文档中对hashCode的说明,如下所示:
/**
* Returns a hash code value for the object. This method is
* supported for the benefit of hash tables such as those provided by
* {@link java.util.HashMap}.
* <p>
* The general contract of {@code hashCode} is:
* <ul>
* <li>Whenever it is invoked on the same object more than once during
* an execution of a Java application, the {@code hashCode} method
* must consistently return the same integer, provided no information
* used in {@code equals} comparisons on the object is modified.
* This integer need not remain consistent from one execution of an
* application to another execution of the same application.
* <li>If two objects are equal according to the {@code equals(Object)}
* method, then calling the {@code hashCode} method on each of
* the two objects must produce the same integer result.
* <li>It is <em>not</em> required that if two objects are unequal
* according to the {@link java.lang.Object#equals(java.lang.Object)}
* method, then calling the {@code hashCode} method on each of the
* two objects must produce distinct integer results. However, the
* programmer should be aware that producing distinct integer results
* for unequal objects may improve the performance of hash tables.
* </ul>
* <p>
* As much as is reasonably practical, the hashCode method defined by
* class {@code Object} does return distinct integers for distinct
* objects. (This is typically implemented by converting the internal
* address of the object into an integer, but this implementation
* technique is not required by the
* Java<font size="-2"><sup>TM</sup></font> programming language.)
*
* @return a hash code value for this object.
* @see java.lang.Object#equals(java.lang.Object)
* @see java.lang.System#identityHashCode
*/
如果英文不好的朋友,我这里提供了自己的翻译理解,应该不会有错,放心大胆的来看吧。
- hashCode方法返回其对象的哈希值。这个方法对于诸如HashMap、HashSet这样拥有哈希表的类非常有用。
- 在java应用的一次执行过程中,无论调用多少次hashCode方法,对于同一个对象都必须返回同样的值。但是对于java应用的多次执行,每次hashCode都可以产生不同的值。
- 如果两个对象的equals方法返回true(即两个对象相等),那么hashCode一定相等。
- 如果两个对象的euqals方法返回false(即两个对象不相等),那么hashCode值可以相等。但是从性能优化的角度来看,对于两个不同的对象返回不同的hashCode会提高哈希表的性能(即会降低哈希冲突)。
- 作为最佳实践,Object类中的hashCode方法对于不同的对象返回了不同的值。其实现方式是直接将对象的内部地址转换为了整型数值。因为对象的地址是不同的,故保证了hashCode的返回值不同。
好,翻译也翻译了,是时候让hashcode和euqals做个了断了。
- hashCode的目的是更好的为java中的一些键值数据结构类型提供支持。比如HashMap、HashSet、HashTable等这种依靠哈希值进行键值存储的数据结构。那其他地方有用吗?当然有用,比如我们想比较自定义对象是否相等,也需要重写hashCode等等。
- 因为相同的对象(equals方法返回true)必须要有相同的hashcode,同时为了保证性能,拥有相同hashcode的对象越少越好,因此重写equals方法必须重写hashcode。
第二点怎么理解?看过之后貌似还是不明白为什么重写equals一定要重写hashcode方法?
举个例子,如果我们重写上面Person类中的equals方法,而在这个方法中我们仅仅实现了对name的比较(即我们认为name相等则对象相等),而没有实现hashcode,如下所示:
public class Person {
public String name;
public int age;
@Override
public boolean equals(Object obj) {//这里重写了equals方法,只要name想等我们就认为两个对象相等
return this.name.equals(((Person) obj).name);
}
}
//测试类
public class Main {
public static void main(String[] args) {
Person person = new Person();
person.name = "zhangsan";
person.age = 20;
Person person2 = new Person();
person2.name = "zhangsan";
person2.age = 20;
System.out.println("person hashcode = " + person.hashCode());
System.out.println("person2 hashcode = " + person2.hashCode());
System.out.println("person equals person2? :" + person.equals(person2));
}
}
执行一次上面的main方法,可以看到如下结果:
person hashcode = 1959371637//注意,每次重新执行程序hashcode都会变
person2 hashcode = 1644859961
person equals person2? :true
从上面的打印结果发现,我们两个对象person和person2相等了,然而hashCode却不相等,这就违背了上面分析hashcode时提到的第三条:如果两个对象的equals方法返回true(即两个对象相等),那么hashCode一定相等。所以重写equals一定要重写hashCode。
那么上面代码如何修改?我们姑且简单的复写一下Person类的hashCode,如下所示:
@Override
public int hashCode() {
return name.hashCode();
}
复写上面代码后,person和person2的hashCode就会相等。当然你可以设置返回任意一个数,比如return 1,return 100等等。这么做能保证equals相等,hashCode一定相等,而与此同时带来的副作用就是,euqals不相等,hashCode也相等,这就会增加哈希的冲突,进而降低性能。
那么是不是hashCode方法都可以这么简单的实现?当然不是。我们可以找到jdk中实现hashCode的方法,都不是这么简单,这里我们重写hashCode的实现方法只是个例子,不能为具体的工程实践用。同样,上面的euqals方法也不能如此草草的写,一般都需要判断参数的合法性等再做比较。我们只是为了阐述原理,所以就从简了。
让我们来看一下关于euqals和hashCode的标准写法。下面是String类的equals和hashCode方法实现
public boolean equals(Object anObject) {
if (this == anObject) {
return true;
}
if (anObject instanceof String) {
String anotherString = (String) anObject;
int n = value.length;
if (n == anotherString.value.length) {
char v1[] = value;
char v2[] = anotherString.value;
int i = 0;
while (n-- != 0) {
if (v1[i] != v2[i])
return false;
i++;
}
return true;
}
}
return false;
}
public int hashCode() {
int h = hash;
if (h == 0 && value.length > 0) {
char val[] = value;
for (int i = 0; i < value.length; i++) {
h = 31 * h + val[i];
}
hash = h;
}
return h;
}
上面代码不再分析,原理都殊途同归,只不过增加了健壮性和更好的性能,而这也是一个优秀程序的必备。
至此,java中的hashCode和equals解析完成了。
我们上面分析了一大篇java中hashCode和equals方法,目的当然是为kotlin服务,实际上kotlin中和java是一样的。上面的分析完全适用于kotlin。而作为kotlin中的数据类,正是同上面我们讲述的一样复写了equals和hashCode方法。看下面的例子:
//kotlin代码,定义了数据类Person
data class Person(val name: String, val age: Int){
}
//kotlin代码,定义了普通类Person2
class Person2(val name: String, val age: Int) {
}
//测试类
class Main {
companion object {
@JvmStatic fun main(args: Array<String>) {
println(Person("zhangsan", 20).equals(Person("zhangsan", 20)))//注意,这里打印了true
println(Person2("zhangsan", 20).equals(Person2("zhangsan", 20)))//注意,这里打印了false
}
}
}
因为数据类复写了equals,而该equals方法会对对象中的name和age成员进行比较,发现二者相等,所以打印了true。而普通类则会默认调用Any的equals方法,该方法默认比较的是两个对象的地址,两个不同对象的地址肯定不一样,故打印了false。
数据类中的copy方法
copy顾名思义,就是复制。kotlin为data类提供了这个方法目的就是便于数据复制,可以类比于java中的clone方法,但是有别于clone方法。下面看下copy方法的使用:
class Main {
companion object {
@JvmStatic fun main(args: Array<String>) {
val p = Person("zhangsan", 20);//Person的定义参考上文。
val p2 = p.copy()
println(p2)//打印'Person(name=zhangsan, age=20)'
println(p2 === p)//打印'false',证明p2和p是两个对象
}
}
上面代码执行了p对象的一个copy,会复制一个对象,这个对象和p并不是一个对象,而是一个新对象。注意,这里的copy和java中的clone一样,是浅拷贝。
这里再回顾下什么是浅拷贝什么是深拷贝。所谓浅拷贝是指,对于拷贝对象中的引用类型,只拷贝其地址。也就是说,如果一个对象中含有引用类型变量,那么拷贝该对象后,所产生的新对象中的引用类型变量,将和原来对象中的引用类型变量共用一个,即地址相同。而深拷贝是指会将拷贝对象中的引用类型也一并递归拷贝其中的内容,而不是仅仅复制其地址。
前面说了kotlin中的copy也是浅拷贝,示例如下:
//Person2类
class Person2(var name: String, var age: Int) {
}
//Person类,注意最后一个参数表示引用类型Person2
data class Person(val name: String, val age: Int, val person2: Person2) {
}
//测试类
class Main {
companion object {
@JvmStatic fun main(args: Array<String>) {
val p = Person("zhangsan", 20, Person2("lisi", 30));//这里生成Person对象的时候,传入了Person2类型对象
val p2 = p.copy()
println(p2)
println(p)
}
}
}
上面代码执行过后打印如下:
Person(name=zhangsan, age=20, person2=test.Person2@2fdb8f3a)
Person(name=zhangsan, age=20, person2=test.Person2@2fdb8f3a)
由打印结果可知,copy前后的person2对象是同一个对象,因此默认是浅拷贝。
上面说copy和clone并不一样,又体现在哪儿呢?那就是copy可以轻松实现深拷贝,也就是copy的本意:可以改变原对象中任意的属性值,而没有改变的则保持默认。看下面例子:
class Main {
companion object {
@JvmStatic fun main(args: Array<String>) {
val p = Person("zhangsan", 20, Person2("lisi", 30));
val p2 = p.copy(person2 = Person2("wangwu", 25))//注意这里改变了Person2的默认传值
println(p2)
println(p)
}
}
}
上面代码执行过后打印如下:
Person(name=zhangsan, age=20, person2=test.Person2@58a40787)
Person(name=zhangsan, age=20, person2=test.Person2@78ea5d87)
由上面打印结果可知,person2的值已经发生了改变。这就是copy的便利之处。
数据类中的componentN()方法
看到componentN()有点迷惑,这个是什么方法?
实际上具体到实现中并不是特指componentN()这一个方法,N只是表示1、2、3等序列,实际使用中会提到N,比如component1()、component2()等等,那具体有多少个component呢?这取决于类的主构造方法中有多少个属性。注意只能是主构造方法中的属性,其他地方定义的都不行。
来看个例子:
//数据类Person
data class Person(val name: String, val age: Int, val person2: Person2) {
}
//测试
class Main {
companion object {
@JvmStatic fun main(args: Array<String>) {
val p = Person("zhangsan", 20, Person2("lisi", 30));
println(p.component1())
println(p.component2())
println(p.component3())
}
}
}
上面打印结果如下:
zhangsan
20
test.Person2@4bc0bec5
由打印结果可知,component1、component2...componentN一一对应于Person中的属性,且会按照属性的书写顺序进行打印。
上个例子当中Person的主构造方法总共定义了三个属性,如果我们要打印component4会怎么样? 这个时候kotlin就会抛出异常,提示找不到匹配方法。
再看一个例子
//数据类
data class Person(val name: String) {
val sex: String = "男"//在方法体中定义了一个属性
constructor(name:String, age:Int):this(name)//第二构造方法
}
//测试
@JvmStatic fun main(args: Array<String>) {
val p = Person("zhangsan", 1)
println(p.component1())//正确!
println(p.component2())//错误!因为Person主构造方法只有一个入参,所以只有一个component与之对应。
}
上面测试清晰标明了,kotlin数据类只为主构造方法中的属性生成与之对应的component方法。
事实上,上面所提到的toString、equals、hashCode、copy四个方法都只能作用于主构造方法中,只有在主构造方法中定义的属性,才会为该属性自动生成上面几个方法以及对应的component方法。
标准数据类
kotlin标准库为我们提供了两个标准的数据类,Pair、Triple,示例如下:
companion object {
@JvmStatic fun main(args: Array<String>) {
val pair = Pair(1, 2)
val triple = Triple(1, 2, 3)
println(pair)//打印 '(1, 2)'
println(triple)//打印 '(1, 2, 3)'
}
}
这两个类使用很简单,不过多阐述。
最后需要强调的是,我们几乎很少会用kotlin提供的这两个数据类。首先,因为自定义数据类会使得语义更加清晰,而使用Pair、Triple等毫无语义可言;其次,具体到我们的业务中,数据类的定义也往往不是这么简单,而自定义数据类会使得我们的代码更加具有可读性。