--- title: Паттерны проектирования description: Какие паттерны GoF встроены в язык, какие упростились и какие остались нужны tags: - 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 ```kotlin object Settings { private val values = mutableMapOf() 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](../Синтаксис%20и%20типы/Базовые%20типы%20ООП/Object%20и%20companion%20object.md) Цена та же, что у любого синглтона: глобальное состояние, которое трудно подменить в тестах. Если объект нужно мокировать - он должен реализовать интерфейс и внедряться параметром. ## Builder Три варианта по возрастанию сложности. Первый - именованные аргументы с значениями по умолчанию: ```kotlin data class HttpRequest( val url: String, val method: String = "GET", val timeoutMs: Int = 5_000, val headers: Map = 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` даёт модификацию: ```kotlin data class HttpRequest( val url: String, val method: String = "GET", val headers: Map = 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` для классов с изменяемыми свойствами (включая чужие): ```kotlin fun main() { val sb = StringBuilder().apply { append("строка") append(" ") append(42) } println(sb) // строка 42 } ``` Третий - DSL, когда структура вложенная: ```kotlin class Form2 { private val fields = mutableListOf() 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](DSL.md) ## Factory Method ```kotlin 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: ```kotlin 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)`. Подробнее: [Инфиксные и операторные функции](Инфиксные%20и%20операторные%20функции.md) ## Strategy Классический паттерн - интерфейс с одним методом и набор реализаций. В Kotlin это функциональный тип: ```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`, выбирать по ключу, комбинировать: ```kotlin fun main() { val strategies: Map 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 ```kotlin 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() 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 каждый декоратор обязан вручную прописать все методы интерфейса. Подробнее: [Делегирование](../ООП/Делегирование.md) ## Observer Простая версия - список лямбд: ```kotlin class Publisher { 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() 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](../Корутины/StateFlow%20и%20SharedFlow.md) Для одного наблюдаемого свойства есть готовый делегат: ```kotlin 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 это функция с лямбдами: ```kotlin fun processFile( lines: List, parse: (String) -> T?, onError: (String) -> Unit = { println("пропущено: $it") }, ): List = 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 ```kotlin 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`: ```kotlin 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](../Синтаксис%20и%20типы/Базовые%20типы%20ООП/Sealed%20class%20и%20interface.md) ## Prototype и Null Object ```kotlin 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](../Синтаксис%20и%20типы/Базовые%20типы%20ООП/Data%20class.md) Null Object создавался, чтобы избежать проверок на `null`. Nullable-типы решают ту же задачу явнее: ```kotlin 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-безопасности](../Синтаксис%20и%20типы/Null-безопасность/Операторы%20null-безопасности.md) ## Что остаётся нужным Паттерны, описывающие **архитектуру**, а не обход ограничений языка, остаются актуальными: Repository, Facade, Adapter между слоями, Strategy с несколькими методами, State-машина, Chain of Responsibility. ```kotlin 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` + `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` делает это автоматически: > ```kotlin > 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](../../Программирование%20на%20Java/Паттерны%20проектирования/Singleton.md), [Builder](../../Программирование%20на%20Java/Паттерны%20проектирования/Builder.md) > [!warning] Подводные камни > **`object` - глобальное состояние, которое не подменить в тестах:** > ```kotlin > 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` с изменяемым состоянием не потокобезопасен.** Создание экземпляра защищено, а доступ к полям - нет: > ```kotlin > object Counter5 { > var count = 0 // гонка при обращении из нескольких потоков > } > > fun main() { > Counter5.count++ > println(Counter5.count) // 1 > } > ``` > > **Стратегия-лямбда теряет имя в стеке и логах:** > ```kotlin > fun main() { > val strategies: Map 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`, а не из делегата: > ```kotlin > 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 > } > ``` > > **Делегат фиксируется при создании:** > ```kotlin > 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 течёт по памяти:** > ```kotlin > 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` с привязкой к области корутины. Подробнее: [Структурированная конкурентность](../Корутины/Структурированная%20конкурентность.md) > > **Лямбда в коллекции не сравнивается:** > ```kotlin > 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`: > ```kotlin > 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 не может быть отрицательным > } > ``` > Подробнее: [Проверки и контракты](../Исключения/Проверки%20и%20контракты.md) > > **Применение паттерна там, где он не нужен, остаётся главной проблемой** - и язык от этого не спасает. Фабрика для класса с одним конструктором, Strategy с единственной реализацией и Repository поверх одного SQL-запроса добавляют уровни без выгоды. Критерий прежний: паттерн оправдан, когда вариативность уже существует, а не когда она предполагается. Далее: [Корутины](../Корутины/Корутины.md)