August 26, 2019

Введение в Kotlin: обобщения (generics)

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

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

Обобщения (Generics)

Как и в Java, в Kotlin классы могут иметь generic типы:

class Box<T>(t: T) {
    var value = t
}

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

val box: Box<Int> = Box<Int>(1)

Но если параметры могут быть выведены из контекста (в аргументах конструктора или в некоторых других случаях), можно опустить указание типа:

val box = Box(1)
// 1 имеет тип Int, поэтому компилятор отмечает для себя,
// что у переменной box тип — Box<Int>

Вариативность

Одним из самых сложных мест в системе типов Java являются маски (ориг. wildcards) (см. Java Generics FAQ). А в Kotlin этого нет. Вместо этого в нем есть две другие особенности: вариативность на уровне объявления и проекции типов.

Для начала давайте подумаем на тему, зачем Java нужны эти странные маски. Проблема описана в книге Effective Java, Item 28: Use bounded wildcards to increase API flexibility. Прежде всего, обобщающие типы в Java являются инвариантными (invariant). Это означает, что List<String> не является подтипом List<Object>. Почему так? Если бы List был изменяемым, единственно лучшим решением для следующей задачи был бы массив, потому что после компиляции данный код вызвал бы ошибку в рантайме:

// Java
List<String> strs = new ArrayList<String>();
// !!! Причина вышеуказанной проблемы заключена в строке ниже, Java запрещает так делать
List<Object> objs = strs;

// Тут мы помещаем Integer в список String'ов
objs.add(1);
String s = strs.get(0);
// !!! ClassCastException: не можем кастовать Integer к String

Таким образом, Java запрещает подобные вещи, гарантируя тем самым безопасность в период выполнения кода. Но у такого подхода есть свои последствия. Рассмотрим, например, метод addAll интерфейса Collection. Какова сигнатура данного метода? Интуитивно мы бы указали её таким образом:

// Java
interface Collection<E> ... {
    void addAll(Collection<E> items);
}

Но тогда мы бы не могли выполнять следующую простую операцию (которая является абсолютно безопасной):

// Java
void copyAll(Collection<Object> to, Collection<String> from) {
    to.addAll(from);
    // !!! Не скомпилируется с нативным объявлением метода addAll:
    // Collection<String> не является подтипом Collection<Object>
}

(В Java нам этот урок дорого стоил, см. Effective Java, Item 25: Prefer lists to arrays)

Вот почему сигнатура addAll() на самом деле такая:

// Java
interface Collection<E> ... {
 void addAll(Collection<? extends E> items);
}

Маска для аргумента '? extends E' указывает на то, что этот метод принимает коллекцию объектов E или некоего типа, унаследованного от E, а не сам E. Это значит, что мы можем безопасно читать объекты типа E из содержимого (элементы коллекции являются экземплярами подкласса E), но не можем их изменять, потому что не знаем, какие объекты соответствуют этому неизвестному подтипу от E. Минуя это ограничение, мы достигаем желаемого результата: Collection<String> является подтипом Collection<? extends Object>. Выражаясь более "умными словами", маска с extends-связкой (верхнее связывание) делает тип ковариантным (covariant).

Ключом к пониманию, почему этот трюк работает, является довольно простая мысль: использование коллекции String'ов и чтение из неё Object'ов нормально только в случае, если вы берёте элементы из коллекции. Наоборот, если вы только вносите элементы в коллекцию, то нормально брать коллекцию Object'ов и помещать в неё String'и: в Java есть List<? super String>, супертип List<Object>'a.

Это называется контрвариантностью. В List<? super String> вы можете вызвать только те методы, которые принимают String в качестве аргумента (например, add(String) или set(int, String)). В случае если вы вызываете из List<T> что-то c возвращаемым значением T, вы получаете не String, а Object.

Джошуа Блок (Joshua Block) называет объекты:

  • Производителями (producers), если вы только читаете из них.
  • Потребителями (consumers), если вы только записываете в них.

Его рекомендация: "Для максимальной гибкости используйте маски (wildcards) на входных параметрах, которые представляют производителей или потребителей"

Вариативность на уровне объявления

Допустим, у нас есть generic интерфейс Source<T>, у которого нет методов, которые принимают T в качестве аргумента. Только методы, возвращающие T:

// Java
interface Source<T> {
    T nextT();
}

Тогда было бы вполне безопасно хранить ссылки на экземпляр Source<String> в переменной типа Source<Object> — не нужно вызывать никакие методы-потребители. Но Java не знает этого и не воспринимает такой код:

// Java
void demo(Source<String> strs) {
    Source<Object> objects = strs; // !!! Запрещено в Java
    // ...
}

Чтобы исправить это, нам нужно объявить объекты типа Source<? extends Object>, что в каком-то роде бессмысленно, потому что мы можем вызывать у переменных только те методы, что и ранее, стало быть более сложный тип не добавляет значения. Но компилятор не знает этого.

В Kotlin существует способ объяснить вещь такого рода компилятору. Он называется вариативность на уровне объявления: мы можем пометить аннотацией параметризованный тип T класса Source, чтобы удостовериться, что он только возвращается (производится) членами Source<T> и никогда не потребляется. Чтобы сделать это, нам необходимо использовать модификатор out:

abstract class Source<out T> {
    abstract fun nextT(): T
}

fun demo(strs: Source<String>) {
    val objects: Source<Any> = strs // Всё в порядке, т.к. T — out-параметр
    // ...
}

Общее правило таково: когда параметр T класса С объявлен как out, он может использоваться только в out-местах в членах C. Но зато C<Base> может быть родителем C<Derived>, и это будет безопасно.

Говоря "умными словами", класс C ковариантен в параметре T, или T является ковариантным параметризованным типом.

