Files
obsidian/Kotlin/Функциональный Kotlin/Паттерны проектирования.md
T

26 KiB
Raw Blame History

title, description, tags
title description tags
Паттерны проектирования Какие паттерны GoF встроены в язык, какие упростились и какие остались нужны
kotlin
functional
patterns

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

Паттерн В Kotlin
Singleton object
Builder именованные аргументы, apply, DSL
Factory Method функция верхнего уровня или invoke в companion
Strategy функциональный тип
Decorator делегирование by
Observer Flow, StateFlow или список лямбд
Template Method функция высшего порядка
Command лямбда
Adapter функции-расширения
Visitor sealed + when
Prototype copy() у data class
Null Object nullable-тип и ?:
Facade, Repository, Proxy остаются как есть

Singleton

object Settings {
    private val values = mutableMapOf<String, String>()

    var theme: String = "light"

    fun put(key: String, value: String) { values[key] = value }
    fun get(key: String): String? = values[key]
}

fun main() {
    Settings.theme = "dark"
    Settings.put("lang", "ru")
    println("${Settings.theme} ${Settings.get("lang")}")      // dark ru
}

object - потокобезопасный ленивый синглтон: экземпляр создаётся при первом обращении, инициализация защищена загрузчиком классов. Никакого getInstance(), volatile и двойной проверки блокировки. Подробнее: Object и companion object

Цена та же, что у любого синглтона: глобальное состояние, которое трудно подменить в тестах. Если объект нужно мокировать - он должен реализовать интерфейс и внедряться параметром.

Builder

Три варианта по возрастанию сложности. Первый - именованные аргументы с значениями по умолчанию:

data class HttpRequest(
    val url: String,
    val method: String = "GET",
    val timeoutMs: Int = 5_000,
    val headers: Map<String, String> = emptyMap(),
    val retries: Int = 0,
)

fun main() {
    val r = HttpRequest(url = "https://example.com", method = "POST", retries = 3)
    println(r)
    // HttpRequest(url=https://example.com, method=POST, timeoutMs=5000, headers={}, retries=3)
}

Это закрывает подавляющее большинство случаев, ради которых пишут builder. Плюс к этому copy даёт модификацию:

data class HttpRequest(
    val url: String,
    val method: String = "GET",
    val headers: Map<String, String> = emptyMap(),
)

fun main() {
    val base = HttpRequest("https://example.com")
    val withAuth = base.copy(headers = mapOf("Authorization" to "Bearer x"))
    println(withAuth.headers)        // {Authorization=Bearer x}
    println(base.headers)             // {} — оригинал цел
}

Второй вариант - apply для классов с изменяемыми свойствами (включая чужие):

fun main() {
    val sb = StringBuilder().apply {
        append("строка")
        append(" ")
        append(42)
    }
    println(sb)        // строка 42
}

Третий - DSL, когда структура вложенная:

class Form2 {
    private val fields = mutableListOf<String>()
    fun field(name: String, required: Boolean = false) {
        fields.add(if (required) "$name*" else name)
    }
    fun render() = fields.joinToString(", ")
}

fun form(block: Form2.() -> Unit) = Form2().apply(block).render()

fun main() {
    println(form {
        field("имя", required = true)
        field("телефон")
    })
    // имя*, телефон
}

Подробнее: DSL

Factory Method

sealed interface Shape2 {
    data class Circle2(val radius: Double) : Shape2
    data class Square(val side: Double) : Shape2
}

fun shapeOf(type: String, size: Double): Shape2? = when (type) {
    "circle" -> Shape2.Circle2(size)
    "square" -> Shape2.Square(size)
    else -> null
}

fun main() {
    println(shapeOf("circle", 2.0))      // Circle2(radius=2.0)
    println(shapeOf("unknown", 1.0))      // null
}

Отдельный класс-фабрика не нужен: функция верхнего уровня делает то же. Когда фабрика должна выглядеть как конструктор, помогает invoke в companion:

class PositiveNumber private constructor(val value: Int) {
    companion object {
        operator fun invoke(raw: Int): PositiveNumber? =
            if (raw > 0) PositiveNumber(raw) else null
    }
    override fun toString() = "+$value"
}

fun main() {
    println(PositiveNumber(5))        // +5
    println(PositiveNumber(-1))       // null
}

Настоящий конструктор не может вернуть null или кешированный экземпляр - здесь это возможно, а вызов выглядит как PositiveNumber(5). Подробнее: Инфиксные и операторные функции

Strategy

Классический паттерн - интерфейс с одним методом и набор реализаций. В Kotlin это функциональный тип:

class Checkout(private val discount: (Long) -> Long) {
    fun total(amount: Long) = discount(amount)
}

