Kotlin Builder Pattern

Przeznaczenie

Wzorzec Builder służy do uproszczenia tworzenia złożonych obiektów ze skomplikowaną logiką budowania lub z wieloma argumentami w konstruktorze. Dzięki zastosowaniu tego wzorca obiekt łatwo może być niemutowalny, bo dostajemy taki obiekt, jaki ustawiliśmy przez Buildera, więc nie ma potrzeby na nim już wołać żadnych setterów :)

Builder niejako zdejmuje z użytkownika konieczność zrozumienia wewnętrznej implementacji sposobu tworzenia obiektu i gwarantuje jego poprawne utworzenie lub zwrócenie błędu.

Zaletą korzystania z Buildera jest możliwość użycia zmiennej liczby argumentów (vararg) w wielu miejscach, bo każda metoda Buildera może przyjmować taki argument, a w konstruktorze klasy może być tylko 1 argument tego typu.

Builder rozwiązuje problem tzw. konstruktorów teleskopowych, czyli wariantów konstruktora z rosnącą liczbą argumentów.

constructor(firstName: String): this(firstName, "", 0)
constructor(firstName: String, lastName: String): this(firstName, lastName, 0)
constructor(firstName: String, lastName: String, age: Int): this(firstName, lastName, age)

W teorii pozwalają one użyć konstruktora z taką ilością argumentów jaką potrzebujemy, ale w praktyce dodanie nowego pola do klasy powoduje konieczność edycji wszystkich konstruktorów. Na szczęście w Kotlinie mamy nazwane argumenty i nie trzeba naśladować Javy.

Z mojego doświadczenia wynika, że tego wzorca znacznie częściej się używa, niż pisze samemu, ale i tak warto go znać i stosować.

Przykładowe użycie

Z racji mojego zawodowego skrzywienia przykłady pochodzą ze świata Androida.

NotificationBuilder

val notificationBuilder = Notification.Builder(this, "channelId")
notificationBuilder.setContentTitle("Title")
notificationBuilder.setContentText("Content")
notificationBuilder.setSmallIcon(R.mipmap.ic_launcher)
val notification = notificationBuilder.build()

Chyba najbardziej tradycyjne użycie wzorca. Konstruktor Buildera przyjmuje 2 argumenty niezbędne do poprawnego utworzenia obiektu powiadomienia, reszta pól jest ustawiana przez settery wywołane na instancji Buildera. Na koniec wywoływana jest metoda build() która zwraca obiekt powiadomienia.

Dexter

DialogOnAnyDeniedMultiplePermissionsListener.Builder
		.withContext(context)
		.withTitle("Camera permission")
		.withMessage("Camera permission is needed to take pictures of your cat")
		.withButtonText(android.R.string.ok)
		.withIcon(R.mipmap.my_icon)
		.build()

W tym przykładzie metody Buildera są połączone w łańcuch, dlatego że każda z nich zwraca instancję Buildera, czyli this. Przy odpowiednim nazywaniu metod czyta się takie wywołanie prawie jak zdanie. Oczywiście w trakcie pisania kodu IDE potrafi wyświetlić nazwę parametru w metodzie i pomaga to zachować właściwą kolejność, ale w trakcie code review, kiedy jesteśmy zdani na sam tekst, to ubranie tworzenia obiektu w ładnie nazwane metody zdecydowanie pomaga zorientować się co się dzieje bez konieczności zaglądania głębiej w kod.

AlertDialog

val dialog = AlertDialog.Builder(this)
    .apply {
        setTitle("Title")
        setIcon(R.mipmap.ic_launcher)
    }.show()

Bardzo Kotlinowe podejście z użyciem apply. Co ciekawe, nie ma metody build() tylko show() która nie tylko zwraca obiekt, ale też wyświetla dialog na ekranie. Teoretycznie metoda powinna robić tylko jedną rzecz, ale tutaj jej odpowiedzialność została rozszerzona. Prawdopodobnie z powodu częstego błędu utworzenia dialogu i późniejszego braku wywołania metody wyświetlenia go.

Elementy

Builder to w najczęściej jedna wewnętrzna klasa pomocnicza. Podejściem “bandy czworga” z wykorzystaniem Director i ConcreteBuilder nie będę się tutaj zajmował, nigdy go nie widziałem w kodzie produkcyjnym :)

Konstruktor

Wbrew pozorom bardzo ważny jest w niej konstruktor, mimo że często nie przyjmuje żadnych argumentów. Powinny się w nim znaleźć wszystkie parametry, bez których nie da się utworzyć poprawnego obiektu tworzonego przez Builder.

