Введение в Kotlin: коллекции, data-классы, деструктуризация и sealed-классы

Введение в Kotlin: функции, переменные, условия, циклы
Введение в Kotlin: классы, конструкторы, методы и свойства, наследование
Введение в Kotlin: интерфейсы, модификаторы доступа, вложенные классы, ключевые слова this и object

Это четвертая статья, входящая в цикл, посвященный введению в главные конструкции языка Kotlin. В этой части будут рассмотрены коллекции, data-классы, деструктуризация и sealed-классы. Подробнее о них можно почитать в официальной документации: коллекции, data-классы, деструктуризация и sealed-классы.

Коллекции

В отличие от многих языков, Kotlin различает изменяемые и неизменяемые коллекции (списки, множества, ассоциативные списки и т.д.). Важно понимать различие между read-only представлением изменяемой коллекции (пример ниже) и фактически неизменяемой коллекцией.

Прим. ред.: конструкции <T> и <out T> - это дженерики языка Kotlin, подробнее о них будет рассказано в одной из следующих статей этой серии.

Тип List<out T> в Kotlin — интерфейс, который предоставляет read-only операции, такие как size, get, и другие. Так же, как и в Java, он наследуется от Collection<T>, а значит и от Iterable<T>, поэтому его элементы могут быть прочитаны через iterator. Методы, которые изменяют список, добавлены в интерфейс MutableList<T>. То же самое относится и к Set<out T>/MutableSet<T>, Map<K, out V>/MutableMap<K, V>.

Пример базового использования списка (list) и множества (set):

val numbers: MutableList<Int> = mutableListOf(1, 2, 3)
val readOnlyNumbers: List<Int> = numbers
println(numbers) // выведет "[1, 2, 3]"

numbers.add(4)
println(readOnlyNumbers) // выведет "[1, 2, 3, 4]"

readOnlyNumbers.clear() // не скомпилируется

val strings = hashSetOf("a", "b", "c", "c")
assert(strings.size == 3)

Для создания списков и множеств используйте методы из стандартной библиотеки, такие как listOf(), mutableListOf(), setOf(), mutableSetOf(). Создание ассоциативного списка может быть осуществлено с помощью простой идиомы: mapOf(a to b, c to d).

Заметьте, что содержимое переменной readOnlyNumbers изменяется вместе со списком, на который она указывает.

Иногда вам необходимо вернуть состояние коллекции в определённый момент времени:

class Controller {
    private val _items = mutableListOf<String>()
    val items: List<String> get() = _items.toList()
}

Расширение toList просто копирует элементы списка. Таким образом, возвращаемый список гарантированно не изменится.

Существует несколько полезных расширений для списков и множеств, с которыми стоит познакомиться:

val items = listOf(1, 2, 3, 4)
items.first() == 1 
items.last() == 4 
items.filter { it % 2 == 0 } // возвратит [2, 4] 

val rwList = mutableListOf(1, 2, 3) 
rwList.requireNoNulls() // возвратит [1, 2, 3] 
if (rwList.none { it > 6 }) 
    println("Нет элементов больше 6") // выведет "Нет элементов больше 6" 
val item = rwList.firstOrNull() 

Также обратите внимание на такие утилиты, как sort, zip, fold, reduce.

То же самое происходит и с ассоциативными списками. Они могут быть с лёгкостью инициализированы и использованы следующим образом:

val readWriteMap = hashMapOf("foo" to 1, "bar" to 2) 
println(readWriteMap["foo"]) // выведет "1" 
val snapshot: Map<String, Int> = HashMap(readWriteMap) 

Data-классы

Нередко мы создаём классы, единственным назначением которых является хранение данных. Функционал таких классов зависит от самих данных, которые в них хранятся. В Kotlin класс может быть отмечен словом data:

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

Такой класс называется классом данных (data-классом). Компилятор автоматически извлекает все члены данного класса из свойств, объявленных в первичном конструкторе:

  • пара функций equals() и hashCode(),
  • toString() в форме "User(name=John, age=42)",
  • функции componentN(), которые соответствуют свойствам, в зависимости от их порядка либо объявления,
  • функция copy() (см. ниже)

