Найти в Дзене
Kotlin King

Kotlin. Ключевые слова in и out. А так же что такое ковариантность и контрвариантность

Оглавление

Здравствуйте!

В этой статье речь пойдет про вариантность параметризованных типов в Котлин.

ВНИМАНИЕ. СТАТЬЯ УСТАРЕЛА. ЧИТАЙТЕ ЕЁ НОВУЮ ВЕРСИЮ НА ХАБРЕ.

Цель данной статьи дать первичное понимание работы ковариантности и контрвариантности в Котлин. Здесь будет рассмотрено использование ключевых слов in и out в параметризованных типах.

Вариантность – это состояние наличия отношений наследования между параметризованными типами, содержащими параметры из одной иерархии наследования. Это мы и будем разбирать на примерах. Про то, что такое параметризованные типы вы можете почитать в
первой части.

В Котлин, для параметризованных типов она встречается в 3х видах:
инвариантность, ковариантность и контрвариантность.

Наследование для параметризованных типов возможно, но по умолчанию оно выключено. Посмотрим на примере:

-2

Есть иерархия наследования, и есть параметризованный тип Box (будем думать что это какая-то коллекция значений), но тут мы не можем иметь такой вид отношений, что написан в последней строке. Такие присвоения не сработают из-за инвариантности.

Инвариантность – это отсутствие отношений наследования между параметризованными типами. Можно передать ровно тот тип, который указан.

val b: Box<
Cat> = Box<Cat>(Cat()) //Так сработает

-3

При инвариантности Box<Animal> и Box<Cat> вообще никак не связаны.

Почему же разработчики Котлин не включили механизм наследования для парм-х типов по умолчанию? Это точно бы пригодилось... Дело в том, что это просто невозможно сделать безопасно для всех случаев использования одновременно. Котлин всё-таки позиционирует себя как более безопасный язык чем Джава, и «исправляет» её ошибки тем, что предупреждает нас о многих вещах ещё на этапе компиляции.

Давайте посмотрим пример из Джавы, который выглядит вполне прилично, но вызывает ошибку времени выполнения. В Джаве массивы ковариантны по умолчанию.


Ковариантность – это включения механизма наследования для парам-х типов в прямом порядке:

-4

Посмотрим код на Java:

-5

Так как Int это подтип Object, то при ковариантности мы можем сделать такое присвоение (1), после которого, мы получили массив Object, и в который по идее можем класть всё что угодно (3), ведь от типа Object в Джаве наследуется всё... Поэтому мы можем положить туда строку например. Но как бы не так! Язык помнит первоначальный тип массива (Integer) и мы получим ошибку времени выполнения из-за строки (3).

Код выше выведет:
25
Exception in thread "main" java.lang.ArrayStoreException


Разработчики Java хотели сделать язык более универсальным, хотели организовать поддержу таких общих операций как например сортировка массив любого типа данных. И они многого достигли, но получилось не все гладко... Однако, стоит заметить, что при таком типе отношений действительно безопасной, является только операция чтения (2). Вы можете только читать из такого массива но не добавлять в него что-либо. Разработчики Котлин учли это.

Ковариантность в Котлин

Включается с помощью обозначения вашего параметра ключевым словом out. Out - означает «наружу». Из-за ограничения, сказанного выше, вы можете использовать такие параметры только на выходных позициях.

-6

Рассмотрим класс Box. Тут T помечен ключевым словом out. А рядом со свойством an стоит модификатор val, и это не случайно - вы можете только получить некий тип T, но не писать в него. По-другому - не скомпилится: то, что вы делаете внутри класса с таким параметром – Котлину не важно, но он четко следит за тем чтобы он не просочился наружу.

Вот так получился класс, который может только отдавать значение, он условно называется «
Производитель».

Зачем это может понадобиться? Например у нас есть клетки с животными разных типов: Cat, Dog, Bird,... все они наследуются от Animal, нам не важно кто они, мы просто хотим всех покормить, скажем, вызвать некую функцию feedMe() на классе Animal, например.

Еще раз отметим, что когда пишете свой класс «производитель», то параметр помеченный
out, может находиться только на «отдающей» позиции:

-7

С точки зрения написания, есть два способа задать out в вашем коде при проектировании класса. Вот такой параметр out T в шапке класса (как в примере выше) называется заданным «на месте объявления» (declaration site variance) и он, как видите, имеет эффект на все функции, которые используют этот T в вашем классе.

