(譯)Effective Kotlin系列之遇到多個構造器引數要考慮使用構建器(二)

極客熊貓發表於2018-08-29

翻譯說明:

原標題: Effective Java in Kotlin, item 2: Consider a builder when faced with many constructor parameters

原文地址: blog.kotlin-academy.com/effective-j…

原文作者: Marcin Moskala

這篇文章對Java程式設計師將會有很大的影響。當我們在處理各種各樣的物件建立的操作是,這是一個很常見的場景。Effective Java中提出的很好的論據建議開發人員使用Builder構建器而不是伸縮建構函式模式。雖然Kotlin改變了很多 - 它給了我們更好的可能性。我們很快就會看到它

這是Effective Java edition 2的第二條規則:

面對許多建構函式時使用BUILDERS

讓我們來探索吧。

內容前情回顧

在Java中,通常方式是使用可選的建構函式引數的可伸縮構造器模式去定義一個物件。當我們使用可伸縮構造器模式時,可以為每個使用的集合或引數定義一個單獨的構造器。以下是Kotlin的一個例子:

class Dialog constructor(
        val title: String,
        val text: String?,
        val onAccept: (() -> Unit)?
) {
    constructor(title: String, text: String)
        : this(title, text, null)
    constructor(title: String)
        : this(title, "")
}
// Usage
val dialog1 = Dialog("Some title", "Great dialog", { toast("I was clicked") })
val dialog2 = Dialog("Another dialog","I have no buttons")
val dialog3 = Dialog("Dialog with just a title")
複製程式碼

在Android中非常常見的例子就是我們如何實現一個自定義View。 儘管這種模式在JVM世界中很流行,但是Effective Java認為對於更大或更復雜的物件,我們應該使用Builder模式。Builder模式首先以可讀和緊湊的方式獲取引數列表,然後驗證並例項化物件。這是一個例子:

class Dialog private constructor(
        val title: String,
        val text: String?,
        val onAccept: (() -> Unit)?
) {
    class Builder(val title: String) {
        var text: String? = null
        var onAccept: (() -> Unit)? = null
        fun setText(text: String?): Builder {
            this.text = text
            return this
        }
        fun setOnAccept(onAccept: (() -> Unit)?): Builder {
            this.onAccept = onAccept
            return this
        }
        fun build() = Dialog(title, text, onAccept)
    }
}
// Usage
val dialog1 = Dialog.Builder("Some title")
        .setText("Great dialog")
        .setOnAccept { toast("I was clicked") }
        .build()
val dialog2 = Dialog.Builder("Another dialog")
        .setText("I have no buttons")
        .build()
val dialog3 = Dialog.Builder("Dialog with just a title").build()
複製程式碼

在可伸縮的構造器模式中,宣告和用法都顯得比較強大,但是builder模式有著更為重要的優點:

  • 引數是顯式的,因此我們在設定它時會看到每個引數的名稱。
  • 我們可以按任何順序設定引數。
  • 它更容易修改,因為當我們需要在可伸縮的構造器模式中更改某些引數時,我們需要在使用它的所有建構函式中更改它。
  • 具有填充值的Builder構建器可以像工廠一樣使用。

當我們需要設定可選引數時,這個特性使構建器模式對大多數類更加明確,有彈性並且更好。

命名可選引數

本章最受歡迎的部分,來自Effective Java的第二版,如下:

Builder模式模擬Ada和Python語言中的命名可選引數。

很棒的是,在Kotlin中,我們不需要模擬命名的可選引數,因為我們可以直接使用它們。在大多數情況下,可選引數比Builder構建器要更好。只需比較上面的構建器模式和下面命名的可選引數即可,就會發現宣告和使用都更清晰,更短,更具表現力:

class Dialog(
        val title: String,
        val text: String? = null,
        val onAccept: (() -> Unit)? = null
)
// Usage
val dialog1 = Dialog(
        title = "Some title",
        text = "Great dialog",
        onAccept = { toast("I was clicked") }
)
val dialog2 = Dialog(
        title = "Another dialog",
        text = "I have no buttons"
)
val dialog3 = Dialog(title = "Dialog with just a title")
複製程式碼

