Kotlin 中的数据类

时间:2024-03-07 10:57:07

1 data class

在一个规范的系统架构中,数据类通常占据着非常重要的角色。

在 Java 中,定义一个数据类,通常需要为其中的每一个属性定义 get/set 方法。如果要支持对象值的比较,甚至还要重写 hashCode、equals 等方法,比如:

public class CellPhone {
    private String brand;
    private double price;

    public CellPhone() {

    }

    public CellPhone(String brand, double price) {
        this.brand = brand;
        this.price = price;
    }

    public String getBrand() {
        return brand;
    }

    public void setBrand(String brand) {
        this.brand = brand;
    }

    public double getPrice() {
        return price;
    }

    public void setPrice(double price) {
        this.price = price;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        CellPhone cellPhone = (CellPhone) o;
        return Double.compare(cellPhone.price, price) == 0 && brand.equals(cellPhone.brand);
    }

    @Override
    public int hashCode() {
        return Objects.hash(brand, price);
    }

    @Override
    public String toString() {
        return "CellPhone{" +
                "brand='" + brand + '\'' +
                ", price=" + price +
                '}';
    }
}

以上是只有 2 个属性的 Java 数据类,已经有 50 多行代码了,属性越多,代码量也就越大。

在 Kotlin 中引入了 data class 的语法来改善着一情况,data class 就是数据类。 把上面的 Java 代码用 Kotlin 的 data class 来实现,只需要一行代码:

data class CellPhone(val brand: String, val price: Double)

当一个类中没有任何代码时,还可以将尾部的大括号省略。

关键在于 data 这个关键字。事实上,在这个关键字的后面,Kotlin 编译器帮我们做了很多的事情。将 Kotlin 代码转为 Java 代码,Tools —> Kotlin —> Show Kotlin Bytecode,点击 Decompile:

public final class CellPhone {
   @NotNull
   private final String brand;
   private final double price;

   @NotNull
   public final String getBrand() {
      return this.brand;
   }

   public final double getPrice() {
      return this.price;
   }

   public CellPhone(@NotNull String brand, double price) {
      Intrinsics.checkNotNullParameter(brand, "brand");
      super();
      this.brand = brand;
      this.price = price;
   }

   @NotNull
   public final String component1() {
      return this.brand;
   }

   public final double component2() {
      return this.price;
   }

   @NotNull
   public final CellPhone copy(@NotNull String brand, double price) {
      Intrinsics.checkNotNullParameter(brand, "brand");
      return new CellPhone(brand, price);
   }

   // $FF: synthetic method
   public static CellPhone copy$default(CellPhone var0, String var1, double var2, int var4, Object var5) {
      if ((var4 & 1) != 0) {
         var1 = var0.brand;
      }

      if ((var4 & 2) != 0) {
         var2 = var0.price;
      }

      return var0.copy(var1, var2);
   }

   @NotNull
   public String toString() {
      return "CellPhone(brand=" + this.brand + ", price=" + this.price + ")";
   }

   public int hashCode() {
      String var10000 = this.brand;
      return (var10000 != null ? var10000.hashCode() : 0) * 31 + Double.hashCode(this.price);
   }

   public boolean equals(@Nullable Object var1) {
      if (this != var1) {
         if (var1 instanceof CellPhone) {
            CellPhone var2 = (CellPhone)var1;
            if (Intrinsics.areEqual(this.brand, var2.brand) && Double.compare(this.price, var2.price) == 0) {
               return true;
            }
         }

         return false;
      } else {
         return true;
      }
   }
}

在上面的 Java 代码中,同样有 get/set、equals、hashCode、构造函数等方法:

  • equals 用来比较实例;
  • hashCode 用来作为例如 HashMap 这种基于哈希容器的键;
  • toString 用来为类生成按声明顺序排列的所有字段的字符串表达式;

equals 和 hashCode 方法会将所有在主构造方法中声明的属性纳入考虑。生成的 equals 方法会检测所有的属性的值是否相等。hashCode 方法会返回一个根据所有属性生成的哈希值。 需要注意的是没有在主构造方法中声明的属性将不会加入到相等性检查和哈希值计算中去。

其中有两个特别的方法 copy 和 componentN。

@NotNull
public final String component1() {
  return this.brand;
}

public final double component2() {
  return this.price;
}

@NotNull
public final CellPhone copy(@NotNull String brand, double price) {
  Intrinsics.checkNotNullParameter(brand, "brand");
  return new CellPhone(brand, price);
}

// $FF: synthetic method
public static CellPhone copy$default(CellPhone var0, String var1, double var2, int var4, Object var5) { // var0 代表被 copy 的对象
  if ((var4 & 1) != 0) {
     var1 = var0.brand; // copy 时若未指定具体属性的值,则使用被 copy 对象的属性值
  }

  if ((var4 & 2) != 0) {
     var2 = var0.price;
  }

  return var0.copy(var1, var2);
}

2 copy 方法

