Files
obsidian/Kotlin/Функциональный Kotlin/Функции-расширения.md
T

18 KiB
Raw Blame History

title, description, tags
title description tags
Функции-расширения Добавление методов к готовым типам, статическое разрешение и свойства-расширения
kotlin
functional

Функция-расширение добавляет метод к типу, который вы не контролируете - без наследования и без обёрток.

fun String.isPalindrome(): Boolean {
    val clean = lowercase().filter { it.isLetter() }
    return clean == clean.reversed()
}

fun main() {
    println("Довод".isPalindrome())            // true
    println("А роза упала на лапу Азора".isPalindrome())   // true
    println("Привет".isPalindrome())            // false
}

Внутри расширения this - это объект, на котором вызвали функцию (его называют получателем, receiver). this можно опускать: lowercase() - это this.lowercase().

Это обычная статическая функция

Расширение не меняет класс и не попадает в него. Компилятор превращает его в статический метод с получателем первым параметром:

fun Int.double(): Int = this * 2

fun main() {
    println(5.double())        // 10 — выглядит как метод
    // В байт-коде: Файл.double(5)
}

Из этого следуют все особенности расширений - и удобные, и опасные.

Нет доступа к private:

class Account(private val balance: Long, val owner: String)

// fun Account.canWithdraw(x: Long) = balance >= x      // ошибка: balance недоступен
fun Account.describe() = "счёт $owner"                   // public — можно

fun main() {
    println(Account(100, "Иван").describe())             // счёт Иван
}

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

Разрешение статическое, не виртуальное:

open class Shape
class Circle : Shape()

fun Shape.name() = "фигура"
fun Circle.name() = "круг"

fun main() {
    val shape: Shape = Circle()
    println(shape.name())          // фигура — тип переменной, не объекта!
    println(Circle().name())        // круг
}

Это главная ловушка расширений. Выбор функции делается компилятором по объявленному типу, поэтому полиморфизма здесь нет. Подробнее: Полиморфизм

Метод класса всегда выигрывает:

class Printer {
    fun show() = "метод класса"
}

fun Printer.show() = "расширение"        // warning: extension is shadowed by a member

fun main() {
    println(Printer().show())        // метод класса
}

Это гарантия совместимости: добавление метода в класс не ломает код, но молча меняет поведение вызова. Компилятор предупреждает.

Расширения на nullable-типах

fun String?.orPlaceholder(): String = if (isNullOrBlank()) "(пусто)" else this

fun main() {
    println("текст".orPlaceholder())      // текст
    println(null.orPlaceholder())          // (пусто)
    println("  ".orPlaceholder())          // (пусто)
}

Расширение на T? можно вызвать на null без ?. - внутри this просто может быть null. Так устроены isNullOrEmpty, orEmpty и toString в stdlib:

fun main() {
    val s: String? = null
    println(s.isNullOrEmpty())        // true — без ?. !
    println(s.orEmpty().length)        // 0
    println(s.toString())               // null — строка "null"
}

Свойства-расширения

val String.firstWord: String get() = substringBefore(" ")
val <T> List<T>.secondOrNull: T? get() = getOrNull(1)
val Int.isEven: Boolean get() = this % 2 == 0

fun main() {
    println("привет мир".firstWord)        // привет
    println(listOf(1, 2, 3).secondOrNull)   // 2
    println(4.isEven)                        // true
}

У свойства-расширения не может быть поля - только геттер (и сеттер, если получатель изменяемый):

var StringBuilder.lastChar: Char
    get() = this[length - 1]
    set(value) { setCharAt(length - 1, value) }

fun main() {
    val sb = StringBuilder("Котлин")
    println(sb.lastChar)        // н
    sb.lastChar = '!'
    println(sb)                  // Котли!
}

Хранить состояние в расширении нельзя: val Int.counter = 0 не скомпилируется, потому что хранить его негде.

Расширения на дженериках