Wydaje się to oczywiste, ale na Androidzie swego czasu można było utworzyć poprawne powiadomienie, które nie miało szans zostać wyświetlone przez system. Wystarczyło zapomnieć ustawić tytuł, treść lub ikonę powiadomienia — żadnej z tych rzeczy nie wymagał konstruktor Buildera. System nie rzucał żadnym wyjątkiem przy próbie wyświetlenia niepoprawnie utworzonego powiadomienia…

Metody

Poza konstruktorem Builder dostarcza metody, którymi możemy ustawić budowany obiekt. W przypadku braku ustawienia jakiejś właściwości obiektu zastosowana będzie wartość domyślna. Może nią być null. Dzięki świetnej obsłudze nullability w Kotlinie znacznie lepiej jest wykorzystać domyślny null niż np. pusty String czy magiczną wartość, jak -1 w miejscu, gdzie spodziewamy się wartości dodatniej. Żeby sprawdzić, czy pole zostało w ogóle ustawione wystarczy potem sprawdzić != null lub lepiej z użyciem ?, zamiast porównywać do jakiejś domyślnej wartości nieustawienia konkretnego typu w danym projekcie/klasie.

Metody powinny być możliwe do wywołania w dowolnej kolejności.

No i obowiązkowa metoda build() (lub inna sensownie nazwana dla domeny, patrz show() dla dialogu) zwracająca budowany obiekt.

Jeśli już decydujemy się na użycie tego wzorca, to dobrze ograniczyć możliwość użycia konstruktora wynikowego obiektu tylko przez Builder.

Weryfikacja argumentów

Jeśli podamy w Builderze niepoprawne dane to kiedy i jak powinniśmy zostać o tym poinformowani? Podejść jest co najmniej kilka, np.:

  1. Jak najszybciej, czyli metoda Buildera przyjmująca argument powinna go weryfikować, ale:
    • co jeśli poprawna wartość jednego argumentu zależy od innego? Jeśli kolejność wywoływania setterów Buildera ma znaczenie to co nam daje ten wzorzec ponad tradycyjne tworzenie obiektu?
    • walidacja w samych setterach może nie być wystarczająca, więc i tak trzeba sprawdzić wartości w metodzie build()
  2. Metoda build() powinna sprawdzić wszystkie argumenty, bo mogą być między nimi zależności i dlatego w ogóle został użyty ten wzorzec, więc:
    • poprawnie stworzony Builder pozwala na wywołanie metod w łańcuchu, więc walidacja w metodzie build() wcale nie jest wywoływana jakoś szczególnie później od setterów
    • jesteśmy w stanie zweryfikować zależności między argumentami jeśli istnieją
    • ale jeśli dany Builder nie jest jedyną możliwością tworzenia obiektu, to walidacje trzeba skopiować w każde miejsce, które tworzy dany obiekt
  3. To nie Builder powinien weryfikować poprawność, tylko sam tworzony obiekt
    • mamy tutaj zachowane SRP (Single-Responsibility Principle), bo Builder buduje obiekt, a obiekt sam potrafi zwalidować swoje pola
    • nie mamy potrzeby kopiowania walidacji, każdy sposób tworzenia obiektu będzie przechodził przez tą w obiekcie

Ciekawy wątek w temacie na StackExchange , gdzie niektórzy sugerują połączenie podejść 2 i 3. Chodzi o to, żeby Builder veryfikował własne kontrakty, a obiekt swoje. Podany tam jest fajny przykład Buildera, który zwraca String zawierający kolejne przedziały liczbowe, np “1-2,3-4,5-6”. Klasa String sama w sobie nie ma jak zweryfikować poprawności dodanych przedziałów — to nie jest jej odpowiedzialność, ona tylko przechowuje ciąg znaków. Za to Builder jest w stanie i powinien w tym przypadku sprawdzić, czy przedziały mają sens i np. nie nachodzą na siebie (jeśli jest taka potrzeba). Wtedy metoda np. addRange(min Int,max Int) powinna rzucić wyjątek IllegalArgumentException kiedy min jest większe od max a metoda build() kiedy dodane zostaną przedziały jak 1-4 i 2-6. Chociaż tutaj też pewnie można dyskutować, czy już addRange() nie powinno tego sprawdzać.

W każdym razie trzymałbym się zasady, że to obiekt sprawdza swoje kontrakty, a Builder swoje. Joshua Bloch w książce “Java - efektywne programowanie” (rozdział 2, temat 2) również sugeruje weryfikację poprawności wartości argumentów dopiero w tworzonym obiekcie, a nie w samym Builderze.

