Дженерики в Котлине

Дженерики в Котлине

1. обзор

В этой статье мы рассмотримgeneric types in the Kotlin language.

Они очень похожи на таковые из языка Java, но создатели языка Kotlin постарались сделать их немного более интуитивными и понятными, введя специальные ключевые слова, такие какout иin..

3. Kotlinout иin Ключевые слова

3.1. Ключевое словоOut

Допустим, мы хотим создать класс производителя, который будет выдавать результат некоторого типа T. Иногда; мы хотим присвоить это произведенное значение ссылке, которая имеет супертип типа T.

Чтобы добиться этого с помощью Kotlin,we need to use theout keyword on the generic type. It means that we can assign this reference to any of its supertypes. The out value can be only be produced by the given class but not consumed:

class ParameterizedProducer(private val value: T) {
    fun get(): T {
        return value
    }
}

Мы определили классParameterizedProducer, который может выдавать значение типа T.

Следующий; мы можем присвоить экземпляр классаParameterizedProducer ссылке, которая является его супертипом:

val parameterizedProducer = ParameterizedProducer("string")

val ref: ParameterizedProducer = parameterizedProducer

assertTrue(ref is ParameterizedProducer)

Если типT в классеParamaterizedProducer не будет типомout, данный оператор вызовет ошибку компилятора.

3.2. Ключевое словоin

Иногда возникает противоположная ситуация, означающая, что у нас есть ссылка типаT, и мы хотим иметь возможность присвоить ее подтипуT.

We can use the in keyword on the generic type if we want to assign it to the reference of its subtype. The in keyword can be used only on the parameter type that is consumed, not produced:

class ParameterizedConsumer {
    fun toString(value: T): String {
        return value.toString()
    }
}

Мы заявляем, что методtoString() будет использовать только значение типаT.

Затем мы можем присвоить ссылку типаNumber ссылке ее подтипа -Double:

val parameterizedConsumer = ParameterizedConsumer()

val ref: ParameterizedConsumer = parameterizedConsumer

assertTrue(ref is ParameterizedConsumer)

Если типT вParameterizedCounsumer не будет типомin, данный оператор вызовет ошибку компилятора.

4. Тип проекции

4.1. Копирование массива подтипов в массив супертипов

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

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

fun copy(from: Array, to: Array) {
    assert(from.size == to.size)
    for (i in from.indices)
        to[i] = from[i]
}

Если параметрfrom не относится к типуout Any, мы не сможем передать массив типаInt в качестве аргумента:

val ints: Array = arrayOf(1, 2, 3)
val any: Array = arrayOfNulls(3)

copy(ints, any)

assertEquals(any[0], 1)
assertEquals(any[1], 2)
assertEquals(any[2], 3)

4.2. Добавление элементов подтипа в массив его супертипа

Допустим, у нас есть следующая ситуация - у нас есть массив типаAny, который является супертипомInt, и мы хотим добавить элементInt в этот массив. Нам нужно использовать ключевое словоin в качестве типа целевого массива, чтобы компилятор знал, что мы можем скопировать значениеInt в этот массив:

fun fill(dest: Array, value: Int) {
    dest[0] = value
}

Затем мы можем скопировать значение типаInt в массивAny:

val objects: Array = arrayOfNulls(1)

fill(objects, 1)

assertEquals(objects[0], 1)

4.3. Звездные проекции

Есть ситуации, когда мы не заботимся о конкретном типе значения. Допустим, мы просто хотим распечатать все элементы массива, и не имеет значения, какой тип элементов в этом массиве.

Чтобы достичь этого, мы можем использовать проекцию звезды:

fun printArray(array: Array<*>) {
    array.forEach { println(it) }
}

Затем мы можем передать массив любого типа методуprintArray():

val array = arrayOf(1,2,3)
printArray(array)

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

5. Общие ограничения

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

fun > sort(list: List): List {
    return list.sorted()
}

В данном примере мы определили, что все элементыT необходимы для реализации интерфейсаComparable. В противном случае, если мы попытаемся передать список элементов, которые не реализуют этот интерфейс, это вызовет ошибку компилятора.

Мы определили функциюsort, которая принимает в качестве аргумента список элементов, реализующихComparable,, поэтому мы можем вызвать для нее методsorted(). Давайте посмотрим на тестовый пример для этого метода:

val listOfInts = listOf(5,2,3,4,1)

val sorted = sort(listOfInts)

assertEquals(sorted, listOf(1,2,3,4,5))

Мы можем легко передать списокInts, потому что типInt реализует интерфейсComparable.

6. Дженерики во время выполнения

6.1. Тип Erasure

Как и в случае с Java, универсальные шаблоны Kotlin - этоerased во время выполнения. То естьan instance of a generic class doesn’t preserve its type parameters at runtime.

Например, если мы создадимSet<String> и поместим в него несколько строк, во время выполнения мы сможем увидеть его только какSet.

Давайте создадим дваSets с двумя разными параметрами типа:

val books: Set = setOf("1984", "Brave new world")
val primes: Set = setOf(2, 3, 11)

Во время выполнения информация о типе дляSet<String> иSet<Int> будет удалена, и мы увидим их как простойSets. So, хотя во время выполнения вполне возможно узнать, что это значениеSet, мы не можем сказать, является ли этоSet строк, целых чисел или чего-то еще:that information has been erased.

Итак, как компилятор Kotlin предотвращает добавлениеNon-String вSet<String>? Или, когда мы получаем элемент изSet<String>, как он узнает, что этоString?

Ответ прост. The compiler is the one responsible for erasing the type information, но до этого он действительно знает, что переменнаяbooks содержит элементыString.

Итак, каждый раз, когда мы получаем из него элемент, компилятор приводил его кString, или когда мы собирались добавить в него элемент, компилятор вводил проверку ввода.

6.2. Параметры Reified Type

Давайте повеселимся с обобщениями и создадим функцию расширения для фильтрации элементовCollection на основе их типа:

fun  Iterable<*>.filterIsInstance() = filter { it is T }
Error: Cannot check for instance of erased type: T

Часть «it is T” для каждого элемента коллекции проверяет, является ли элемент экземпляром типаT, но, поскольку информация о типе была удалена во время выполнения, мы не можем отразить параметры типа таким образом .

Или можем?

Правило стирания типа в целом верно, но есть один случай, когда мы можем избежать этого ограничения:Inline functions. Type parameters of inline functions can be reified, so we can refer to those type parameters at runtime.

Тело встроенных функций является встроенным. То есть компилятор подставляет тело непосредственно в места, где вызывается функция вместо обычного вызова функции.

Если мы объявим предыдущую функцию какinline и пометим параметр типа какreified, то мы сможем получить доступ к информации об общем типе во время выполнения:

inline fun  Iterable<*>.filterIsInstance() = filter { it is T }

Встроенный reification работает как шарм:

>> val set = setOf("1984", 2, 3, "Brave new world", 11)
>> println(set.filterIsInstance())
[2, 3, 11]

Напишем еще один пример. Все мы знакомы с этими типичными определениями SLF4jLogger:

class User {
    private val log = LoggerFactory.getLogger(User::class.java)

    // ...
}

Используя овеществленные встроенные функции, мы можем написать более элегантные и менее ужасающие синтаксисы определенияLogger:

inline fun  logger(): Logger = LoggerFactory.getLogger(T::class.java)

Тогда мы можем написать:

class User {
    private val log = logger()

    // ...
}

6.3. Глубокое погружение в инлайн-реификацию

Итак, что же такого особенного во встроенных функциях, что воссоздание типов работает только с ними? Как мы знаем, компилятор Kotlin копирует байт-код встроенных функций в места, где функция вызывается.

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

Например, когда мы пишем:

class User {
    private val log = logger()

    // ...
}

When the compiler inlines the logger<User>() function call, it knows the actual generic type parameter –User. Таким образом, вместо того, чтобы стирать информацию о типе, компилятор использует возможность реификации и уточняет фактический параметр типа.

7. Заключение

В этой статье мы рассмотрели общие типы Kotlin. Мы увидели, как правильно использовать ключевые словаout иin. Мы использовали проекции типов и определили универсальный метод, который использует общие ограничения.

Реализация всех этих примеров и фрагментов кода можно найти вGitHub project - это проект Maven, поэтому его должно быть легко импортировать и запускать как есть.