fun <T> List<T>.penultimate(): T? = getOrNull(size - 2)

fun <T : Comparable<T>> List<T>.isSorted(): Boolean = zipWithNext().all { (a, b) -> a <= b }

fun <T> T.alsoPrint(label: String): T = also { println("$label: $it") }

fun main() {
    println(listOf(1, 2, 3).penultimate())      // 2
    println(listOf(1, 2, 3).isSorted())          // true
    val x = 42.alsoPrint("значение")              // значение: 42
    println(x + 1)                                 // 43
}

Расширение на T без ограничений применимо к любому типу - именно так объявлены все scope-функции.

Расширение можно объявить на конкретной параметризации:

fun List<Int>.sumOfSquares(): Int = sumOf { it * it }
fun List<String>.longest(): String? = maxByOrNull { it.length }

fun main() {
    println(listOf(1, 2, 3).sumOfSquares())             // 14
    println(listOf("a", "ccc", "bb").longest())          // ccc
}

Так же работает Iterable<Int>.sum() в stdlib - одна функция на каждый числовой тип.

Расширения на функциональных типах

fun <T, R> ((T) -> R).withLogging(name: String): (T) -> R = { arg ->
    println("$name($arg)")
    this(arg)
}

fun main() {
    val double: (Int) -> Int = { it * 2 }
    val logged = double.withLogging("double")
    println(logged(21))
    // double(21)
    // 42
}

Функциональный тип - обычный тип, расширения на нём работают так же.

Расширения на объектах-компаньонах

class Celsius(val value: Double) {
    companion object {}
    override fun toString() = "$value°C"
}

fun Celsius.Companion.fromFahrenheit(f: Double) = Celsius((f - 32) * 5 / 9)

fun main() {
    println(Celsius.fromFahrenheit(68.0))        // 20.0°C
}

Так к типу добавляют фабричный метод, вызываемый через имя класса. Два условия: companion object должен быть объявлен в классе (пустого объявления достаточно) и конструктор должен быть доступен - расширение на Companion остаётся внешним кодом и приватный конструктор не увидит. Подробнее: Object и companion object

Где объявлять

class Order6(val items: List<String>, val total: Long) {
    // расширение внутри класса — видно только внутри
    private fun String.shorten() = take(3)

    fun summary() = items.joinToString { it.shorten() }
}

fun main() {
    println(Order6(listOf("молоко", "хлеб"), 100).summary())      // мол, хле
}

Расширение, объявленное внутри класса, имеет два получателя: this расширения и this класса. Это основа DSL - подробнее: Receivers и context parameters

Для расширений верхнего уровня принято заводить файл по теме:

// StringExtensions.kt
fun String.toSlug() = lowercase().replace(Regex("[^a-zа-я0-9]+"), "-").trim('-')

fun main() {
    println("Привет, Мир!".toSlug())        // привет-мир
}

Когда расширение, когда метод

Ситуация Выбор
нужен доступ к private-состоянию метод класса
поведение - часть сути типа метод класса
нужен полиморфизм (override) метод класса
тип чужой (stdlib, библиотека) расширение
утилита, нужная не всем пользователям типа расширение
адаптация типа к своему слою (DTO -> модель) расширение
логика зависит от внешних сервисов расширение или отдельный класс

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

[!info] Отличия от Java

Java Kotlin
StringUtils.isPalindrome(s) s.isPalindrome()
класс *Utils со статикой расширения в файле
default-метод в интерфейсе расширение на интерфейсе
наследование или декоратор расширение
нет аналога свойства-расширения

Расширение - это и есть статический утилитный метод, но с синтаксисом метода. В Java накопление утилит приводит к классам вроде StringUtils, CollectionUtils, DateUtils, и вызов читается наизнанку: StringUtils.capitalize(StringUtils.trim(s)). В Kotlin то же самое читается слева направо:

