Kotlin
March 16, 2023

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

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

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

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

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

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

class Box<T : Animal>(val animal: T)

open class Animal()
class Cat : Animal()


fun main() {

    val a: Animal = Cat()  //так можно
    val b: Box<Animal> = Box<Cat>(Cat())  //а вот так не получится
}

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

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

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

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

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

Для небольшого экскурса в историю, я решил включить в статью пример с массивами из Java. Они имеют опосредованное отношение к параметризованным типам, о которых тут идет речь, но они интересны с точки зрения сравнения и вашего личного с ними эксперимента, если пожелаете. Массивы в Java помнят свой тип хранимого значения в рантайме, а еще они ковариантны.

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

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

 public static void main(String[] args) {
        Integer[] numbers = {10, 15, 25, 30};
        Object[] objects = numbers; //1

        System.out.println(objects[2]);  //2
        objects[2] = "meow!";   //3
    }

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

Код выше выведет:

25

Exception in thread “main” java.lang.ArrayStoreException

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

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

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

class Box<out T : Animal>(val animal: T)

open class Animal()
class Cat : Animal()

fun main() {
    val a: Animal = Cat()  
    val b: Box<Animal> = Box<Cat>(Cat())  //теперь можно
    println(b.animal) //читать можно
}

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

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

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

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

 class ABox<out T>(var animal: T // нельзя
) {
    fun setT(new: T) { // нельзя
        animal = new
    }
    fun getT(): T = animal  //можно
}

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

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

class Box<T : Animal>(val animal: T)

open class Animal() {
    fun feedMe() {
        println("eda!")
    }
}
class Cat : Animal()

 class AnimalFeeder(){
    fun feed(animal: Box<out Animal>) : Box<out Animal> //см. сюда   {
             animal.animal.feedMe()
        return animal
    }
}

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

open class Animal() {
    fun feedMe() {
        println("eda!") }
}
class Cat : Animal()
class Dog : Animal()
class Bird : Animal()

fun main() {
    val list: MutableList<out Animal> = mutableListOf(Cat(), Dog(), Bird())
    list.forEach { animal -> animal.feedMe() }
}

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

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

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

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

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

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

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

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

class Processor<in T : Number>() {
    fun process(a: T) {
        //как то работаем с числами тут
    }
}

fun main() {
    val p: Processor<Int> = Processor<Number>()
    p.process(1)     //Int можно
    p.process(1.2F)  //float теперь нельзя
    p.process(1.2)   //double теперь нельзя
}

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

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

 class BoxX<in T>(
    var element: T // нельзя
) {
    fun set(new: T) { //можно
        element = new
    }
    fun get(): T = element // нельзя
} 

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

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

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

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

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

open class User() {
    var rank: Int = 0
}
open class Moderator : User()
class Admin : Moderator()

val userComparator: Comparator<User> = Comparator<User> { firstUser, secondUser ->
    firstUser.rank - secondUser.rank
}


fun main() {
    val listA: MutableList<User> = mutableListOf()
    val listB: MutableList<Moderator> = mutableListOf()
    val listC: MutableList<Admin> = mutableListOf()

    listA.sortedWith(userComparator).forEach { println(it) }
    listB.sortedWith(userComparator).forEach { println(it) }
    listC.sortedWith(userComparator).forEach { println(it) }

}

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

public fun <T> Iterable<T>.sortedWith(comparator: Comparator<in T>): List<T> {
//...
}

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

//Иерархия юзеров
open class User(var rank: Int) {
    override fun toString(): String {
        return "user.rank=$rank"
    }
}
open class BackendUser(value: Int) : User(value)
open class Moderator(value: Int) : User(value)
open class Admin(value: Int) : Moderator(value)


//компаратор с реализацией
 interface Comparator<T> {  
    fun compare(o1: T, o2: T): Boolean
}

val userComparator  = object : Comparator<User> { //Сортировать по Юзеру
    override fun compare(firstUser: User, secondUser: User): Boolean {
        return firstUser.rank > secondUser.rank
    }
}


val moderatorComparator: Comparator<in Moderator> = userComparator //Сортировать только модераторов и их потомков


//пузырьковая сортировка
fun <T : Any> bubbleSort(values: Array<T>, comp: Comparator<in T>) { //использование In
    for (i in values.size - 1 downTo 0) {
        for (j in 0 until i) {
            if (comp.compare(values[j], values[j + 1])) {
                swap(values, j, j + 1)
            }
        }
    }
}
fun <T : Any> swap(values: Array<T>, f: Int, s: Int) {
    val buff = values[f]
    values[f] = values[s]
    values[s] = buff
}



fun main() {
    val users = arrayOf(User(12220), User(550), User(120))
    val mederators = arrayOf(Moderator(555), Moderator(52220), Moderator(18888))
    val admin = arrayOf(Admin(212), Admin(689), Admin(15))
    val backendUsers = arrayOf(BackendUser(25), BackendUser(69), BackendUser(145))

    //мы можем сортировать с помощью userComparator все типы юзеров
    bubbleSort(users, userComparator)
    bubbleSort(mederators, userComparator)
    bubbleSort(admin, userComparator)
    bubbleSort(backendUsers, userComparator)
    
    println(users.joinToString())
    println(mederators.joinToString())
    println(admin.joinToString())
    println(backendUsers.joinToString())
    
    println()
    
    //moderatorComparator позволяет ограничить использование сортировки только для модераторов и всех кто "ниже" по наследованию
    bubbleSort(mederators, moderatorComparator)
    bubbleSort(admin, moderatorComparator)
}

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

