Всем привет! На связи Сергей, Android-разработчик Студии Олега Чулакова на проектах Сбера.

В этой статье мы углубимся в мир Kotlin, рассмотрим его основные преимущества, недостатки и особенности. Мы разберем такие важные аспекты, как безопасность работы с null-значениями, гибкость типизации с помощью Generics, возможности расширения функциональности с помощью extension-функций, inline-функции, а также многое другое.

Давайте начнем наше увлекательное путешествие в мир Kotlin и раскроем его потенциал!

Содержание

1. Краткая история языка
2. Any, Unit, Nothing
3. Data class
4. Sealed class
5. Функции области видимости: let, run, with, apply, also
6. Null safety
7. Static: object, companion object, @JvmStatic, const val
8. lateInit
9. Делегирование
10. Extensions
11. Как extensions выглядят в Java
12. Функциональные (SAM) интерфейсы
13. Generics: инвариантность, ковариантность, контравариантность, where
14. inline, noinline, non-local return, crossinline, reified, итоги
15. Заключение
16. Полезные ссылки

1. Краткая история языка

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

Работа над Kotlin началась в 2010 году, но официальное представление языка произошло только в 2011-м на конференции разработчиков Google I/O. Большая часть команды разработчиков Kotlin в JetBrains состояла из программистов русского происхождения, что отразилось на языке и его названии. Kotlin происходит от острова Котлин, расположенного рядом с Санкт-Петербургом в России.

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

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

2. Any

В Kotlin тип данных Any является верхней границей иерархии типов и базовым для всех других типов в языке. Можно сказать что Any — аналог класса Object в Java.

В Kotlin все типы являются подтипами Any. Это означает, что любой объект в Kotlin может быть присвоен переменной типа Any. Например:

val anyObject: Any = "Пример строки"
val anyNumber: Any = 42

В отличие от Object в Java, который имеет 11 методов в классе, в Any только 3 метода: equals(), hashCode() и toString().

package kotlin

/**
 * The root of the Kotlin class hierarchy. Every Kotlin class has [Any] as a superclass.
 */
public open class Any {
    
    public open operator fun equals(other: Any?): Boolean

    public open fun hashCode(): Int

    public open fun toString(): String
}

Тип Any не является нулевым, то есть он не допускает значения null. Если вам нужна возможность хранения значения null, вы можете использовать тип Any?, который является нулевым.

Правильнее будет сказать, что Any? соответствует любому типу и null, а Any — только любому типу.

Если у вас есть объект типа Any, вы можете выполнить явное приведение типа для доступа к методам и свойствам, специфичным для конкретного типа. Для этого используется оператор as.

val anyObject: Any = "Пример строки"
val length: Int = (anyObject as String).length

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

Unit

Unit — это специальный тип, который представляет отсутствие значения или «пустоту». Он аналогичен void в Java. 

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

fun printHello(): Unit {
    println("Привет, мир!")
}

Если функция не имеет явно указанного возвращаемого типа, она автоматически возвращает Unit. Например:

fun doSomething() {
    // Код функции
}

Здесь doSomething() будет возвращать Unit по умолчанию.

Unit является синглтоном (ключевое слово object). Это означает, что вы не можете создавать экземпляры Unit.

package kotlin

/**
 * The type with only one value: the `Unit` object. This type corresponds to the `void` type in Java.
 */
public object Unit {
    override fun toString() = "kotlin.Unit"
}

Unit не является нулевым типом. Он представляет отсутствие значения, но не допускает значения null. Если вы хотите, чтобы функция возвращала Unit или null, вы можете использовать тип Unit?.

fun doSomething(): Unit? {
    // Код функции
    return null
}

Nothing

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

fun throwError(message: String): Nothing {
    throw RuntimeException(message)
}

В этом примере функция throwError явно указывает возвращаемый тип Nothing и всегда генерирует исключение. Код после вызова этой функции будет недостижим.

Nothing является так называемым bottom type. Bottom type — это тип, который является подтипом для всех других типов, но сам не имеет подтипов, за исключением самого себя.

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

package kotlin

/**
 * Nothing has no instances. You can use Nothing to represent "a value that never exists": for example,
 * if a function has the return type of Nothing, it means that it never returns (always throws an exception).
 */
public class Nothing private constructor()

Тип Nothing может быть полезен для указания, что в определенных ситуациях конкретные данные не требуются или не могут быть предоставлены, например:

sealed class Result<out T> {

    data class Success<T>(val data: T) : Result<T>()

    object Loading : Result<Nothing>()

    data class Error(val message: String) : Result<Nothing>()
}
  • sealed class Result<out T> принимает обобщенный аргумент T для представления фактических данных в состояниях успеха.

  • Success представляет состояние успеха операции. Он содержит поле data, которое хранит фактические данные результата операции. Этот класс наследуется от Result<T>, что позволяет использовать его как подтип Result с обобщенным аргументом T.

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

  • Error представляет состояние ошибки. Он содержит поле message, в котором хранится сообщение об ошибке. Как и в предыдущем случае, используется Nothing, так как состояние ошибки не содержит фактических данных.

3. Data class

Data-классы в Kotlin — это специальный тип классов, предназначенных для хранения и управления данными. Они предоставляют набор встроенных функций для автоматической генерации стандартных методов, таких как equals(), hashCode(), toString(), copy() и componentN(). 

