1. Знакомство с List<*>
Представьте реальную жизнь (и реальный проект): вы пишете функцию логирования или отладочного вывода. Ей совершенно не важно, из чего состоит список — Expense, String или Int. Ей важно пробежать по элементам и напечатать их. Или, например, показать размер, первые несколько элементов, а дальше аккуратно промолчать, чтобы не спамить консоль.
Если вы попытаетесь сделать “универсальную” функцию через List<Any?>, вы довольно быстро упрётесь в то, что да, List ковариантен, но как только вы захотите поддержать изменяемые коллекции (MutableList) или контейнеры с более сложными параметрами типов, начнутся неприятные ограничения. Поэтому Kotlin даёт отдельный синтаксис: List<*>, который читается как:
«Это список элементов какого-то типа, но какого именно — неизвестно».
И вот это “неизвестно” — не философия, а конкретные правила компилятора: что можно делать с таким списком, а что нельзя.
2. Что можно делать с List<*>
Если у нас List<*>, мы можем делать всё “читающее”: size, isEmpty(), проходить for, брать элементы по индексу. Но есть цена: тип элемента становится максимально безопасным — Any?.
Мини-пример “печать всего”:
fun printAll(xs: List<*>) {
for (x in xs) {
println(x) // x: Any?
}
}
fun main() {
printAll(listOf(1, 2, 3))
// 1
// 2
// 3
printAll(listOf("a", "b"))
// a
// b
}
Почему Any?, а не Any? Потому что Kotlin допускает списки, где элементы могут быть null (например, List<String?>). Когда тип неизвестен, компилятор обязан помнить и про такой вариант.
Теперь важная практическая мысль: если вы достали элемент из List<*>, то дальше вы работаете с ним как с Any?. Хотите обращаться к строковым методам — сначала проверка типа.
fun printStringLengths(xs: List<*>) {
for (x in xs) {
if (x is String) {
println("${x} -> len=${x.length}") // smart-cast к String
}
}
}
fun main() {
printStringLengths(listOf("Kotlin", "is", "fun"))
// Kotlin -> len=6
// is -> len=2
// fun -> len=3
}
Здесь Kotlin ведёт себя очень дружелюбно: после if (x is String) он делает smart cast, и x.length становится легальным.
3. Почему нельзя добавлять в MutableList<*>
С List<*> всё выглядит почти как “универсальный список для чтения”. И тут новички часто делают следующий логический прыжок:
«Окей, раз List<*> — это список неизвестных элементов, значит MutableList<*> — это изменяемый список неизвестных элементов. Ну я же могу туда что-то добавить… хотя бы null…»
И вот тут Kotlin включает режим “злой охранник клуба”: добавлять нельзя.
fun tryToAdd(xs: MutableList<*>) {
// xs.add(1) // Ошибка компиляции
// xs.add("hi") // Ошибка компиляции
// xs.add(null) // Тоже ошибка компиляции
println(xs.size)
}
Почему настолько жёстко? Потому что реальный список может быть, например, MutableList<String>. Если бы компилятор разрешил вам добавить Int, то вы бы сломали контракт списка строк. А если бы разрешил добавить null, то вы бы сломали MutableList<String> (где null запрещён).
На уровне формальной модели Kotlin описывает star-projection так: если параметр типа неизвестен, то чтение становится “как out ...”, а запись — “как in Nothing” (то есть “ничего нельзя безопасно записать”). Это описано прямо в правилах star-projection: для инвариантного параметра Foo<T> звёздочка ведёт себя как безопасное чтение верхней границы и как запрет на запись через Nothing.
Интуитивно это можно запомнить так: MutableList<*> — это “коробка, на которой написано: НЕИЗВЕСТНО ЧТО”. Если вы начнёте туда класть предметы, вы рискуете получить “коробку для ложек”, в которой внезапно оказалась отвёртка. Коробка-то переживёт, а вот ваш типовой контракт — нет.
Сравнение: * — это не “Any”
Очень частая путаница: “звёздочка — это Any”. Нет. Иногда по поведению при чтении похоже, но смысл другой.
Давайте зафиксируем разницу через небольшую таблицу (и мы будем говорить простыми словами, без погружения в формальную теорию типов):
| Тип | Что обещает вызывающему коду | Что можно читать | Что можно добавлять |
|---|---|---|---|
|
“это список, но тип элементов неизвестен” | |
(не применимо, read-only) |
|
“это список, элементы которого точно Any?” | |
(не применимо) |
|
“это изменяемый список, но тип элементов неизвестен” | |
ничего (запрещено компилятором) |
|
“это изменяемый список, который хранит Any?” | |
можно добавлять Any? (включая null) |
То есть MutableList<Any?> — это “контейнер для всего подряд”. А MutableList<*> — это “контейнер неизвестно для чего”, и именно поэтому запись запрещена.
Проверим на коротком примере, почему MutableList<*> удобно как входной параметр, когда вы не собираетесь писать:
fun printFirst(xs: MutableList<*>) {
val first = xs[0]
println("first=$first") // first: Any?
}
fun main() {
val names: MutableList<String> = mutableListOf("Ann", "Bob")
val nums: MutableList<Int> = mutableListOf(10, 20)
printFirst(names) // first=Ann
printFirst(nums) // first=10
}
Здесь MutableList<*> полезен: функция может принять любую MutableList<T>, но сама обещает “я только читаю”. И компилятор гарантирует, что вы случайно не начнёте добавлять туда “не то”.
Что сломалось бы без запрета на add
Давайте на секунду представим, что Kotlin позволял бы делать так:
fun breakWorld(xs: MutableList<*>) {
xs.add(123) // допустим, это разрешено (в реальности — нет)
}
Теперь вызываем:
val names: MutableList<String> = mutableListOf("Ann", "Bob")
breakWorld(names)
println(names[2].length) // а что здесь?
Если бы add(123) было разрешено, то names перестал бы быть “списком строк” по факту. И дальше вы бы получили либо падение в рантайме (например, ClassCastException где-то далеко), либо ещё хуже — тихую логическую ошибку.
Именно поэтому Kotlin предпочитает “пусть лучше не компилируется, чем сломается у пользователя”. А MutableList<*> как раз и означает: “тип неизвестен, мы не имеем права писать”.
4. Практика: отладочный вывод списка в Expense Tracker
Чтобы это не осталось чистой теорией, встроим идею List<*> в наш консольный Expense Tracker (тот самый, где мы храним расходы и показываем отчёты). Нам периодически хочется быстро посмотреть “что сейчас лежит в коллекции”, не создавая десять разных функций.
Сделаем маленькую утилиту “печати списка” (обычная функция, без расширений):
data class Expense(val title: String, val amount: Double)
fun printList(label: String, xs: List<*>) {
println("== $label (size=${xs.size}) ==")
for (x in xs) println(x)
}
fun main() {
val expenses = listOf(
Expense("Coffee", 3.5),
Expense("Book", 12.0)
)
val tags = listOf("food", "education")
printList("Expenses", expenses)
// == Expenses (size=2) ==
// Expense(title=Coffee, amount=3.5)
// Expense(title=Book, amount=12.0)
printList("Tags", tags)
// == Tags (size=2) ==
// food
// education
}
Обратите внимание на важный эффект: printList не может “знать” ничего про тип элемента, и это нормально. Она не претендует на бизнес-логику, она — утилита для вывода. В таких местах List<*> — идеальное попадание.
Превью: печатать первые N элементов
Теперь добавим чуть больше пользы: печатать первые N элементов, чтобы огромный список не “убил” консоль.
fun printPreview(label: String, xs: List<*>, limit: Int) {
val n = if (xs.size < limit) xs.size else limit
println("== $label (showing $n of ${xs.size}) ==")
for (i in 0 until n) println(xs[i])
}
fun main() {
val nums = listOf(1, 2, 3, 4, 5)
printPreview("Numbers", nums, limit = 3)
// == Numbers (showing 3 of 5) ==
// 1
// 2
// 3
}
Заметьте, как естественно это читается: мы не знаем тип элементов — и нам не нужно его знать, потому что задача “отладочный вывод” не требует никаких операций, кроме печати.
5. Проверка is List<*> и разбор элементов
В реальном коде иногда прилетает Any (например, из очень общего API, из JSON-дерева, из Map<String, Any?>, из внешней библиотеки). И вы хотите понять: это список или нет?
Kotlin разрешает проверку вида x is List<*>. Это важный момент: проверка “это список неизвестных элементов” допустима, и Kotlin прямо приводит такой пример, отмечая, что элементы при этом имеют тип Any?.
Сделаем мини-функцию:
fun describeValue(x: Any?) {
if (x is List<*>) {
println("This is a list, size=${x.size}") // ok
} else {
println("This is not a list")
}
}
fun main() {
describeValue(listOf(1, 2, 3)) // This is a list, size=3
describeValue("hello") // This is not a list
}
А теперь типичный следующий шаг: “если это список, давай попробуем достать из него строки”. Здесь важно помнить, что элементы — Any?, и придётся фильтровать через проверки типов.
fun printOnlyStrings(x: Any?) {
if (x !is List<*>) return
for (item in x) {
if (item is String) {
println(item.uppercase())
}
}
}
fun main() {
printOnlyStrings(listOf("a", 1, "b", null))
// A
// B
}
Это выглядит чуть более многословно, чем “волшебное приведение типов”, зато работает предсказуемо и не превращает ваш код в минное поле.
6. out/in-проекции и *
Очень полезно держать в голове разницу между ситуациями “я знаю, что это за элементы, но хочу ограничить операции” и “я вообще не знаю, что за элементы”.
В MutableList<out Number> вы знаете, что это “список каких-то чисел”, и поэтому можете безопасно читать как Number, но не можете добавлять (потому что “какие именно числа” неизвестно). В MutableList<in Int> вы знаете, что туда безопасно добавлять Int, но читать вы сможете только максимально общо (обычно как Any?).
А MutableList<*> — это “я не знаю ничего”: ни верхней границы кроме Any?, ни нижней границы для записи. Поэтому он максимально строгий к записи.
Можно представить это как такой “ползунок информации”:
flowchart LR
A["MutableList⟨Any?⟩
(всё знаю: могу читать Any? и писать Any?)"] -->
B["MutableList⟨out Number⟩
(знаю верхнюю границу: читаю Number, не пишу)"] -->
C["MutableList⟨*⟩
(почти ничего не знаю: читаю Any?, не пишу)"]
Да, это упрощение (как все хорошие учебные объяснения), но оно отлично работает как интуиция: чем меньше вы знаете про тип — тем меньше Kotlin вам разрешает делать.
7. Типичные ошибки при работе со star-projection *
Ошибка №1: думать, что List<*> — это “список Any”, и сразу обращаться к методам элементов.
Когда вы пишете val x: Any? = xs[0], а потом пытаетесь сделать x.length, компилятор совершенно справедливо не понимает, с чего вы решили, что это строка. Правильный путь — проверка типа (if (x is String)) и только потом работа со строковыми свойствами.
Ошибка №2: пытаться добавлять элементы в MutableList<*> и удивляться, что нельзя даже null.
Это один из самых “обидных” моментов для новичка, потому что кажется, что null — универсален. Но MutableList<*> означает, что на запись там стоит логика уровня “in Nothing”, то есть нельзя записать ничего типобезопасного (и null тоже не подходит). Если вам нужно добавлять элементы, значит в контракте функции должен быть конкретный “входной” тип, например MutableList<in Int> или MutableList<Any?>, а не *.
Ошибка №3: путать MutableList<*> и MutableList<Any?> и выбирать звёздочку “на всякий случай”.
Звёздочка — это не “универсальнее”, а “строже”. Она идеально подходит для сценариев “прочитать/перебрать/распечатать”, но она неудобна там, где вы хотите что-то складывать внутрь. Если ваша функция по смыслу должна наполнять список — значит вы не в мире *, вы в мире in.
Ошибка №4: делать небезопасные приведения типа (as MutableList<String>) вместо звёздочки, чтобы “просто заработало”.
Иногда очень хочется “продавить компилятор”: написать as и пойти дальше. Но в generics это часто заканчивается тем, что программа компилируется, а потом падает в рантайме в неожиданном месте. List<*> и MutableList<*> полезны тем, что позволяют честно сказать: “я не знаю тип” — и компилятор сам не даст вам сделать опасные операции.
Ошибка №5: ждать, что проверка x is List<*> автоматически даст вам знания о типе элементов.
Эта проверка отвечает только на вопрос “это список или нет”. После неё элементы всё равно будут Any?, и это нормально. Если вам нужно понять, что внутри строки, числа или ваши Expense, придётся делать проверку элементов (is String, is Int, is Expense) или строить другой контракт функции, где тип известен заранее.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