Здравствуйте!
Хотели бы вы создавать “универсальные” классы, которые могут работать с любым типом данных, будь то Int, Double, String, … (или какой-то свой тип), причем этот код будет поддерживать все типы данных одновременно?
Речь пойдет про обобщенное программирование в Котлин. Мы сможем создавать универсальные структуры, такие как классы, интерфейсы и функции, которые подходят для работы с любыми типами данных.
Сразу давайте введем новое понятие — «тип» . Типом называют некую структуру, которая содержит состояние (properties) и поведение (member-functions), которая может как-то использоваться вашем коде. Это общий термин для таких вещей как класс, интерфейс, если нужно говорить про них всех сразу. Так вот, мы будем работать с типами, будем параметризовывать их.
Рассмотрим такой вот класс:
class Box (var i: Int)
Ничего необычного, просто класс, который может содержать значение Int, и мы можем им как-то воспользоваться. Давайте изменим его так, чтобы он мог содержать не только Int а вообще всё что угодно. Перепишем его вот так:
class Box (var i: T)
Это конечно не скомпилится, потому что Котлин не знает что такое T. Это что, класс такой или шо?=) Однако у него есть такая фича как параметры. Мы должны указать компилятору, что пишем универсальный класс:
class Box <T> (var i: T)
Вот так уже скомпилится. Нужно указать в угловых скобках после имени класса параметр, который мы хотим использовать для данного класса. T — это просто буква, мы могли бы написать E, R или любую другую. <T> показывает, что это параметр для этого класса, а не какая-то переменная i класса T.
А пользоваться нашим классом можно так:
fun main(){
val a1 = Box<Int>(1)
val a2 = Box<Double>(2.1)
val a3 = Box<String>("Cat")
val a4 = Box<Any>(Any())
val a5 = Box<Any?>(null)
//...
}
Тут видно что, в наш класс «коробка» (Box) может хранить теперь всё что угодно. Круто да?
В угловых скобках мы указали аргумент Int, ниже Double, затем String и так далее. Так мы указали классу что он теперь может хранить. Вообще компилятор умный, и часто сам может понять что мы хотим там содержать, так что иногда аргумент в угловых скобках можно опускать.
А давайте добавим больше параметров в наш класс! Ну скажем 3 шт, ну и свойств (properties) соответственно тоже:
class Box <T,S,R> (var i: T, var i2: S, var i3: R)
Мы использовали 3 буквы T,S,R, которые перечислили в угловых скобках, и теперь Котлин знает что это параметры, и дальше в свойствах i.. можно хранить всё что угодно:
fun main(){
val a1 =Box(Any(),14,1.9F)
val a2 = Box(2.1, "Cat", null)
val a3 = Box(1, Any(), 555.00)
}
Вот так мы обобщили класс =)
Саму возможность языка программирования иметь обобщенные типы называют — дженериками (generics). Дальше посмотрим как они нам пригодятся.
По поводу букв: По соглашению идущему ещё из Java, принято использовать одиночные заглавные буквы для параметров. Они часто являются сокращениями: T — type, E — element, N — number, V — value, S, U, и так далее.
Зачем вообще нужны дженерики?
1) Дженерики предотвращают ошибки использования неверных типов данных во время компиляции, когда их ещё легко исправить, а не потом, в рантайме когда всё упадет на глазах у вашего изумленного клиента.
Для примера, рассмотрим часто использующуюся коллекцию ArrayList. Её нужно обязательно параметризовать, тем самым сообщить компилятору, какие данные мы хотим в ней хранить.
val list = ArrayList<Int>() //1
list.add(1)
list.add(2)
list.add(3)
list.add(Any()) //2
list.add("StringABC") //3
Мы используем тут Int (1), поэтому легко можем добавлять целые числа (и только их), а всякую фигню (2, 3) специально или случайно мы уже положить туда не можем, так Котлин нас защищает. Поэтому мы всегда можем быть уверены, что наша коллекция хранит именно Int а не чёртечо. И мы можем легко проходиться по коллекции, не боясь ошибки:
for (i in list.indices){
list[i] = list[i] * 2
}
То что лежит в list[i] мы можем смело умножать на 2, потому там точно Int, и мы не получим ClassCastException в рантайме, если бы нам случайно попался бы объект не того типа. Кроме того нам не нужно приводить каждый элемент list[i] к нужному нам типу(чтобы умножить на 2 например), это уменьшает количество cast-в, которые тоже могли бы быть причиной ошибки.
2) Дженерики позволяют писать вам свои собственные коллекции, которые подойдут конкретно под ваши задачи. Вы можете написать свою новую уникальную структуру данных или же сделать обертку над уже существующей, добавив нужный вам функционал.
class Keeper <T> {
private var a : MutableList<T> = mutableListOf()
fun add(t : T){
a.add(t)
}
fun get(index: Int) : T {
//какой-то полезный код тут
return a[index]
}
}
Обертка вокруг MutableList.
Обобщенные функции
В Котлин можно обобщать и функции. Так, параметризовать можно функции-члены классов, функции расширения и функции верхнего уровня. Посмотрим поближе:
fun <T> isNull(t : T): Boolean {
return t == null
}
Вот функция верхнего уровня, перед её именем (isNull) мы пишем её параметр (да, я опять использовал букву T=) в угловых скобках. После чего я могу использовать эту T уже в параметрах этой функции (t). Теперь весь код полностью:
fun <T> isNull(t : T): Boolean {
return t == null
}
fun main(){
val x: Any? = null
println(isNull(x))
}
Выведет: true
А вот пример функции-расширения:
fun <T> T.isNull(): Boolean {
return this == null
}
fun main(){
val x: Any? = null
println(x.isNull())
}
Выведет: true
Обобщенные интерфейсы
Обобщать можно и интерфейсы. Вот так мы добавили параметр T в интерфейс:
interface ContainerT <T> {
fun get() : T?
fun set(v : T?)
}
попробуем его имплементировать вот этим классом:
class BoxT <T>(private var t : T? = null)
Мы можем сделать это двумя способами. Например прямо передать наш параметр T в интерфейс, написав ContainerT<T>. Вот так:
class BoxT <T>(private var t : T? = null) : ContainerT<T> {
override fun get() : T? {
//
}
override fun set(v: T?) {
//
}
}
и тогда наш интерфейс обяжет класс BoxT иметь функции get и set некого параметра типа T.
Либо мы можем предать интерфейсу конкретный тип сразу (скажем — String) и тогда интерфейс обяжет нас иметь функции типа String, вот так:
class BoxT <T>(private var t : T? = null) : ContainerT<String> {
override fun get() : String? {
//
}
override fun set(v: String?) {
//
}
}
В общем случае параметр представляет собой некий неизвестный, произвольный тип данных. А можно ли выполнять какие-либо операции ничего, не зная об этом типе заранее? Оказывается что — да. Вот некоторые из них:
//проверить на null
fun <T> isNull(t : T): Boolean {
return t == null
}
// вызвать метод toString()
fun <T> toString(t : T) :String {
return t.toString()
}
//вывести на экран
fun <T> display(t : T) {
println(t)
}
//взять-отдать
fun <T> post(t : T) : T {
return t
}
//сравнить
fun <T, S> equals(t : T, s : S): Boolean {
if (t == null || s == null) return false
return t == s
}
//скопировать/хранить
fun <T> getListOfT(t : T, amount : Int): MutableList<T> {
return MutableList(amount) { t }
}
//узнать количество элементов
fun <T> size(t : List<T>): Int {
return t.size
}
Список операций не густ. И создавать объекты типа T — нельзя…
А вам не показалось что эти операции, это то, что можно делать над Any типом(как если бы мы вмеcто T писали Any)? Точнее сказать на Any?. Отвлечемся немного, и представим что у нас есть классы с такой иерархией наследования:
open class Animal {
fun feedMe() {}
}
class Cat : Animal() {
fun meow() {}
}
В Котлин, диаграмма классов(class diagram) для них будет такой:
Так как Котлин разделяет null-овые типы от тех, что не могут иметь null, чтобы по максиму исключить NullPointerException из вашего кода. Поэтому самый корневой тип это Any? (который может иметь null), от него идет “обычный” Any, и уже дальше все остальные классы. Эта диаграмма очень упрощена, больше об этом можете почитать здесь.
Так вот, когда мы параметризуем тип (пишем некий параметр T, как мы делали это раньше) то компилятор думает, что мы хотим работать с всей иерархией, наследования начиная с Any?.
Ок, А что если мы хотим написать код, который работает только с типом Animal и его потомками? Нам поможет установление верхней границы.
Ограничения типовых параметров
Допустим у нас есть класс Cage (клетка):
class Cage <T> (var i: T)
Мы можем посадить туда кошку (1):
fun main() {
Cage(Cat()) //1
Cage(11111) //2
}
Но так же можем запихнуть туда Int, Double или что-то другое (2) что не имеет смысла для клетки. Запретить это нам поможет установление верхней границы.
Перепишем наш класс Cage вот так:
class Cage <T: Animal> (var i: T)
Написав : Animal, (двоеточие, затем тип данных) мы установили верхнюю границу для этого класса. Теперь клетка может содержать Cat, Dog, Bird и остальных, кто наследуется от Animal (включая сам Animal).
fun main() {
Cage(Cat())
Cage(Animal())
Cage(11111) //1 ошибка
}
В клетке могут быть только те, кто имеет отношение к Animal. Int (1) уже не прокатит.
Ограничения могут быть ещё более жесткими. Мы можем установить не только верхнюю границу — класс (только один класс может быть верхней границей), но и обязать его имплементировать интерфейсы.
interface Breed {
val breed : String
}
class Cage <T> (var i: T) where T : Animal, T : Breed
Здесь мы видим наш класс Cage, после объявления которого следует ключевое слово where, за которым следует перечисление ограничений. Да =), ограничения просто переехали из угловых скобок в конец строки. Там может содержаться класс и все интерфейсы, которые нам нужны.
fun main() {
Cage(Cat("сиамский кот"))
}
Теперь класс Кошка обязан имплементировать интерфейс Breed чтобы иметь возможность быть в клетке. Обычная, не породистая кошка теперь тоже не прокатит. Вот такая у нас ветеринарка…
Вот ещё пример:
fun <T : Animal> feedMe(animal : T){
animal.feedMe()
}
Мы ограничили параметр T для функции, нам стал доступна функция feedMe() из верхней границы, то есть из класса Animal. А если мы хотим кормить только породистых кошек :( то так же можем использовать where и тут
fun <T> feedMe(animal : T) where T : Animal, T : Breed {
animal.feedMe()
}
Кормите всех кошек вне зависимости от породы!!
Стирание параметров типов обобщенных классов в Котлин
На самом деле все эти параметры в угловых скобках, которые мы использовали для наших типов просто берут и безжалостно стираются компилятором после компиляции программы. Ведь они нужны только чтобы защитить нас на этапе компиляции от ошибок (а ещё для совместимости со старыми версиями Java). Кроме того, без них программа будет меньше расходовать памяти.
После запуска они будут заменены на Any (или на то, что мы использовали в качестве верхней границы). Поэтому, во время работы нашей программы нет шанса проверить что же содержали параметризованные типы. Мы можем только знать, что был класс Cage, но вот что в нем было, при работе программы мы уже не узнаем.
Например, если мы захотим узнать какого типа был x, то мы не сможем:
fun <T> typeIs(x : Any): Boolean {
return x is T // ошибка
}
но в Котлин есть обходной путь….
Ключевое слово reified
Котлин дает возможность овеществлять типовые параметры. То есть мы можем иметь информацию о T, даже вовремя работы программы, чтобы как-то воспользоваться ей в нашей работе. Перепишем нашу функцию:
inline fun <reified T> typeIs(x : Any): Boolean {
return x is T
}
Теперь это сработает:
fun main() {
println(typeIs<Int>("abc")) //выведет false
println(typeIs<String>("abc")) //выведет true
}
Здесь ключевое слово reified сообщает компилятору, что мы хотим оставить информацию об этом типе для доступа в рантайме. Про inline я уже писал тут. Это ключевое слово используется для пометки функций в Котлин, оно задействует механизм встройки кода функции в места её вызова, и там же хранится переменная, где помещается информация о типе T. Поэтому, там где пишем reified, всегда пишем inline.
Это была первая часть, посвященная дженерикам. Будет продолжение, там еще много нюансов.
Пожалуйста подпишитесь ✅, мне это очень помогает!
Материалы: