Kotlin Bootcamp для программистов 5.2: Дженерики

Эта практическая работа является частью курса Kotlin Bootcamp for Programmers . Вы получите максимальную пользу от этого курса, если будете выполнять задания последовательно. В зависимости от вашего уровня знаний, вы можете пропустить некоторые разделы. Этот курс ориентирован на программистов, владеющих объектно-ориентированным языком программирования и желающих изучить Kotlin .

Введение

В этой лабораторной работе вы познакомитесь с универсальными классами, функциями и методами, а также с тем, как они работают в Kotlin.

Вместо создания одного примера приложения уроки этого курса направлены на углубление ваших знаний, но при этом они частично независимы друг от друга, чтобы вы могли бегло просмотреть знакомые разделы. Чтобы связать их воедино, многие примеры используют тему аквариума. А если вы хотите узнать всё об аквариуме, ознакомьтесь с курсом Kotlin Bootcamp for Programmers на Udacity.

Что вам уже следует знать

  • Синтаксис функций, классов и методов Kotlin
  • Как создать новый класс в IntelliJ IDEA и запустить программу

Чему вы научитесь

  • Как работать с универсальными классами, методами и функциями

Что ты будешь делать?

  • Создайте универсальный класс и добавьте ограничения
  • Создайте типы in и out
  • Создание универсальных функций, методов и функций расширения

Введение в дженерики

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

Представьте, что вы реализуете класс MyList , содержащий список элементов. Без дженериков вам пришлось бы реализовать новую версию MyList для каждого типа: одну для Double , одну для String и одну для Fish . С дженериками вы можете сделать список дженериком, чтобы он мог содержать объекты любого типа. Это как сделать тип подстановочным знаком, который подходит ко многим типам.

Чтобы определить универсальный тип, поместите T в угловые скобки <T> после имени класса. (Можно использовать другую букву или более длинное имя, но общепринятым соглашением для универсального типа является T.)

class MyList<T> {
    fun get(pos: Int): T {
        TODO("implement")
    }
    fun addItem(item: T) {}
}

Вы можете ссылаться на T , как на обычный тип. Возвращаемый тип функции get()T , а параметр функции addItem()T . Конечно, универсальные списки очень полезны, поэтому класс List встроен в Kotlin.

Шаг 1: Создайте иерархию типов

На этом этапе вы создаёте несколько классов для использования на следующем этапе. Создание подклассов рассматривалось в предыдущей лабораторной работе, но вот краткий обзор.

  1. Чтобы не перегружать пример, создайте новый пакет в src и назовите его generics .
  2. В пакете generics создайте новый файл Aquarium.kt . Это позволит вам переопределять элементы, используя те же имена, без конфликтов, поэтому весь остальной код для этой лабораторной работы будет помещен в этот файл.
  3. Создайте иерархию типов водоснабжения. Для начала сделайте класс WaterSupply open , чтобы его можно было подклассифицировать.
  4. Добавьте логический параметр var needsProcessing . Это автоматически создаст изменяемое свойство, а также методы получения и установки.
  5. Создайте подкласс TapWater , расширяющий WaterSupply , и передайте true для needsProcessing , поскольку водопроводная вода содержит добавки, вредные для рыб.
  6. В TapWater определите функцию addChemicalCleaners() , которая устанавливает needsProcessing в значение false после очистки воды. Свойство needsProcessing можно задать из TapWater , поскольку оно по умолчанию public и доступно подклассам. Вот готовый код.
package generics

open class WaterSupply(var needsProcessing: Boolean)

class TapWater : WaterSupply(true) {
   fun addChemicalCleaners() {
       needsProcessing = false
   }
}
  1. Создайте ещё два подкласса класса WaterSupply : FishStoreWater и LakeWater . FishStoreWater не требует обработки, но LakeWater необходимо отфильтровать с помощью метода filter() . После фильтрации повторная обработка не требуется, поэтому в filter() установите needsProcessing = false .
class FishStoreWater : WaterSupply(false)

class LakeWater : WaterSupply(true) {
   fun filter() {
       needsProcessing = false
   }
}

Если вам нужна дополнительная информация, просмотрите предыдущий урок о наследовании в Kotlin.

Шаг 2: Создайте универсальный класс

На этом этапе вы измените класс Aquarium для поддержки различных типов водоснабжения.

  1. В Aquarium.kt определите класс Aquarium , поместив <T> в скобки после имени класса.
  2. Добавьте неизменяемое свойство waterSupply типа T в Aquarium .
class Aquarium<T>(val waterSupply: T)
  1. Напишите функцию genericsExample() . Она не является частью класса, поэтому может располагаться на верхнем уровне файла, как функция main() или определения классов. В этой функции создайте Aquarium и передайте ему WaterSupply . Поскольку параметр waterSupply является универсальным, необходимо указать тип в угловых скобках <> .
fun genericsExample() {
    val aquarium = Aquarium<TapWater>(TapWater())
}
  1. В genericsExample() ваш код может получить доступ waterSupply аквариума. Поскольку он относится к типу TapWater , вы можете вызвать addChemicalCleaners() без приведения типов.
fun genericsExample() {
    val aquarium = Aquarium<TapWater>(TapWater())
    aquarium.waterSupply.addChemicalCleaners()
}
  1. При создании объекта Aquarium можно удалить угловые скобки и всё, что между ними, поскольку в Kotlin реализовано выведение типов. Поэтому нет смысла дважды указывать TapWater при создании экземпляра. Тип можно вывести с помощью аргумента Aquarium ; он всё равно создаст Aquarium типа TapWater .
fun genericsExample() {
    val aquarium = Aquarium(TapWater())
    aquarium.waterSupply.addChemicalCleaners()
}
  1. Чтобы увидеть, что происходит, выведите needsProcessing до и после вызова addChemicalCleaners() . Ниже представлен готовый код функции.
fun genericsExample() {
    val aquarium = Aquarium<TapWater>(TapWater())
    println("water needs processing: ${aquarium.waterSupply.needsProcessing}")
    aquarium.waterSupply.addChemicalCleaners()
    println("water needs processing: ${aquarium.waterSupply.needsProcessing}")
}
  1. Добавьте функцию main() для вызова genericsExample() , затем запустите программу и посмотрите на результат.
fun main() {
    genericsExample()
}
⇒ water needs processing: true
water needs processing: false

Шаг 3: Сделайте это более конкретным

Обобщённый тип означает, что можно передать практически всё, что угодно, и иногда это проблема. На этом этапе вы конкретизируете класс Aquarium , указывая, что именно можно в него поместить.

  1. В genericsExample() создайте Aquarium , передав строку для waterSupply , затем выведите свойство waterSupply аквариума.
fun genericsExample() {
    val aquarium2 = Aquarium("string")
    println(aquarium2.waterSupply)
}
  1. Запустите программу и посмотрите на результат.
⇒ string

Результатом будет переданная вами строка, поскольку Aquarium не накладывает никаких ограничений на T. Можно передать любой тип, включая String .

  1. В genericsExample() создайте ещё один Aquarium , передав значение null для waterSupply . Если значение waterSupply равно null, выведите "waterSupply is null" .
fun genericsExample() {
    val aquarium3 = Aquarium(null)
    if (aquarium3.waterSupply == null) {
        println("waterSupply is null")
    }
}
  1. Запустите программу и посмотрите на результат.
⇒ waterSupply is null

Почему можно передать null при создании Aquarium ? Это возможно, поскольку по умолчанию T обозначает тип Any? который допускает значение NULL, — тип, находящийся на вершине иерархии типов. Следующий код эквивалентен тому, что вы ввели ранее.

class Aquarium<T: Any?>(val waterSupply: T)
  1. Чтобы не допустить передачу null , явно сделайте T типом Any , удалив ? после Any .
class Aquarium<T: Any>(val waterSupply: T)

В этом контексте Any называется общим ограничением . Это означает, что для T может быть передан любой тип, при условии, что он не равен null .

  1. На самом деле вам нужно убедиться, что для T может быть передан только WaterSupply (или один из его подклассов). Замените Any на WaterSupply , чтобы определить более конкретное общее ограничение.
class Aquarium<T: WaterSupply>(val waterSupply: T)

Шаг 4: Добавьте больше проверок

На этом этапе вы узнаете о функции check() , которая поможет вам убедиться, что ваш код работает ожидаемым образом. Функция check() — это стандартная библиотечная функция Kotlin. Она действует как утверждение и выдаёт исключение IllegalStateException , если её аргумент принимает значение false .

  1. Добавьте метод addWater() к классу Aquarium для добавления воды с методом check() который гарантирует, что вам не нужно предварительно обрабатывать воду.
class Aquarium<T: WaterSupply>(val waterSupply: T) {
    fun addWater() {
        check(!waterSupply.needsProcessing) { "water supply needs processing first" }
        println("adding water from $waterSupply")
    }    
}