Implementacja

W stylu Javy

Goły przykład najprostszego możliwego Buildera. W zasadzie Java przekonwertowana na Kotlin, bez użycia żadnych fajerwerków. Mamy tutaj:

  • prywatny konstruktor w Product żeby tylko wewnętrzny Builder mógł utworzyć obiekt
  • wymagane pole requiredProperty w konstruktorze Buildera
  • opcjonalne pole ustawiane przez metodę optionalProperty(), domyślnie null co jest akceptowane przez obiekt Product
  • metodę optionalProperty() zwraca instancję Buildera po to, żeby można było łączyć metody w łańcuch
  • metodę build() tworzącą Product z ustawionymi w Builderze argumentami
class Product private constructor(
    val property: Any,
    val optionalProperty: Any?
) {

    class Builder(private val requiredProperty: Any) {
        private var optionalProperty: Any? = null

        fun optionalProperty(value: Any?): Builder {
            this.optionalProperty = value
            return this
        }

        fun build(): Product {
            return Product(requiredProperty, optionalProperty)
        }
    }
}

I wywołanie

val product = Product.Builder("required")
        .optionalProperty("optional")
        .build()

Bardziej Kotlin

Builder w konstruktorze określa wszystkie pola i ich ewentualne domyślne wartości. Mógłby to być data class ale w tym przypadku nie daje to żadnych korzyści. Brak domyślnej wartości w konstruktorze Buildera powoduje, że argument requiredProperty staje się wymagany.

Użycie apply sprawia, że z optionalProperty() jest zwracana instancja Buildera. Jest tu też użyty tzw single-expression function, czyli brak jawnej deklaracji zwracanego przez metodę typu.

class FancyProduct private constructor(
    val property: Any,
    val optionalProperty: Any?
) {

    class Builder(
        private var requiredProperty: String,
        private var optionalProperty: Any? = null,
    ) {

        fun optionalProperty(value: Any) = apply { this.optionalProperty = value }

        fun build(): FancyProduct {
            return FancyProduct(requiredProperty, optionalProperty)
        }
    }
}

Takiego Buildera możemy użyć na kilka sposobów:

  • identycznie jak w Javie lub w pierwszym przykładzie w Kotlinie wzorowanym na Javie
  • podając oba argumenty (wymagany i opcjonalny) od razu w konstruktorze Buildera
  • również podając oba, ale w dowolnej kolejności korzystając z nazwanych argumentów
val fancyProduct = FancyProduct.Builder("required")
    .optionalProperty("optional")
    .build()

val fancyProduct2 = FancyProduct.Builder(
    "required",
    "optional"
).build()

val fancyProduct3 = FancyProduct.Builder(
    optionalProperty = "optional",
    requiredProperty = "required"
).build()

Kotlin DSL

DSL (Domain Specific Language) czyli język programowania utworzony pod konkretną domenę. Kotlin pozwala na dość proste i przyjemne tworzenie funkcji, które potem umożliwiają w czysto domenowy sposób opisać nasz obiekt.

Builder wygląda tak samo, jak w poprzednim przykładzie.

W klasie DslProduct doszedł companion object który posiada metodę dslProduct() za pomocą której będziemy tworzyć nasz obiekt.

class DslProduct private constructor(
        val requiredProperty: Any,
        val optionalProperty: Any?
) {
    companion object {
        inline fun dslProduct(requiredProperty: Any, block: Builder.() -> Unit) =
                Builder(requiredProperty)
                        .apply(block)
                        .build()
    }

    class Builder(
            private val requiredProperty: Any,
            private var optionalProperty: Any? = null
    ) {
        fun optionalProperty(value: Any?) = apply { this.optionalProperty = value }
        fun build() = DslProduct(requiredProperty, optionalProperty)
    }
}

Wywołanie takiego prostego DSLa wygląda tak:

val dslProduct = dslProduct("required") {
        optionalProperty("optional")
    }

Dla tak prostego obiektu nie wygląda to szczególnie zachęcająco, ale jeśli nasz Builder jest kompozycją innych Builderów, to zaczyna robić się ciekawie. Przykład poniżej pochodzi z mojej aplikacji, który zrealizowałem przy pomocy DslMaker. To część standardowej biblioteki Kotlina i dodatkowo zadba o zasięgi wewnętrznych Builderów. Adres, położenie i godziny otwarcia również wykorzystują Buildery i DSL do stworzenia swoich obiektów.