Важно:

  • Data class должен принимать как минимум один параметр в конструктор.

  • От data class нельзя наследоваться, так как он является final.

  • Data class может быть унаследован от других open-классов.

  • Все параметры основного конструктора должны быть отмечены val или var (рекомендуется val).

  • Data class не может быть abstract, open, sealed или inner.

Объявление data-класса   

Для объявления data-класса в Kotlin используется ключевое слово data перед объявлением класса. Data-класс должен иметь конструктор с параметрами, которые определяют свойства класса.

data class Person(val name: String, val age: Int)

В этом примере объявлен data-класс Person с двумя свойствами: name типа String и age типа Int.

Стандартные методы

Data-классы в Kotlin автоматически генерируют следующие стандартные методы:

  • equals() — сравнивает два объекта на идентичность полей;

  • hashCode() — возвращает хеш-код объекта, основанный на его полях;

  • toString() — возвращает строковое представление объекта, содержащее значения его полей;

  • copy() — возвращает новый объект с указанными измененными полями;

  • componentN() — возвращает значение переменной и позволяет обращаться к свойствам объекта класса по их порядковому номеру.

Пример с copy()

val person = Person("John", 25)
val updatedPerson = person.copy(age = 30)

println(person) // Выводит исходный объект
println(updatedPerson) // Выводит объект с обновленным возрастом

В этом примере мы создаем объект person типа Person и используем функцию copy() для создания копии объекта с обновленным значением возраста. Исходный объект остается неизменным, а новая копия возвращается с обновленным свойством.

Пример с componentN()

data class Person(val name: String, val age: Int)

val person = Person("Sergei", 27)
println(person .component1()) // Выведет: Sergei

Мы вызываем метод component1() на объекте person, который является автоматически сгенерированным методом для доступа к первому свойству name объекта Person. 

Использование componentN для деструктуризации

componentN позволяет использовать деструктуризацию для извлечения значений свойств и присваивания их отдельным переменным.

val person = Person("John", 25)
val (name, age) = person

println(name) // Выводит имя
println(age) // Выводит возраст

В этом примере мы создаем объект person типа Person и применяем деструктуризацию для извлечения его свойств name и age и присваивания их переменным name и age. Теперь мы можем использовать эти переменные отдельно для дальнейшей обработки.

Методы componentN в data-классах Kotlin широко используются для работы с моделями данных, сериализации/десериализации объектов, обмена данными между компонентами и других сценариев, где требуется удобство и гибкость при работе с отдельными свойствами объекта.

Важно:

  • Не стоит использовать data-классы там, где нет необходимости в применении сгенерированных методов. Если вам не требуются автоматически сгенерированные методы или если вам нужно расширять функциональность класса собственными реализациями методов, тогда использование data-классов может быть избыточным.

  • Автоматически сгенерированные методы data-классов могут иметь некоторые ограничения и особенности в специфических сценариях. Например, метод equals() сравнивает значения всех свойств класса, что может быть нежелательным, если некоторые свойства должны быть игнорированы при сравнении.

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

4. Sealed class

Sealed-класс в Kotlin — это класс, ограничивающий набор подклассов, которые могут быть унаследованы от него. Он предоставляет контроль над возможными вариантами классов, которые могут быть созданы из данного sealed-класса.

Объявление sealed-класса

Для объявления sealed-класса в Kotlin используется ключевое слово sealed перед объявлением класса. Затем внутри sealed-класса определяются все возможные подклассы:  class, object, data class и другие.

sealed class Result {
    data class Success(val data: String) : Result()
    data class Error(val message: String) : Result()
    object Loading : Result()
}

В этом примере объявлен sealed-класс Result. Внутри него определены три подкласса: Success, Error и Loading. Каждый из подклассов имеет свои уникальные свойства и может представлять различные варианты результата.

Важно:

  • Конструктор sealed-класса всегда приватен.

  • У sealed-класса могут быть наследники, но все они должны находиться в одном пакете с sealed-классом.

  • Наследники sealed-класса могут быть классами любого типа: data class, object, class, другим sealed-классом.

  • Sealed-классы могут содержать в себе абстрактные компоненты.

  • Sealed-классы нельзя инициализировать.

Проверка подклассов

Одной из особенностей sealed-классов является возможность использования конструкции when для проверки всех возможных подклассов. Это делает код более безопасным, так как все подклассы известны и проверяются компилятором.

fun processResult(result: Result) {
    when (result) {
        is Result.Success -> {
            println("Успешный результат: ${result.data}")
        }
        is Result.Error -> {
            println("Ошибка: ${result.message}")
        }
        Result.Loading -> {
            println("Загрузка...")
        }
    }
}

В этом примере функция processResult принимает любого наследника sealed-класса Result. С использованием конструкции when мы проверяем все возможные подклассы sealed-класса и выполняем соответствующие действия в зависимости от типа результата.

Sealed-классы часто используются для моделирования ограниченного и контролируемого набора состояний, результатов или ошибок. Они могут применяться для обработки различных сценариев и предоставления четкой структуры для работы с различными вариантами.

5. Функции области видимости (Scope Functions) — let, run, with, apply, also

В Kotlin существуют пять стандартных функций высшего порядка: let, run, with, apply и also. Эти функции позволяют выполнять операции над объектами с удобным синтаксисом и обеспечивают более читаемый и выразительный код. 

let

Функция let позволяет выполнить блок кода над объектом и возвращает результат этого блока. Она полезна, когда вам нужно выполнить операции над объектом и вернуть результат в цепочке вызовов.

val str = "Hello"
val result = str.let {
    println(it) // Выводит "Hello"
    it.length // Возвращает длину строки
}
println(result) // Выводит 5