fun String.capitalizeFirst() = replaceFirstChar { it.uppercase() }

fun main() {
    println("  привет  ".trim().capitalizeFirst())      // Привет
}

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

default-методы требуют владения интерфейсом. В Java добавить поведение к List можно только если вы автор List. Расширение объявляется где угодно - поэтому List в Kotlin имеет ~150 операций, не входящих в интерфейс.

Вызов из Java выглядит как статика. Расширение fun String.toSlug() в файле StringExtensions.kt компилируется в StringExtensionsKt.toSlug(String):

@file:JvmName("StringUtils")
package demo

fun String.toSlug() = lowercase()

Java-код вызовет StringUtils.toSlug(s). То есть для Java-клиентов это ровно привычный утилитный класс. Подробнее: Kotlin из Java

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

Сравнить: Функции в Java, Интерфейсы в Java

[!warning] Подводные камни Разрешение статическое - полиморфизма нет:

open class Animal
class Dog : Animal()

fun Animal.sound() = "..."
fun Dog.sound() = "гав"

fun makeSound(a: Animal) = a.sound()

fun main() {
    println(Dog().sound())           // гав
    println(makeSound(Dog()))         // ... — выбрано по типу параметра!
}

Это самая частая ошибка с расширениями. Если поведение должно зависеть от фактического типа - нужен метод класса или when по типу.

Метод класса перекрывает расширение молча для вызывающего:

class Box8 {
    fun size() = 1
}

fun Box8.size() = 999

fun main() { println(Box8().size()) }      // 1

Опаснее другое: если библиотека добавит метод size() в новой версии, ваше расширение перестанет вызываться после обновления - без ошибки компиляции.

Нет доступа к private и protected:

class Counter2 {
    private var count = 0
    fun increment() { count++ }
    fun value() = count
}

// fun Counter2.reset() { count = 0 }      // ошибка
fun Counter2.incrementTwice() { increment(); increment() }

fun main() {
    val c = Counter2()
    c.incrementTwice()
    println(c.value())        // 2
}

Расширение на T? скрывает проверку на null:

fun String?.safeLength(): Int = this?.length ?: 0

fun main() {
    val s: String? = null
    println(s.safeLength())       // 0 — без ?. и без предупреждений
}

Читатель видит s.safeLength() и может решить, что s не может быть null. Для такой функции нужно очень явное имя.

Свойство-расширение не хранит состояние:

// val Int.cached: Int = compute()       // ошибка: initializer not allowed
val Int.computed: Int get() = this * 2    // только геттер

fun main() { println(2.computed) }         // 4

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

Расширения не участвуют в when по типу и в рефлексии:

fun Any.describe5() = "объект"

fun main() {
    println("x".describe5())                                   // объект
    println(String::class.members.any { it.name == "describe5" })   // false
}

Для фреймворков, работающих через рефлексию (сериализация, ORM), расширения невидимы.

Расширение на Any засоряет автодополнение:

fun Any?.debug(): String = "значение: $this"

fun main() { println(1.debug()) }      // значение: 1

Такая функция появится в подсказках для каждого типа в проекте. Расширения на Any оправданы только в stdlib-подобных случаях.

Одинаковые расширения из разных пакетов конфликтуют. Если два импорта дают String.toSlug(), компилятор потребует явного импорта или алиаса (import a.toSlug as aSlug). Для библиотек это аргумент за специфичные имена.

Расширение не наследуется и не переопределяется. override fun String.foo() невозможно: у расширений нет виртуальной таблицы.

Расширение на платформенном типе может получить null вопреки сигнатуре:

fun String.shout() = uppercase() + "!"

fun main() {
    println("да".shout())        // ДА!
}

Если такое расширение вызвать на значении, пришедшем из Java-метода без аннотаций, проверка на null сработает на входе и даст NPE внутри расширения - со стеком, указывающим не на источник проблемы. Подробнее: Платформенные типы

Далее: Scope-функции