Первый релиз языка Kotlin включает в себя Kotlin Generics. Обобщенные типы, благодаря которым мы с вами можем писать более гибкие приложения с меньшим дублированием кода и большей типобезопасностью. Одно из важных преимуществ, которое дает вашему приложению использование Generics - проверка типов на этапе компиляции. Дизайнеры языка Kotlin ввели новые ключевые слова для работы с дженериками, что по началу может ввести в ступор даже опытного Java разработчика. Сегодня мы с вами обсудим что такое “дженерики”, удалим страх перед словами типа “контравариантность”, а заодно познакомимся с reified generics и star-projection.
Generics
Для тех кто вовсе не знаком с обобщенными типами (generics) и для тех, кто порядком подзабыл, я напомню,
что обобщенные типы это возможность выполнять контроль типов в вашем приложении при том, что точный тип вам не известен.
Приведу пример, у вас есть коллекция Set, которая может быть хранилищем для абсолютно разных объектов, так, в одном
месте приложения вы возпользуетесь Set<User>
для работы с множеством пользователей, а в другой части приложения это
будет Set<Task>
- коллекция, которая хранит выполненные задачи. В обоих случаях коллекция Set выполняет возложенные
на неё обязательства, более того, компилятор может проверить, что метод add принимает объект соответствующего типа, ровно
как мы получим объекты этого типа проходя по коллекции.
Создание классов с обобщенными Kotlin типами в простейшем виде выглядит следующим образом:
class Container<T>(var contained: T)
T - это лишь типичный пример именования неизвестного типа, вместо T вы можете использовать любой идентификатор для
удобства. В примере выше мы описали класс, единственный параметр класса имеет общенный тип. Обратите внимание, что свойство
класса имеет модификатор var
и здесь это не с проста, ниже я объясню почему. Воспользуемся контейнером
следующим образом:
val container = Container(25)
Обратите внимание, в сравнении с Java нам нет необходимости явно указывать Generic тип для контейнера, т.к. компилятор обладает полной информацией для выведения этого типа. Аналогично, начиная с Java 7, мы имеем возможность писать сокращенный вариант с использованием Diamond Operator. В совокупности с возможностью Kotlin выводить типы и для переменных мы имеем сокращенный синтаксис и при этом гибкий набор инструментов.
Но что, если мы хотим положить в наш контейнер другой Int? Легко!
container.contained = 42
Затем, мы можем прочитать его, также легко.
println(container.contained)
На каждом этапе, на каждой строке компилятор проверяет, что мы работаем именно с Int, а не с чем бы то ни было другим, благодаря этому, мы, например, не можем записать в наш контейнер String.
Инвариантный, ковариантный, контравариантный
Должен вам признаться, если не работать со сложными вариациями обобщенных типов, то со временем всё забывается и приходится обновлять знания, вспоминать некоторые термины и иногда это дается с трудом, т.к. множество источников по generics пестрят красивыми словами инвариантный, ковариантный, контравариантный, однако, эти же источники совершенно невнятно объясняют терминологию. Сегодня я нацелен исправить это и перешагнуть через тонкую грань непонимания в данном вопросе.
Инвариантность
Возвращаясь к примеру с контейнером из предыдущего раздела, мы имеем тип Container<T>
, где вместо T может оказаться
любой тип с верхней границей Any? (об этом позже, коротко, любой nullable и не-nullable тип). Допустим, что мы
сохранили в контейнер значение с типом Int следующим образом:
val container: Container<Int> = Container<Int>(25)
Я определяю тип переменной и дженерик для очевидности, по умолчанию, компилятор вполне справляется с их выводом
Сможем ли мы обобщить нашу переменную чуть больше. Int наследуется от Number, и допустим мы хотим хранить в переменной контейнер не только с Int, а в принципе с Number, т.е. в нем может находиться любое число, например, Double. Интуитивно хочется написать:
val container: Container<Number> = Container<Int>(25)
Однако, такой код не скомпилируется, т.к. не смотря на то, что Int наследуется от Number, то типы с дженериками
Container<Number>
и Container<Int>
не находятся в одной иерархии, по сути, это абсолютно разные типы, никак не
совместимые. Другим языком, класс Container неизменен (инвариантен) по типу T. Следовательно, независимо от иерархии
наследования самих обобщенных типов, по умолчанию, мы не можем говорить об каком-либо следствии иерархии классов
использующих их.
Ковариантность
Вы можете задаться вопросом: “А зачем это сделано? Почему не дать иерархию как следствие иерархии обобщенных типов по умолчанию?“. Дело в том, что основная задача обобщенных типов - обеспечить безопасность типизации в вашем коде, уменьшить количество выбрасываемых исключений во времени выполнения уже на этапе компиляции, ну и конечно сделать всё это гибко. Обрисовать проблему мне помогут массивы из Java. Так получилось, что в Java массивы появились на много раньше чем обобщенные типы. Когда вводили массивы дали возможность сказать, что если у вас есть массив целочисленных значений (Integer[]), то вы можете присвоить это значение в переменную Number[]. Казалось бы, вот мы и получили наследование, но давайте рассмотрим следующий кусочек кода:
Integer[] a = { 4, 5 }; //создаем массив Integer Number[] b = a; // делаем еще одну ссылку на массив с типом Number[] и это допустимо b[1] = 4.4; //сохраняем Double значение в массив Integer //Exception in thread "main" java.lang.ArrayStoreException: java.lang.Double
Вот так просто мы можем получить ошибку, которую увидим только на этапе выполнения (в JVM есть специальная проверка, которая выполняется на записи), следовательно, наследование как следствие по умолчанию - это плохая идея. Однако, следующий код допустим, не так ли?
Integer[] a = { 4, 5 }; Number[] b = a; System.out.println(b[1]); //output: 5
Здесь всё законно, мы сделали те же самые действия, но вместо присвоения значения, мы его прочитали. Получается, что иногда поведение передачи наследования в соответствии с обобщенным типом может быть безопасно. Например, у нас есть метод, который будет только распечатывать значения из входного параметра и, в принципе, он может принимать любые числа, хоть Integer и Double в одном массиве, а затем печатать их. Так мы можем вывести правило:
если наша пременная только предоставляет значения, то мы можем допустить, что в ней может находиться значение ниже по иерархии обобщенного типа
аналогично для классов с обобщенными типами:
если объект нашего класса только предоставляет значения, то мы можем допустить, что в нём хранится значение ниже по иерархии обобщенного типа
Что значит “ниже по иерархии”? В Kotlin, как и в любом другом типизированном языке, существует иерархия типов. Если вы слабо представляете её, то я могу порекомендовать вам вот эту статью, которая поможет разобраться в системе типов. Ну а сейчас, для краткости, я лишь скажу, что для типа Number типы Int и Double лежат ниже по иерархии типов (могут быть безопасно приведены к Number), а тип Any лежит выше по иерархии типов (к нему можно безопасно привести Number).
Другими словами, нужно как-то объяснить компилятору, что в некоторых ситуациях мы готовы к ковариантности. Тут то мы и подобрались к этому страшному слову. Ковариантностью называется сохранение иерархии типов в производных типах в том же порядке.
Мы можем сказать, что Container<T>
ковариантен своему параметру-типу T. Это будет означать, что если Int - потомок
Number, то Container<Int>
будет тоже считаться потомком Container<Number>
. Напомню, что выше мы убедились
в том, что ковариантность допустима, только когда мы говорим о получении значения, а по умолчанию обобщенные типы инвариантны,
т.е. мы можем безопасно получить Number, если работаем с Container<Int>
или контейнером для любого другого наследника Number.
Существует два способа объяснить компилятору то, что Container ковариантен по параметру-типу T в Kotlin. Давайте их рассмотрим.
Ковариантность на уровне класса (на месте объявления)
Когда мы объявляем класс, мы можем изначально говорить о том, что класс ковариантен своему типу-параметру. Работать с ковариантными
типами нам помогает ключевое слово out
. Семантика его крайне проста в понимании. Если класс выступает в качестве Producer’a,
т.е. мы планируем значения типа Т только получать, то мы используем ключевое слово out
, т.е. класс поставляет
значения типа наружу. Давайте изменим декларацию нашего класса Container следующим образом:
class Container<out T>(var contained: T)
Тут же мы получаем ошибку компиляции, но какую?
Type parameter T is declared as 'out' but occurs in 'invariant' position in type T
Какая странная ошибка, где это у нас возникает потребность в инвариантном состоянии? Точно! Ключевое слово var
!
Мы совсем забыли о том, что var
отличается от val
наличием возможности изменить это значение, а как мы помним,
ковариантность возможна только в случае, если вы запретите любые изменения значения. Заменим var на val
class Container<out T>(val contained: T)
Вот и всё. Теперь наш класс Container ковариантен параметру-типу T, это означает, что мы легко можем использовать следующую конструкцию:
val container: Container<Number> = Container<Int>(25)
Инвариантность как рукой сняло! Но чем мы за это заплатили?
val container: Container<Int> = Container<Int>(25) val container2: Container<Number> = container container2.contained = 4.0 //не компилируется, т.к. нельзя менять значение, зато мы // защищены от ошибок как с Java массивами
Как вы видите теперь тип Container<Int>
наследник типа Container<Number>
, благодаря чему мы можем свободно присваивать его переменным
типа Container<Number>
, но не можем менять значения типа T и это правильно. Представьте, если бы последняя строчка
компилировась. Это значит, что через свойство contained было бы записано Double значение! Получается, что переменная
container с типом Container<Int>
содержала бы не Int, а Double, ведь и container, и container2 это две ссылки на один и
тот же объект в памяти. И при попытке получения container.contained мы бы словили ClassCastException
.
Ковариантность на уровне класса - дело хорошее, оно гребёт под себя весь класс и заставляет нас не менять значения, но, что если нам не нужна ковариантность для всего класса? Пора разобраться с ковариантностью на месте использования.
Ковариантность на месте использования
Один из мощных механизмов управления иерархиями обобщенных типов это ковариантность на уровне объявления. Вспомним нашу первую версию инвариантного контейнера:
class Container<T>(var contained: T)
Если наш класс не ковариантен какому-либо параметру T, то мы можем свободно менять его значения как хотим:
val sourceContainer: Container<Int> = Container<Int>(25) sourceContainer.contained = 4 // компилируется
А в тот момент, когда нам понадобится завести общую переменную под контейнеры с разными параметрами-типами, мы сможем это сделать, воспользовавшись ковариантностью на месте использования (проекцией типа).
val covariantContainer: Container<out Number> = sourceContainer covariantContainer.contained = 4 /* не компилируется, работаем с переменной у которой тип ковариантен параметру-типу значит нельзя менять значения, т.к. мы не знаем исходный тип, там может быть любой контейнер с параметром-типом наследником Number */
Благодаря, ковариантности на уровне объявления мы можем делать многие вещи очень гибко, например, написать следующий код:
class Container<T: Any> { //что такое T: Any вы узнаете чуть позже /* Пусть на момент создания контейнера мы еще не знаем, что конкретно он будет содержать, но знаем точно, что что-то с некоторым типом T он должен, следовательно после создания, рано или поздно, мы поменяем значение поля contained, а значит оно по определению не может быть val. В такой ситуации, мы не можем объявлять ковариантность на уровне класса */ lateinit var contained: T override fun toString(): String { return "Container(contained=$contained)" } } /* Возвращаемый тип Container<out Number> создает ковариантность на месте использования, следовательно, из метода мы возвращаем контейнер, который типизирован чем-то ниже в иерархии наследования */ fun createIntContainer(): Container<out Number> { val container = Container<Int>() container.contained = 42 return container } fun createDoubleContainer(): Container<out Number> { val container = Container<Double>() container.contained = 42.0 return container } fun main(args: Array<String>) { var covariantContainer = createIntContainer() //Container<out Number> println(covariantContainer) covariantContainer = createDoubleContainer() //Container<out Number> println(covariantContainer) }
Вывод в консоли:
Container(contained=42)
Container(contained=42.0)
Таким образом, мы успешно хранили в одной переменной контейнеры с разным параметром-типом, при том, что сам контейнер инвариантен по своему параметру-типу Т.
С ковариантностью разобрались, осталась контравариантность.
Контравариантность
Кроме того, чтобы описывать классы как producer’ы, иногда, нам может понадобиться описать классы как consumer’ы или потребители. Давайте рассмотрим следующую иерархию типов:
Пользователь
/ \
Работник Администратор
Пусть перед нами стоит задача написать логгер тех, кто входит в систему. Каждый пользователь системы имеет логин и именно его мы будем распечатывать.
Объявление такой иерархии типов может выглядеть, например, так:
open class User(var login: String) class Worker(login: String): User(login) class Admin(login: String): User(login)
Создадим класс Printer, который инкапсулирует в себе логику печати:
class Printer<T: User> { fun print(user: T) { println("User ${user.login} is logged in") } }
Пока класс инвариантен и в большинстве случаев этого будет достаточно благодаря автоматическому приведению типов. Подумайте,
если бы мы передали в метод print объект типа Admin, то он спокойно был бы принят компилятором и приведен к верней границе
обобщенного типа User, но все приложения разные. Допустим, у нас есть метод printAdmin
, который знает какие предобработки нужно
сделать, а затем вызывает правильный метод у объекта класса Printer
. Напишем метод printAdmin
:
fun printAdmin(printer: Printer<Admin>, admin: Admin) { //admin specific printer.print(admin) }
Такой метод в своём использовании сужает вариативность нашего принтера, теперь принтер должен уметь печатать именно администратора.
Принтер для работника здесь не подойдет, а подойдет ли принтер для пользователя в принципе? Почему бы и нет, админ - тоже пользователь,
значит мы должны уметь печатать его тем же принтером, что и других пользователей. Получается, если Админ наследуется от Пользователя, то
принтер пользователя, должен быть наследником принтера админа (только в этом случае мы сможем безопасно привести тип
Printer<User>
к типу Printer<Admin>
). За сим мы наблюдаем разворот иерархии в другую сторону - это и обозначают сложным
словом контравариантность. Схематично контравариантность можно обозначить следующим образом:
User Printer<Admin>
↑ → ↑
Admin Printer<User>
Как вы, уверен, уже запомнили, обобщенные типы сами по себе инвариантны, по этому следующий код не скомпилируется:
val admin = Admin("admin") printAdmin(Printer<User>(), admin)
Однако, мы можем это изменить. Для этого в нашем арсенале есть ключевое слово in
. Применять мы можем его как на уровне объявления,
так и на уровне использования, аналогично ковариантной аннотации обобщенного типа out
. Выглядит применение на уровне класса
следующим образом:
class Printer<in T: User> { //появился in fun print(user: T) { println("User ${user.login} is logged in") } }
Компилятор будет всячески не давать заполучть T из объектов типа контравариантного типу-параметру. После манипуляций выше наш код ниже компилируется:
val admin = Admin("admin") printAdmin(Printer<User>(), admin)
И печатает:
User admin is logged in
Producer Out, Consumer In
В классической книге Effective Java автор Joshua Bloch предлагает аббревиатуру PECS для запоминания правила использования
ключевых слов extends и super в обобщенных типах Java. Аббревиатура гласит, Producer Extends, Consumer Super
, что
означает если класс является Producer’ом обобщенного типа, то необходимо использовать ключевое слово extends, а если потребителем, то
super. В Kotlin всё на много проще. Хотя мы можем переизобрести аббревиатуру блоха POCI (Producer Out, Consumer In),
это того не стоит. На самом деле, ключевые слова out и in говорят сами за себя, только думать нужно не в терминах иерархии типов,
а, скорее, думать о том как перемещаются данные. Если ваш класс поставляет обобщенно-типизированные значения наружу,
то вам нужен out
, а если класс потребляет значения обобщенного типа, то нужен in
.
Верхняя граница обобщенного типа
Как вы уже заметили, несколько раз за статью я использовал конструкции вида <T: SomeClass>
. Прежде всего я хочу вас спросить,
а какой тип находится на вершине иерархии типов в Kotlin? Аналогично Object в Java, к чему можно привести любой тип?
Правильным ответом будет Any?
. Сам по себе класс Any
это аналог Object
, однако, в следствие поддержки Nullable и не
Nullable типов в Kotlin мы получили Any?
. Фактически, Any?
соответствует любому типу и null
, а Any
только любому типу.
Подробнее об иерархии типов я предлагаю прочитать статью, которую вы найдете в последнем разделе “Бонус”. Обобщенные
типы ограничены сверху максимально широким типом, а именно Any?
из-за чего может возникать некоторое недопонимание в использовании их.
Следовательно, по умолчанию дженерик тип - Nullable, это означает, что свойства с таким типом могут принимать значения
null
, но когда мы будем работать с ними, то компилятор потребует удостовериться, что через свойство доступно не null значение.
Таким образом, когда мы пишем <T: Any>
мы явно говорим, что тип, указываемый в качестве конкретного варианта вместо
обобщенного типа не может содержать null
. Аналогично, мы можем понизить верхнюю границу по иерархии еще ниже и потребовать, например,
указывать в качестве типов только чила: <T: Number>
.
Верхних границ может быть несколько, в этом случае необходимо указывать их в специальном разделяющем where
условии:
class Container<T>(var contained: T) where T: Number, T: Comparable<T>
Аналогично для функции для которой объявлен обобщенный тип.
Star-Projection
Часто мы сталкиваемся с так называемой проекцией типов. Простым языком, это ограничение типа на месте использования. Например, мы не хотим, чтобы в наш контейнер могли по ошибке что-то записать внутри метода, для этого ограничиваем тип:
fun process(container: Container<out Number>) { container.contained = 4 //не компилируется }
Благодаря out здесь мы можем только читать из контейнера, но не записывать. В примерах выше мы обсуждали это поведение.
Однако, иногда возникает ситуация, что у нас нет информации о необходимом нам типе проекции, но мы знаем, что Container, к примеру, имеет верхнюю границу для ковариантного параметра-типа - Number и нам этого достаточно. Тогда, мы можем воспользоваться star-projection.
fun process(container: Container<*>) { // символ * println(container.value) // мы можем печатать значения, т.к. знаем верхнюю границу обобщенного типа container.value = 4 // но мы не можем писать значения, т.к. не знаем типа }
Для ковариантных параметров * означает “могу читать значения с типом верхней границы обобщенного типа”. Для контравариантных параметров * означает “не могу ничего безопасно писать” Для инвариантных параметров символ * означает: “при чтении аналогично * для ковариантного, при записи аналогично * для контравариантного параметра”.
Стирание типов
Аналогично с Java в Kotlin происходит стирание информации об обобщенных типах, т.е. обобщенные типы - наши помощники только на этапе компиляции. На этапе выполнения кода информации уже нет, однако, есть хитрые способы, которые вы можете найти для частного решения в некоторых случаях.
Reified Generics и inline function
Модификатор inline для функций позволяет встраивать код в место вызова без какого-либо обращения к реальной функции. Такой модификатор позволяет не создавать слишком много лямбд, которые передаются в методы и является небольшой оптимизацией. Кроме того, этот модификатор позволяет сохранить информацию об обобщенных типах объявленных в рамках методов даже на этапе выполнения. Рассмотрим небольшой пример:
inline fun <reified T: Number> printClass(value: T) { println(T::class) } fun main(args: Array<String>) { printClass(4) printClass(4.0) }
Результат выполнения:
class kotlin.Int
class kotlin.Double
Ключевое слово reified
говорит о том, что нужно иметь доступ к информации о типе T. Таким образом, в некоторых случаях,
даже явно не передавая тип, мы можем работать с ним.
Резюме
Подводя итоги статьи, хочется отметить, что обобщенные типы - довольно сложная часть как Java, так и Kotlin и их понимание
крайне важно для бесстрашного построения гибких классов, готовых к разностороннему применению. Можно долго спорить,
что понятнее Java программисту, который пришел в Kotlin, Java синтаксис обобщенных типов или out
и in
. На мой взгляд,
если у вас есть понимание того для чего вы применяете обобщенные типы, то никакой синтаксис не помешает вам их использовать.
Бонус
Погружаясь в тему обобщенных типов вы можете посмотреть видео с конференции JPoint2016 “Неочевидные Дженерики” об обобщенных типах из Java вот здесь
Статья об иерархии типов в Kotlin
Альтернативное чтение на тему дженериков
Немає коментарів:
Дописати коментар