fun main() {
    val none = Checkout { it }
    val tenPercent = Checkout { it * 90 / 100 }
    val fixed = Checkout { (it - 100).coerceAtLeast(0) }

    println("${none.total(1000)} ${tenPercent.total(1000)} ${fixed.total(1000)}")
    // 1000 900 900
}

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

fun main() {
    val strategies: Map<String, (Long) -> Long> = mapOf(
        "none" to { it },
        "ten" to { it * 90 / 100 },
        "half" to { it / 2 },
    )

    val chosen = strategies["ten"] ?: strategies.getValue("none")
    println(chosen(1000))        // 900

    val combined: (Long) -> Long = { strategies.getValue("ten")(strategies.getValue("half")(it)) }
    println(combined(1000))      // 450
}

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

Decorator

interface Repository8 {
    fun find(id: Int): String?
}

class DatabaseRepository : Repository8 {
    override fun find(id: Int): String? = if (id == 1) "Иван" else null
}

class CachingRepository(private val delegate: Repository8) : Repository8 by delegate {
    private val cache = mutableMapOf<Int, String?>()

    override fun find(id: Int): String? = cache.getOrPut(id) {
        println("запрос в БД: $id")
        delegate.find(id)
    }
}

class LoggingRepository(private val delegate: Repository8) : Repository8 by delegate

fun main() {
    val repo = LoggingRepository(CachingRepository(DatabaseRepository()))
    println(repo.find(1))        // запрос в БД: 1 \n Иван
    println(repo.find(1))        // Иван — из кеша
}

by delegate генерирует делегирующие реализации для всех методов интерфейса, а переопределить нужно только изменённые. В Java каждый декоратор обязан вручную прописать все методы интерфейса. Подробнее: Делегирование

Observer

Простая версия - список лямбд:

class Publisher<T> {
    private val subscribers = mutableListOf<(T) -> Unit>()

    fun subscribe(handler: (T) -> Unit): () -> Unit {
        subscribers.add(handler)
        return { subscribers.remove(handler) }      // функция отписки
    }

    fun publish(event: T) = subscribers.forEach { it(event) }
}

fun main() {
    val publisher = Publisher<String>()
    val unsubscribe = publisher.subscribe { println("получено: $it") }
    publisher.subscribe { println("второй: $it") }

    publisher.publish("событие 1")
    // получено: событие 1
    // второй: событие 1

    unsubscribe()
    publisher.publish("событие 2")
    // второй: событие 2
}

Возврат функции отписки вместо метода unsubscribe(handler) избавляет от хранения ссылки на обработчик - частая причина утечек в Java.

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

Для одного наблюдаемого свойства есть готовый делегат:

import kotlin.properties.Delegates

class Model {
    var status: String by Delegates.observable("новый") { _, old, new ->
        println("статус: $old -> $new")
    }
}

fun main() {
    val m = Model()
    m.status = "в работе"        // статус: новый -> в работе
    m.status = "готов"           // статус: в работе -> готов
}

Template Method

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

fun <T> processFile(
    lines: List<String>,
    parse: (String) -> T?,
    onError: (String) -> Unit = { println("пропущено: $it") },
): List<T> = lines.mapNotNull { line ->
    parse(line) ?: null.also { onError(line) }
}

fun main() {
    val result = processFile(
        lines = listOf("1", "два", "3"),
        parse = { it.toIntOrNull() },
    )
    println(result)
    // пропущено: два
    // [1, 3]
}

Наследование не нужно: шаги передаются параметрами. Это и проще (нет иерархии), и гибче (шаги можно комбинировать в рантайме).

Command

class History {
    private val undoStack = mutableListOf<() -> Unit>()

    fun execute(action: () -> Unit, undo: () -> Unit) {
        action()
        undoStack.add(undo)
    }

    fun undo() { undoStack.removeLastOrNull()?.invoke() }
}

fun main() {
    val text = StringBuilder()
    val history = History()

    history.execute({ text.append("Привет") }, { text.setLength(0) })
    println(text)        // Привет
    history.undo()
    println("[$text]")   // []
}

Интерфейс Command с методом execute заменяется лямбдой. Если у команды есть состояние или несколько операций - data class с методами честнее.

Visitor

Паттерн нужен там, где над закрытой иерархией выполняют разные операции. В Kotlin это sealed + when:

sealed interface Expr {
    data class Num(val value: Int) : Expr
    data class Add(val left: Expr, val right: Expr) : Expr
    data class Mul(val left: Expr, val right: Expr) : Expr
}

fun eval(e: Expr): Int = when (e) {
    is Expr.Num -> e.value
    is Expr.Add -> eval(e.left) + eval(e.right)
    is Expr.Mul -> eval(e.left) * eval(e.right)
}

fun render(e: Expr): String = when (e) {
    is Expr.Num -> e.value.toString()
    is Expr.Add -> "(${render(e.left)} + ${render(e.right)})"
    is Expr.Mul -> "${render(e.left)} * ${render(e.right)}"
}

fun main() {
    val expr = Expr.Add(Expr.Num(2), Expr.Mul(Expr.Num(3), Expr.Num(4)))
    println(render(expr))      // (2 + 3 * 4)
    println(eval(expr))         // 14
}

Новая операция - новая функция, менять классы не нужно. Компилятор проверяет полноту when, поэтому добавление варианта в sealed сразу покажет все места, требующие правки - то, чего Visitor добивается ценой двойной диспетчеризации. Подробнее: Sealed class и interface

Prototype и Null Object

data class Config4(val host: String, val port: Int, val debug: Boolean = false)

fun main() {
    val prod = Config4("example.com", 443)
    val staging = prod.copy(host = "staging.example.com", debug = true)
    println(staging)
    // Config4(host=staging.example.com, port=443, debug=true)
}

copy - это Prototype, встроенный в data class. Подробнее: Data class

Null Object создавался, чтобы избежать проверок на null. Nullable-типы решают ту же задачу явнее:

interface Logger3 { fun log(m: String) }

fun process6(data: String, logger: Logger3? = null) {
    logger?.log("начало")
    println("обработано: $data")
    logger?.log("конец")
}

fun main() {
    process6("x")                                                        // обработано: x
    process6("y", object : Logger3 { override fun log(m: String) = println("[$m]") })
    // [начало]
    // обработано: y
    // [конец]
}

Пустая реализация-заглушка не нужна: ?. не вызывает метод при null. Подробнее: Операторы null-безопасности

Что остаётся нужным

Паттерны, описывающие архитектуру, а не обход ограничений языка, остаются актуальными: Repository, Facade, Adapter между слоями, Strategy с несколькими методами, State-машина, Chain of Responsibility.

sealed interface OrderState {
    data object New : OrderState
    data object Paid : OrderState
    data class Shipped(val track: String) : OrderState
    data object Cancelled : OrderState
}

fun next(state: OrderState, event: String): OrderState = when (state) {
    OrderState.New -> when (event) {
        "pay" -> OrderState.Paid
        "cancel" -> OrderState.Cancelled
        else -> state
    }
    OrderState.Paid -> if (event == "ship") OrderState.Shipped("TRACK-1") else state
    is OrderState.Shipped -> state
    OrderState.Cancelled -> state
}

fun main() {
    var state: OrderState = OrderState.New
    for (event in listOf("pay", "ship", "cancel")) {
        state = next(state, event)
        println("$event -> $state")
    }
    // pay -> Paid
    // ship -> Shipped(track=TRACK-1)
    // cancel -> Shipped(track=TRACK-1)
}

Здесь sealed + when не отменяют паттерн State, а дают ему надёжную реализацию: невозможное состояние не создать, а забытый переход не скомпилируется.

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

Паттерн в Java В Kotlin
private static final INSTANCE + getInstance() object
класс Builder с 10 методами и build() именованные аргументы
interface Strategy + 3 класса (T) -> R
декоратор с ручным проксированием всех методов by delegate
List<Listener> + addListener/removeListener лямбды или Flow
abstract class с protected abstract шагами функция с лямбдами
interface Visitor с visitX на каждый тип sealed + when
clone() или конструктор копирования copy()
NullLogger implements Logger {} Logger? + ?.

Главная разница - в количестве кода на тот же смысл. Builder для класса с пятью полями в Java - это ~60 строк (вложенный класс, поле на каждый параметр, метод на каждый параметр, build()). В Kotlin это объявление data class с значениями по умолчанию, то есть ноль дополнительных строк. То же с Singleton (было: приватный конструктор, статическое поле, getInstance, возможно двойная проверка блокировки; стало: object) и Strategy (было: интерфейс плюс класс на каждую реализацию; стало: лямбда).

Декоратор - самый показательный случай. В Java, чтобы задекорировать интерфейс с 15 методами, нужно написать 15 делегирующих методов, из которых интересен один. Отсюда классы вроде HttpServletRequestWrapper в стандартной библиотеке - их пишут специально, чтобы наследоваться. В Kotlin by delegate делает это автоматически:

interface Big {
    fun a(): Int
    fun b(): Int
    fun c(): Int
}

class Impl : Big {
    override fun a() = 1
    override fun b() = 2
    override fun c() = 3
}

class OnlyBChanged(d: Big) : Big by d {
    override fun b() = 99
}

fun main() {
    val x = OnlyBChanged(Impl())
    println("${x.a()} ${x.b()} ${x.c()}")      // 1 99 3
}

Паттерны не исчезли - исчез их boilerplate. Смысл Strategy (вынести изменяемое поведение) остался и в Kotlin, просто выражается функциональным типом. Полезно знать паттерны именно как словарь: «здесь Strategy» понятнее, чем «здесь лямбда в конструкторе».

