Spec-Zone.ru › Kotlin 2

Обобщения: in, out, where

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

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

Чтобы создать экземпляр такого класса, просто укажите аргументы типа:

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

Если компилятор может вывести аргументы типа, например, из аргументов конструктора, указывать их явно не нужно:

val box = Box(1) // 1 has type Int, so the compiler figures out that it is Box<Int>

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

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

Вариантность и подстановочные знаки в Java

Давайте разберёмся, зачем Java нужны эти загадочные подстановочные знаки. Во-первых, обобщённые типы в Java инвариантны, то есть List<String> не является подтипом List<Object>. Если бы List не был инвариантным, он ничем не отличался бы от массивов Java, поскольку следующий код компилировался бы, но приводил к исключению во время выполнения:

// Java
List<String> strs = new ArrayList<String>();

// Java reports a type mismatch here at compile-time.
List<Object> objs = strs;

// What if it didn't?
// We would be able to put an Integer into a list of Strings.
objs.add(1);

// And then at runtime, Java would throw
// a ClassCastException: Integer cannot be cast to String
String s = strs.get(0); 

Java запрещает подобные операции, чтобы гарантировать безопасность во время выполнения. Но это имеет свои последствия. Например, рассмотрим метод addAll() интерфейса Collection. Какова сигнатура этого метода? Интуитивно её можно было бы записать так:

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

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

// Java

// The following would not compile with the naive declaration of addAll:
// Collection<String> is not a subtype of Collection<Object>
void copyAll(Collection<Object> to, Collection<String> from) {
    to.addAll(from);
}

Именно поэтому фактическая сигнатура 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 (верхней границей) делает тип ковариантным.

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

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

Джошуа Блох в своей книге «Эффективная Java», 3-е издание, хорошо объясняет эту проблему (пункт 31: «Используйте ограниченные подстановочные знаки, чтобы повысить гибкость API»). Он называет производителями объекты, из которых можно только читать, а потребителями — те, в которые можно только записывать. Он рекомендует:

«Для максимальной гибкости используйте типы с подстановочными знаками для входных параметров, представляющих производителей или потребителей».

Затем он предлагает следующую мнемонику: PECS расшифровывается как Producer-Extends, Consumer-Super («производитель — extends, потребитель — super»).

Если вы используете объект-производитель, например List<? extends Foo>, вам нельзя вызывать у этого объекта add() или set(), но это не значит, что он неизменяемый: например, ничто не мешает вызвать clear(), чтобы удалить все элементы из списка, поскольку clear() вообще не принимает параметров.

Подстановочные знаки (или другие виды вариантности) гарантируют только безопасность типов. Неизменяемость — совсем другая тема.

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

Предположим, что существует обобщённый интерфейс 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; // !!! Not allowed in Java
    // ...
}

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

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

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

fun demo(strs: Source<String>) {
    val objects: Source<Any> = strs // This is OK, since T is an out-parameter
    // ...
}

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

Иными словами, можно сказать, что класс C ковариантен по параметру T, или что T — это ковариантный параметр типа. Можно считать, что C — это производитель объектов типа T, а НЕ потребитель объектов типа T.

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

Помимо out, в Kotlin есть дополнительная аннотация вариантности: in. Она делает параметр типа контравариантным, то есть позволяет только потреблять значения этого типа, но не производить их. Хороший пример контравариантного типа — Comparable:

interface Comparable<in T> {
    operator fun compareTo(other: T): Int
}

fun demo(x: Comparable<Number>) {
    x.compareTo(1.0) // 1.0 has type Double, which is a subtype of Number
    // Thus, you can assign x to a variable of type Comparable<Double>
    val y: Comparable<Double> = x // OK!
}

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

Экзистенциальное преобразование: потребитель — in, производитель — out!:-)

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

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

Очень легко объявить параметр типа T как out и избежать проблем с подтипами на месте использования, но некоторые классы нельзя ограничить только возвратом T! Хороший пример этого — Array:

class Array<T>(val size: Int) {
    operator fun get(index: Int): T { ... }
    operator 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)
//   ^ type is Array<Int> but Array<Any> was expected

Здесь вы сталкиваетесь с той же знакомой проблемой: Array<T> является инвариантным относительно T, поэтому ни Array<Int>, ни Array<Any> не является подтипом другого. Почему? И снова причина в том, что copy может вести себя неожиданным образом: например, попытаться записать 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. Это означает, что функции fill() можно передать массив String, CharSequence или Object.

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

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

В Kotlin для этого предусмотрен так называемый синтаксис звёздочной проекции:

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

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

  • Для Foo<T : TUpper>, где 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, но безопасны.

Захваченные типы

При использовании проекции типа, например out T или in T, компилятор внутренне представляет неизвестный конкретный тип как захваченный тип. Захваченный тип — это неизвестный тип с известными верхней и нижней границами.

