Kotlin 数据类与数据对象

626 阅读7分钟

data class

所谓数据类,是类似于 C 中的 struct,只是用来作为一组数据的抽象。Kotlin 中的数据类主要用于存储数据。对于每个数据类,编译器会自动生成额外的成员函数,这些函数允许你将实例打印为可读的输出、比较实例、复制实例等。数据类以 data 修饰符标记:

data class User(val name: String, val age: Int)

编译器自动生成函数

编译器会根据主构造函数中声明的所有属性自动派生出以下成员:

  • equals()hashCode() 方法对;

  • toString() 方法,形式为 “User(name=John, age=42)”;

  • componentN() 函数,对应于按照声明顺序排列的属性,以上面的声明为例:

       public final string component1() {
          return this.name;
       }
    
       public final int component2() {
          return this.age;
       }
    
  • copy() 方法:

       @NotNull
       public final User copy(@NotNull String name, int age) {
          Intrinsics.checkNotNullParameter(name, "name");
          return new User(name, age);
       }
    
       // $FF: synthetic method
       public static User copy$default(User var0, String var1, int var2, int var3, Object var4) {
          if ((var3 & 1) != 0) {
             var1 = var0.name;
          }
    
          if ((var3 & 2) != 0) {
             var2 = var0.age;
          }
    
          return var0.copy(var1, var2);
       }
    

数据类必须满足以下要求:

  • 主构造函数必须至少有一个参数。
  • 所有主构造函数参数必须标记为 val 或 var。
  • 数据类不能是 abstract、open、sealed 的或 inner 修饰的。

data class 反编译后的 java 代码中,class 是 final 修饰的,不可继承

此外,数据类成员的生成遵循以下关于成员继承的规则:

  • 如果数据类主体中有 equals()hashCode() toString() 的显式实现或超类中的最终实现,则不会生成这些函数,而是使用现有的实现。

  • 如果一个父类具有开放的 componentN() 函数并返回兼容的类型,那么数据类会为这些函数生成对应的方法,并重写超类中的方法。如果超类中的函数由于签名不兼容或为 final 而无法被重写,则会报告错误。

    例如:

    data class User(val name: String, val age: Int): IUser()
    
    abstract class IUser {
        abstract fun component1(): String
    }
    
    open class IUser {
        open fun component1(): String {
            return "A"
        }
    }
    

    这两种情况都不会报错,因为父类中的 component1 是可以重写的。

    而如果是:

    open class IUser {
        fun component1(): String {
            return "A"
        }
    }
    

    会报错:

    Function 'component1' generated for the data class conflicts with member of supertype 'IUser'
    

    还有一种情况是:

    open class IUser {
        fun component3(): String {
            return "C"
        }
    }
    

    这样也不会报错,因为 User 只有两个参数,只会生成 component1、component2,所以 component3 不会与编译器产生冲突。虽然可以这样,但建议不要以 componentN 命名函数。

  • 不允许为 componentN()copy() 函数提供显式实现。因为上面的例子说明如果提供显示实现,会和编译器自动生成产生冲突:

    data class User(val name: String, val age: Int) { // line error
        fun component1(): String {
            return "From"
        }
    
        fun copy(name: String, age: Int): User { // line error
            Intrinsics.checkNotNullParameter(name, "name")
            return User(name, age)
        }
    }
    

    报错:

    Conflicting overloads: public final fun copy(name: String, age: Int): User defined in com.example.composeread.User
    

    根本原因是编译器自动生成的 componentN 和 copy 函数是 final 修饰的,不可重写,而 equals、hashCode 等不是 final 的,可以重写。

在 JVM 上,如果生成的类需要有无参数构造函数,则必须指定属性的默认值(请参阅构造函数):

data class User(val name: String = "", val age: Int = 0)

在类中的属性声明

编译器仅使用主构造函数中定义的属性来自动生成函数。要从生成的实现中排除某个属性,请在类主体中声明它:

data class Person(val name: String) {
    var age: Int = 0
}

在下面的例子中,默认情况下,只有 name 属性被用于 .toString()、.equals()、.hashCode() 和 .copy() 实现中,并且只有一个组件函数 .component1()。age 属性是在类体内声明的,因此被排除在外。因此,两个具有相同名字但不同年龄值的 Person 对象被认为是相等的,因为 .equals() 只评估主构造函数中的属性。

val person1 = Person("John")
val person2 = Person("John")
person1.age = 10
person2.age = 20

println("person1 == person2: ${person1 == person2}")
// person1 == person2: true

println("person1 with age ${person1.age}: ${person1}")
// person1 with age 10: Person(name=John)

println("person2 with age ${person2.age}: ${person2}")
// person2 with age 20: Person(name=John)

复制