具有命名可選引數的建構函式具有Builder構建器模式的大部分優點

  • 引數是顯式的,因此我們在設定時設定每個引數的名稱。
  • 我們可以按任何順序設定引數。
  • 它更容易修改(甚至比Builder構建器模式更容易)

在這個簡單的示例中,具有命名可選引數的建構函式看起來更好,但是,如果我們需要針對不同引數的不同建立變體呢?假設我們為不同的引數集建立不同型別的對話方塊。我們可以在Builder構建器中輕鬆地解決該問題:

interface Dialog {
    fun show()
    class Builder(val title: String) {
        var text: String? = null
        var onAccept: (() -> Unit)? = null
        fun setText(text: String?): Builder {
            this.text = text
            return this
        }
        fun setOnAccept(onAccept: (() -> Unit)?): Builder {
            this.onAccept = onAccept
            return this
        }
        fun build(): Dialog = when {
            text != null && onAccept != null ->
                TitleTextAcceptationDialog(title, text!!, onAccept!!)
            text != null ->
                TitleTextDialog(title, text!!)
            onAccept != null ->
                TitleAcceptationDialog(title, onAccept!!)
            else -> TitleDialog(title)
        }
    }
}
// Usage
val dialog1 = Dialog.Builder("Some title")
        .setText("Great dialog")
        .setOnAccept { toast("I was clicked") }
        .build()
val dialog2 = Dialog.Builder("Another dialog")
        .setText("I have no buttons")
        .build()
val dialog3 = Dialog.Builder("Dialog with just a title").build()
複製程式碼

那我們可以使用命名的可選引數來解決該類問題嗎?是的,我們可以使用不同的建構函式或使用工廠方法來實現相同的功能!以下是針對上述問題示例的解決方案:

interface Dialog {
    fun show()
}
fun makeDialog(
    title: String, 
    text: String? = null, 
    onAccept: (() -> Unit)?
): Dialog = when {
    text != null && onAccept != null -> 
        TitleTextAcceptationDialog(title, text, onAccept)
    text != null -> 
        TitleTextDialog(title, text)
    onAccept != null -> 
        TitleAcceptationDialog(title, onAccept)
    else -> 
        TitleDialog(title)
}
// Usage
val dialog1 = makeDialog(
        title = "Some title",
        text = "Great dialog",
        onAccept = { toast("I was clicked") }
)
val dialog2 = makeDialog(
        title = "Another dialog",
        text = "I have no buttons"
)
val dialog3 = makeDialog(title = "Dialog with just a title")
複製程式碼

這是我們的另一個例子,我們再次看到命名引數優於builder模式的地方:

  • 它更短 - 建構函式或工廠方法比構建器模式更容易實現。我們不需要在呼叫地方指定每個可選引數的函式名稱4次(作為屬性,方法,引數和建構函式的名稱)。型別不需要宣告3次(在引數,屬性和建構函式中)。這很重要,因為當我們想要更改某些引數名稱時,我們只更改工廠方法中的單個宣告即可,而不要去修改認為相同的4個方法名。
  • 它更清晰 - 當你想要檢視物件構造的實現方式時,您需要的只是一個方法而不是遍佈整個構建器類。物件之間如何引用?他們之間如何通訊?當我們擁有比較複雜的Builder構建器時,這些問題一時都很難去回答。另一方面,類建立通常在工廠方法中是很明確的。
  • 沒有併發問題 - 這不是個很常見的問題,但函式引數在Kotlin中總是不可變的,而大多數建設者的屬性是可變的。因此,為Builder構建器實現執行緒安全構建函式顯得更加困難。

Builder構建器模式的一個優點在於具有填充引數特性可用作工廠模式。雖然這種情況很少見,但這種優勢微乎其微。

Builder構建器模式的另一個討論點是我們可以部分填充構建器並進一步傳遞它。這樣我們就可以定義建立部分填充構建器的方法,並且可以修改它們(比如我們的應用程式的預設對話方塊)。為了有類似的建構函式或工廠方法的可能性,我們需要自動的柯里化(這在Kotlin中是可能的,但不是沒有名稱和預設引數丟失)。雖然這種物件建立方式不是非常常見,但通常也不是優選的。如果我們要為應用程式定義預設對話方塊,可以使用函式來建立它並將所有自定義元素作為可選引數傳遞。這種方法可以更好地控制對話方塊Dialog的建立。