Захваченные типы нельзя выразить напрямую, поэтому их нельзя непосредственно записать в коде Kotlin. Чаще всего захваченные типы встречаются в сообщениях компилятора, например CapturedType(out X). Например, в следующем сообщении об ошибке несоответствия типов содержится захваченный тип:

val array: Array<out CharSequence> = arrayOf("str")
val item: Int = array.get(0)
// Initializer type mismatch: expected 'Int', actual 'CapturedType(out CharSequence)'

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

// The projected type is Array<out CharSequence>
val array: Array<out CharSequence> = arrayOf("Kotlin")

// The get() read operation uses the captured type's upper bound, CharSequence
val item = array.get(0)

// The set() write operation uses the captured type's lower bound, Nothing,
// which results in an error
array.set(0, "New value")
// Receiver type 'Array<out CharSequence>' contains out projection
// which prohibits the use of 'fun set(index: Int, value: T): Unit'

В этом примере:

  • Переменная array имеет проецированный тип Array<out CharSequence>. Компилятор представляет проецированный аргумент типа out CharSequence как захваченный тип с верхней границей CharSequence и нижней границей Nothing.

  • Для операции get() компилятор приближает захваченный тип к его верхней границе CharSequence и выводит CharSequence как тип item.

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

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

Параметры типа могут быть не только у классов, но и у других объявлений. Например, у функций. Параметры типа указываются перед именем функции:

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

fun <T> T.basicToString(): String { // extension function
    // ...
}

Чтобы вызвать обобщённую функцию, укажите аргументы типа в месте вызова после имени функции:

val l = singletonList<Int>(1)

Аргументы типа можно опустить, если их можно вывести из контекста. Поэтому следующий пример тоже работает:

val l = singletonList(1)

Ограничения обобщённых типов

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

Верхние границы

Наиболее распространённый тип ограничения — верхняя граница, соответствующая ключевому слову extends в Java:

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

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

sort(listOf(1, 2, 3)) // OK. Int is a subtype of Comparable<Int>
sort(listOf(HashMap<Int, String>())) // Error: HashMap<Int, String> is not a subtype of Comparable<HashMap<Int, String>>

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

fun <T> copyWhenGreater(list: List<T>, threshold: T): List<String>
    where T : CharSequence,
          T : Comparable<T> {
    return list.filter { it > threshold }.map { it.toString() }
}

Переданный тип должен одновременно удовлетворять всем условиям части where. В приведённом выше примере тип T должен реализовывать оба интерфейса: CharSequence и Comparable.

Типы, определённо не допускающие null

Чтобы упростить взаимодействие с обобщёнными классами и интерфейсами Java, Kotlin поддерживает объявление обобщённого параметра типа как определённо не допускающего null.

Чтобы объявить обобщённый тип T как определённо не допускающий null, объявите тип с помощью & Any. Например: T & Any.

У типа, определённо не допускающего null, должна быть допускающая null верхняя граница.

Чаще всего типы, определённо не допускающие null, объявляют, когда нужно переопределить метод Java, который принимает @NotNull в качестве аргумента. Например, рассмотрим метод load():

import org.jetbrains.annotations.*;

public interface Game<T> {
    public T save(T x) {}
    @NotNull
    public T load(@NotNull T x) {}
}

Чтобы успешно переопределить метод load() в Kotlin, нужно объявить T1 как тип, определённо не допускающий null:

interface ArcadeGame<T1> : Game<T1> {
    override fun save(x: T1): T1
    // T1 is definitely non-nullable
    override fun load(x: T1 & Any): T1 & Any
}

При работе только с Kotlin явное объявление типов, определённо не допускающих null, вряд ли понадобится, поскольку вывод типов в Kotlin сделает это за вас.

Стирание типов

Проверки типобезопасности, которые Kotlin выполняет для использований обобщённых объявлений, проводятся во время компиляции. Во время выполнения экземпляры обобщённых типов не содержат сведений об их фактических аргументах типа. Говорят, что информация о типе стирается. Например, экземпляры Foo<Bar> и Foo<Baz?> стираются до просто Foo<*>.

Проверки и приведения обобщённых типов

Из-за стирания типов не существует универсального способа проверить во время выполнения, был ли экземпляр обобщённого типа создан с определёнными аргументами типа, поэтому компилятор запрещает такие проверки is, как ints is List<Int> или list is T (параметр типа). Однако можно проверить экземпляр на соответствие типу со звёздной проекцией:

if (something is List<*>) {
    something.forEach { println(it) } // The items are typed as `Any?`
}

Аналогично, если аргументы типа экземпляра уже статически проверены (во время компиляции), можно выполнить проверку is или приведение, затрагивающее необобщённую часть типа. Обратите внимание, что в этом случае угловые скобки опускаются:

fun handleStrings(list: MutableList<String>) {
    if (list is ArrayList) {
        // `list` is smart-cast to `ArrayList<String>`
    }
}