В этом примере блок кода внутри let выполняется над строковым объектом str, где it является ссылкой на этот объект. Мы выводим значение it и возвращаем его длину, которая присваивается переменной result.

run

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

val result = "Hello".run {
    println(this) // Выводит "Hello"
    length // Возвращает длину строки
}
println(result) // Выводит 5

В этом примере мы вызываем run на строковом объекте "Hello". Внутри блока кода this ссылается на этот объект. Мы выводим значение this и возвращаем его длину, которая присваивается переменной result.

with

Функция with позволяет выполнить блок кода над объектом, не используя расширение объекта. Она не возвращает результат блока кода.

val str = "Hello"
with(str) {
    println(this) // Выводит "Hello"
    val length = length // Доступ к свойству объекта
    println(length) // Выводит 5
}

В этом примере мы вызываем with на объекте str. Внутри блока кода this ссылается на этот объект. Мы выводим значение this и получаем доступ к его свойству length, выводя его значение.

apply

Функция apply применяет блок кода к объекту и возвращает измененный объект. Она полезна, когда вам нужно настроить свойства объекта внутри блока кода.

val person = Person().apply {
    name = "John"
    age = 30
}

В этом примере мы создаем объект Person и применяем блок кода к этому объекту с помощью apply. Внутри блока кода мы устанавливаем значения свойств name и age объекта person.

also   

Функция also похожа на apply, но при использовании apply вы всегда ссылаетесь на receiver с помощью this.

val person = Person().apply {
    name = "Tony Stark"
    age = 52
}

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

val person = Person().also { newPerson ->
    newPerson.name = "Tony Stark"
    newPerson.age = 52
}

6. Null safety

Null safety в Kotlin — это особенность языка, которая направлена на предотвращение ошибок, связанных с работой с null-значениями. Она обеспечивает безопасность типов и требует явного указания, когда переменная может иметь значение null.

В Kotlin существуют два типа данных для работы с nullable-значениями: nullable-типы и non-null-типы. Nullable-типы обозначаются добавлением символа «?» после типа данных. Non-null-типы не могут содержать null-значения и не требуют проверок на null.

Объявление nullable- и non-null-переменных

var nullableString: String? = null
var nonNullString: String = "Hello, Kotlin!"

В приведенном примере nullableString может содержать null-значение, тогда как nonNullString гарантированно не может быть null. Если мы попытаемся присвоить nonNullString-значение null, то увидим следующую ошибку: «Null can not be a value of a non-null type String».

Использование оператора безопасного вызова «?.»

val length: Int? = nullableString?.length

Оператор «?.» позволяет безопасно вызывать методы или получать доступ к свойствам nullable-объектов. Если nullableString равно null, выражение nullableString?.length вернет null, иначе вернется длина строки.

Однако чрезмерное использование оператора «?.» может привести к ухудшению читаемости кода.

Рассмотрим следующий пример:

val user: User? = getUser()
val imageUrl = user?.friend?.photo?.image?.imageUrl

В данном примере только переменная user может быть null, а все остальные поля являются non-null-типами, но мы вынуждены использовать оператор «?.» для всех полей, чтобы добраться до imageUrl. Вместо этого можно просто проверить переменную user на null и получить imageUrl:

val user: User? = getUser()
if (user != null) {
    val imageUrl = user.friend.photo.image.imageUrl
}

Такой подход с использованием явных проверок на null обычно делает код более читаемым и понятным, особенно при работе с глубокой вложенностью объектов.

Использование Элвис-оператора «?:»

val nonNullStringLength: Int = nullableString?.length ?: 0

Оператор «?:», также известный как Элвис-оператор, позволяет вернуть альтернативное значение, если значение до оператора равно null. В приведенном примере, если nullableString равно null, nonNullStringLength будет равна 0.

Функция let и безопасное приведение типа

nullableString?.let { str ->
    // Блок кода, выполняемый только при ненулевом значении nullableString
    val length: Int = str.length
}

Функция let выполняет блок кода только при ненулевом значении nullableString и передает это значение в качестве аргумента. Это позволяет безопасно выполнять операции с nullable-объектами внутри блока.

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

val nullableString: String? = null

nullableString?.let { str ->
    // Блок кода, который должен выполниться, если nullableString не равен null
    val length: Int = str.length
} ?: {
    // Блок кода, который будет выполнен, если nullableString равен null
    println("nullableString равен null")
}

Функции проверки на null «checkNotNull» и «requireNotNull»

val nonNullString: String = checkNotNull(nullableString)

Функция checkNotNull проверяет, что значение не равно null, и возвращает это значение или выбрасывает IllegalStateException, если значение null. Функция requireNotNull вызывает IllegalArgumentException, если значение равно null.

7. Static

В Kotlin отсутствует привычное ключевое слово static, которое используется в Java для объявления статических членов класса. Вместо этого Kotlin предоставляет несколько альтернативных механизмов для работы со статическими членами.

object как Singleton

  • В Kotlin object используются для создания единственного экземпляра класса, то есть для реализации паттерна Singleton.

  • Объект объявляется с использованием ключевого слова object и может содержать свои методы, свойства и блоки инициализации.

  • Объект создается лениво при первом обращении к нему, а его состояние и поведение доступны через имя объекта.

     object MySingleton {

         val someField = 0

         fun someFun() {
             // Логика функции
         }
     }

