Files
obsidian/Kotlin/ООП/Делегирование.md
T

17 KiB
Raw Blame History

title, description, tags
title description tags
Делегирование Делегирование интерфейсов через by, делегаты свойств и границы автоматического перенаправления
kotlin
oop

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

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

interface Logger5 {
    fun log(msg: String): String
    fun warn(msg: String): String
}

class ConsoleLogger3 : Logger5 {
    override fun log(msg: String) = "[LOG] $msg"
    override fun warn(msg: String) = "[WARN] $msg"
}

class Service15(logger: Logger5) : Logger5 by logger {
    fun work() = log("начинаю работу")
}

fun main() {
    val s = Service15(ConsoleLogger3())
    println(s.work())            // [LOG] начинаю работу
    println(s.warn("внимание"))  // [WARN] внимание — метод перенаправлен
}

Service15 реализует Logger5, но код методов не пишет: компилятор генерирует перенаправление на logger. Унаследованной реализации при этом нет - есть ссылка на другой объект.

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

Делегировать можно всё, кроме того, что нужно изменить:

interface Repository3 {
    fun find(id: Int): String?
    fun save(id: Int, value: String): String
}

class DbRepository : Repository3 {
    private val data = mutableMapOf<Int, String>()
    override fun find(id: Int) = data[id]
    override fun save(id: Int, value: String): String {
        data[id] = value
        return "сохранено"
    }
}

class CachingRepository(private val origin: Repository3) : Repository3 by origin {
    private val cache = mutableMapOf<Int, String>()

    override fun find(id: Int): String? =        // только этот метод свой
        cache.getOrPut(id) { origin.find(id) ?: return null }
}

fun main() {
    val repo = CachingRepository(DbRepository())
    println(repo.save(1, "Иван"))   // сохранено — делегировано
    println(repo.find(1))           // Иван — через кеш
    println(repo.find(1))           // Иван — уже из кеша
    println(repo.find(99))          // null
}

Это декоратор в три строки. На наследовании он потребовал бы open-класса и был бы связан с его внутренностями.

Делегирование нескольких интерфейсов

interface Reader { fun read(): String }
interface Writer2 { fun write(data: String): String }

class FileReader2 : Reader { override fun read() = "данные из файла" }
class FileWriter2 : Writer2 { override fun write(data: String) = "записано: $data" }

class ReadWriteFile(
    reader: Reader,
    writer: Writer2,
) : Reader by reader, Writer2 by writer

fun main() {
    val f = ReadWriteFile(FileReader2(), FileWriter2())
    println(f.read())            // данные из файла
    println(f.write("текст"))    // записано: текст
}

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

Расширение коллекции

Классическая задача, которую нельзя безопасно решить наследованием:

class CountingList3<T>(private val inner: MutableList<T> = mutableListOf())
    : MutableList<T> by inner {

    var addedCount = 0
        private set

    override fun add(element: T): Boolean {
        addedCount++
        return inner.add(element)
    }

    override fun addAll(elements: Collection<T>): Boolean {
        addedCount += elements.size
        return inner.addAll(elements)
    }
}

fun main() {
    val list = CountingList3<Int>()
    list.add(1)
    list.addAll(listOf(2, 3))
    println("${list.size} элементов, добавлений: ${list.addedCount}")   // 3 элементов, добавлений: 3
    println(list.toList())                                              // [1, 2, 3]
}

При наследовании от ArrayList счётчик мог бы удвоиться, если addAll внутри вызывает add. С делегированием внутренние вызовы inner остаются внутри inner и наших переопределений не касаются.

Обратите внимание на list.toList(): сам println(list) вывел бы CountingList3@5fa7e7ff, потому что toString - член Any, а члены Any делегирование не перенаправляет.

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

Делегаты свойств

Тот же by для свойства передаёт работу объекту с методами getValue и setValue.

Готовые делегаты

import kotlin.properties.Delegates

class Form3 {
    val config: String by lazy { "вычислено один раз" }

    var name: String by Delegates.observable("") { _, old, new ->
        println("name: '$old' -> '$new'")
    }

    var age: Int by Delegates.vetoable(0) { _, _, new -> new in 0..150 }

    var port: Int by Delegates.notNull()
}

fun main() {
    val f = Form3()
    println(f.config)         // вычислено один раз
    f.name = "Иван"           // name: '' -> 'Иван'
    f.age = 200               // отклонено
    println(f.age)            // 0
    f.port = 8080
    println(f.port)           // 8080
}

Подробнее: Свойства

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

class Settings(private val map: Map<String, Any?>) {
    val host: String by map
    val port: Int by map
}

fun main() {
    val s = Settings(mapOf("host" to "localhost", "port" to 8080))
    println("${s.host}:${s.port}")      // localhost:8080
}

Имя свойства становится ключом. Для var нужен MutableMap.

Свой делегат

Нужны операторы getValue и (для var) setValue:

import kotlin.reflect.KProperty

class NonBlank(private var value: String = "") {
    operator fun getValue(thisRef: Any?, property: KProperty<*>) = value

    operator fun setValue(thisRef: Any?, property: KProperty<*>, newValue: String) {
        require(newValue.isNotBlank()) { "${property.name} не может быть пустым" }
        value = newValue.trim()
    }
}

class User6 {
    var name: String by NonBlank()
}

fun main() {
    val u = User6()
    u.name = "  Иван  "
    println("'${u.name}'")      // 'Иван'
    try {
        u.name = "   "
    } catch (e: IllegalArgumentException) {
        println(e.message)       // name не может быть пустым
    }
}

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

Для делегата только на чтение достаточно реализовать ReadOnlyProperty:

import kotlin.properties.ReadOnlyProperty

