Статьи

Потенциальные ловушки в классах данных Kotlin

Цель этого поста не в том, чтобы указать на некоторые серьезные недостатки в дизайне классов данных Kotlin и показать вам, как их пропустить. На самом деле, это совсем наоборот. Содержание этого поста четко документировано в документации Kotlin . Я просто здесь, чтобы донести эту информацию до тех, кто не заметил, как именно работают их классы данных.

Классы данных удобны для нас, разработчиков, особенно для тех из нас, кто пришел из Java. Они предоставляют несколько сгенерированных функций, позволяющих вам написать полностью функциональный класс с очень небольшим количеством кода. Классы данных предоставляют следующие сгенерированные функции:

Все вышеперечисленное генерируется для свойств, определенных в первичном конструкторе класса. Все, что определено за пределами этого конструктора, игнорируется. Это потенциальная ловушка . Но это только ловушка, если вы не знаете, как работают классы данных. Как я упоминал ранее, это четко задокументировано , вам нужно только помнить об этом. Кем вы сейчас являетесь, конечно.

Если вы не продумываете, как вы определяете свои классы данных, вы, вероятно, найдете некоторые ошибки в своем приложении. equals и hashCode как правило, важные функции. Если они не работают должным образом, ошибки обязательно последуют.

Ниже приведен пример этого:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
data class MyClass(val a: String, val b: Int) {
  // property defined outside of primary constructor
  lateinit var c: String
}
 
fun main() {
  // create two equal objects
  val myClass = MyClass("abc", 0)
  val myClass2 = myClass.copy()
  // check their hashCodes are the same and that they equal each other
  println("myClass hashCode: ${myClass.hashCode()}")
  println("myClass2 hashCode: ${myClass2.hashCode()}")
  println("myClass == myClass2: ${myClass == myClass2}")
  // set the lateinit variables
  myClass.c = "im a lateinit var"
  myClass2.c = "i have a different value"
  // have their hashCodes changed?
  println("myClass hashCode after setting lateinit var: ${myClass.hashCode()}")
  println("myClass2 hashCode after setting lateinit var: ${myClass2.hashCode()}")
  // are they still equal to each other?
  println("myClass == myClass2 after setting lateinit vars: ${myClass == myClass2}")
  // sanity check to make sure I'm not being stupid
  println("sanity checking myClass.c: ${myClass.c}")
  println("sanity checking myClass2.c: ${myClass2.c}")
}

Выполнение этого примера выводит:

1
2
3
4
5
6
7
8
myClass hashCode: 2986974
myClass2 hashCode: 2986974
myClass == myClass2: true
myClass hashCode after setting lateinit var: 2986974
myClass2 hashCode after setting lateinit var: 2986974
myClass == myClass2 after setting lateinit vars: true
sanity checking myClass.c: im a lateinit var
sanity checking myClass2.c: i have a different value

Как вы можете видеть, hashCode каждого объекта одинаков, и они оба равны друг другу, хотя их свойства c различны. Если вы попытались использовать MyClass внутри Map или Set , вероятность столкновения записей друг с другом увеличивается. Это сказанное, это действительно зависит от того, чего вы пытаетесь достичь. Может быть, это именно то, что вы хотите, чтобы это произошло. В этом случае, больше власти для вас.

Помещение c в конструктор MyClass повлияет на hashCode и equals . Затем c будет участвовать в любых вызовах hashCode , equals и остальных сгенерированных функций.

Другое решение — вручную реализовать сгенерированные функции. Переписать класс как:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
data class MyClass(val a: String, val b: Int) {
  lateinit var c: String
 
  override fun equals(other: Any?): Boolean {
    if (this === other) return true
    if (javaClass != other?.javaClass) return false
 
    other as MyClass
 
    if (a != other.a) return false
    if (b != other.b) return false
    if (c != other.c) return false
 
    return true
  }
 
  override fun hashCode(): Int {
    var result = a.hashCode()
    result = 31 * result + b
    result = 31 * result + c.hashCode()
    return result
  }
 
  override fun toString(): String {
    return "MyClass(a='$a', b=$b, c='$c')"
  }
}

Эти реализации были любезно предоставлены Intellij ?. Указывая все свойства в каждой из переопределенных функций, теперь используются любые свойства, не включенные в основной конструктор (в данном случае c ). hashCode и equals теперь лучше представляют класс и улучшают его использование внутри Map или Set .

Но и большой, но ?. По крайней мере, в коде, который я написал. Ошибка была введена. c — это lateinit var и теперь каждая из переопределенных функций пытается получить к ней доступ. Если какая-либо из этих функций вызывается до установки c вы получите исключение:

1
2
3
4
Exception in thread "main" kotlin.UninitializedPropertyAccessException: lateinit property c has not been initialized
    at dev.lankydan.MyClass.hashCode(DataClasses.kt:60)
    at dev.lankydan.DataClassesKt.main(DataClasses.kt:72)
    at dev.lankydan.DataClassesKt.main(DataClasses.kt)

Перезапись equals , hashCode и toString для размещения lateinit var разрешит эту ошибку:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
data class MyClass(val a: String, val b: Int) {
  lateinit var c: String
 
  override fun equals(other: Any?): Boolean {
    if (this === other) return true
    if (javaClass != other?.javaClass) return false
 
    other as MyClass
 
    if (a != other.a) return false
    if (b != other.b) return false
    if (this::c.isInitialized && (other as MyClass)::c.isInitialized && c != other.c) return false
 
    return true
  }
 
  override fun hashCode(): Int {
    var result = a.hashCode()
    result = 31 * result + b
    if (this::c.isInitialized) {
      result = 31 * result + c.hashCode()
    }
    return result
  }
 
  override fun toString(): String {
    return if (this::c.isInitialized) "MyClass(a='$a', b=$b, c='$c')"
    else "MyClass(a='$a', b=$b)"
  }
}

Эта реализация безопасна в использовании, даже если значение lateinit var не установлено.

Хотите ли вы сделать это или нет, зависит от требований вашего класса. Использование класса данных, как у меня здесь, внутри Map , вероятно, не рекомендуется. Если вы хотите сделать это, вы можете. Просто знайте, как все это работает.

Если вы еще этого не сделали, я рекомендую вам быстро взглянуть на документацию по этому вопросу. Выделение этой информации было целью этого поста. Это не какой-то причудливый код, который делает что-то волшебное. Вместо этого это нечто более фундаментальное и фундаментальное для того, как работает Котлин. Знание того, как работают классы данных в этом аспекте, может иметь жизненно важное значение для уменьшения количества ошибок в вашем приложении.

Опубликовано на Java Code Geeks с разрешения Дэна Ньютона, партнера нашей программы JCG . Смотрите оригинальную статью здесь: потенциальные ловушки в классах данных Kotlin

Мнения, высказанные участниками Java Code Geeks, являются их собственными.