В этом примере MySingleton является объектом, который представляет собой Singleton. Мы можем обращаться к его свойствам и методам через имя объекта, например MySingleton.someField или MySingleton.someFun().

companion object (также Singleton)

  • Является объектом, который объявляется внутри класса и связан с этим классом.

  • К его членам имеет доступ класс, в котором он объявлен.

  • Инициализируется при первом доступе к классу или создании первого экземпляра класса.

class MyClass {

       companion object {
             const val count = 0

             fun staticMethod() {
                 // статический метод
             }
       }
}

В этом примере MyClass содержит companion object, который содержит статические переменные и методы. Мы можем обращаться к этим статическим членам через имя класса, например MyClass.count или MyClass.staticMethod().

По умолчанию у companion object будет имя Companion, но мы можем дать ему свое.

class MyClass {

    companion object MyCompanion {
        const val a = 2
    }
    
}

MyClass.MyCompanion.a

Аннотация @JvmStatic

Для объявления методов, которые должны быть истинно статическими, можно использовать аннотацию @JvmStatic. Эта аннотация позволяет применять статические методы в Kotlin так, как если бы они были объявлены как статические в Java. Это полезно, когда требуется совместимость с Java-кодом, который ожидает наличие статических методов.

object MyObject {

    @JvmStatic
     fun staticMethod() {
         // статический метод
     }
}

В этом примере staticMethod будет действительно объявлен статическим при компиляции в Java.

const val

  • В Kotlin есть возможность объявить константы на уровне файла, в object или companion object с помощью ключевых слов const val.

  • Константы, объявленные с помощью const val, должны быть примитивными типами данных или строками и быть известны на этапе компиляции.

  • Константы const val, объявленные на уровне файла, могут использоваться без создания экземпляра класса.

8. lateinit

lateinit — это ключевое слово в Kotlin, которое позволяет отложить инициализацию переменной, объявленной с ключевым словом var, до момента первого обращения к этой переменной. Данная «затычка» была добавлена в Kotlin для облегчения работы с некоторыми фреймворками и библиотеками, такими как Dagger, которые требуют отложенной инициализации свойств.

class MyClass {
    lateinit var name: String
    
    fun initialize() {
        name = "Kotlin"
    }
    
    fun printName() {
        if (this::name.isInitialized) {
            println(name)
        } else {
            println("Name is not initialized")
        }
    }
}

fun main() {
    val obj = MyClass()
    obj.initialize()
    obj.printName()
}

В этом примере у класса MyClass есть переменная name, объявленная с ключевым словом lateinit. В методе initialize() мы инициализируем переменную name. Метод printName() проверяет, была ли переменная name инициализирована с помощью this::name.isInitialized, и выводит соответствующее сообщение.

Однако если мы не присвоим значение lateinit переменной до обращения к ней и не добавим проверку isInitialized, то наша программа упадет с UninitializedPropertyAccessException.

Многие разработчики считают lateinit плохим решением по следующим причинам:

  • Небезопасность. Когда переменная объявлена с lateinit, компилятор Kotlin не выполняет проверку на null-значение, и следовательно, возможно возникновение NullPointerException, если переменная не была инициализирована до момента первого обращения.

  • Сложность отслеживания ошибок. Использование lateinit усложняет статический анализ кода. Компилятор не может определить, будет ли переменная lateinit инициализирована перед применением, что может привести к ошибкам во время выполнения.

  • Нарушение принципов чистоты и предсказуемости кода. В Kotlin рекомендуется использовать иммутабельные свойства, когда это возможно. Применение lateinit нарушает этот принцип, так как переменная с lateinit может быть изменена после инициализации.

В целом использование lateinit следует ограничивать только в тех случаях, когда это необходимо для совместимости с определенными фреймворками или библиотеками. В остальных случаях рекомендуется применять обычную инициализацию переменных в момент их объявления или использования nullable-типов с явной проверкой на null.

9. Делегирование

Делегирование в Kotlin — это механизм, который позволяет передать реализацию методов одного класса другому классу. Делегирование помогает достичь принципа разделения ответственности и повторного использования кода.

Создание делегата

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

interface Printer {
    fun print(text: String)
}

class MyClass(private val printer: Printer) : Printer by printer {
    // ...
}

В этом примере MyClass делегирует реализацию метода print() объекту printer.

Переопределение методов делегата

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

interface Calculator {
    fun sum(a: Int, b: Int): Int
}

class BasicCalculator : Calculator {
    override fun sum(a: Int, b: Int): Int {
        return a + b
    }
}

class LoggingCalculator(private val calculator: Calculator) : Calculator by calculator {
    override fun sum(a: Int, b: Int): Int {
        val result = calculator.sum(a, b)
        println("Выполнено сложение: $a + $b = $result")
        return result
    }
}

В этом примере LoggingCalculator делегирует реализацию метода sum() объекту calculator. Однако мы также переопределяем метод sum() для добавления логики.

Делегирование свойств

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

import kotlin.properties.Delegates

class Person {
    var name: String by Delegates.observable("") { _, oldName, newName ->
        println("Изменилось имя: $oldName -> $newName")
    }
}

В этом примере свойство name делегируется объекту Delegates.observable, который обрабатывает изменение значения свойства и выполняет определенные действия.

Делегаты стандартной библиотеки 

Kotlin предоставляет некоторые стандартные делегаты, которые можно использовать для общих сценариев. Например, lazy — для ленивой инициализации, vetoable — для валидации изменения значения и другие.

lazy

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

class Example {
    val lazyValue: String by lazy {
        println("Вычисление значения")
        "Ленивое значение"
    }
}