一般規則是,在大多數情況下,命名可選引數應優先於構建器模式。儘管這不是Kotlin給我們的Builder建造者模式的唯一新選擇。另一個非常受歡迎的是用於物件構建的DSL。我們來描述一下。

用於構建物件的DSL

假設我們需要設定具有多個處理程式的監聽器。類似於Java的經典方法是使用物件表示式:

taskNameView.addTextChangedListener(object : TextWatcher {
    override fun afterTextChanged(s: Editable?) {
        // ...
    }
    override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
        // ...
    }
    override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
        // no-op
    }
})
複製程式碼

這種方法不是很方便,可以使用命名的可選引數輕鬆替換為更簡潔的工廠方法:

 fun makeTextWatcher(
        afterTextChanged: ((s: Editable?) -> Unit)? = null,
        beforeTextChanged: ((s: CharSequence?, start: Int, count: Int, after: Int) -> Unit)? = null,
        onTextChanged: ((s: CharSequence?, start: Int, before: Int, count: Int) -> Unit)? = null
) = object : TextWatcher {
    override fun afterTextChanged(s: Editable?) {
        afterTextChanged?.invoke(s)
    }
    override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
        beforeTextChanged?.invoke(s, start, count, after)
    }
    override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
        onTextChanged?.invoke(s, start, before, count)
    }
}
// Usage
taskNameView.addTextChangedListener(makeTextWatcher(
        afterTextChanged = { s ->
            // ..
        },
        beforeTextChanged = { s, start, count, after ->
            // ...
        }
))
複製程式碼

請注意,我們可以輕鬆地把進一步改造成TextView的擴充套件函式:

taskNameView.addTextChangedListener(
    afterTextChanged = { s ->
       // ..
    },
    beforeTextChanged = { s, start, count, after ->
       // ...
    }
)
複製程式碼

這是DSL的一個簡單示例。支援這種表示法的函式可以在類似AnkoAndroid-ktx這樣的流行Kotlin庫中找到。例如,這是我們如何在Anko中定義和顯示warning對話方塊:

alert("Hi, I'm Roy", "Have you tried turning it off and on again?"){
    yesButton { toast("Oh…") }
    noButton {}
}.show()
複製程式碼

問題在於這種表示方法需要寫很多支援它們的宣告,例如這是我們如何定義上面的TextView的addOnTextChangedListener擴充套件方法:

fun TextView.addOnTextChangedListener(
    config: TextWatcherConfiguration.() -> Unit
) {
    val listener = TextWatcherConfiguration().apply { config() }
    addTextChangedListener(listener)
}
class TextWatcherConfiguration : TextWatcher {
    private var beforeTextChangedCallback: (BeforeTextChangedFunction)? = null
    private var onTextChangedCallback: (OnTextChangedFunction)? = null
    private var afterTextChangedCallback: (AfterTextChangedFunction)? = null
    fun beforeTextChanged(callback: BeforeTextChangedFunction) {
       beforeTextChangedCallback = callback
    }
    fun onTextChanged(callback: OnTextChangedFunction) {
        onTextChangedCallback = callback
    }
    fun afterTextChanged(callback: AfterTextChangedFunction) {
        afterTextChangedCallback = callback
    }
    override fun beforeTextChanged(
        s: CharSequence, 
        start: Int, 
        count: Int, 
        after: Int
    ) {
        beforeTextChangedCallback?.invoke(s.toString(), start, count, after)
    }
    override fun onTextChanged(
         s: CharSequence, 
         start: Int, 
         before: Int, 
         count: Int
    ) {
        onTextChangedCallback?.invoke(s.toString(), start, before, count)
    }
    override fun afterTextChanged(s: Editable) {
        afterTextChangedCallback?.invoke(s)
    }
}
private typealias BeforeTextChangedFunction = 
    (text: String, start: Int, count: Int, after: Int) -> Unit