Модификатор out называют вариативной аннотацией, и так как он указывается на месте объявления типа параметра, речь идёт о вариативности на месте объявления. Эта концепция противопоставлена вариативности на месте использования из Java, где маски при использовании типа делают типы ковариантными.

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

abstract class Comparable<in T> {
    abstract fun compareTo(other: T): Int
}

fun demo(x: Comparable<Number>) {
    x.compareTo(1.0) // 1.0 имеет тип Double, расширяющий Number
    // Таким образом, мы можем присвоить значение x переменной типа Comparable<Double>
    val y: Comparable<Double> = x // OK!
}

Мы верим, что слова in и out говорят сами за себя (так как они довольно успешно используются в C# уже долгое время). Таким образом, мнемоника, приведённая выше, не так уж и нужна, и её можно перефразировать следующим образом:

Экзистенцианальная трансформация: Consumer in, Producer out! :-)

Проекции типов

Вариативность на месте использования

Объявлять параметризованный тип T как out очень удобно: при его использовании не будет никаких проблем с подтипами. И это действительно так в случае с классами, которые могут быть ограничены на только возвращение T. А как быть с теми классами, которые ещё и принимают T? Пример: класс Array

class Array<T>(val size: Int) {
    fun get(index: Int): T { /* ... */ }
    fun set(index: Int, value: T) { /* ... */ }
}

Этот класс не может быть ни ко-, ни контравариантным в T, что ведёт к некоторому снижению гибкости. Рассмотрим следующую функцию:

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

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

val ints: Array<Int> = arrayOf(1, 2, 3)
val any = Array<Any>(3)
copy(ints, any) // Ошибка: ожидалось (Array<Any>, Array<Any>)

Здесь мы попадаем в уже знакомую нам проблему: Array<T> инвариантен в T, таким образом Array<Int> не является подтипом Array<Any>. Почему? Опять же, потому что копирование может сотворить плохие вещи, например, может произойти попытка записать, скажем, значение типа String в from. И если мы на самом деле передадим туда массив Int, через некоторое время будет выброшен ClassCastException.

Тогда единственная вещь, в которой мы хотим удостовериться, это то, что copy() не сделает ничего плохого. Мы хотим запретить методу записывать в from, и мы можем это сделать:

fun copy(from: Array<out Any>, to: Array<Any>) {
    // ...
}

Произошедшее здесь называется проекция типов: мы сказали, что from — не просто массив, а ограниченный (спроецированный): мы можем вызывать только те методы, которые возвращают параметризованный тип T, что в этом случае означает, что мы можем вызывать только get(). Таков наш подход к вариативности на месте использования, и он соответствует Array<? extends Object> из Java, но в более простом виде.

Вы также можете проецировать тип с in:

fun fill(dest: Array<in String>, value: String) {
    // ...
}

Array<in String> соответствует Array<? super String> из Java, то есть мы можем передать массив CharSequence или массив Object в функцию fill().

"Звёздные" проекции

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

Kotlin предоставляет так называемый star-projection синтаксис для этого:

  • Для Foo<out T>, где T — ковариантный параметризованный тип с верхней границей TUpper, Foo<*> является эквивалентом Foo<out TUpper>. Это значит, что когда T неизвестен, вы можете безопасно читать значения типа TUpper из Foo<*>.
  • Для Foo<in T>, где T — контравариантный параметризованный тип, Foo<*> является эквивалентом Foo<in Nothing>. Это значит, что вы не можете безопасно писать в Foo<*> при неизвестном T.
  • Для Foo<T>, где T — инвариантный параметризованный тип с верхней границей TUpper, Foo<*> является эквивалентом Foo<out TUpper> при чтении значений и Foo<in Nothing> при записи значений.

Если параметризованный тип имеет несколько параметров, каждый из них проецируется независимо. Например, если тип объявлен как interface Function<in T, out U>, мы можем представить следующую "звёздную" проекцию:

  • Function<*, String> означает Function<in Nothing, String>;
  • Function<Int, *> означает Function<Int, out Any?>;
  • Function<*, *> означает Function<in Nothing, out Any?>.

Примечание: "звёздные" проекции очень похожи на сырые (raw) типы из Java, за тем исключением, что являются безопасными.

Обобщённые функции

Функции, как и классы, могут иметь типовые параметры. Типовые параметры помещаются перед именем функции:

fun <T> singletonList(item: T): List<T> {
    // ...
}

fun <T> T.basicToString() : String { // функция-расширение
    // ...
}

Для вызова обобщённой функции укажите тип аргументов на месте вызова после имени функции:

val l = singletonList<Int>(1)

Обобщённые ограничения

Набор всех возможных типов, которые могут быть переданы в качестве параметра, может быть ограничен с помощью обобщённых ограничений.

Самый распространённый тип ограничений - верхняя граница, которая соответствует ключевому слову extends из Java:

fun <T : Comparable<T>> sort(list: List<T>) {
    // ...
}

Тип, указанный после двоеточия, является верхней границей: только подтип Comparable<T> может быть передан в T. Например:

sort(listOf(1, 2, 3))
// Всё в порядке. Int — подтип Comparable<Int>

sort(listOf(HashMap<Int, String>()))
// Ошибка: HashMap<Int, String> не является подтипом Comparable<HashMap<Int, String>>

По умолчанию (если не указана явно) верхняя граница — Any?. Только одна верхняя граница может быть указана в угловых скобках. В случае если один параметризованный тип требует больше чем одной верхней границы, нам нужно использовать разделяющее where-условие:

fun <T> cloneWhenGreater(list: List<T>, threshold: T): List<T>
                                      where T : Comparable, T : Cloneable {
    return list.filter { it > threshold }.map { it.clone() }
}

Источник: Документация Kotlin: Обобщения (Generics)