В этом случае, если needsProcessing имеет значение true, check() выдаст исключение.

  1. В genericsExample() добавьте код для создания Aquarium с LakeWater , а затем добавьте в него немного воды.
fun genericsExample() {
    val aquarium4 = Aquarium(LakeWater())
    aquarium4.addWater()
}
  1. Запустите программу, и вы получите исключение, поскольку воду сначала нужно отфильтровать.
⇒ Exception in thread "main" java.lang.IllegalStateException: water supply needs processing first
        at Aquarium.generics.Aquarium.addWater(Aquarium.kt:21)
  1. Добавьте вызов для фильтрации воды перед добавлением её в Aquarium . Теперь при запуске программы не будет генерироваться исключение.
fun genericsExample() {
    val aquarium4 = Aquarium(LakeWater())
    aquarium4.waterSupply.filter()
    aquarium4.addWater()
}
⇒ adding water from generics.LakeWater@880ec60

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

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

Взгляните на класс Aquarium , и вы увидите, что универсальный тип возвращается только при получении свойства waterSupply . В нём нет методов, принимающих значение типа T в качестве параметра (за исключением определения в конструкторе). Kotlin позволяет определить out -типы именно для этого случая и вывести дополнительную информацию о том, где эти типы безопасно использовать. Аналогично, вы можете определить in типы для универсальных типов, которые всегда передаются в методы, а не возвращаются. Это позволяет Kotlin выполнять дополнительные проверки безопасности кода.

Типы in и out — это директивы системы типов Kotlin. Описание всей системы типов выходит за рамки этого учебного курса (она довольно сложная); однако компилятор будет помечать типы, которые не помечены как in и out соответствующим образом, поэтому вам необходимо знать о них.

Шаг 1: Определите тип выхода

  1. В классе Aquarium измените T: WaterSupply на out тип.
class Aquarium<out T: WaterSupply>(val waterSupply: T) {
    ...
}
  1. В том же файле, вне класса, объявите функцию addItemTo() , которая ожидает Aquarium WaterSupply .
fun addItemTo(aquarium: Aquarium<WaterSupply>) = println("item added")
  1. Вызовите addItemTo() из genericsExample() и запустите свою программу.
fun genericsExample() {
    val aquarium = Aquarium(TapWater())
    addItemTo(aquarium)
}
⇒ item added

Kotlin может гарантировать, что addItemTo() не сделает ничего небезопасного с универсальным WaterSupply , поскольку он объявлен как out тип.

  1. Если удалить ключевое слово out , компилятор выдаст ошибку при вызове addItemTo() , поскольку Kotlin не может гарантировать, что вы не делаете ничего небезопасного с типом.

Шаг 2: Определите тип in

Тип in аналогичен типу out , но предназначен для универсальных типов, которые передаются только в функции, а не возвращаются. При попытке вернуть тип in возникнет ошибка компиляции. В этом примере тип in будет определён как часть интерфейса.

  1. В Aquarium.kt определите интерфейс Cleaner , который принимает универсальный T , ограниченный WaterSupply . Поскольку он используется только как аргумент для clean() , вы можете сделать его in параметром.
interface Cleaner<in T: WaterSupply> {
    fun clean(waterSupply: T)
}
  1. Чтобы использовать интерфейс Cleaner , создайте класс TapWaterCleaner , который реализует Cleaner для очистки TapWater путем добавления химикатов.
class TapWaterCleaner : Cleaner<TapWater> {
    override fun clean(waterSupply: TapWater) =   waterSupply.addChemicalCleaners()
}
  1. В классе Aquarium обновите addWater() так, чтобы он принимал Cleaner типа T и очищал воду перед ее добавлением.
class Aquarium<out T: WaterSupply>(val waterSupply: T) {
    fun addWater(cleaner: Cleaner<T>) {
        if (waterSupply.needsProcessing) {
            cleaner.clean(waterSupply)
        }
        println("water added")
    }
}
  1. Обновите код примера genericsExample() , чтобы создать TapWaterCleanerAquarium с TapWater — и добавить немного воды с помощью очистителя. Он будет использовать очиститель по мере необходимости.
fun genericsExample() {
    val cleaner = TapWaterCleaner()
    val aquarium = Aquarium(TapWater())
    aquarium.addWater(cleaner)
}