В этом примере свойство lazyValue объявлено с использованием делегата lazy. При первом обращении к этому свойству будет вызван initializer, переданный делегату lazy. initializer содержит код, который вычисляет и возвращает значение свойства. В данном случае при первом обращении к lazyValue будет выведено сообщение «Вычисление значения» и возвращено значение «Ленивое значение». При последующих обращениях к lazyValue будет возвращено ранее вычисленное значение без повторного вычисления.

val example = Example()

// Выводит "Вычисление значения" и "Ленивое значение"
println(example.lazyValue)

// Выводит только "Ленивое значение" (без повторного вычисления)
println(example.lazyValue)

vetoable

Делегат vetoable позволяет валидировать изменение значения свойства перед его присвоением. Если валидация не прошла успешно, изменение значения будет отклонено.

class Example {

    var counter: Int by Delegates.vetoable(0) { _, oldValue, newValue ->

        newValue > oldValue

    }

}

В этом примере свойство counter объявлено с использованием делегата vetoable, в который мы обязаны передать initialValue. При попытке присвоения значения этому свойству будет вызвана лямбда, переданная делегату vetoable. Лямбда должна вернуть значение — true, если присваиваемое значение допустимо, и false, если оно должно быть отклонено. В данном случае лямбда проверяет, что новое значение newValue должно быть больше предыдущего значения oldValue. Если это условие выполняется, присваивание значения будет разрешено. В противном случае присваивание будет отклонено, и свойство counter останется с предыдущим значением.

val example = Example()

// Устанавливает значение свойства counter равным 5

example.counter = 5

// Выводит 5

println(example.counter)

// Попытка установить значение меньше предыдущего

example.counter = 2

// Выводит 5 (значение не изменилось)

println(example.counter)

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

10. Extensions

Расширения (extensions) в Kotlin — это возможность добавлять новые функции и свойства к существующим классам без необходимости изменять их исходный код. Они позволяют расширять функциональность классов, включая стандартные классы из стандартной библиотеки, классы сторонних библиотек или ваши собственные классы.

Расширения объявляются с помощью ключевого слова fun, за которым следует получатель (тип, к которому мы хотим добавить функцию или свойство), затем точка и имя функции или свойства.

   fun String.customExtensionFunction() {

        // Реализация расширения

   }

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

   val str = "Hello"

   str.customExtensionFunction() // Вызов расширения

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

   fun String.customExtensionFunction(): Int {

       return this.length + 10

   }

   

   val str = "Hello"

   val result = str.customExtensionFunction() // 15

Расширения могут быть объявлены внутри класса или файла. Видимость расширения определяется видимостью объявления расширения и импортом в месте использования.

   // Объявление расширения внутри класса

   class MyClass {

       fun String.customExtensionFunction() {

           // Реализация расширения

       }

   }

   

   val str = "Hello"

   // Доступно только из экземпляров класса MyClass

   str.customExtensionFunction()

Кроме функций, расширения могут быть использованы для добавления свойств к классам.

   val String.customExtensionProperty: Int

       get() = this.length + 10

   

   val str = "Hello"

   val lengthWithExtension = str.customExtensionProperty // 15

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

Расширения для companion object

Для companion object мы также можем определить функции и свойства расширения. Их можно вызывать, используя в качестве определителя только имя класса.

class MyClass {

    companion object { }

}

fun MyClass.Companion.someFun() = Unit

MyClass.someFun()

11. Как extensions выглядят в Java

Допустим, мы написали следующее расширение:

fun <T> MutableList<T>.swap(index1: Int, index2: Int) {

    this[index1] = this[index2].also { this[index2] = this[index1] }

}

Эта функция позволяет поменять местами значения двух элементов списка по заданным индексам.

Расширение swap в Kotlin позволяет нам вызывать этот метод прямо на объекте списка, несмотря на то что этот метод не является частью исходного класса MutableList. 

В Java отсутствует прямой аналог расширений в Kotlin. Вместо этого под капотом создается отдельный класс или утилитарный класс и объявляется статический метод swap. В этот метод передается список в качестве первого аргумента и выполняется операция обмена элементами списка по заданным индексам.

Вот как будет выглядеть упрощенный вариант декомпиляции:

// Код на Java

public static final <T> void swap(@NonNull List<T> list, int index1, int index2) {

        T temp = list.get(index2);

        list.set(index2, list.get(index1));

        list.set(index1, temp );

}

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

class MyClass {

    fun printName() {

        println(this::class.simpleName)

    }

}

fun MyClass.printName() {

    println("Extension: ${this::class.simpleName}")

}

MyClass().printName()

Вывод: MyClass

Однако мы можем перегрузить нашу функцию, добавив, к примеру, дополнительный параметр title.

class MyClass {

    fun printName() {

        println(this::class.simpleName)

    }

    

}

fun MyClass.printName(title: String) {

    println("$title: ${this::class.simpleName}")

}

MyClass().printName(title = "Name")

Вывод: Name: MyClass

12. Функциональные (SAM) интерфейсы

Функциональные интерфейсы (также известные как SAM-интерфейсы) — это интерфейсы, которые содержат только один абстрактный метод. Они могут быть использованы в Kotlin для создания лямбда-выражений или анонимных функций. Это позволяет сделать код более приятным для восприятия при работе с функциями высшего порядка и обратными вызовами.

Определение функционального (SAM) интерфейса

