Почему Builder в Kotlin — это не просто "копипаста" из Java?
Если вы когда-нибудь писали на Java, вы наверняка сталкивались с классическим Builder pattern. Десятки строк кода, внутренние статические классы, методы set и финальный build(). Всё это работает, но выглядит громоздко.
А теперь представьте, что вы перешли на Kotlin. Язык, который создавался, чтобы быть лаконичным и выразительным. И тут вы сталкиваетесь с задачей: нужно создать объект со множеством параметров, часть из которых — опциональные. Ваша рука тянется написать классический Builder из Java...
Стоп! В Kotlin этот паттерн можно реализовать в 10 раз проще, элегантнее и безопаснее. В этой статье мы разберём 4 способа реализации Builder pattern в Kotlin — от самого простого до продвинутого DSL-подхода.
Этот материал основан на реальном вопросе со Stack Overflow, который набрал тысячи просмотров. Мы адаптировали его для образовательного контента, чтобы вы могли сразу применить знания на практике.
Проблема: когда нужен Builder?
Представьте, что у вас есть класс Pizza:
class Pizza(
val size: String,
val cheese: Boolean = false,
val pepperoni: Boolean = false,
val mushrooms: Boolean = false,
val olives: Boolean = false,
val extraCheese: Boolean = false
)
Вроде бы всё хорошо. Но когда вы создаёте пиццу с 5 опциональными параметрами, код становится нечитаемым:
val pizza = Pizza("Large", true, false, true, false, true)
Глазами искать, какой параметр что означает — мучительно. Тут на помощь и приходит Builder pattern.
Способ 1. Классический Java-style Builder (для совместимости)
Если вам нужно поддерживать Java-код или вы хотите максимально явный API, можно использовать классический подход:
class Pizza private constructor(
val size: String,
val cheese: Boolean,
val pepperoni: Boolean,
val mushrooms: Boolean,
val olives: Boolean,
val extraCheese: Boolean
) {
class Builder(private val size: String) {
private var cheese: Boolean = false
private var pepperoni: Boolean = false
private var mushrooms: Boolean = false
private var olives: Boolean = false
private var extraCheese: Boolean = false
fun cheese(value: Boolean) = apply { cheese = value }
fun pepperoni(value: Boolean) = apply { pepperoni = value }
fun mushrooms(value: Boolean) = apply { mushrooms = value }
fun olives(value: Boolean) = apply { olives = value }
fun extraCheese(value: Boolean) = apply { extraCheese = value }
fun build() = Pizza(size, cheese, pepperoni, mushrooms, olives, extraCheese)
}
}
// Использование:
val pizza = Pizza.Builder("Large")
.cheese(true)
.pepperoni(true)
.mushrooms(true)
.build()
Плюсы: очевидный код, понятный Java-разработчикам.
Минусы: много boilerplate-кода, не использует возможности Kotlin.
Способ 2. Kotlin-стиль с именованными аргументами и значениями по умолчанию
Самый простой и часто лучший способ — это вообще не использовать Builder, а положиться на возможности самого языка:
data class Pizza(
val size: String,
val cheese: Boolean = false,
val pepperoni: Boolean = false,
val mushrooms: Boolean = false,
val olives: Boolean = false,
val extraCheese: Boolean = false
)
// Использование:
val pizza = Pizza(
size = "Large",
cheese = true,
pepperoni = true,
mushrooms = true
)
Это чистый, читаемый и типобезопасный код. Именованные аргументы Kotlin решают проблему "магических чисел" и порядка параметров.
Когда стоит использовать: всегда, когда достаточно значений по умолчанию.
Способ 3. Builder через apply() и скоуп-функции
Иногда вам нужно постепенно собирать объект, передавая его между функциями. Тут пригодится подход с использованием apply():
class PizzaBuilder {
var size: String = ""
var cheese: Boolean = false
var pepperoni: Boolean = false
var mushrooms: Boolean = false
var olives: Boolean = false
var extraCheese: Boolean = false
fun build(): Pizza = Pizza(
size = size,
cheese = cheese,
pepperoni = pepperoni,
mushrooms = mushrooms,
olives = olives,
extraCheese = extraCheese
)
}
fun pizza(block: PizzaBuilder.() -> Unit): Pizza {
return PizzaBuilder().apply(block).build()
}
// Использование:
val pizza = pizza {
size = "Large"
cheese = true
pepperoni = true
mushrooms = true
}
Это уже DSL-подобный синтаксис. Выглядит как настоящий конструктор пиццы, не правда ли?
Способ 4. Type-safe Builder (DSL) — высший пилотаж
Если вы хотите создать настоящий Domain-Specific Language (DSL) для построения объектов, используйте ресиверы (receiver) и вложенные билдеры:
data class Topping(
val name: String,
val double: Boolean = false
)
data class Pizza(
val size: String,
val toppings: List<Topping>,
val cheese: Boolean = false
)
class ToppingBuilder {
private val toppings = mutableListOf<Topping>()
fun add(name: String, double: Boolean = false) {
toppings.add(Topping(name, double))
}
fun build(): List<Topping> = toppings.toList()
}
class PizzaBuilder {
var size: String = "Medium"
var cheese: Boolean = false
private val toppingBuilder = ToppingBuilder()
fun toppings(block: ToppingBuilder.() -> Unit) {
toppingBuilder.apply(block)
}
fun build(): Pizza = Pizza(
size = size,
cheese = cheese,
toppings = toppingBuilder.build()
)
}
fun pizza(block: PizzaBuilder.() -> Unit): Pizza {
return PizzaBuilder().apply(block).build()
}
// Использование:
val pizza = pizza {
size = "Large"
cheese = true
toppings {
add("Pepperoni")
add("Mushrooms")
add("Olives", double = true)
}
}
Внимание: этот подход требует больше кода, но даёт максимальную гибкость и читаемость. Идеально для сложных конфигураций.
Сравнение подходов: что выбрать?
- Именованные аргументы + default values — для 80% случаев. Просто и эффективно.
- Java-style Builder — когда нужна совместимость с Java или очень строгий API.
- apply() + скоуп-функции — для постепенного построения объектов в цепочке вызовов.
- Type-safe DSL — для сложных иерархических структур (например, конфигурации, HTML-генерация, UI).
Лучшие практики и частые ошибки
Ошибка №1: Использовать Builder там, где не нужно
Если у класса 2-3 параметра — не нужен Builder. Именованные аргументы решают проблему.