Kotlin будет использовать информацию о типах in и out , чтобы гарантировать безопасное использование дженериков в вашем коде. Out и in легко запомнить: типы out можно передавать наружу как возвращаемые значения, а типы in — внутрь как аргументы.

Если вы хотите подробнее разобраться в том, какие проблемы решают типы in и out , документация подробно описывает их.

В этом задании вы узнаете о функциях-обобщениях и о том, когда их следует использовать. Как правило, создание функции-обобщения — это хорошая идея, когда функция принимает аргумент класса с обобщённым типом.

Шаг 1: Создание универсальной функции

  1. В generics/Aquarium.kt создайте функцию isWaterClean() , которая принимает Aquarium . Необходимо указать универсальный тип параметра; один из вариантов — использовать WaterSupply .
fun isWaterClean(aquarium: Aquarium<WaterSupply>) {
   println("aquarium water is clean: ${aquarium.waterSupply.needsProcessing}")
}

Но это означает, что для вызова этой функции Aquarium должен быть параметр типа out . Иногда out или in слишком строгие, поскольку требуется использовать тип как для входных, так и для выходных данных. Требование out можно устранить, сделав функцию универсальной.

  1. Чтобы сделать функцию универсальной, добавьте угловые скобки после ключевого слова fun с универсальным типом T и любыми ограничениями, в данном случае WaterSupply . Измените ограничение Aquarium так, чтобы оно ограничивалось значением T вместо WaterSupply .
fun <T: WaterSupply> isWaterClean(aquarium: Aquarium<T>) {
   println("aquarium water is clean: ${!aquarium.waterSupply.needsProcessing}")
}

T — это параметр типа для isWaterClean() , который используется для указания общего типа аквариума. Этот шаблон очень распространён, и стоит уделить ему немного времени и разобраться в нём.

  1. Вызовите функцию isWaterClean() указав тип в угловых скобках сразу после имени функции и перед круглыми скобками.
fun genericsExample() {
    val aquarium = Aquarium(TapWater())
    isWaterClean<TapWater>(aquarium)
}
  1. Из-за вывода типа из аргумента aquarium этот тип не нужен, поэтому удалите его. Запустите программу и посмотрите на вывод.
fun genericsExample() {
    val aquarium = Aquarium(TapWater())
    isWaterClean(aquarium)
}
⇒ aquarium water is clean: false

Шаг 2: Создайте универсальный метод с материализованным типом

Вы также можете использовать универсальные функции для методов, даже в классах с собственным универсальным типом. На этом этапе вы добавляете в Aquarium универсальный метод, который проверяет, принадлежит ли объект типу WaterSupply .

  1. В классе Aquarium объявите метод hasWaterSupplyOfType() , который принимает универсальный параметр R ( T уже используется), ограниченный WaterSupply , и возвращает true если waterSupply имеет тип R Он похож на функцию, объявленную ранее, но внутри класса Aquarium .
fun <R: WaterSupply> hasWaterSupplyOfType() = waterSupply is R
  1. Обратите внимание, что последняя R подчеркнута красным. Наведите на неё указатель мыши, чтобы увидеть ошибку.
  2. Чтобы выполнить проверку is , необходимо сообщить Kotlin, что тип является reified (реальным) и может быть использован в функции. Для этого добавьте inline перед ключевым словом fun и reified in перед обобщенным типом R .
inline fun <reified R: WaterSupply> hasWaterSupplyOfType() = waterSupply is R

После того, как тип материализован, его можно использовать как обычный тип, поскольку после встраивания он становится реальным типом. Это означает, is с его помощью можно выполнять проверки.

Если вы не используете здесь reified , тип не будет достаточно «реальным», чтобы Kotlin допускал проверки is . Это связано с тем, что не овеществленные типы доступны только во время компиляции и не могут использоваться во время выполнения программы. Подробнее об этом говорится в следующем разделе.

  1. Передайте TapWater в качестве типа. Как и при вызове универсальных функций, для вызова универсальных методов используйте угловые скобки с указанием типа после имени функции. Запустите программу и посмотрите на результат.
fun genericsExample() {
    val aquarium = Aquarium(TapWater())
    println(aquarium.hasWaterSupplyOfType<TapWater>())   // true
}
⇒ true

Шаг 3: Создание функций расширения

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

  1. За пределами класса Aquarium определите функцию расширения для WaterSupply с именем isOfType() , которая проверяет, имеет ли переданный WaterSupply определенный тип, например, TapWater .
inline fun <reified T: WaterSupply> WaterSupply.isOfType() = this is T
  1. Вызовите функцию расширения так же, как метод.