Для определения функционального интерфейса в Kotlin достаточно объявить интерфейс с единственным абстрактным методом. Этот метод будет являться функциональным методом, к которому можно ссылаться с использованием лямбда-выражений или анонимных функций.

interface OnClickListener {

    fun onClick()

}

В этом примере OnClickListener — функциональный (SAM) интерфейс, содержащий единственный метод onClick(). Этот интерфейс может быть использован для обработки события клика.

Использование функциональных (SAM) интерфейсов

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

fun setOnClickListener(listener: OnClickListener) {

    // Логика для установки обработчика клика

}

fun main() {

    val clickListener = OnClickListener {

        println("Клик выполнен")

    }

    setOnClickListener(clickListener)

}

В этом примере мы создаем экземпляр функционального интерфейса OnClickListener с применением лямбда-выражения. Затем этот экземпляр передается в функцию setOnClickListener(), которая может использовать его для обработки события клика.

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

13. Generics

Обобщения (Generics) являются важной частью языка Kotlin, которая позволяет создавать универсальные классы и функции, способные работать с разными типами данных. В Kotlin есть вариативность на уровне объявления и проекции типов.

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

Инвариантность

Инвариантность означает, что тип является неизменным относительно других типов. В контексте обобщений, если у вас есть обобщенный тип, например MyType<T>, то MyType<A> и MyType<B> не считаются связанными или находящимися в иерархии друг с другом, даже если типы A и B связаны отношением наследования.

class MyType<T> {

    // Класс реализации

}

val myTypeA: MyType<A> = MyType() // Не совместимо

val myTypeB: MyType<B> = MyType() // Не совместимо

В этом примере типы MyType<A> и MyType<B> являются инвариантными, и они не считаются совместимыми друг с другом, даже если A и B наследуются от одного общего типа.

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

Ковариантность позволяет использовать подтипы вместо основного типа. Это означает, что если у вас есть обобщенный тип MyType<out T>, то MyType<A> является подтипом MyType<B>, если A является подтипом B. Ковариантные типы могут использоваться только в «выходных» позициях (например возвращаемых значениях функций).

fun main() {

    // ProducerImplA реализует Producer<A>

    val producerA: Producer<A> = ProducerImplA()

    // Ковариантность: Producer<A> является подтипом Producer<B>

    val producerB: Producer<B> = producerA

}

interface Producer<out T> {

    fun produce(): T

}

class A : B()

open class B

В этом примере Producer<A> является подтипом Producer<B>, поэтому мы можем присвоить producerA (тип Producer<A>) переменной producerB (тип Producer<B>).

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

Контравариантность позволяет использовать супертипы вместо основного типа. Это означает, что если у вас есть обобщенный тип MyType<in T>, то MyType<A> является супертипом MyType<B>, если A является супертипом B. Контравариантные типы могут использоваться только во «входных» позициях (например в параметрах функций).

fun main() {

    // ConsumerImplB реализует Consumer<B>

    val consumerB: Consumer<B> = ConsumerImplB()

   

    // Контравариантность: Consumer<B> является супертипом Consumer<A>

    val consumerA: Consumer<A> = consumerB

}

interface Consumer<in T> {

    fun consume(item: T)

}

open class B

class A : B()

В этом примере Consumer<B> является супертипом Consumer<A>, поэтому мы можем присвоить consumerB (тип Consumer<B>) переменной consumerA (тип Consumer<A>).

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

where (ограничения обобщений)

Ключевое слово where используется для установки ограничений на обобщенные типы в Kotlin. Это позволяет определить допустимые ограничения для обобщенных параметров, такие как наследование классов или реализация интерфейсов.

fun <T> doSomething(obj: T) where T : CharSequence, T : Comparable<T> {

    // Реализация функции

}

В этом примере функция doSomething принимает обобщенный тип T, ограниченный типами CharSequence и Comparable<T>. Это означает, что аргумент функции должен быть подтипом CharSequence и должен реализовывать интерфейс Comparable с типом T.

Обобщения (Generics) в Kotlin предоставляют мощные возможности для создания гибких и типобезопасных компонентов. Использование ключевых слов in, out и where позволяет более точно определить требования к типам и применять обобщенный код в различных сценариях.

14. inline

Давайте представим, что у нас есть собственное расширение для Collection:

fun  Collection.filter(predicate: (T) -> Boolean): Collection = //…

val numbers = listOf(1, 2, 3, 4, 5)

val evenNumbers = numbers.filter { it % 2 == 0 }

В этом примере мы используем filter, чтобы оставить только четные числа из коллекции. Однако при компиляции этого кода в Java создается объект специального типа, и вызывается метод invoke для каждого элемента. Пример преобразования кода в Java может выглядеть следующим образом:

// Код на Java

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);

List<Integer> evenNumbers = CollectionsKt.filter(numbers, new Function1() {

    @Override

    public Boolean invoke(Integer it) {

        return it % 2 == 0;

    }

});

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

Однако, чтобы избежать такой ситуации, в Kotlin мы можем использовать ключевое слово inline перед функцией высшего порядка. Это позволяет компилятору встроить код лямбда-выражения непосредственно в вызывающий код. Таким образом, мы избежим создания дополнительного объекта Function1 и вызова метода invoke, что повысит нашу производительность.

Вот как выглядит filter в kotlin.collections:

public inline fun <T> Iterable<T>.filter(predicate: (T) -> Boolean): List<T> {

    return filterTo(ArrayList<T>(), predicate)

}

noinline

