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

578 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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<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](../Синтаксис%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<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` даёт модификацию:
```kotlin
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` для классов с изменяемыми свойствами (включая чужие):
```kotlin
fun main() {
val sb = StringBuilder().apply {
append("строка")
append(" ")
append(42)
}
println(sb) // строка 42
}
```
Третий - DSL, когда структура вложенная:
```kotlin
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](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<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
```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<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 каждый декоратор обязан вручную прописать все методы интерфейса. Подробнее: [Делегирование](../ООП/Делегирование.md)
## Observer
Простая версия - список лямбд:
```kotlin
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](../Корутины/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 <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
```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<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` делает это автоматически:
> ```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<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`, а не из делегата:
> ```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)