虽然数据类的属性并没有要求是 val,也可以是 var,但是还是推荐只使用只读属性,让数据类的实例不可变。

不可变对象比较容易理解,特别是在多线程代码中:一旦一个对象被创建出来,它会一直保持初始状态,也不用担心在代码工作时被其他线程修改了对象的值。

为了让使用不可变对象的数据类变得更容易,Kotlin 编译器为它们多生成了一个方法:一个允许 copy 类的实例的方法,并在 copy 的同时修改某些属性的值。

创建副本通常是修改实例的好选择:副本有着单独的生命周期而且不会影响代码中引用原始实例的位置。

copy 方法的主要作用是可以帮我们从已有的数据类对象中拷贝一个新的数据类对象,也可以传入相应的参数来生成不同的对象。如果未指定具体属性的值,那么新生成的对象的属性值将使用被 copy 对象的属性值,这就是平时所说的浅拷贝。

CellPhone cellPhone = new CellPhone("Redmi", 1000);
CellPhone cellPhone2 = cellPhone;
cellPhone2.setPrice(2000);
System.out.println(cellPhone.getPrice());

在上面的代码中,明明是对 cellPhone2 作了修改,但是却影响到了 cellPhone。实际上,对于引用类型(除基本数据类型)的属性,仅拷贝其引用,这就是浅拷贝的特点。

实际上 copy 更像是一种语法糖,假如类是不可变的,属性不可以修改,那么我们只能通过 copy 来帮我们基于原有对象生成一个新的对象:

// 声明的 CellPhone 属性可变
val cellPhone = CellPhone("iphone", 100.0)
val cellPhone2 = cellPhone
cellPhone2.brand = "Redmi"

// 声明 CellPhone 属性不可变
val cellPhone = CellPhone("iphone", 100.0)
val cellPhone2 = cellPhone.copy(brand = "Redmi") // 只能通过 copy

copy 更像是提供了一种简洁的方式帮我们复制一个对象,但它是一种浅拷贝的方式。 所以在使用 copy 的时候要注意使用场景,因为数据类的属性可以被修饰为 var,这便不能保证不会出现引用修改的问题。

3 componentN

componentN 可以理解为类属性的值,其中 N 代表属性的顺序,比如 component1 代表第 1 个属性的值,component3 代表第 3 个属性的值。

val cellPhone = CellPhone("华为", 100.0)
// 一般方式
val brand = cellPhone.brand
val price = cellPhone.price

// Kotlin 进阶
val (brand, price) = cellPhone

val info = "Redmi,200.0"
val (brand1, price1) = info.split(",")

这就是解构,通过编译器的约定实现解构。

Kotlin 对于数组的解构也有一定限制,在数组中它默认最多允许赋值 5 个变量,如果变量过多,效果反倒不好,容易搞混,所以一定要合理的使用这一特性。

在数据类中,除了可以利用编译器自动生成 componentN 方法外,还可以自己实现对应属性的 componentN 方法,比如:

data class CellPhone(val brand: String, val price: Double) {
    var color = "white"

    operator fun component3(): String {
        return this.color
    }

    constructor(brand: String, price: Double, color: String) : this(brand, price) {
        this.color = color
    }
}

val cellPhone = CellPhone("iphone", 100.0, "black")
val (brand, price, color) = cellPhone

除了对象支持解构外,Kotlin 也提供了其他常用的数据类,让使用者不必主动声明这些数据类,它们分别是 Pair 和 Triple。其中 Pair 是二元组,可以理解为这个数据类中有两个属性;Triple 是三元组,对应的则是三个属性。以下是它们的源码:

public data class Pair<out A, out B>(
    public val first: A,
    public val second: B
) : Serializable {
    public override fun toString(): String = "($first, $second)"
}

public data class Triple<out A, out B, out C>(
    public val first: A,
    public val second: B,
    public val third: C
) : Serializable {
    public override fun toString(): String = "($first, $second, $third)"
}

Pair 和 Triple 都是数据类,它们的属性可以是任意类型,我们按照属性的顺序来获取对应属性的值:

val pair = Pair("xiaomi", 10.9)
val triple = Triple("xiaomi", 10.9, "white")

val brandP = pair.first
val priceP = pair.second

val brandT = triple.first
val priceT = triple.second
val colorT = triple.third

val (brand2, price2) = pair
val (brand3, price3, color3) = triple

数据类中的解构基于 componentN 函数,如果自己不声明 componentN 函数,那么就会默认根据主构造参数来生成具体个数的 componetN 函数,与从构造函数中的参数无关。

4 总结

在 Kotlin 中声明一个数据类,必须满足一下几点:

  • 数据类必须拥有一个构造方法,该方法至少包含一个参数,一个没有数据的数据类是没有任何用处的;
  • 与普通的类不同,数据类构造方法的参数强制使用 val 或者 var 进行声明;
  • data class 之前不能用 abstract、open、sealed 或者 inner 进行修饰;
  • Kotlin 1.1 之前的版本,数据类只允许实现接口,之后的版本既可以实现接口也可以继承类;