JavaRush /Курсы /Kotlin SELF /Подтипность и generics: почему List поднимается, а Mutabl...

Подтипность и generics: почему List поднимается, а MutableList — нет

Kotlin SELF
36 уровень , 0 лекция
Открыта

1. Введение

Когда мы говорим «подтипность», мы обычно вспоминаем наследование: один тип расширяет другой. И в обычной (не-обобщённой) жизни всё довольно линейно: если Expense наследуется от Transaction, то переменная типа Transaction может хранить ссылку на Expense. Это основа полиморфизма: «можно подставить наследника вместо базового класса».

Давайте зафиксируем это на маленьком фрагменте кода из нашего условного приложения учёта финансов (пусть оно называется BudgetTracker). Здесь транзакция — базовый тип, а расход — конкретный вид транзакции:


open class Transaction(val amount: Int, val comment: String)

class Expense(amount: Int, comment: String, val category: String) :
    Transaction(amount, comment)

fun main() {
    val t: Transaction = Expense(500, "Кофе и круассан", "Food")
    println(t.comment) // Кофе и круассан
}

Это выглядит логично: расход — это транзакция, значит переменная «транзакция» может на него ссылаться. И вот здесь легко сделать следующий «интуитивный шаг»… который окажется не всегда верным, когда появятся угловые скобки.

2. Почему Box<Expense> не является Box<Transaction>

Как только мы берём тип T и начинаем заворачивать его в обобщённый контейнер Box<T>, у нас появляется новая сущность: не T, а «коробка с T». И вот тут Kotlin по умолчанию выбирает максимально осторожную позицию: обобщённые типы по умолчанию считаются инвариантными, то есть «никаких автоматических подстановок по наследованию не обещаем».

Сделаем простейшую «коробку»:

class Box<T>(var value: T)

open class Transaction(val amount: Int)
class Expense(amount: Int) : Transaction(amount)

fun main() {
    val expenseBox: Box<Expense> = Box(Expense(100))

    // Так нельзя (и это не каприз):
    val txBox: Box<Transaction> = expenseBox
}

На уровне человека хочется сказать: «Ну Expense же наследник Transaction, значит и коробка с расходом — коробка с транзакцией». Но компилятор не согласен. И он не вредничает: он заранее предотвращает ситуацию, когда через «коробку транзакций» мы внезапно засунем туда не расход, а что-то другое (например, доход), и сломаем предположение, что внутри лежит именно Expense.

В этом месте полезно запомнить короткую мысль: подтипность элементов не обязана переноситься на подтипность контейнеров. Дальше мы увидим, почему для List Kotlin делает приятное исключение — а для MutableList нет.

3. Почему List<Expense> можно использовать как List<Transaction>

Сейчас будет важный момент, который в Kotlin часто путают: List — это не «просто список», а read-only интерфейс. Он даёт операции чтения: получить размер, взять элемент по индексу, пройти циклом, но не даёт «добавить» или «удалить». Kotlin стандартно разделяет коллекции на read-only и mutable-интерфейсы: есть интерфейс для чтения и отдельный интерфейс для чтения+записи.

И вот ключ: read-only коллекции в Kotlin ковариантны, то есть List<Rectangle> можно использовать там, где ожидают List<Shape>, если Rectangle наследуется от Shape. По-человечески: «если список только читаем, то список более конкретных элементов можно воспринимать как список более общих».

Перенесём это на наши транзакции:

open class Transaction(val amount: Int, val comment: String)
class Expense(amount: Int, comment: String) : Transaction(amount, comment)

fun main() {
    val expenses: List<Expense> = listOf(
        Expense(120, "Кофе"),
        Expense(350, "Такси")
    )

    val transactions: List<Transaction> = expenses

    println(transactions[0].comment) // Кофе
}

Почему это безопасно? Потому что через переменную transactions вы не можете сделать transactions.add(...) — у List просто нет такой операции. То есть вы не сможете «подмешать» внутрь исходного списка расходов какую-нибудь другую транзакцию. Вы только читаете то, что там уже лежит.

Чтобы закрепить, полезно сравнить контракты:

Тип Можно читать элементы Можно добавлять/удалять
List<T>
да нет
MutableList<T>
да да

И именно из-за этого read-only интерфейса List Kotlin разрешает «поднимать» тип по иерархии.

Важно: read-only — это не то же самое, что «неизменяемый навсегда». List не обещает, что данные никогда не изменятся где-то в другом месте; он обещает, что через этот конкретный тип ссылки вы не сможете мутировать коллекцию. Kotlin прямо описывает такую модель read-only vs mutable интерфейсов.

4. Почему MutableList<Expense> нельзя использовать как MutableList<Transaction>

Сейчас мы подходим к самой «больной» части: почему компилятор запрещает то, что кажется таким логичным. Здесь всё упирается в одну вещь: MutableList позволяет записывать.

Представим, что Kotlin разрешил бы такую строчку:

