(译)Effective Kotlin系列之遇到多个构造器参数要考虑使用构建器(二)

5,105 阅读11分钟

翻译说明:

原标题: 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不同以及各自优缺点。

欢迎关注Kotlin开发者联盟,这里有最新Kotlin技术文章,每周会不定期翻译一篇Kotlin国外技术文章。如果你也喜欢Kotlin,欢迎加入我们~~~