Иерархия типов Kotlin — от Any? до Nothing

· 7 минут чтения

kotlinосновы

По Kotlin написана гора документации и туториалов, но целостной картинки «как устроена система типов» в них обычно нет: null safety объясняют отдельно, Unit — отдельно, Nothing вообще упоминают вскользь, как экзотику.

А картинка того стоит, потому что она маленькая. Правил буквально несколько, они не конфликтуют друг с другом и из них уже выводится всё остальное: и то, почему String нельзя присвоить null, и то, почему IDE подсвечивает недостижимый код, и то, почему ?: throw ... вообще компилируется.

Ниже — вся иерархия по слоям, снизу вверх, с примерами, где это всплывает в реальном коде.

Диаграммы в статье — из заметки Ната Прайса A Whirlwind Tour of the Kotlin Type Hierarchy, она же и подтолкнула написать этот разбор.

Наверху — Any

Все типы Kotlin выстроены в иерархию отношений «подтип — супертип». На вершине стоит абстрактный класс Any. String и Int — оба его подтипы.

Any, а под ним String и Int

Any — это аналог явского Object, но с важной разницей. В Java есть примитивы (int, double, boolean), которые живут вне иерархии классов, и есть обёртки над ними. В Kotlin такого разделения на уровне языка нет: Int — обычный тип, подтип Any, и стоит в иерархии ровно там же, где ваш собственный класс. Во что это скомпилируется на JVM — примитив или объект — дело компилятора, а не вашей модели типов.

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

class Fruit(val ripeness: Double)

Fruit как непосредственный подтип Any

Если базовый класс указан — он и станет непосредственным супертипом, а Any останется общим предком всей ветки:

abstract class Fruit(val ripeness: Double)

class Banana(ripeness: Double, val bendiness: Double) : Fruit(ripeness)
class Peach(ripeness: Double, val fuzziness: Double) : Fruit(ripeness)

Banana и Peach под Fruit, Fruit под Any

Интерфейсы дают несколько непосредственных супертипов сразу — но корень всё тот же:

interface ICanGoInASalad
interface ICanBeSunDried

class Tomato(ripeness: Double) :
    Fruit(ripeness),
    ICanGoInASalad,
    ICanBeSunDried

Tomato с несколькими супертипами

Дальше работает единственное правило проверки типов: значение подтипа можно положить в переменную супертипа, наоборот — нельзя.

var f: Fruit = Banana(ripeness = 0.4, bendiness = 0.5)
f = Peach(ripeness = 0.6, fuzziness = 0.8)   // ок

val b = Banana(ripeness = 0.4, bendiness = 0.5)
val f2: Fruit = b                             // ок
val b2: Banana = f2                           // ошибка компиляции
// Type mismatch: inferred type is Fruit but Banana was expected

Пока ничего нового по сравнению с Java. Интересное начинается дальше.

Вторая иерархия — nullable-типы

В отличие от Java, Kotlin различает «ненулевые» и «обнуляемые» типы. Всё, что было выше, — ненулевое. Положить null в такую переменную нельзя, и разыменование ссылки такого типа гарантированно не бросит NullPointerException.

var s: String = null
// Error: Null can not be a value of a non-null type String

Если значение может отсутствовать, берётся nullable-эквивалент — тот же тип с суффиксом ?. String? — это все значения String плюс null:

var s: String? = null
s = "foo"
s = null

Ключевой момент: nullable-типы — не пометка на типе и не аннотация. Это полноценная параллельная иерархия, устроенная по тем же правилам. Если Banana — подтип Fruit, то Banana? — подтип Fruit?. И раз Any — корень ненулевой иерархии, то Any? — корень обнуляемой.

Две параллельные иерархии: ненулевая и nullable

Дальше добавляется вторая половина правила, которая и связывает две иерархии в одну: ненулевой тип является подтипом своего nullable-эквивалента. String — подтип и Any, и String?.

String как подтип и Any, и String?

Вот отсюда и растёт вся null safety. Никакого отдельного механизма нет: String можно положить в String?, потому что это подтип; String? нельзя положить в String, потому что это супертип. То самое правило, что и с Banana и Fruit.

Для пользовательских иерархий всё точно так же:

Nullable-иерархия для пользовательских типов

И раз Any? — супертип Any, то именно Any? находится на самой вершине всей системы типов Kotlin. Это единственный тип, в переменную которого можно положить вообще что угодно.

Unit — вместо void

Kotlin — язык выражений: почти все конструкции управления потоком (кроме присваивания, что как раз необычно) являются выражениями и имеют значение. Функций, не возвращающих ничего, здесь нет — вместо void есть тип Unit с единственным значением, тоже Unit.

Писать его руками почти никогда не нужно. У функции с блочным телом без указанного типа результата тип выводится в Unit; у функции-выражения — так же, как и любой другой тип:

fun example1() {
    println("блочное тело без типа результата — значит Unit")
}

val u1: Unit = example1()

fun example2() =
    println("функция-выражение, компилятор сам выводит Unit")

val u2: Unit = example2()

Ничего особенного в Unit нет — обычный тип. Подтип Any, имеет nullable-эквивалент Unit?, который, в свою очередь, подтип Any?.

Unit и Unit? в иерархии

Unit? — забавный пограничный случай, который существует просто потому, что система типов последовательна. В нём ровно два значения: Unit и null. На практике писать его руками не приходится, но именно отсутствие спецслучая для «void» позволяет обобщённо работать с любыми функциями: () -> Unit — такой же нормальный тип функции, как () -> String, и Unit спокойно подставляется в generic-параметр.

