1. Обзор
В этой статье мы рассмотрим универсальные типы в языке Kotlin .
Они очень похожи на язык Java, но создатели языка Kotlin постарались сделать их немного более интуитивными и понятными, введя специальные ключевые слова, такие как out и in.
2. Создание параметризованных классов
Допустим, мы хотим создать параметризованный класс. Мы можем легко сделать это на языке Kotlin, используя универсальные типы:
class ParameterizedClass<A>(private val value: A) {
fun getValue(): A {
return value
}
}
Мы можем создать экземпляр такого класса, явно задав параметризованный тип при использовании конструктора:
val parameterizedClass = ParameterizedClass<String>("string-value")
val res = parameterizedClass.getValue()
assertTrue(res is String)
К счастью, Kotlin может вывести общий тип из типа параметра, поэтому мы можем опустить его при использовании конструктора:
val parameterizedClass = ParameterizedClass("string-value")
val res = parameterizedClass.getValue()
assertTrue(res is String)
3. Kotlin снаружи и внутри Ключевые слова
3.1. Ключевое слово Out
Допустим, мы хотим создать класс-производитель, который будет выдавать результат некоторого типа T. Иногда; мы хотим присвоить это произведенное значение ссылке, которая имеет супертип типа T.
Чтобы добиться этого с помощью Kotlin, нам нужно использовать ключевое слово out для универсального типа. Это означает, что мы можем присвоить эту ссылку любому из его супертипов. Выходное значение может быть произведено только данным классом, но не использовано:
class ParameterizedProducer<out T>(private val value: T) {
fun get(): T {
return value
}
}
Мы определили класс ParameterizedProducer , который может создавать значение типа T.
Следующий; мы можем назначить экземпляр класса ParameterizedProducer ссылке, которая является его супертипом:
val parameterizedProducer = ParameterizedProducer("string")
val ref: ParameterizedProducer<Any> = parameterizedProducer
assertTrue(ref is ParameterizedProducer<Any>)
Если тип T в классе ParamaterizedProducer не будет выходным типом, данный оператор выдаст ошибку компилятора.
3.2. Ключевое слово
Иногда возникает противоположная ситуация , означающая, что у нас есть ссылка типа T , и мы хотим иметь возможность присвоить ее подтипу T.
Мы можем использовать ключевое слово in для универсального типа, если хотим присвоить его ссылке на его подтип. Ключевое слово in можно использовать только для типа параметра, который используется, а не создается:
class ParameterizedConsumer<in T> {
fun toString(value: T): String {
return value.toString()
}
}
Мы заявляем, что метод toString() будет использовать только значение типа T.
Далее мы можем присвоить ссылку типа Number ссылке ее подтипа — Double:
val parameterizedConsumer = ParameterizedConsumer<Number>()
val ref: ParameterizedConsumer<Double> = parameterizedConsumer
assertTrue(ref is ParameterizedConsumer<Double>)
Если тип T в параметризованном счетчике не будет типом in , данный оператор выдаст ошибку компилятора.
4. Типовые прогнозы
4.1. Скопируйте массив подтипов в массив супертипов
Допустим, у нас есть массив какого-то типа, и мы хотим скопировать весь массив в массив любого типа . Это допустимая операция, но чтобы позволить компилятору скомпилировать наш код, нам нужно аннотировать входной параметр ключевым словом out .
Это позволяет компилятору узнать, что входной аргумент может иметь любой тип, который является подтипом Any:
fun copy(from: Array<out Any>, to: Array<Any?>) {
assert(from.size == to.size)
for (i in from.indices)
to[i] = from[i]
}
Если параметр from не имеет типа out Any , мы не сможем передать в качестве аргумента массив типа Int:
val ints: Array<Int> = arrayOf(1, 2, 3)
val any: Array<Any?> = 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<in Int>, value: Int) {
dest[0] = value
}
Затем мы можем скопировать значение типа Int в массив Any:
val objects: Array<Any?> = 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 <T: Comparable<T>> sort(list: List<T>): List<T> {
return list.sorted()
}
В данном примере мы определили, что все элементы T необходимы для реализации интерфейса Comparable . В противном случае, если мы попытаемся передать список элементов, не реализующих этот интерфейс, это вызовет ошибку компилятора.
Мы определили функцию сортировки , которая принимает в качестве аргумента список элементов, реализующих Comparable, поэтому мы можем вызвать для нее метод sorted() . Давайте посмотрим на тестовый пример для этого метода:
val listOfInts = listOf(5,2,3,4,1)
val sorted = sort(listOfInts)
assertEquals(sorted, listOf(1,2,3,4,5))
Мы можем легко передать список целых чисел , поскольку тип Int реализует интерфейс Comparable.
5.1. Несколько верхних границ
Используя обозначение угловых скобок, мы можем объявить не более одной общей верхней границы. Если параметру типа требуется несколько общих верхних границ, то нам следует использовать отдельные предложенияwhere для этого конкретного параметра типа. Например:
fun <T> sort(xs: List<T>) where T : CharSequence, T : Comparable<T> {
// sort the collection in place
}
Как показано выше, параметр T должен одновременно реализовывать интерфейсы CharSequence и Comparable . Аналогичным образом мы можем объявить классы с несколькими общими верхними границами:
class StringCollection<T>(xs: List<T>) where T : CharSequence, T : Comparable<T> {
// omitted
}
6. Дженерики во время выполнения
6.1. Тип Стирание
Как и в случае с Java, дженерики Kotlin удаляются во время выполнения. То есть экземпляр универсального класса не сохраняет параметры своего типа во время выполнения .
Например, если мы создадим Set<String> и поместим в него несколько строк, во время выполнения мы сможем видеть его только как Set .
Давайте создадим два набора с двумя параметрами разных типов:
val books: Set<String> = setOf("1984", "Brave new world")
val primes: Set<Int> = setOf(2, 3, 11)
Во время выполнения информация о типе для Set<String> и Set<Int> будет удалена, и мы увидим их обоих как простые наборы. Таким образом, даже несмотря на то, что во время выполнения вполне возможно узнать, что значение представляет собой Set , мы не можем сказать, является ли это набором строк, целых чисел или чего-то еще: эта информация была стерта.
Итак, как же компилятор Kotlin не позволяет нам добавлять Non-String в Set<String> ? Или, когда мы получаем элемент из Set<String> , как он узнает, что этот элемент является String ?
Ответ прост. Компилятор несет ответственность за стирание информации о типе, но до этого он фактически знает, что переменная book содержит элементы String .
Таким образом, каждый раз, когда мы получаем из него элемент, компилятор преобразует его в строку , или когда мы собираемся добавить в него элемент, компилятор будет проверять ввод.
6.2. Параметры типа Reified
Давайте повеселимся с дженериками и создадим функцию расширения для фильтрации элементов Collection по их типу:
fun <T> Iterable<*>.filterIsInstance() = filter { it is T }
Error: Cannot check for instance of erased type: T
Часть « it is T» для каждого элемента коллекции проверяет, является ли элемент экземпляром типа T , но поскольку информация о типе была стерта во время выполнения, мы не можем таким образом отражать параметры типа.
Или можем?
Правило стирания типов в целом верно, но есть один случай, когда мы можем обойти это ограничение: встроенные функции . Параметры типа встроенных функций могут быть конкретизированы , поэтому мы можем ссылаться на эти параметры типа во время выполнения.
Тело встроенных функций является встроенным. То есть компилятор подставляет тело непосредственно в места вызова функции вместо обычного вызова функции.
Если мы объявим предыдущую функцию как встроенную и пометим параметр типа как reified , то мы сможем получить доступ к информации об универсальном типе во время выполнения:
inline fun <reified T> Iterable<*>.filterIsInstance() = filter { it is T }
Встроенная реификация работает как шарм:
>> val set = setOf("1984", 2, 3, "Brave new world", 11)
>> println(set.filterIsInstance<Int>())
[2, 3, 11]
Давайте напишем еще один пример. Мы все знакомы с типичными определениями SLF4j Logger:
class User {
private val log = LoggerFactory.getLogger(User::class.java)
// ...
}
Используя реифицированные встроенные функции, мы можем писать более элегантные и менее устрашающие синтаксис определения Logger:
inline fun <reified T> logger(): Logger = LoggerFactory.getLogger(T::class.java)
Тогда мы можем написать:
class User {
private val log = logger<User>()
// ...
}
Это дает нам более простой вариант реализации журналирования, как это делается в Kotlin.
6.3. Глубокое погружение во встроенную реификацию
Так что же такого особенного во встроенных функциях, что реификация типов работает только с ними? Как мы знаем, компилятор Котлина копирует байт-код встроенных функций в места вызова функции.
Поскольку в каждом месте вызова компилятор знает точный тип параметра, он может заменить параметр универсального типа фактическими ссылками на тип.
Например, когда мы пишем:
class User {
private val log = logger<User>()
// ...
}
Когда компилятор встраивает вызов функции logger<User>() , он знает фактический параметр универсального типа — User. Таким образом, вместо того, чтобы стирать информацию о типе, компилятор использует возможность реификации и реифицирует фактический параметр типа.
7. Заключение
В этой статье мы рассмотрели универсальные типы Kotlin. Мы увидели , как правильно использовать их в ключевых словах . Мы использовали проекции типов и определили универсальный метод, использующий общие ограничения.
Оригинал статьи: https://www.baeldung.com/kotlin/generics