private typealias OnTextChangedFunction = 
    (text: String, start: Int, before: Int, count: Int) -> Unit
private typealias AfterTextChangedFunction = 
    (s: Editable) -> Unit
複製程式碼

對單個甚至兩個用法進行此類宣告是不合理的。另一方面,當我們開發一個庫時,這不是問題。這就是為什麼在大多數情況下我們使用庫中定義的DSL的原因。當我們定義它們時,它們非常強大。請注意,在DSL內部,包括使用控制迴圈語句結構(if for,等等),定義變數等。這是一個為Kot.Academy官網生成的HTML使用DSL的例子。

private fun RDOMBuilder<DIV>.authorDiv(
    author: String?, 
    authorUrl: String?
) {
    author ?: return
    div(classes = "main-text multiline space-top") {
        +"Author: "
        if (authorUrl.isNullOrBlank()) {
            +author
        } else {
            a(href = authorUrl) { +author }
        }
    }
}
複製程式碼

除了宣告之外,我們也指定了如何定義這個元素的邏輯。這樣的DSL通常比具有命名可選引數的建構函式或工廠方法強大得多。當然它也更復雜,更難定義。

用於已經有Builder構建者的簡單DSL使用

在一些Android專案中可以觀察到有趣的解決方案,其中開發人員實現了使用切除構建器的簡化DSL。

假設我們使用來自庫(或框架)的對話方塊Dialog,它提供構建器作為建立方法(假設它是用Java實現的):

val dialog1 = Dialog.Builder("Some title")
        .setText("Great dialog")
        .setOnAccept { toast("I was clicked") }
        .build()
複製程式碼

這就是如何實現和使用非常簡單的DSL構建器:

fun Dialog(title: String, init: Dialog.Builder.()->Unit) = 
    Dialog.Builder(title).apply(init).build()
// Usage
val dialog1 = Dialog("Some title") {
     text = "Great dialog"
     setOnAccept { toast("I was clicked") }
}
複製程式碼

(只有在Java中定義了此方法時,我們才能將text設定為屬性) 這樣我們就擁有了DSL的最大優點和非常簡單的宣告。這也表明了DSL和Builder構建器模式的共同點。他們有類似的理念,但DSL更像是下一代的建造者模式。

總結

Effective Java的引數在Kotlin中仍然有效,而構建器模式比之前的Java替代方案更合理。儘管 Kotlin介紹了按名稱指定引數並提供預設引數。多虧了這一點,我們可以更好地替代構建器模式。Kotlin還提供了允許DSL使用的功能。定義良好的DSL甚至是更好的替代方案,因為它提供了更大的靈活性並允許在物件定義實現邏輯。

去判定物件建立到底有多複雜不是一個簡單的問題,而且往往需要一定的經驗。Kotlin給我們帶來非常重要的可能性,此外它們對Kotlin的發展也產生了積極的影響。

譯者有話說

首先,回答下為什麼要翻譯這篇文章,這篇文章是Effective Kotlin系列的第二篇,這篇講的是我們熟悉的Builder建造者模式,當我們遇到構造器中有很多引數的時,我都會考慮使用Builder模式來替代它。當然這只是Java中常見操作,但是Kotlin是不是得按部就班照著Java來呢?顯然不是,Kotlin中有著更為優雅和強大的實現方式構造器+預設值引數。如果不瞭解本篇文章初學者,估計就會拿著Kotlin語言生搬硬套成Java的Builder實現方式,卻不知Kotlin中有更為優雅的實現方案。

其實,本篇文章繼續體現Effective Kotlin一個宗旨: 通過對比Effective Java高效編碼準則,在Kotlin中去尋找更為優雅實現方式和替代解決方案,而不是帶著Java思維寫著Kotlin程式碼來翻譯實現。從而進一步體驗Kotlin與Java不同以及各自優缺點。

(譯)Effective Kotlin系列之遇到多個構造器引數要考慮使用構建器(二)

歡迎關注Kotlin開發者聯盟,這裡有最新Kotlin技術文章,每週會不定期翻譯一篇Kotlin國外技術文章。如果你也喜歡Kotlin,歡迎加入我們~~~

相關文章