Ключевое слово noinline в Kotlin используется для указания компилятору, что лямбда-выражение, переданное в качестве аргумента функции высшего порядка, не должно быть встроено в месте вызова, как в случае с inline. 

Рассмотрим пример использования ключевого слова noinline.

inline fun processNumbers(
    numbers: List<Int>,
    operation: (Int) -> Unit,
    noinline filter: (Int) -> Boolean
) {
    for (number in numbers) {
        if (filter(number)) {
            operation(number)
        }
    }
}

В этом примере у нас есть функция processNumbers, которая принимает список чисел numbers, операцию operation, которая применяется к каждому числу, и фильтр filter, определяющий, должно ли число проходить фильтрацию. Здесь мы использовали ключевое слово noinline перед аргументом filter.

Теперь рассмотрим пример использования функции processNumbers.

val numbers = listOf(1, 2, 3, 4, 5)

processNumbers(

    numbers = numbers,

    filter = { it % 2 == 0 },

    operation = { println(it) }

)

В этом примере мы передаем список чисел numbers, лямбда-выражение для операции (в данном случае вывод числа в консоль) и лямбда-выражение для фильтрации (в данном случае проверка четности числа). 

Ключевое слово noinline позволяет нам использовать лямбда-выражение filter без встраивания в место вызова, как в случае с inline. Это особенно полезно, если мы хотим сохранить лямбда-выражение для последующего применения или передачи другой функции.

non-local return

Рассмотрим пример:

fun checkNumbers(numbers: List<Int>): Boolean {

    numbers.forEach {

        if (it < 0) return false // нелокальный return из функции checkNumbers

    }

    return true

}

В этом примере у нас есть функция checkNumbers, которая принимает список чисел. Мы используем метод forEach для итерации по списку чисел. Если находим число, которое меньше нуля, мы выполняем нелокальный return из функции checkNumbers, возвращая false. 

Ключевое слово inline в функции forEach позволяет нам сделать нелокальный return.

public inline fun <T> Iterable<T>.forEach(action: (T) -> Unit): Unit {

    for (element in this) action(element)

}

Как мы уже выяснили, когда функция объявлена с модификатором inline, ее код вставляется непосредственно в место вызова вместо создания отдельного объекта функции и вызова invoke. Это позволяет иметь доступ к нелокальному return внутри лямбда-выражений, которые передаются внутрь таких инлайн-функций. 

Для наглядности давайте напишем свою функцию forEach без ключевого слова inline:

fun <T> Iterable<T>.noinlineForEach(action: (T) -> Unit) {

    for (element in this) action(element)

}

fun checkNumbers(numbers: List<Int>): Boolean {

    numbers.noinlineForEach {

        if (it < 0) return false // Допустимо только return@noinlineForEach

    }

    return true

}

Функция noinlineForEach не является инлайн-функцией, поэтому такой нелокальный return недопустим. Компилятор выдаст ошибку, указывая на недопустимость функций. Вместо этого мы сможем выйти только из функции noinlineForEach, указав return@noinlineForEach.

Таким образом, благодаря применению inline мы получаем возможность использовать нелокальный return внутри лямбда-выражений, переданных в инлайн-функции, что дает нам большую гибкость в контроле потока выполнения и прерывании обработки по требованию.

crossinline

Для того чтобы указать, что лямбда-выражение не может содержать нелокальные return, даже если оно передано в inline-функцию, используется ключевое слово crossinline.

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

inline fun foo(crossinline action: () -> Unit) {

    thread {

        action()

    }.start()

}

reified

Ключевое слово reified (с английского «овеществленный») в Kotlin используется только в inline-функциях. reified позволяет получить информацию о типе generic-параметра во время выполнения программы. В обычном случае информация о типах стирается и недоступна во время выполнения, но с помощью reified можно сохранять эту информацию и использовать в других частях приложения.

reified может понадобиться для получения информации о типе:

inline fun <reified T> printTypeName() {

    val typeName = T::class.simpleName

    println("Type name: $typeName")

}

printTypeName<Int>() // Выводит: Type name: Int

printTypeName<String>() // Выводит: Type name: String

В этом примере у нас есть инлайн-функция printTypeName, которая принимает параметр типа T. С помощью ключевого слова reified мы можем получить информацию о типе T во время выполнения с помощью выражения T::class. Затем мы выводим имя типа в консоль. 

Рассмотрим пример поиска элемента определенного типа в списке:

inline fun <reified T> findElement(list: List<Any>): T? {

    for (element in list) {

        if (element is T) {

            return element

        }

    }

    return null

}

val numbers = listOf(1, 2, 3, "four", "five")

val result: String? = findElement<String>(numbers)

println(result) // Выводит: four

В этом примере у нас есть инлайн-функция findElement, которая принимает параметр типа T и список элементов типа Any. Мы используем цикл for для итерации по списку и проверяем, является ли каждый элемент типом T с помощью оператора is. Если находим элемент указанного типа, мы возвращаем его. Если такой элемент не найден, возвращаем null. Затем мы вызываем функцию findElement с типом String и списком, содержащим числа и строки. Функция возвращает элемент типа String из списка.