Если какая-либо из этих функций явно определена в теле класса (или унаследована от родительского класса), то генерироваться она не будет.

Для того чтобы поведение генерируемого кода соответствовало здравому смыслу, классы данных должны быть оформлены с соблюдением некоторых требований:

  • Первичный конструктор должен иметь как минимум один параметр.
  • Все параметры первичного конструктора должны быть отмечены как val или var.
  • Классы данных не могут быть абстрактными, open, sealed или inner.

Классы данных могут расширять другие классы (см. примеры ниже в блоке "Sealed-классы").

Data-классы, как и обычные классы, могут использовать свойства по-умолчанию:

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

Довольно часто нам приходится копировать объект с изменением только некоторых его свойств. Для этой задачи генерируется функция 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 (name, age) = person

Этот синтаксис называется деструктуризирующее присваивание. Он позволяет присвоить объект сразу нескольким переменным, разбив его на части. Мы объявили две переменные: name и age, и теперь можем использовать их по отдельности:

println(name)
println(age)

Деструктуризирующие присваивания также работают в циклах for:

for ((a, b) in collection) { ... }

Предположим, нам нужно вернуть два значения из функции. Например, результат вычисления и какой-нибудь статус. Компактный способ достичь этого — объявление data-класса и возвращение его экземпляра:

data class Result(val result: Int, val status: Status)

fun function(...): Result {
    // вычисления return Result(result, status)
}

// Теперь мы можем использовать деструктуризирующее присваивание:
val (result, status) = function(...)

Так как data-классы автоматически реализуют методы componentN(), необходимые для деструктуризации (подробнее здесь), то она будет работать с ними "из коробки".

Примечание: мы также могли использовать стандартный класс Pair, чтобы заставить функцию вернуть Pair<Int, Status>, но правильнее будет именовать ваши данные должным образом.

Пожалуй, самый лучший способ итерации по ассоциативному списку:

for ((key, value) in map) {
    // do something with the key and the value
}

Вы можете свободно использовать мульти-декларации в циклах for с ассоциативными списками, так же как и с коллекциями экземпляров data-классов.

Sealed-классы

Sealed-классы ("изолированные классы") используются для отражения ограниченных иерархий классов, когда значение может иметь тип только из ограниченного набора и никакой другой. Они являются, по сути, расширением enum-классов: набор значений enum типа также ограничен, но каждая enum-константа существует только в единственном экземпляре, в то время как наследник изолированного класса может иметь множество экземпляров, которые могут нести в себе какое-то состояние.

Чтобы описать изолированный класс, укажите модификатор sealed перед именем класса. Изолированный класс может иметь наследников, но все они должны быть объявлены в том же файле, что и сам sealed-класс, или внутри sealed-класса.

sealed class Expr
data class Const(val number: Double) : Expr()
data class Sum(val e1: Expr, val e2: Expr) : Expr()
object NotANumber : Expr()

fun eval(expr: Expr): Double = when (expr) {
    is Const -> expr.number
    is Sum -> eval(expr.e1) + eval(expr.e2)
    NotANumber -> Double.NaN
}

Обратите внимание, что классы, которые расширяют наследников изолированного класса (непрямые наследники), могут быть помещены где угодно, не обязательно в том же файле.

Ключевое преимущество от использования изолированных классов проявляется тогда, когда вы используете их в выражении when. Если возможно проверить, что выражение покрывает все случаи, то вам не нужно добавлять else.

fun eval(expr: Expr): Double = when(expr) {
    is Expr.Const -> expr.number
    is Expr.Sum -> eval(expr.e1) + eval(expr.e2)
    Expr.NotANumber -> Double.NaN
    // оператор `else` не требуется, потому что мы покрыли все возможные случаи
}

Продолжение серии статей "Введение в Kotlin" доступно здесь.

Источники:

Документация Kotlin: Коллекции

Документация Kotlin: Классы данных

Документация Kotlin: Мульти-декларации

Документация Kotlin: Изолированные классы