fun envVar(name: String) = ReadOnlyProperty<Any?, String> { _, _ ->
    System.getenv(name) ?: "<не задано>"
}

class Config7 {
    val home: String by envVar("HOME")
    val missing: String by envVar("НЕТ_ТАКОЙ_ПЕРЕМЕННОЙ")
}

fun main() {
    println(Config7().home.isNotEmpty())    // true
    println(Config7().missing)              // <не задано>
}

provideDelegate

Позволяет проверить свойство при создании делегата - например, что имя есть в конфигурации:

import kotlin.reflect.KProperty

class Required(private val source: Map<String, String>) {
    operator fun provideDelegate(thisRef: Any?, property: KProperty<*>): Required {
        require(property.name in source) { "нет ключа '${property.name}'" }
        return this
    }

    operator fun getValue(thisRef: Any?, property: KProperty<*>) = source[property.name]!!
}

fun main() {
    val source = mapOf("host" to "localhost")

    class Ok3 { val host: String by Required(source) }
    println(Ok3().host)      // localhost

    try {
        class Bad4 { val port: String by Required(source) }
        Bad4()
    } catch (e: IllegalArgumentException) {
        println(e.message)    // нет ключа 'port'
    }
}

Проверка происходит при создании объекта, а не при первом чтении - ошибка обнаруживается раньше.

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

Java Kotlin
все методы перенаправлять вручную by генерирует их
декоратор = класс с полем и 10 методами class D(x: I) : I by x
Supplier + volatile для ленивости by lazy
PropertyChangeSupport Delegates.observable
нет аналога делегатов свойств by с getValue/setValue

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

class CachingRepository implements Repository {
    private final Repository origin;
    CachingRepository(Repository origin) { this.origin = origin; }
    public String save(int id, String v) { return origin.save(id, v); }  // шум
    public String find(int id) { /* своя логика */ }
}

Делегатов свойств в Java нет совсем. Ленивое поле требует ручной двойной проверки блокировки, наблюдаемое - PropertyChangeSupport и явных вызовов. В Kotlin это by lazy и by Delegates.observable.

Паттерн Декоратор перестаёт быть паттерном - он становится однострочной конструкцией языка, поэтому его применяют там, где в Java предпочли бы наследование «чтобы не писать много кода».

Сравнить: Полиморфизм подтипов в Java, Абстрактные классы

[!warning] Подводные камни Делегат не знает о переопределениях - это главная ловушка. Если один метод делегата вызывает другой, он вызовет свою версию, а не вашу:

interface Sender {
    fun send(msg: String): String
    fun sendAll(msgs: List<String>): String
}

class BasicSender : Sender {
    override fun send(msg: String) = "отправлено: $msg"
    override fun sendAll(msgs: List<String>) = msgs.joinToString { send(it) }
}

class LoggingSender(private val inner: Sender) : Sender by inner {
    var logged = 0
        private set

    override fun send(msg: String): String {
        logged++
        return inner.send(msg)
    }
}

fun main() {
    val s = LoggingSender(BasicSender())
    s.sendAll(listOf("a", "b"))
    println("залогировано: ${s.logged}")      // 0 — наш send не вызвался!
}

sendAll делегирован и внутри вызывает send объекта inner. Чтобы счётчик работал, нужно переопределить и sendAll. Это обратная сторона изоляции: наследование здесь сработало бы «как ожидается», но стало бы хрупким.

Члены Any не делегируются. toString, equals и hashCode остаются реализациями по умолчанию, то есть по ссылке:

class Wrapped<T>(private val inner: MutableList<T> = mutableListOf()) : MutableList<T> by inner

fun main() {
    val a = Wrapped<Int>().apply { add(1) }
    val b = Wrapped<Int>().apply { add(1) }
    println(a.toString())             // Wrapped@4554617c, а не [1]
    println(a == b)                   // false, хотя содержимое одинаково
    println(a == listOf(1))           // false
}

Обёртка над коллекцией, попавшая в Set или в сравнение, поведёт себя не как коллекция. Эти три метода нужно переопределять вручную. Подробнее: equals, hashCode и toString

Делегат фиксируется при создании и не может быть заменён:

fun interface Source { fun get(): String }

class Service16(private var source: Source) : Source by source {
    fun replace(newSource: Source) { source = newSource }    // не влияет на by!
}

fun main() {
    val s = Service16 { "первый" }
    s.replace { "второй" }
    println(s.get())      // первый — by хранит исходную ссылку
}

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

Делегирование нельзя сделать by this или сослаться на собственное свойство в заголовке - объект ещё не создан.

Из делегированных методов нет доступа к this обёртки. Делегат не знает, что его кто-то оборачивает, - он не может вызвать методы обёртки.

by lazy на var невозможен - только val.

Делегат свойства - объект на каждый экземпляр:

class Heavy {
    val a: String by lazy { "a" }
    val b: String by lazy { "b" }      // ещё один объект Lazy
}

fun main() { println(Heavy().a + Heavy().b) }    // ab

Для класса с десятками ленивых свойств, создаваемого миллионами экземпляров, это заметный расход памяти.

Делегирование в Map падает в рантайме при отсутствии ключа:

class Settings2(map: Map<String, Any?>) {
    val host: String by map
}

fun main() {
    val s = Settings2(emptyMap())
    try {
        println(s.host)
    } catch (e: NoSuchElementException) {
        println("ключ отсутствует")      // ключ отсутствует
    }
}

Проверить это заранее помогает provideDelegate.

Делегирование MutableList раскрывает полный изменяемый API. Обёртка, делегирующая MutableList, позволяет внешнему коду добавлять элементы в обход вашей логики - через iterator().remove(), subList() и другие пути.

Дополнительно: Свойства Далее: Вложенные и внутренние классы