val shop = shop("ID") {
    address = address {
        cityName = "Poznań"
        streetName = "ul. Półwiejska"
        streetNumber = "123/2"
    }
    location = location {
        lat = 53.12
        lng = 23.4
    }
    openHours = openHours {
        weekDay = "6:00-22:00"
        saturday = "7:00-23:00"
        sunday = "closed"
    }
    features(
            Feature.Bakery,
            Feature.Atm
    )
}

Pełny przykład z użyciem DslMaker jest -> tutaj <- Ale to nadal dość prosty przykład i sam DSL zasługuje na osobny wpis.

Podejście DSL wymaga jednak napisania pewnego kodu boilerplate, który na pierwszy rzut oka nie wygląda zbyt przyjaźnie.

Alternatywa

Nazwane parametry w konstruktorze i domyślne wartości parametrów w tworzonym obiekcie mogą w pewnym sensie dawać podobny efekt co zastosowanie Buildera. Sprawdzi się to raczej do mniej rozbudowanych obiektów. Warto też zadbać, żeby domyślne wartości tworzyły sensowną instancję obiektu z punktu widzenia domeny, a nie tylko “żeby się kompilowało”.

val person = Person(
        firstName = "Adam",
        lastName = "Świderski",
        address = Address(
                cityName = "Poznań",
                streetName = "ul. Półwiejska",
                streetNumber = "123/1",
                country = "Poland",
                postalCode = "60-000"
        ),
        contact = Contact(
                workEmail = "adam@work.email",
                workPhoneNumber = "+48 123112312",
                privateEmail = "adam@private.email"
        ),
)

Wygląda nawet podobnie do DSL, ale bez konieczności używania adnotacji, pisania dodatkowych metod etc. Warto zwrócić uwagę na pola takie jak val height: Float? = null - uznałem, że nie jest to niezbędna informacja do utworzenia instancji Person, dlatego domyślna wartość tego pola to null. Tak null a nie np. 0.0 czy -1, albo jakaś magiczna stała DEFAULT_HEIGHT.

W blokach init są sprawdzane wartości podanych argumentów (patrz wyżej Weryfikacja argumentów). Wrzucenie złego typu argumentu zostanie wychwycone już na etapie kompilacji, ale obiekt może mieć jakieś domenowe wymagania, jak np. że wzrost jest zawsze dodatni, a rozmiar buta, o ile został podany, nie może być mniejszy niż 4.

data class Person(
    val firstName: String,
    val lastName: String,
    val address: Address,
    val contact: Contact,
    val height: Float? = null,
    val shoeSize: Float? = null,
) {
    init { 
        height?.let { require(0f < it) { "height is always greater than 0" } }
        shoeSize?.let { require(4f <= it) { "smallest standard shoe size is 4" } }
    }
}

data class Address(
    val country: String,
    val cityName: String,
    val streetName: String,
    val streetNumber: String,
    val postalCode: String,
    val district: String? = null,
)

data class Contact(
    val workPhoneNumber: String,
    val workEmail: String,
    val privatePhoneNumber: String? = null,
    val privateEmail: String? = null,
) {
    init {
        require(workPhoneNumber.isValidPhoneNumber())
        require(workEmail.isValidEmail())
        require(privatePhoneNumber?.isValidPhoneNumber() ?: true)
        require(privateEmail?.isValidEmail() ?: true)
    }
}

Podsumowanie

Builder jest całkiem przydatnym wzorcem konstrukcyjnym i na pewno w trakcie pracy się z nim spotkasz. Potrafi znacząco uprościć tworzenie rozbudowanych obiektów. Dobrze nazwane metody Buildera połączone w łańcuch mogą sprawić, że tworzenie obiektu czyta się jak zdanie. Kotlin pozwala na znaczne uproszczenie boilerplate Buildera i wykorzystanie sposobów takich jak DSL i nazwane argumenty i wartości domyślne na osiągnięcie podobnego rezultatu bez pisania dodatkowego kodu.

W literaturze nt. wzorców projektowych można spotkać implementację Buildera z wykorzystaniem elementów takich jak: Director i ConcreteBuilder. Dopóki nie trzeba przekazywać jako argumenty do jakiegoś Buildera instancji innych Builderów aby w generyczny sposób tworzyć rozbudowane obiekty - powyższe przykłady i opis będą w zupełności wystarczające. A jeśli najdzie Cię potrzeba Builder-cepcji - może warto skorzystać z innego wzorca zamiast komplikować sobie życie :)


Adam Świderski

software engineer