1. Введение
Если упростить жизнь до уровня «компилятор пусть не умничает», то, конечно, хочется написать as и жить дальше. Но Kotlin как раз пытается спасти нас от ситуации, когда программа компилируется, а падает в рантайме где-нибудь далеко от места преступления — причём с выражением лица «я вообще-то предупреждал».
Проблема в том, что некоторые контейнеры по природе своей «и читают, и пишут». У такого контейнера нельзя честно поставить out или in в объявлении класса — иначе он потеряет половину смысла. Классический пример — Array<T>, где есть и чтение по индексу, и запись по индексу. Kotlin прямо использует этот пример, объясняя, почему Array инвариантен и как это лечится проекциями.
Type projections — это компромисс: мы не меняем класс Array, но в конкретной функции «урезаем» его возможности так, чтобы компилятор мог гарантировать безопасность.
2. Почему Array<T> инвариантен
С массивами очень легко попасть в ловушку «ну это же контейнер, значит должен вести себя как List». Но List в Kotlin — это интерфейс «на чтение», а Array — настоящий «ящик с доступом по индексу», куда можно и заглядывать, и запихивать что-то обратно.
Упрощённо можно думать, что у Array<T> есть такие операции:
operator fun get(index: Int): T
operator fun set(index: Int, value: T)
Именно поэтому Kotlin не может сделать Array ковариантным (как List), иначе возникла бы возможность записать не тот тип. Это и есть причина, почему простой “copy из Array<Int> в Array<Any>” не компилируется без дополнительных приёмов.
Давайте почувствуем это на коже.
fun copyNaive(from: Array<Any>, to: Array<Any>) {
for (i in from.indices) {
to[i] = from[i]
}
}
fun main() {
val ints: Array<Int> = arrayOf(1, 2, 3)
val any: Array<Any> = arrayOf("x", "y", "z")
// copyNaive(ints, any) // не компилируется: Array<Int> не является Array<Any>
}
И это хорошо: если бы компилятор разрешил, внутри copyNaive теоретически можно было бы сделать from[0] = "oops" (то есть записать String в массив Int). Kotlin блокирует этот класс ошибок заранее.
3. Проекции для Array: out и in
Проекция Array<out X>: массив-источник
Когда вы пишете Array<out Any> в параметре функции, вы говорите компилятору: «Я беру массив, который гарантированно содержит элементы не хуже Any, но обещаю: я не буду записывать в него». То есть массив становится producer-объектом — источником значений.
Важно уловить философию: Array<out Any> — это не новый тип массива. Это тот же массив, но через эту ссылку вы не получите доступ к опасным операциям записи (или они станут типово невозможными).
Kotlin в официальном объяснении type projections использует именно идею «копирования из массива, который мы не должны модифицировать».
Пишем безопасную функцию copyToAny
Сделаем аккуратный copy, где from — только читается.
fun copyToAny(from: Array<out Any>, to: Array<Any>) {
for (i in from.indices) {
to[i] = from[i]
}
}
fun main() {
val ints: Array<Int> = arrayOf(1, 2, 3)
val dest: Array<Any> = Array(3) { "?" }
copyToAny(ints, dest)
println(dest.joinToString()) // 1, 2, 3
}
Обратите внимание на магию, которая не выглядит магией: мы передали Array<Int> туда, где ожидается Array<out Any>. Это стало возможно потому, что Kotlin теперь уверен: мы не будем писать в from, а значит нельзя «сломать» массив Int записью String. Именно это и есть смысл use-site variance / type projection.
Что запрещено в Array<out X>
Если очень хочется испытать компилятор на терпение, попробуйте:
fun breakIt(from: Array<out Any>) {
// from[0] = "boom" // не компилируется
}
И это не «Kotlin вредничает». Это Kotlin честно выполняет контракт: out-проекция означает «читаем, но не пишем».
Проекция Array<in X>: массив-приёмник
Теперь зеркальная ситуация. Иногда массив нужен не как источник, а как приёмник: мы хотим его заполнить. Тогда нам важна операция set, а чтение — вторично.
Array<in String> читается как: «массив чего-то супертипа String, в который я могу безопасно положить строку».
Заполняем массив строками: fillWithOk
fun fillWithOk(dest: Array<in String>) {
for (i in dest.indices) {
dest[i] = "ok"
}
}
fun main() {
val anyArray: Array<Any> = Array(3) { 0 }
fillWithOk(anyArray)
println(anyArray.joinToString()) // ok, ok, ok
}
Почему это безопасно? Потому что Array<Any> действительно может хранить String: строка — это Any.
Почему чтение из Array<in X> становится беднее
Вот важный момент, который многих раздражает (а потом они привыкают и начинают раздражаться уже на другие темы).
Если у нас Array<in String>, то читать как String из него нельзя: ведь фактически это может быть Array<Any>, и там могут лежать не только строки. Поэтому безопасное чтение обычно получается как Any?.
fun showFirst(dest: Array<in String>) {
val x: Any? = dest[0]
println("first = $x") // first = ...
}
fun main() {
val anyArray: Array<Any> = arrayOf(10, 20, 30)
showFirst(anyArray) // first = 10
}
И снова: это не вредность, а гарантия. Контракт in говорит: «я могу писать String», но ничего не обещает про то, что там уже лежит и какого это типа.
4. Проекции для MutableList: out и in
С массивами мы разобрались, теперь перейдём к более жизненной штуке: MutableList. Он по смыслу «и читает, и пишет», поэтому тоже не может быть ковариантным “просто так”. Но в параметрах функций мы часто используем MutableList в одном из двух режимов:
- как источник элементов (читаем, но не меняем);
- как приёмник (добавляем элементы).
И вот здесь очень помогает use-site variance: MutableList<out T> и MutableList<in T>.
Важно не перепутать: мы не делаем MutableList навсегда ковариантным. Мы говорим: «в этой функции относись к нему как к producer / consumer».
MutableList<out Number>: читаем числа, не добавляем
Представим, что у нас есть список MutableList<Int>, но функция хочет работать с Number. Без проекций вы бы упёрлись в несовместимость типов (инвариантность мутабельной коллекции), но с out-проекцией — всё нормально, если вы не добавляете элементы.
fun sumFirstTwo(list: MutableList<out Number>): Double {
val a = list[0].toDouble()
val b = list[1].toDouble()
return a + b
// list.add(1) // не компилируется: out-проекция запрещает запись
}
fun main() {
val ints: MutableList<Int> = mutableListOf(10, 20, 30)
println(sumFirstTwo(ints)) // 30.0
}
Здесь Kotlin защищает нас от тонкой ошибки: если бы запись была разрешена, мы могли бы добавить Double в список Int, и всё бы “поехало”.
MutableList<in Int>: добавляем Int в подходящий список
Теперь наоборот: функция должна добавлять Int. При этом полезно, если ей можно передать список более общего типа, например MutableList<Number>.
fun addOne(list: MutableList<in Int>) {
list.add(1)
}
fun main() {
val numbers: MutableList<Number> = mutableListOf(10, 2.5)
addOne(numbers)
println(numbers) // [10, 2.5, 1]
}
Смысл контракта такой: раз список гарантированно способен принять Int, значит его элементный тип — Int или любой супертип (Number, Any). Это как раз логика consumer.
5. Шпаргалка по out и in
Когда начинаешь работать с проекциями, мозг сначала протестует: «почему я не могу просто сделать как хочу». Чтобы мозг не уволился, полезно держать короткую таблицу.
После этого абзаца вы можете возвращаться к ней каждый раз, когда компилятор говорит вам “нельзя”.
| Проекция в параметре | Идея | Что удобно делать | Что Kotlin специально усложняет |
|---|---|---|---|
|
источник | читать T | записывать T |
|
приёмник | записывать T | читать как T (обычно читаем как Any?) |
Эта логика буквально следует из идеи type projections (use-site variance) и того, что Kotlin запрещает операции, которые могут разрушить типобезопасность.
6. Пример: Finance Tracker и проекции на MutableList
Чтобы не оставлять тему на уровне “абстрактные собаки и кошки”, давайте встроим проекции в кусок кода, похожий на реальность.
Представим, что у нас есть консольное приложение Finance Tracker, где мы храним финансовые операции. Мы уже умеем хранить коллекции объектов, писать функции и работать с наследованием, так что сделаем базовый тип операции и два подтипа.
open class Operation(
val amount: Double,
val note: String,
)
class Expense(amount: Double, note: String) : Operation(amount, note)
class Income(amount: Double, note: String) : Operation(amount, note)
Печать операций: producer MutableList<out Operation>
В этой функции нам важно только прочитать операции и вывести их. Менять список не нужно, иначе она станет слишком “наглой”.
fun printOperations(ops: MutableList<out Operation>) {
for (op in ops) {
println("${op::class.simpleName}: ${op.amount} (${op.note})")
// Пример вывода: Expense: 12.5 (coffee)
}
}
fun main() {
val expenses: MutableList<Expense> = mutableListOf(
Expense(12.5, "coffee"),
Expense(40.0, "groceries"),
)
printOperations(expenses)
}
Почему это важно? Потому что без out вы бы не смогли передать MutableList<Expense> туда, где ожидается “список операций”. А с out вы честно фиксируете: список используется как источник.
Добавление расхода: consumer MutableList<in Expense>
Теперь обратная задача: функция добавляет расход. Полезно, если она умеет добавлять расход не только в MutableList<Expense>, но и в общий журнал MutableList<Operation>.
fun addExpense(ops: MutableList<in Expense>, amount: Double, note: String) {
ops.add(Expense(amount, note))
}
fun main() {
val journal: MutableList<Operation> = mutableListOf(
Income(1000.0, "salary"),
)
addExpense(journal, 12.5, "coffee")
printOperations(journal)
// Income: 1000.0 (salary)
// Expense: 12.5 (coffee)
}
С точки зрения типов это звучит так: список MutableList<Operation> — отличный приёмник Expense, потому что Expense является Operation.
7. Проекции ограничивают API ссылки, а не объект
Очень частая путаница: кажется, что Array<out Any> — это какой-то «особенный массив». Но на самом деле массив остаётся тем же. Меняется только то, что вам разрешено делать через эту переменную.
Можно представить себе такую мини-схему:
flowchart LR
A["Реальный объект: Array⟨Int⟩"]
B["Ссылка как Array⟨out Any⟩"]
C["Ссылка как Array⟨Int⟩"]
A --> B
A --> C
B -->|"можно читать (Any)"| R["get(i)"]
B -.->|"нельзя писать"| W["set(i, ...)"]
C -->|"можно читать (Int)"| R2["get(i)"]
C -->|"можно писать (Int)"| W2["set(i, Int)"]
В этом смысле проекции — очень “инженерный” инструмент. Вы не переписываете модель данных. Вы задаёте компилятору понятные ограничения: «в этой функции я веду себя прилично».
8. Типичные ошибки
Ошибка №1: пытаться “дописать” в Array<out …> или MutableList<out …>.
Обычно это выглядит так: вы аккуратно объявили параметр как producer, а потом внутри функции у вас просыпается желание «ну я же чуть-чуть добавлю». Компилятор не даст. И правильно: если вы смогли передать туда MutableList<Int> как MutableList<out Number>, то запись 2.5 была бы логической катастрофой.
Ошибка №2: ожидать, что из Array<in …> можно читать как из Array<T>.
Если параметр объявлен как consumer, Kotlin сознательно делает чтение “неприятным”: безопасный тип чтения получается вроде Any?. Это нормально. Если вам нужно и читать как T, и писать T, значит это уже не consumer/producer сценарий — и, возможно, проекции в этом месте не подходят.
Ошибка №3: лечить несовместимость типов через as вместо проекций.
Когда хочется “чтобы компилилось”, рука тянется к as. Но приведение типов в generics часто превращает ошибку компиляции в будущий сюрприз в рантайме. Проекции хороши тем, что не прячут проблему, а заставляют вас честно сформулировать контракт: «только читаю» или «только пишу».
Ошибка №4: путать проекции с out/in в объявлении типа.
out и in в объявлении типа — это решение дизайна вашего класса или интерфейса (“этот тип по природе producer”). Проекции в параметрах функций — это точечное решение (“в этой функции я использую контейнер как producer/consumer”). На практике это разные уровни, и полезно прямо проговаривать себе: «я сейчас проектирую тип или просто делаю безопасную сигнатуру функции».
Ошибка №5: пытаться сделать универсальную функцию, которая и читает, и пишет.
Иногда хочется функцию “и копировать, и чуть-чуть подправить”, а параметры сделать максимально широкими. Но инвариантность как раз говорит: если вы хотите и читать, и писать — будьте добры принимать точный тип (Array<T>, MutableList<T>). А если хотите принимать шире — придётся чем-то пожертвовать (обычно возможностью записи или точным типом чтения).
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