inline: итоги

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

  • Объем кода: inline-функции копируют свой код в каждое место вызова, поэтому большие функции могут привести к увеличению размера исходного кода. Рекомендуется использовать инлайн-функции для небольших участков кода, часто вызываемых или содержащих лямбда-выражения.

  • Производительность: inline-функции могут повысить производительность. Однако иногда компилятор может решить не «инлайнить» функцию из-за сложных условий или объема кода. В таких случаях инлайн-функция может не принести значительного улучшения производительности. Рекомендуется измерять производительность и анализировать сгенерированный байт-код для оценки эффекта inline-функции на производительность.

  • Отладка: inline-функции могут усложнить отладку, так как код функции будет распределен по разным местам в исходном коде, в зависимости от мест вызова. Это может затруднить отслеживание ошибок и понимание потока выполнения программы.

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

15. Заключение

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

Мы изучили стандартные типы, такие как Any, Unit, Nothing, а также использование функций области видимости, таких как let, run, with, apply, also. Они позволяют упростить код, делают его более читаемым и экономят время разработчика.

Одно из ключевых преимуществ Kotlin — это его поддержка null safety, которая помогает предотвратить ошибки, связанные с NullPointerException. Kotlin предлагает строгую систему типов, которая позволяет более безопасно работать с nullable- и non-nullable-типами данных.

Мы рассмотрели и некоторые другие возможности Kotlin, такие как lateinit, делегирование, generics с поддержкой инвариантности, ковариантности и контравариантности, а также ключевые слова inline, noinline, non-local return, crossinline, reified. Все эти возможности помогают более эффективной разработке и повышают гибкость и мощность языка.

Object, companion object и const val раскрывают различные способы работы с синглтонами и константами в Kotlin, делая код более организованным и понятным.

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

Мы также рассмотрели data class и sealed class, которые предоставляют мощные инструменты для работы с данными и моделирования ограниченных наборов состояний.

Кроме того, Kotlin имеет прекрасную поддержку функциональных (SAM) интерфейсов, что упрощает работу с функциями высшего порядка и обратными вызовами.

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

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

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

16. Полезные ссылки

Документация — https://kotlinlang.org/docs/home.html

Исходники — https://github.com/JetBrains/kotlin

Руководство по языку Kotlin — https://kotlinlang.ru

Комментарии (12)


  1. Akela_wolf
    08.08.2023 15:44
    +14

    А где, простите, преимущества? Где недостатки?

    Преимущества и недостатки предполагают какое-то сравнение: "здесь сделано так, поэтому в сравнении с X это преимущество, а вот здесь так, поэтому в сравнении с Y это недостаток"

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


    1. Rusrst
      08.08.2023 15:44
      -3

      Ну кто в студию Чулакова на Android developer собеситься будет, будет в курсе что там спрашивают :)

      Можно сказать, что первые 15-20 минут о чем поговорить будет.


  1. hVostt
    08.08.2023 15:44
    +3

    Глазами дотнетчика со стажем, "типы" Any, Unit, Nothing выглядят однозначно как костыли и подпорки. Дата-классы без наследования? Печаль :( Целое ключевое слово sealed там, где просто можно сделать приватный конструктор? Why? Обычный декоратор почему-то называется делегатом да ещё и синтаксис придумали, в котором в общем-то нет нужды, только если вся платформа не построена на декорировании. Целый ворох других точечных ключевых слов, которые особо-то и не нужны. К сожалению, так и не смог разделить восторга, ну разве что если с Java сравнивать.


    1. Kinski
      08.08.2023 15:44

      В сравнении с чистой Java, особенно до 17 версии, котлин на голову выше.

      Что касается sealed классов, основное назначение - это ограничить число наследников. Боюсь только приватным конструктором это не решить.


      1. Rusrst
        08.08.2023 15:44
        +1

        Все ж это не про ограничение, а про знание всех потомков на этапе компиляции (else не нужен, если все обработаны). В количестве то никто не ограничивает.


        1. Kinski
          08.08.2023 15:44
          +1

          Да, полностью согласен, так понятнее звучит)


      1. aleksandy
        08.08.2023 15:44

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


  1. 18741878
    08.08.2023 15:44

    Все это, конечно, здорово. Но все одно: где-то в потрохах лежит старая добрая JVM. Кого мы обманываем?


    1. Akela_wolf
      08.08.2023 15:44
      +1

      Во-первых, Котлин умеет компиляцию (транспиляцию) в JavaScript

      Во-вторых, Котлин умеет компиляцию даже в native

      В-третьих, у Андроида, насколько я знаю, своя VM, несовместимая с JVM

      И в четвертых, а почему JVM под капотом означает что мы кого-то обманываем? Кроме Java на JVM работают многие другие языки - Scala, Groovy, JRuby и т.д. Точно также как на платформе .net существует не только C#, но и другие языки.


  1. Kill_Voice
    08.08.2023 15:44

    Кажется в моменте когда технологии уже почти 5 лет, пора остановиться в разборах плюсов и минусов


  1. quaer
    08.08.2023 15:44
    +3

    Одно из ключевых преимуществ Kotlin — это его поддержка null safety

    Как считаете, у них это получилось? В яве равн нулл, не равно нулл, ну потом еще Optional придумали. Тут стало в 2 раза больше типов с вопросом и без, добавились команды "?.", "?:", "!!", потом выяснилось, что объект, который объявлен как не могущий быть null, непонятно чем инициализировать и ввели lateinit, ну и дальше насыпали методов вроде orNull(). Ну а в коде для полей с вопросом дополнительно вызывается проверка на null с бросанием NullPointerException, которое бы выбросилось бы и так при попытке доступа.

    Давайте обсудим в комментариях перспективы языка Kotlin в контексте мобильной разработки

    Компилируется долго почему-то.


  1. eminen
    08.08.2023 15:44

    Nothing - это не специальный тип, а класс.