А если нам не нужно делать параметр T ковариантным на весь класс? В таком случае нам поможет объявление «
на месте использования» (use site variance).

-8

В таком виде использование Box в функции feed() – уже идет само по себе, оно способно запутать и нарушить первоначальную логику класса, о которой писалось выше, и опытные разработчики не рекомендуют так делать (ну только если вы очень хорошо понимаете что делаете). Но даже в таком виде вы можете только читать из Box.
Такое использование так же называется «
проекцией» типа. Взглянем на код ниже:

-9

тут list превратился в проекцию MutableList, эта коллекция теперь с ограничениями – вы можете только брать из нее, а вот положить чего-либо в неё не удастся.

Кстати, слово out в примере выше можно было не писать потому оно уже есть в «недрах» Котлин в интерфейсе List<out E>, который имплементирует наша коллекция.

Здесь мы прошлись по коллекции животных и покормили всех, вне зависимости кто кем был конкретно.

Где ковариантность используется? В самом Котлин есть примеры – это интерфейсы Iterator и Iterable. Мы ими пользуемся каждый раз когда последовательно проходимся по коллекциям.

Контрвариантность в Котлин

Контрвариантность представляют как обратный процесс ковариантности, это тоже способ включить наследование, но работает он иначе. Если есть Аnimal который является предком Cat, то параметризованный тип Box<Аnimal> является потомком Box<Cat>.

-10

Отношения между Box<Аnimal> и Box<Cat> инвертированы. Но это не значит, что весь механизм наследования для них пошел вспять.

Включается это с помощью ключевого слова "in"– что значит «внутрь». Рассмотрим пример:

-11

Есть класс Processor, он может обрабатывать любые типы чисел, (умножать, складывать - не важно что), но объявив p как Processor<Int> мы сузили его возможности, теперь он может работать только с Int (и его потомками если бы они могли существовать)

В отличии от ковариантности, для которой безопасной является только операция чтения, для контрвариантности такой операцией является - запись. Вы можете передать некий Т в класс, но не вернуть его обратно наружу:

-12

Мы помечаем параметр T с помощью in. Так получается класс «потребитель». Свойство element должно быть приватным, а функцию get вообще придется убрать отсюда, оставив, возможность лишь принимать.

Контрвариантность тоже может быть задействована как «на месте объявления» так и «на месте использования».

Реальный пример использования контрвариантности в Котлин – это Компаратор.


Посмотрим ещё пример. Обратим внимание на котлинскую функцию
sortedWith в примере ниже. Допустим у нас есть иерархия юзеров в системе: Класс User от которого наследуется и Модератор и Админ и тд. Предположим, что у класса User, есть свойство rank (Int), которое нам нужно для сортировки.

Благодаря контрвариантности типов мы можем отсортировать всю иерархию начиная с User и заканчивая самым дальним его подтипом (Admin) - одним компаратором (userComparator).

-13

Функция sortedWith внутри выглядит вот так. Видим тут in:

-14

Ок. Теперь давайте ещё раз напишем по смыслу всё тоже самое, но сделаем полностью свое, а не котлиновское: свой компаратор, свою сортировку и будем сортировать юзеров всех типов. Вот. Взгляните сюда https://gist.github.com/AlexanderKott/bdf6b4cf6541812cccbf4f7810f77677

Благодаря включению контрвариантности (Comparator<in T>), мы видим, что отсортированы все 4 типа юзеров причем одним единственным компаратором. Если мы можем сортировать User, то сможем сортировать все его подтипы «вниз» по наследованию.

А строка val
moderatorComparator: Comparator<in Moderator> = userComparator
позволяет установить границу подтипов, при желании, если мы хотим сортировать только модераторов их подтипы (отрезав «вверх» иерархии наследования).

Экспериментируем, добавляем больше типов юзеров, развлекаемся =)

На этом подведем итог.

  • Если вы пишете класс, который должен только возвращать тип T, то используйте out. Это класс «производитель».
  • Если ваш класс будет только что-нибудь принимать, то – используйте in, так получится класс «потребитель».
  • А если нужно и то и другое, то придется делать класс инвариантным.


    Большое спасибо за внимание!
    Подписывайтесь ✅ на канал, это очень помогает его развитию.