open class Transaction(val amount: Int)
class Expense(amount: Int) : Transaction(amount)
class Income(amount: Int) : Transaction(amount)

fun main() {
    val expenses: MutableList<Expense> = mutableListOf(Expense(100))

    // Если бы это разрешили...
    val txs: MutableList<Transaction> = expenses
    txs.add(Income(5000)) // «в список транзакций» добавили доход

    // ...то теперь внутри expenses лежал бы Income, и это ломает тип!
}

Если переменная txs — это «список транзакций», то по её контракту добавление Income(...) абсолютно легально. Но если под капотом это тот же объект, что и expenses (список расходов), то мы только что незаметно подмешали доход в список расходов. Получается нарушение типобезопасности: MutableList<Expense> перестал быть «настоящим списком Expense».

Именно поэтому Kotlin делает строгое правило: мутабельные коллекции не ковариантны, иначе это привело бы к runtime-проблемам и нарушению типов.

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

Type mismatch: Required `MutableList<Transaction>`, found `MutableList<Expense>`

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

5. Ментальная модель: «выдача» vs «приёмка»

Чтобы не заучивать всё как заклинание, полезно держать в голове простую бытовую аналогию. Представьте, что есть склад, и вы пришли к окошку. В одном случае вам разрешено только получать товары («выдача»), в другом — только принимать товары («приёмка»), а в третьем — и получать, и принимать (и тогда начинается веселье, потому что кто-то обязательно принесёт не то).

Read-only List — это почти «выдача»: вы берёте элементы и читаете их. Поэтому «склад с расходами» можно рассматривать как «склад с транзакциями»: любой расход — это транзакция, значит читать как транзакции безопасно.

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

Небольшая схема, чтобы мозг зацепился:

flowchart LR
    A["List⟨Expense⟩
(только читаем)"] --> B["List⟨Transaction⟩
(только читаем)"] C["MutableList⟨Expense⟩
(читаем+пишем)"] -.-x D["MutableList⟨Transaction⟩ (читаем+пишем)"]

Сплошная стрелка — «безопасно подставить», пунктир с крестиком — «опасно, поэтому запрещено».

6. Практика в BudgetTracker: как принимать «правильные» списки

В реальном коде боль от MutableList<Expense> обычно появляется не в абстрактных примерах, а в момент, когда вы пишете полезную функцию и хотите «просто принять список». Например: посчитать сумму, вывести в консоль, найти максимум, отсортировать, сгруппировать.

И вот здесь главный практический совет, который прямо следует из модели Kotlin: если функция не обязана менять коллекцию — принимайте List, а не MutableList. Это сразу делает ваш API гибче, и вы автоматически получаете то самое «поднятие» типов.

Сделаем в нашем приложении базовый тип и две операции: печать и подсчёт суммы. Обе не мутируют список.

open class Transaction(val amount: Int, val comment: String)
class Expense(amount: Int, comment: String) : Transaction(amount, comment)

fun printTransactions(items: List<Transaction>) {
    for (t in items) {
        println("${t.amount} — ${t.comment}")
    }
}

fun main() {
    val expenses: List<Expense> = listOf(
        Expense(120, "Кофе"),
        Expense(350, "Такси")
    )

    printTransactions(expenses)
    // 120 — Кофе
    // 350 — Такси
}

Обратите внимание на «магию без магии»: printTransactions ждёт List<Transaction>, а мы передали List<Expense>, и всё хорошо — потому что List read-only и ковариантен.

Теперь пример функции, которая хочет мутировать список — например, добавить транзакцию. Здесь уже нельзя «просто принять что угодно»:

open class Transaction(val amount: Int, val comment: String)
class Expense(amount: Int, comment: String) : Transaction(amount, comment)

fun addTestTransaction(storage: MutableList<Transaction>) {
    storage.add(Expense(10, "Тестовый расход"))
}

fun main() {
    val txs: MutableList<Transaction> = mutableListOf()
    addTestTransaction(txs)

    println(txs.size) // 1
}

И вот важное наблюдение: если у вас где-то хранится MutableList<Expense>, вы не сможете передать её в addTestTransaction, потому что это было бы потенциально опасно (мы уже обсуждали почему).

Одна из временных «тактик выживания», пока мы не изучаем специальные инструменты типовой системы (мы их сегодня ещё не трогаем), — делать копию, когда вам нужно «временно помутировать»:

open class Transaction(val amount: Int)
class Expense(amount: Int) : Transaction(amount)

fun addSomething(storage: MutableList<Transaction>) {
    storage.add(Expense(999))
}

fun main() {
    val expensesOnly: List<Expense> = listOf(Expense(100))

    val copyAsTransactions: MutableList<Transaction> = expensesOnly.toMutableList()
    addSomething(copyAsTransactions)

    println(copyAsTransactions.size) // 2
}

Да, это копирование. Да, оно стоит памяти и времени. Но зато оно честное: мы не ломаем исходный список expensesOnly, а работаем с отдельной мутабельной копией. Это нормальный «переходный мостик», пока вы ещё не вооружены более точными типовыми контрактами.

Как читать сообщения компилятора про List и MutableList

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

Если вы видите, что List<Expense> не подходит к List<Transaction>, то, скорее всего, вы на самом деле где-то используете не List, а мутабельный тип (или ожидаете его). В Kotlin read-only коллекции ковариантны, а mutable — нет. Поэтому первое, что стоит сделать — проверить сигнатуру функции: действительно ли там нужен MutableList?

Если сообщение говорит, что требуется MutableList<Transaction>, а у вас есть MutableList<Expense>, это почти всегда означает: «я вижу, что ты хочешь писать в коллекцию, а это опасно при такой подстановке». И дальше у вас есть выбор: либо изменить дизайн так, чтобы функция принимала List и не мутировала; либо хранить данные иначе; либо явно копировать коллекцию в более общий тип, как мы показывали выше.

И отдельно полезно помнить: Kotlin чётко разделяет read-only и mutable интерфейсы коллекций. Иногда достаточно поменять MutableList на List в параметре функции — и половина «странных» проблем внезапно исчезает.

7. Аналогия с функциями: почему (Transaction) -> String подходит для (Expense) -> String

Есть ещё один интересный кусочек, который помогает мозгу привыкнуть к идее «подстановка работает не всегда так, как кажется». Это типы функций.

В Kotlin функция — это тоже значение, и у неё есть тип вида (A) -> B: принимает A, возвращает B. Тут работает очень логичная мысль: если функция умеет принимать любую транзакцию, то она точно умеет принимать и расход (ведь расход — частный случай транзакции).

С точки зрения типов это выглядит «наоборот» по сравнению со списками: по входному параметру функция ведёт себя как будто «разрешает подстановку в другую сторону».

open class Transaction(val comment: String)
class Expense(comment: String) : Transaction(comment)

fun main() {
    val formatTransaction: (Transaction) -> String = { t ->
        "TX: ${t.comment}"
    }

    val formatExpense: (Expense) -> String = formatTransaction

    println(formatExpense(Expense("Кофе"))) // TX: Кофе
}

Почему это безопасно? Потому что formatTransaction готова к любому Transaction. Если ей дают Expense, она не теряется: «ну окей, это тоже транзакция». А вот если бы мы пытались сделать наоборот (подставить функцию, которая умеет форматировать только расходы, туда, где могут прилетать любые транзакции) — это было бы опасно: ей могли бы передать доход, а она «не обещала» с ним работать.

Этот пример нужен не для того, чтобы вы прямо сейчас выучили слово «контравариантность» и сказали его на собеседовании с умным видом. Он нужен, чтобы вы приняли идею: подтипность у обобщённых конструкций зависит от того, читаем мы значения или записываем. У функций «чтение/запись» проявляется как «принимаем аргумент / возвращаем результат».

8. Типичные ошибки

Ошибка №1: «Раз это List, значит он неизменяемый».
Новички часто думают, что List — это «immutable список». На самом деле List — это read-only интерфейс: вы не можете менять его через этот тип ссылки, но сама коллекция может быть мутабельной где-то ещё. Kotlin именно так и описывает модель: есть read-only интерфейс и есть mutable интерфейс, расширяющий его операциями записи.

Ошибка №2: Писать функции, которые ничего не меняют, но принимают MutableList.
Это не просто «стиль». Это практическая ловушка: вы теряете возможность передать List<Expense> туда, где ждут MutableList<Transaction>, и получаете ошибки типов. Если вы не собираетесь добавлять/удалять элементы — принимайте List, и вы автоматически получаете безопасное «поднятие» типов у read-only коллекций.

Ошибка №3: Пытаться «обмануть» систему через приведение типов as.
Иногда появляется соблазн сделать что-то вроде val x = expenses as MutableList<Transaction>, чтобы «компилилось». Это ровно тот путь, который приводит к проблемам в рантайме: вы вручную отключаете страховку компилятора. Если код реально небезопасен, вы просто откладываете падение на потом, делая его более неприятным и менее очевидным.

Ошибка №4: Путать «поднимается» и «не поднимается» как случайное правило.
Легко выучить «List можно, MutableList нельзя» как волшебное заклинание. Но гораздо полезнее держать причину: read-only коллекции ковариантны, потому что через них нельзя записывать, а mutable — не ковариантны, иначе можно нарушить тип аргумента (подмешать другой наследник). Kotlin прямо формулирует этот мотив.

1
Задача
Kotlin SELF, 36 уровень, 0 лекция
Недоступна
Список приюта
Список приюта
1
Задача
Kotlin SELF, 36 уровень, 0 лекция
Недоступна
Каталог фигур
Каталог фигур
1
Задача
Kotlin SELF, 36 уровень, 0 лекция
Недоступна
Коробка транзакций
Коробка транзакций
1
Задача
Kotlin SELF, 36 уровень, 0 лекция
Недоступна
Копия журнала
Копия журнала
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