Если мы можем сортировать User, то сможем сортировать все его подтипы «вниз» по наследованию.

А строка

val moderatorComparator: Comparator<in Moderator> = userComparator

позволяет установить границу подтипов, при желании, если мы хотим сортировать только модераторов их подтипы (отрезав «вверх» иерархии наследования).

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

Поддержка границ типов

Модификаторы вариантности (In, out) могут использоваться совместно с границами типов, вы наверняка это заметили ещё выше, но стоит ещё раз на этом остановится:

class Box<out T : Number>(val payload: List<T>)

Написав двоеточие после Т, мы установили верхнюю границу Number для класса «производителя» Box. Теперь он может хранить список любых чисел, идущих от Number, но не что‑то ещё. Мы можем наложить еще больше подобных ограничений на класс, однако для этого нам потребуется ключевое слово where, которое требует перечислить все такие ограничения через запятую. Давайте посмотрим пример, где наложим их сразу три:

open class Animal()

interface HasBreed {
 fun breedName()
}

interface HasOwner {
 fun ownerName()
}

class Cat : Animal(), HasBreed, HasOwner {
 override fun breedName() {}
 override fun ownerName() {}
}


//1
class Cage<out T>(val room: T) where T : Animal, T : HasBreed, T : HasOwner

fun main() {
 val b1 = Cage(Cat())
}

У нас есть клетка Cage (1), в которую мы можем помещать любое животное (Animal), причем породистое(HasBreed) и оно должно иметь своего владельца(HasOwner).

Так, мы можем задействовать только один класс и сколько угодно интерфейсов.

Свойства, накладываемые вариантностью на класс, всё так же сохраняются.

Звездная проекция

Представим типичную иерархию наследования в Котлин, например такую:

Так вот, существует короткое обозначение для любого типа, идущего от самого Any? и ниже по иерархии наследования (включая все ветви), которая называется «звездной проекцией» (star‑projection).

Чтобы её задать мы пишем * в угловых скобках. Содержать в себе такая проекция может любой тип: как общий Any? так и вполне конкретный и определенный, например только значения Int, Dobule или Cats. Давайте посмотрим пример ниже:

fun getSize(list: List<*>): Int {
    return list.size
}

Наша функция выполняет некую операцию (getSize), которой совершенно не важно какой тип значений мы храним в списке. В данном случае нам только нужен размер коллекции, а что там именно лежит — вторично, мы даже можем не знать это вовсе. Вот для таких специфичных случаев такое решение и существует. Можно было написать и так:

fun <T> getSize(list: List<T>): Int {
    return list.size
}

Но первая запись лаконичнее. А вот ещё примеры подобных операций:

fun checkNull(list: MutableList<*>) : Boolean {
   return list.any { it == null }
} 
//проверяем что в коллекции есть хотя бы один null

fun display(list: MutableList<*>) {
      list.forEach { println(it) }
}
//либо просто выводим содержимое на экран чем бы оно не было

Важно отметить, что звездная проекция позволяет читать значения (Any?), но не дает возможности что‑либо записывать. Ведь не известно какой именно тип там лежит, потому давать возможность туда писать — не безопасно. Так что кроме лаконичности, такая запись дает гарантию для коллекции, что она не будет изменена где‑то в процессе специально или случайно.

 val d : MutableList<*> = mutableListOf<Int?>(1,2,3, null)
    val element : Any? = d[0]  //вытащить можно только Any?
    d.add(1)  //нельзя
    d.add(Any()) //нельзя
    d.add(null) //даже null нельзя

Если выражаться более точно, то класть в неё можно только тип Nothing. Но в Котлин нельзя иметь экземпляры класса Nothing, так что как ни старайтесь, положить в такую коллекцию ничего не удастся.

Мы можем еще раз в этом убедиться. Рассмотрим такой пример с интерфейсом, где разместим звездочки на входной и выходной позиции (1) нашей проекции fi:

interface Function<in T, out E> {
    fun take(t : T)
    fun bring(): E
}

fun main() {
    val fi : Function<*, *> = object : Function<Any, Int> { //1
        override fun take(t: Any) { }
        override fun bring(): Int = 5
    }
    fi.take(1) //нельзя ничего передать. Ожидается тип Nothing
    val x = fi.bring() //можно - но при этом вернет Any?
}

Как видим эффект наблюдается такой же: передать в класс ничего нельзя, несмотря на то, что на «прием» был указан тип Any. А на «выход» будет всё так же Any? как и в предыдущем примере, хоть мы и хотели изначально Int.

Есть ли пример использования звездной проекции в самом Котлин? Да, в Котлин есть функция бинарного поиска binarySearchBy которая представлена ниже:

val list1 = mutableListOf(1, 2, 3, 4, 5, 6, 7)
    println(list1.binarySearchBy(3) { it } ) 

Если захотите ознакомиться, то в её недрах вы можете найти функцию

public fun <T : Comparable<*>> compareValues(a: T?, b: T?): Int

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

Это было краткое введение в вариантность в Котлин. На этом подведем итог.

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

Благодарности:

Хочу выразить благодарность Александру Нозику, за его ценные комментарии, которые помогли существенно улучшить статью на этапе подготовки.

На этом пока всё. Большое спасибо за внимание!

Источник