fun genericsExample() {
    val aquarium = Aquarium(TapWater())
    println(aquarium.waterSupply.isOfType<TapWater>())  
}
⇒ true

С этими функциями расширения неважно, какой тип Aquarium используется ( Aquarium , TowerTank или какой-либо другой подкласс), главное, чтобы это был Aquarium . Использование синтаксиса звёздной проекции — удобный способ указать множество соответствий. Кроме того, при использовании звёздной проекции Kotlin гарантирует, что вы не сделаете ничего опасного.

  1. Чтобы использовать проекцию звёзд, добавьте <*> после Aquarium . Переведите hasWaterSupplyOfType() в функцию расширения, поскольку она не является частью основного API Aquarium .
inline fun <reified R: WaterSupply> Aquarium<*>.hasWaterSupplyOfType() = waterSupply is R
  1. Измените вызов на hasWaterSupplyOfType() и запустите свою программу.
fun genericsExample() {
    val aquarium = Aquarium(TapWater())
    println(aquarium.hasWaterSupplyOfType<TapWater>())
}
⇒ true

В предыдущем примере вам пришлось пометить универсальный тип как reified и сделать функцию inline , поскольку Kotlin должен знать о них во время выполнения, а не только во время компиляции.

Все универсальные типы используются Kotlin только во время компиляции. Это позволяет компилятору убедиться в безопасности выполнения. К моменту выполнения все универсальные типы удаляются, отсюда и предыдущее сообщение об ошибке проверки удалённого типа.

Оказывается, компилятор может создавать корректный код, не сохраняя обобщённые типы до времени выполнения. Но это означает, что иногда вы делаете что-то, is проверяете обобщённые типы, что компилятор не может поддержать. Именно поэтому в Kotlin появились овеществлённые, или вещественные, типы.

Подробнее о конкретизированных типах и стирании типов можно прочитать в документации Kotlin.

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

  • Создавайте универсальные классы, чтобы сделать код более гибким.
  • Добавьте общие ограничения, чтобы ограничить типы, используемые с общими типами.
  • Используйте in и out типы с обобщенными типами, чтобы обеспечить лучшую проверку типов и ограничить типы, передаваемые в классы или возвращаемые из них.
  • Создайте универсальные функции и методы для работы с универсальными типами. Например:
    fun <T: WaterSupply> isWaterClean(aquarium: Aquarium<T>) { ... }
  • Используйте общие функции расширения для добавления в класс дополнительных функций.
  • Конкретизированные типы иногда необходимы из-за стирания типов. Конкретизированные типы, в отличие от универсальных, сохраняются во время выполнения.
  • Используйте функцию check() , чтобы убедиться, что ваш код работает как надо. Например:
    check(!waterSupply.needsProcessing) { "water supply needs processing first" }

Документация Kotlin

Если вам нужна дополнительная информация по какой-либо теме этого курса или вы застряли, лучшей отправной точкой будет https://kotlinlang.org .

Учебники по Kotlin

На сайте https://try.kotlinlang.org вы найдете подробные учебные пособия по Kotlin Koans, веб-интерпретатор и полный набор справочной документации с примерами.

Курс Udacity

Чтобы просмотреть курс Udacity по этой теме, см. Kotlin Bootcamp for Programmers .

IntelliJ IDEA

Документацию по IntelliJ IDEA можно найти на сайте JetBrains.

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

  • При необходимости задавайте домашнее задание.
  • Объясните учащимся, как следует сдавать домашние задания.
  • Оцените домашние задания.

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

Если вы работаете с этой лабораторной работой самостоятельно, можете использовать эти домашние задания для проверки своих знаний.

Ответьте на эти вопросы

Вопрос 1

Какое из следующих соглашений является соглашением об именовании универсального типа?

<Gen>

<Generic>

<T>

<X>

Вопрос 2

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

▢ общее ограничение

▢ общее ограничение

▢ разрешение неоднозначности

▢ ограничение универсального типа

Вопрос 3

Овеществление означает:

▢ Рассчитано реальное влияние исполнения объекта.

▢ Для класса установлен ограниченный индекс записи.

▢ Параметр универсального типа преобразован в действительный тип.

▢ Сработал удаленный индикатор ошибки.

Перейти к следующему уроку: 6. Функциональная манипуляция

Обзор курса, включая ссылки на другие практические занятия, см. в статье «Kotlin Bootcamp for Programmers: Welcome to the course».