В процессе разработки нам часто приходится создавать классы, предназначенные исключительно для хранения каких-либо данных. При этом, чтобы такой класс стал максимально удобным переопределяются методы toString()
, equals()
и hashCode()
.
Обычно данные методы имеют одинаковую реализацию и чтобы каждый раз не писать один и тот же код можно просто отметить класс ключевым словом data - все необходимые методы будут сгенерированы автоматически. В Kotlin такие классы называются классами данных (data classes).
Не каждый класс можно отметить ключевым словом data. Для этого он должен соответствовать определённым требованиям:
- В основном конструкторе должен быть как минимум один параметр.
- Все параметры основного конструктора должны быть отмечены ключевыми слова val или var (рекомендуется val).
- Классы данных не могут быть отмечены ключевыми словами abstract, open, sealed, inner.
Переопределяемые функции
Возможно у кого-то возникнет вопрос: для чего вообще нужны методы toString()
, equals()
и hashCode()
? В данном разделе остановимся на этом подробнее, а также опишем какие ещё функции в классах данных генерируются автоматически.
toString()
Часто, особенно при отладке, возникает необходимость вывести в лог информацию об экземпляре класса. Если метод toString()
не переопределён, то при обращении к экземпляру в лог будет выведена ссылка на него.
1
2
3
4
5
6
7
8
9
10
11
class Person(val name: String , val age: Int) {
...
}
...
val person = Person("Adam", 27)
println(person)
// Выведется в лог
Person@Se9f23b4
Это не очень информативно и вряд ли чем-то поможет. Чтобы исправить ситуацию достаточно переопределить метод toString()
и указать в нём, что именно нужно выводить в лог при обращении к экземпляру класса.
1
2
3
4
5
6
7
8
9
10
11
class Person(val name: String , val age: Int) {
override fun toString() = "Person(name = $name , age = $age )"
}
...
val person = Person("Adam", 27)
println(person)
// Выведется в лог
Person(name = Adam , age = 27)
Если же мы отметим класс ключевым словом data, метод toString()
будет переопределён автоматически. При этом в лог будут выводиться все поля, указанные в конструкторе, в порядке их добавления.
1
2
3
4
5
6
7
8
9
data class Person(val name: String , val age: Int)
...
val person = Person("Adam", 27)
println(person)
// Выведется в лог
Person(name = Adam , age = 27)
equals()
Иногда нам может потребоваться сравнить между собой два объекта таким образом, чтобы они считались равными, если содержат одни и те же данные.
1
2
3
4
5
6
val person1 = Person("Adam", 27)
val person2 = Person("Adam", 27)
println(person1 == person2)
// Выведется в лог
false
Объекты не равны, потому что по умолчанию сравниваются не данные, которые они хранят, а ссылки на объекты. Чтобы задать свой алгоритм сравнения переопределяется метод equals()
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Person(val name: String , val age: Int) {
override fun toString() = "Person(name = $name , age = $age )"
override fun equals(other: Any?): Вооlean {
if (other == null || other !is Person)
return false
return name == other.name && age == other.age
}
}
...
val person1 = Person("Adam", 27)
val person2 = Person("Adam", 27)
println(person1 == person2)
// Выведется в лог
true
Если же мы отметим класс ключевым словом data, метод equals()
будет переопределён автоматически. При этом работать будет точно также, как и в примере выше: будет проверять на равенство все значения, указанные в основном конструкторе.
1
2
3
4
5
6
7
8
9
10
data class Person(val name: String , val age: Int)
...
val person1 = Person("Adam", 27)
val person2 = Person("Adam", 27)
println(person1 == person2)
// Выведется в лог
true
Так как оператор ==
за кулисами вызывает метод equals()
, для сравнения ссылок объектов используется оператор ===
.
1
2
3
4
5
6
7
8
9
10
data class Person(val name: String , val age: Int)
...
val person1 = Person("Adam", 27)
val person2 = Person("Adam", 27)
println(person1 === person2)
// Выведется в лог
false
hashCode()
Экземпляр класса можно использовать как ключ в структурах данных на основе хэш-функций. Это возможно благодаря тому, что каждому объекту присваивается уникальный хэш-код, даже если значения этих объектов идентичны.
1
2
3
4
5
val hashSet = hashSetOf(Person("Adam", 27))
println(hashSet.contains(Person("Adam", 27)))
// Выведется в лог
false
Но по логике, если два объекта содержат одинаковые значения, значит и хэш-код у них должен быть одинаковым. Для этого и переопределяется метод hashCode()
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Person(val name: String , val age: Int) {
override fun toString() = "Person(name = $name , age = $age )"
override fun equals(other: Any?): Вооlean {
if (other == null || other !is Person)
return false
return name == other.name && age == other.age
}
override fun hashCode(): Int = name.hashCode() * 31 + age
}
...
val hashSet = hashSetOf(Person("Adam", 27))
println(hashSet.contains(Person("Adam", 27)))
// Выведется в лог
true
Обратите внимание, что метод hashCode()
работает совместно с методом equals()
. Это означает, что если переопределить метод hashCode()
без переопределения метода equals()
, каждому объекту будет присвоен уникальный хэш-код, даже если значения этих объектов равны. Связано это с тем, что перед присвоением хэш-кода происходит сравнение объектов. А без метода equals()
объекты сравниваются по их ссылкам, а не значениям.
Опять же, чтобы обо всём этом не думать, достаточно отметить класс ключевым словом data и метод hashCode()
(и все остальные) будет переопределён автоматически. При этом работать будет точно также, как и в примере выше: будет возвращать значение, зависящее от хэш-кодов всех свойств, объявленных в основном конструкторе.
1
2
3
4
5
6
7
8
9
data class Person(val name: String , val age: Int)
...
val hashSet = hashSetOf(Person("Adam", 27))
println(hashSet.contains(Person("Adam", 27)))
// Выведется в лог
true
copy()
Ещё один метод, который генерируется автоматически для всех классов данных. Он позволяет копировать экземпляры класса, изменяя значения некоторых свойств.
Разработчики Kotlin пытаются заложить нам в голову идею о том, что вместо модификации объекта лучше создать новый объект. Поэтому и была добавлена данная функция - упростить создание новых объектов.
На практике это может выглядеть примерно следующим образом:
1
2
3
4
5
6
7
8
9
data class Person(val name: String , val age: Int)
...
val person = Person("Adam", 27)
println(person.copy(age = 28))
// Выведется в лог
Person(name=Adam, age=28)
Можно реализовать самостоятельно следующим образом:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Person(val name: String , val age: Int) {
override fun toString() = "Person(name = $name , age = $age)"
fun copy(name: String = this.name, age: Int = this.age) = Person(name, age)
}
...
val person = Person("Adam", 27)
println(person.copy(age = 28))
// Выведется в лог
Person(name=Adam, age=28)
Мультидекларации
Мультидекларации (destructuring declarations) - это особенность, характерная для классов данных, которая позволяет распаковать объект и использовать его значения для инициализации сразу нескольких переменных.
1
2
3
4
5
6
7
val person = Person("Adam", 27)
val(name, age) = person
println(name)
// Выведется в лог
Adam
Достигается это все благодаря тому, что для каждой переменной, объявленной в основном конструкторе, автоматически генерируются функции componentN(), где N - номер позиции переменной в конструкторе. Что делает данная функция? Возвращает значение переменной. Такую функцию можно создать самому для класса, который не является классом данных.
1
2
3
4
5
6
7
8
9
class Person(val name : String , val age : Int) {
operator fun component1() = name
operator fun component2() = age
}
...
val person = Person("Adam", 27)
val name = person.component1()
val age = person.component2()
Стандартные классы данных
В стандартной библиотеке Kotlin есть два класса данных: Pair
и Triple
. Из названий понятно, что они позволяют хранить две и три переменных разного типа одновременно. В данной статье подробно их описывать не буду, добавлено в познавательных целях.
Влияние data-классов на вес приложения
Мне встречались споры о том, стоит ли в принципе использовать data-классы в своём приложении. Ведь порой мы хоть и создаём data-класс, но не используем его функциональность, потому что, например, нам нужно просто получить ответ от сервера и передать данные в другое место. И в этом случае выходит так, что все эти автоматически сгенерированные методы впустую занимают место. К тому же, если действительно понадобится любой из вышеперечисленных методов, то его можно самостоятельно реализовать.
И вот перед разработчиками встал вопрос: стоит ли при создании класса задаваться вопросом “а нужен ли мне здесь data-класс”?
Чтобы ответить на этот вопрос, сначала нужно ответить на другой: насколько велико влияние data-классов на вес приложения?
И вот я наткнулась на статью, где автор провёл исследование, чтобы ответить на этот вопрос:
Влияние Kotlin data-классов на вес приложения.
В этой статье автор описал, как он создал плагин для удаления всех автогенерируемых методов с целью сравнить вес приложения с этими методами и без них. Разница составила 4%, подробнее о деталях читайте в статье.
Так каков в результате ответ? Использовать везде или использовать с осторожностью?
Тут всё достаточно просто. При создании класса для хранения данных подумайте для чего он будет использоваться. Понадобится ли функциональность data-класса? Если этот класс нужен только как переходник для данных, то подойдёт и обычный класс.
Хотя на мой взгляд оптимизация в 4% слишком мала, чтобы тратить время на раздумья. Но в любом случае проведённое исследование похвально.
Полезные ссылки
Data Classes - официальная документация.
Destructuring Declarations - официальная документация.
Классы данных - перевод на русский.
Мультидекларации - перевод на русский.