使用 .copy() 函数复制对象,允许您更改其某些属性,同时保持其余属性不变。上述 User 类的此函数的实现如下:

fun copy(name: String = this.name, age: Int = this.age) = User(name, age)

然后您可以编写以下内容:

val jack = User(name = "Jack", age = 1)
val olderJack = jack.copy(age = 2)

数据类和解构声明

为数据类生成的组件函数使得可以在解构声明中使用它们:

val jane = User("Jane", 35)
val (name, age) = jane
println("$name, $age years of age")
// Jane, 35 years of age

所谓解构,就是把数据类的主构造函数中保存的数据拆解为一组属性声明。

反编译后:

User jane = new User("Jane", 35);
String name = jane.component1();
int age = jane.component2();

data object

data class 一样,data 关键字还可以修饰 object,与数据类一样,data 修饰符会让编译器为 object 对象生成一些函数:

  • toString()返回 data object 的名字;

    在 kotlin 中声明的对象被打印时,字符串包含它的名字和对象的 hash 值,例如:

    object MyObject
    
    fun main() {
      println(MyObject) // MyObject@1f32e575
    }
    

    而数据对象的 toString() 函数则会返回对象的名称:

    data object MyDataObject {
        val x: Int = 3
    }
    
    fun main() {
        println(MyDataObject) // MyDataObject
    }
    
  • equals()/hashCode()成对出现。

    编译器会自动生成这对方法:

       public int hashCode() {
          return 1082431430;
       }
    
       public boolean equals(@Nullable Object other) {
          if (this == other) {
             return true;
          } else if (!(other instanceof Gust)) {
             return false;
          } else {
             Gust var2 = (Gust)other;
             return true;
          }
       }
    

    equals() 函数首先是使用 == 来判断对象是否相等,值对象判断值,引用对象判断地址;如果判断不是同一个对象(这里应该能排除值对象了,剩下的只是引用类型的情况),则先去判断对象的类型是否是当前数据对象的类型或其子类型,如果是,则最后类型强转,返回为 true。

    !与 data class 不同的是你将不能为 data object 提供 equals/hashCode 的自定义实现:

    data object Gust {
        override fun hashCode(): Int { // error line
            return 10302 
        }
    
        override fun equals(other: Any?): Boolean { // // error line
            return super.equals(other)
        }
    }
    

    报错: Data object cannot have a custom implementation of 'equals' or 'hashCode'

equals

equals() 对于一个数据对象确保所有具有该数据对象类型的对象被认为是相等的。在大多数情况下,你的程序运行时只会有该数据对象的一个实例(毕竟,一个数据对象声明了单例)。然而,在极少数情况下,另一个相同类型的对象在运行时被生成(例如,通过使用 Java 平台的反射机制(java.lang.reflect)或一个 JVM 序列化库在底层使用了这个 API),这可以确保这些对象被视为相等。

确保只通过结构(使用 == 运算符)来比较数据对象,而不是通过引用(使用 === 运算符)。这可以帮助你在运行时存在多个数据对象实例时避免一些陷阱。

import java.lang.reflect.Constructor

data object MySingleton

fun main() {
    val evilTwin = createInstanceViaReflection()

    println(MySingleton) // MySingleton
    println(evilTwin) // MySingleton

    // 即使库强制创建 MySingleton 的第二个实例,其 `equals` 方法也会返回 true:
    println(MySingleton == evilTwin) // true

    // Do not compare data objects via ===.
    println(MySingleton === evilTwin) // false
}

// 反射创建对象
fun createInstanceViaReflection(): MySingleton {
    return (MySingleton.javaClass.declaredConstructors[0].apply { isAccessible = true } as Constructor<MySingleton>).newInstance()
}

数据对象与数据类的不同之处

尽管数据对象和数据类的声明常常一起使用且有一些相似之处,但数据对象不会生成某些功能:

  • 没有 copy() 函数。由于数据对象声明旨在用作单例对象,因此不会生成 copy() 函数。单例模式限制了一个类只能被实例化为单个实例,允许创建该实例的副本将违反这一原则。
  • **没有 componentN() 函数。**与数据类不同,数据对象没有任何数据属性。由于没有数据属性,对这样的对象进行解构是没有意义的,因此不会生成 componentN() 函数。

在密封结构中使用数据对象

数据对象声明在封闭层次结构(如密封类或密封接口)中特别有用,因为它们允许你与任何可能与对象一起定义的数据类保持对称性。在这个例子中,将 EndOfFile 声明为数据对象而不是普通对象意味着它将自动获得 toString() 函数,而无需手动重写:

sealed interface ReadResult
data class Number(val number: Int) : ReadResult
data class Text(val text: String) : ReadResult
data object EndOfFile : ReadResult

fun main() {
    println(Number(7)) // Number(number=7)
    println(EndOfFile) // EndOfFile
}