Тот же синтаксис, но без указания аргументов типа, можно использовать для приведений, не учитывающих аргументы типа: list as ArrayList.

Аргументы типа при вызовах обобщённых функций также проверяются только во время компиляции. В телах функций параметры типа нельзя использовать для проверок типов, а приведения к параметрам типа (foo as T) не проверяются. Исключение составляют встроенные функции с reified-параметрами типа, фактические аргументы типа которых подставляются при каждом вызове. Это позволяет выполнять проверки типов и приведения для параметров типа. Однако описанные выше ограничения по-прежнему применяются к экземплярам обобщённых типов, используемым в проверках или приведениях. Например, при проверке типа arg is T, если arg сам является экземпляром обобщённого типа, его аргументы типа всё равно стираются.

//sampleStart
inline fun <reified A, reified B> Pair<*, *>.asPairOf(): Pair<A, B>? {
    if (first !is A || second !is B) return null
    return first as A to second as B
}

val somePair: Pair<Any?, Any?> = "items" to listOf(1, 2, 3)


val stringToSomething = somePair.asPairOf<String, Any>()
val stringToInt = somePair.asPairOf<String, Int>()
val stringToList = somePair.asPairOf<String, List<*>>()
val stringToStringList = somePair.asPairOf<String, List<String>>() // Compiles but breaks type safety!
// Expand the sample for more details

//sampleEnd

fun main() {
    println("stringToSomething = " + stringToSomething)
    println("stringToInt = " + stringToInt)
    println("stringToList = " + stringToList)
    println("stringToStringList = " + stringToStringList)
    //println(stringToStringList?.second?.forEach() {it.length}) // This will throw ClassCastException as list items are not String
}

Непроверяемые приведения

Приведения к обобщённым типам с конкретными аргументами типа, например foo as List<String>, невозможно проверить во время выполнения.
Такие непроверяемые приведения можно использовать, если типобезопасность следует из общей логики программы, но компилятор не может сделать такой вывод напрямую. См. пример ниже.

fun readDictionary(file: File): Map<String, *> = file.inputStream().use { 
    TODO("Read a mapping of strings to arbitrary elements.")
}

// We saved a map with `Int`s into this file
val intsFile = File("ints.dictionary")

// Warning: Unchecked cast: `Map<String, *>` to `Map<String, Int>`
val intsDictionary: Map<String, Int> = readDictionary(intsFile) as Map<String, Int>

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

Чтобы избежать непроверяемых приведений, можно изменить структуру программы. В приведённом выше примере можно использовать интерфейсы DictionaryReader<T> и DictionaryWriter<T> с типобезопасными реализациями для разных типов. Можно ввести подходящие абстракции, чтобы перенести непроверяемые приведения из места вызова в детали реализации. Также может помочь правильное использование вариантности обобщённых типов.

Для обобщённых функций использование reified-параметров типа позволяет проверять такие приведения, как arg as T, если только тип arg не имеет собственных аргументов типа, которые стираются.

Предупреждение о непроверяемом приведении можно подавить, аннотировав оператор или объявление, в котором оно выполняется, с помощью @Suppress("UNCHECKED_CAST"):

inline fun <reified T> List<*>.asListOfType(): List<T>? =
    if (all { it is T })
        @Suppress("UNCHECKED_CAST")
        this as List<T> else
        null

На JVM: типы массивов (Array<Foo>) сохраняют информацию о стёртом типе своих элементов, и приведения к типу массива проверяются частично: nullability и фактические аргументы типа типа элемента всё равно стираются. Например, приведение foo as Array<List<String>?> будет успешным, если foo — это массив, содержащий любые List<*>, независимо от того, допускают ли они значение null.

Оператор подчёркивания для аргументов типа

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

abstract class SomeClass<T> {
    abstract fun execute() : T
}

class SomeImplementation : SomeClass<String>() {
    override fun execute(): String = "Test"
}

class OtherImplementation : SomeClass<Int>() {
    override fun execute(): Int = 42
}

object Runner {
    inline fun <reified S: SomeClass<T>, T> run() : T {
        return S::class.java.getDeclaredConstructor().newInstance().execute()
    }
}

fun main() {
    // T is inferred as String because SomeImplementation derives from SomeClass<String>
    val s = Runner.run<SomeImplementation, _>()
    assert(s == "Test")

    // T is inferred as Int because OtherImplementation derives from SomeClass<Int>
    val n = Runner.run<OtherImplementation, _>()
    assert(n == 42)
}
12 августа 2026 г.
РавенствоМетоды асинхронного программирования

© 2010–2026 JetBrains s.r.o. and Kotlin Programming Language contributors
Licensed under the Apache License, Version 2.0.
https://kotlinlang.org/docs/generics.html

Spec-Zone.ru

Настройки Оффлайн Что нового Помощь О нас
Spec-Zone .ru
спецификации, руководства, описания, API