Nothing — на самом дне

В самом низу иерархии находится Nothing.

Nothing как подтип всех типов

Название буквальное: у Nothing нет ни одного экземпляра. Выражение типа Nothing не вычисляется в значение — вообще никогда.

Разница с Unit здесь принципиальная и её стоит проговорить: вычисление выражения типа Unit даёт значение — синглтон Unit. Вычисление выражения типа Nothing не возвращает управление.

Отсюда следует, что код после выражения типа Nothing недостижим — и компилятор с IDE вас об этом предупредят.

Какие выражения имеют тип Nothing? Те, что передают управление куда-то в сторону.

throw прерывает вычисление и выбрасывает исключение наружу — значит, throw это выражение типа Nothing. И вот тут начинает работать главное свойство: Nothing — подтип любого типа. Именно поэтому throw можно поставить в любую ветку любого выражения, и тип выражения от этого не испортится:

fun formatCell(value: Double): String =
    if (value.isNaN())
        throw IllegalArgumentException("$value не число")
    else
        value.toString()

Тип if-выражения — общий супертип обеих веток. Одна ветка даёт String, другая Nothing, а раз Nothing — подтип String, общий супертип это String. Никакой магии, обычный вывод типов.

Тип Nothing есть и у return — это тоже передача управления, прерывающая вычисление выражения, частью которого она является:

fun formatCellRounded(value: Double): String {
    val rounded: Long = if (value.isNaN()) return "#ERROR" else Math.round(value)
    return rounded.toString()
}

Функция, уходящая в бесконечный цикл или убивающая процесс, тоже возвращает Nothing. В стандартной библиотеке, например, так объявлен exitProcess:

fun exitProcess(status: Int): Nothing

И если вы напишете свою функцию с результатом Nothing, компилятор будет проверять недостижимый код после её вызова ровно так же, как после встроенных конструкций:

inline fun forever(action: () -> Unit): Nothing {
    while (true) action()
}

fun example() {
    forever {
        println("работаем...")
    }
    println("готово") // Warning: Unreachable code
}

Как и null safety, анализ недостижимого кода здесь не отдельная проверка в компиляторе и IDE, как приходится делать в Java. Это следствие системы типов.

Nothing? — тип значения null

Nothing, как и любой другой тип, можно сделать обнуляемым — получится Nothing?. В нём может лежать ровно одно значение: null. Собственно, Nothing? и есть тип литерала null.

Nothing? — предельный подтип всех обнуляемых типов, и именно поэтому null подставляется в переменную любого nullable-типа.

Nothing? как подтип всех nullable-типов

Проверить это можно прямо в IDE:

val x = null            // выведенный тип: Nothing?
val list = listOf(null) // List<Nothing?>

Где это пригодится на практике

Всё вышесказанное — не только про красоту устройства. Несколько мест, где знание про Nothing экономит время:

Elvis с выходом из функции. Классика, которая работает именно из-за Nothing:

fun loadName(id: String): String {
    val user = repository.find(id) ?: return "аноним"
    return user.name
}

Правая часть ?: имеет тип Nothing, поэтому тип всего выражения — User, а не какой-нибудь Any. По той же причине после такой строки работает smart cast: компилятор знает, что дальше user не может быть null.

when, который не разъезжается в Any. Сравните две ветки-заглушки:

val level = when (code) {
    1 -> "низкий"
    2 -> "высокий"
    else -> error("неизвестный код $code")  // error(): Nothing → level: String
}

Если бы error() возвращала Unit, общим супертипом веток стал бы Any и переменная получила бы бесполезный тип. error(), TODO() и exitProcess() объявлены возвращающими Nothing как раз для этого — заглушка не портит вывод типов, и TODO() можно смело ставить в тело функции с любым типом результата.

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

sealed interface Result<out T> {
    data class Ok<out T>(val value: T) : Result<T>
    data class Err(val message: String) : Result<Nothing>
}

fun parse(raw: String): Result<Int> =
    raw.toIntOrNull()?.let { Result.Ok(it) } ?: Result.Err("не число: $raw")

Err не хранит значение, поэтому параметризован Nothing. А раз Nothing — подтип всего и T объявлен как out, Result<Nothing> оказывается подтипом Result<Int>, Result<User> и вообще любого Result<T>. Один объект ошибки подходит везде, дублировать его под каждый тип не нужно.

Ровно по этой же причине в стандартной библиотеке пустая коллекция — один синглтон на всю программу: emptyList() возвращает объект типа List<Nothing>, который является List<T> для любого T.

Вся картина целиком

Если посмотреть на всё сразу, выглядит внушительно:

Полная иерархия типов Kotlin

Но пугаться нечего — правил здесь всего ничего:

  1. Типы образуют иерархию «подтип — супертип», на вершине ненулевой ветки — Any.
  2. У каждого типа есть nullable-эквивалент, и nullable-типы образуют такую же иерархию.
  3. Ненулевой тип — подтип своего nullable-эквивалента. Отсюда Any? на самой вершине.
  4. Nothing — подтип вообще всех типов, а Nothing? — тип значения null.

Ни одного спецслучая. Null safety, полиморфизм и анализ недостижимого кода — не отдельные фичи, прикрученные к компилятору сбоку, а прямые следствия этих четырёх пунктов. Потому проверка типов в Kotlin и работает предсказуемо: она не «знает про null», она просто сравнивает типы.


Оригинал, откуда взяты диаграммы: Nat Pryce, A Whirlwind Tour of the Kotlin Type Hierarchy (2016).