Некоторые паттерны в Kotlin не нужны вообще. Null Object существовал, чтобы не писать if (x != null); при наличии ?. он только добавляет класс. Double-Checked Locking для синглтона не нужен, потому что object и lazy уже потокобезопасны.

Сравнить: Singleton в Java, Builder

[!warning] Подводные камни object - глобальное состояние, которое не подменить в тестах:

object Clock2 {
    fun now(): Long = System.currentTimeMillis()
}

interface Clock3 { fun now(): Long }

class Service4(private val clock: Clock3) {
    fun timestamp() = clock.now()
}

fun main() {
    val fake = object : Clock3 { override fun now() = 1_000L }
    println(Service4(fake).timestamp())      // 1000 — тест детерминирован
    println(Clock2.now() > 0)                 // true — а это не подменить
}

Между object и интерфейсом с внедрением выбирайте второе, если поведение нужно будет подменить.

object с изменяемым состоянием не потокобезопасен. Создание экземпляра защищено, а доступ к полям - нет:

object Counter5 {
    var count = 0        // гонка при обращении из нескольких потоков
}

fun main() {
    Counter5.count++
    println(Counter5.count)      // 1
}

Стратегия-лямбда теряет имя в стеке и логах:

fun main() {
    val strategies: Map<String, (Int) -> Int> = mapOf("double" to { it * 2 })
    val f = strategies.getValue("double")
    println(f::class.simpleName?.contains("Lambda"))      // true — имя сгенерировано
}

Вместо осмысленного имени получается что-то вроде MainKt$$Lambda/0x00001e0001040620 - с адресом, меняющимся от запуска к запуску. Отладка «какая стратегия сработала» требует хранить имя рядом. Если это важно - интерфейс или data class с полем name.

by delegate делегирует только методы интерфейса. equals, hashCode и toString берутся из Any, а не из делегата:

interface Named5 { val name: String }
data class Impl2(override val name: String) : Named5

class Wrapper(d: Named5) : Named5 by d

fun main() {
    val w = Wrapper(Impl2("x"))
    println(w.name)                           // x
    println(w.toString().startsWith("Wrapper"))   // true — не делегировано
    println(w == Wrapper(Impl2("x")))          // false
}

Делегат фиксируется при создании:

interface Source { fun value(): Int }

class Changing(private var inner: Source) : Source by inner {
    fun replace(newInner: Source) { inner = newInner }
}

fun main() {
    val first = object : Source { override fun value() = 1 }
    val second = object : Source { override fun value() = 2 }
    val c = Changing(first)
    c.replace(second)
    println(c.value())        // 1 — делегирует первому!
}

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

Свой Observer течёт по памяти:

class Leaky3 {
    private val handlers = mutableListOf<(Int) -> Unit>()
    fun subscribe(h: (Int) -> Unit) { handlers.add(h) }
    fun count() = handlers.size
}

fun main() {
    val p = Leaky3()
    repeat(3) { p.subscribe { } }
    println(p.count())        // 3 — и никогда не уменьшится
}

Без механизма отписки подписчики живут до конца жизни издателя и держат свои замыкания. Поэтому для событий лучше Flow с привязкой к области корутины. Подробнее: Структурированная конкурентность

Лямбда в коллекции не сравнивается:

fun main() {
    val handlers = mutableListOf<() -> Unit>()
    val h = { println("x") }
    handlers.add(h)
    println(handlers.remove(h))          // true — та же ссылка
    handlers.add { println("x") }
    println(handlers.remove { println("x") })      // false — другой объект
}

Отписка по «такой же» лямбде не работает - нужна именно та ссылка или токен отписки.

Builder через apply на val-полях невозможен - нужны var, а значит объект изменяем после сборки. Для неизменяемого результата используйте data class с конструктором или отдельный билдер, возвращающий immutable-объект.

Именованные аргументы не защищают от неверного сочетания. HttpRequest(url = "x", retries = -5) скомпилируется. Валидацию нужно писать в init:

data class Retry(val attempts: Int) {
    init { require(attempts >= 0) { "attempts не может быть отрицательным" } }
}

fun main() {
    println(Retry(3))
    try { Retry(-1) } catch (e: IllegalArgumentException) { println(e.message) }
    // Retry(attempts=3)
    // attempts не может быть отрицательным
}

Подробнее: Проверки и контракты

Применение паттерна там, где он не нужен, остаётся главной проблемой - и язык от этого не спасает. Фабрика для класса с одним конструктором, Strategy с единственной реализацией и Repository поверх одного SQL-запроса добавляют уровни без выгоды. Критерий прежний: паттерн оправдан, когда вариативность уже существует, а не когда она предполагается.

Далее: Корутины