26 KiB
title, description, tags
| title | description | tags | |||
|---|---|---|---|---|---|
| Паттерны проектирования | Какие паттерны GoF встроены в язык, какие упростились и какие остались нужны |
|
Многие паттерны проектирования - это обходные приёмы для того, чего не хватает языку. 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 delegateList<Listener>+addListener/removeListenerлямбды или Flowabstract classсprotected abstractшагамифункция с лямбдами interface VisitorсvisitXна каждый типsealed+whenclone()или конструктор копированияcopy()NullLogger implements Logger {}Logger?+?.Главная разница - в количестве кода на тот же смысл. Builder для класса с пятью полями в Java - это ~60 строк (вложенный класс, поле на каждый параметр, метод на каждый параметр,
build()). В Kotlin это объявлениеdata classс значениями по умолчанию, то есть ноль дополнительных строк. То же с Singleton (было: приватный конструктор, статическое поле,getInstance, возможно двойная проверка блокировки; стало:object) и Strategy (было: интерфейс плюс класс на каждую реализацию; стало: лямбда).Декоратор - самый показательный случай. В Java, чтобы задекорировать интерфейс с 15 методами, нужно написать 15 делегирующих методов, из которых интересен один. Отсюда классы вроде
HttpServletRequestWrapperв стандартной библиотеке - их пишут специально, чтобы наследоваться. В Kotlinby 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-запроса добавляют уровни без выгоды. Критерий прежний: паттерн оправдан, когда вариативность уже существует, а не когда она предполагается.
Далее: Корутины