什么是Scala中等价于Java构建器模式的方法?

63
在我日常的Java开发中,我经常使用建造者模式来创建流畅的接口,例如:new PizzaBuilder(Size.Large).onTopOf(Base.Cheesy).with(Ingredient.Ham).build(); 在快速而不太规范的Java方法中,每个方法调用都会改变生成器实例并返回this。使用不可变方法时需要更多的键入,先复制生成器再进行修改。最终,构建方法将处理生成器状态。
如何以优雅的方式在Scala中实现同样的功能呢?
如果我想确保只能调用onTopOf(base:Base)一次,然后随后只能调用with(ingredient:Ingredient)build():Pizza,这就像一个有向的生成器,我该如何处理?
5个回答

59

在Scala 2.8中,Builder模式的另一种替代方案是使用具有默认参数和命名参数的不可变case类。这与Builder模式略有不同,但可以实现智能默认值,指定所有值并使用语法检查仅指定一次的效果...

为了简洁快速起见,以下示例使用字符串作为值...

scala> case class Pizza(ingredients: Traversable[String], base: String = "Normal", topping: String = "Mozzarella")
defined class Pizza

scala> val p1 = Pizza(Seq("Ham", "Mushroom"))                                                                     
p1: Pizza = Pizza(List(Ham, Mushroom),Normal,Mozzarella)

scala> val p2 = Pizza(Seq("Mushroom"), topping = "Edam")                               
p2: Pizza = Pizza(List(Mushroom),Normal,Edam)

scala> val p3 = Pizza(Seq("Ham", "Pineapple"), topping = "Edam", base = "Small")       
p3: Pizza = Pizza(List(Ham, Pineapple),Small,Edam)

你还可以使用现有的不可变实例作为构建器...

scala> val lp2 = p3.copy(base = "Large")
lp2: Pizza = Pizza(List(Ham, Pineapple),Large,Edam)

我觉得这真的很棒。我觉得我需要深入了解一下case类。谢谢! - Jakub Korab
11
当意图是从Java使用构建器时,这是否是一个好的解决方案?我认为可能不是。 - Landon Kuhn
7
抱歉,但这个回答很荒谬。 - Rob
不幸的是,这并不能处理常见的用例,即您可能希望可选地指定某些参数并将其他参数保留为默认值。 (或者至少,您无法这样做而不重复作为参数传递的默认值。) - Michael Mior

33

你有三种主要的选择。

  1. 使用与Java相同的模式,包括类等内容。

  2. 使用命名参数、默认参数和复制方法。Case类已经为您提供了这个功能,但这里是一个不是case类的示例,只是为了让您更好地理解它。

  3. object Size {
        sealed abstract class Type
        object Large extends Type
    }
    
    object Base {
        sealed abstract class Type
        object Cheesy extends Type
    }
    
    object Ingredient {
        sealed abstract class Type
        object Ham extends Type
    }
    
    class Pizza(size: Size.Type, 
                base: Base.Type, 
                ingredients: List[Ingredient.Type])
    
    class PizzaBuilder(size: Size.Type, 
                       base: Base.Type = null, 
                       ingredients: List[Ingredient.Type] = Nil) {
    
        // A generic copy method
        def copy(size: Size.Type = this.size,
                 base: Base.Type = this.base,
                 ingredients: List[Ingredient.Type] = this.ingredients) = 
            new PizzaBuilder(size, base, ingredients)
    
    
        // An onTopOf method based on copy
        def onTopOf(base: Base.Type) = copy(base = base)
    
    
        // A with method based on copy, with `` because with is a keyword in Scala
        def `with`(ingredient: Ingredient.Type) = copy(ingredients = ingredient :: ingredients)
    
    
        // A build method to create the Pizza
        def build() = {
            if (size == null || base == null || ingredients == Nil) error("Missing stuff")
            else new Pizza(size, base, ingredients)
        }
    }
    
    // Possible ways of using it:
    new PizzaBuilder(Size.Large).onTopOf(Base.Cheesy).`with`(Ingredient.Ham).build();
    // or
    new PizzaBuilder(Size.Large).copy(base = Base.Cheesy).copy(ingredients = List(Ingredient.Ham)).build()
    // or
    new PizzaBuilder(size = Size.Large, 
                     base = Base.Cheesy, 
                     ingredients = Ingredient.Ham :: Nil).build()
    // or even forgo the Builder altogether and just 
    // use named and default parameters on Pizza itself
    
  4. 使用类型安全的构建器模式。我知道的最好的介绍是这篇博客,其中还包含了许多其他相关文章的参考资料。

    基本上,类型安全的构建器模式可以在编译时保证所有必需组件都已提供。你甚至可以保证选项的互斥或元数。成本是构建器代码的复杂性,但是...


2
我不明白为什么pizzabuilder本身需要是不可变的。这似乎很浪费,性能也不够好,也没有必要。最终它只是一个暂时的对象,用于构建一个不可变的披萨。 - Andrew Norman
4
我并没有建议使用不可变的构建者,问题是要求提供这样一个解决方案。实际上,我最初的建议是继续使用Java模式。然而,可变性会防止重复使用,因此在某些情况下可变性可能有所帮助。此外,短暂的堆对象是Java垃圾收集器进行优化的对象,它们应该在L3甚至L2缓存中度过它们的整个生命周期,因此性能问题并不是很大。 - Daniel C. Sobral
问题在于这应该是一个 "build-er" 模式,而不是一个 "built" 模式。使构建器本身不可变的问题在于:1. 对于单线程系统,在每次调用时构造新构建器的副本是不必要的开销;2. 如果此构建器在多线程场景中使用,则每次调用都会在每个线程中生成新版本的构建器,每个版本都包含不完整的总内容! - Andrew Norman
2
你(@AndrewNorman)所忽略的是,使用不可变的构建器可以保存中间状态。然后,您可以一遍又一遍地基于该中间状态进行构建。如果您使用的构建器在构建过程中自我更改,则无法做到这一点。 - John Arrowwood
1
只有进行基准测试才能确定。哪种解决方案更容易编码?哪个更容易理解?哪个会给从未查看过实现的不谨慎开发人员带来最令人惊讶的陷阱?哪个将实际产生可衡量的惩罚。我猜必须使用“缓存”比仅将构建器分配给变量并使用它来构建多个对象要复杂得多(从代码使用角度)。那么,从经验角度来看,这种性能惩罚到底有多大呢? - John Arrowwood
显示剩余3条评论

13

案例类可以像以前的答案所示解决问题,但是当您的对象中有Scala集合时,从Java使用它们的API很难使用。为了向Java用户提供流畅的API,请尝试以下操作:

case class SEEConfiguration(parameters : Set[Parameter],
                               plugins : Set[PlugIn])

case class Parameter(name: String, value:String)
case class PlugIn(id: String)

trait SEEConfigurationGrammar {

  def withParameter(name: String, value:String) : SEEConfigurationGrammar

  def withParameter(toAdd : Parameter) : SEEConfigurationGrammar

  def withPlugin(toAdd : PlugIn) : SEEConfigurationGrammar

  def build : SEEConfiguration

}

object SEEConfigurationBuilder {
  def empty : SEEConfigurationGrammar = SEEConfigurationBuilder(Set.empty,Set.empty)
}


case class SEEConfigurationBuilder(
                               parameters : Set[Parameter],
                               plugins : Set[PlugIn]
                               ) extends SEEConfigurationGrammar {
  val config : SEEConfiguration = SEEConfiguration(parameters,plugins)

  def withParameter(name: String, value:String) = withParameter(Parameter(name,value))

  def withParameter(toAdd : Parameter) = new SEEConfigurationBuilder(parameters + toAdd, plugins)

  def withPlugin(toAdd : PlugIn) = new SEEConfigurationBuilder(parameters , plugins + toAdd)

  def build = config

}

然后在Java代码中,API非常容易使用。

SEEConfigurationGrammar builder = SEEConfigurationBuilder.empty();
SEEConfiguration configuration = builder
    .withParameter(new Parameter("name","value"))
    .withParameter("directGivenName","Value")
    .withPlugin(new PlugIn("pluginid"))
    .build();

11

这是完全相同的模式。Scala允许变异和副作用。话虽如此,如果您想更加纯粹,请使每个方法返回一个新的对象实例,该实例使用已更改的元素构造。您甚至可以将函数放在类的Object中,以便在代码中有更高级别的分离。

class Pizza(size:SizeType, layers:List[Layers], toppings:List[Toppings]){
    def Pizza(size:SizeType) = this(size, List[Layers](), List[Toppings]())

object Pizza{
    def onTopOf( layer:Layer ) = new Pizza(size, layers :+ layer, toppings)
    def withTopping( topping:Topping ) = new Pizza(size, layers, toppings :+ topping)
}

这样你的代码可能会变成以下形式:

val myPizza = new Pizza(Large) onTopOf(MarinaraSauce) onTopOf(Cheese) withTopping(Ham) withTopping(Pineapple)

(注意:我可能在这里搞砸了一些语法。)


如果我理解正确的话(并且我的Scala行为解释正确),这不意味着你可以在Pizza对象上调用withTopping(Ham)吗?这不会导致某种崩溃吗(抱歉,这台电脑上没有REPL)? - Jakub Korab
4
我很确定第四行的 object Pizza{ 应该不需要出现。没有它,您将得到 class Pizza 的所有构建器方法。此外,通过 def this(size: SizeType) 定义了多个构造函数(与 Java 的语法不同)。但是我仍然会使用默认参数:class Pizza(size: SizeType, layers: List[Layers] = List(), toppings: List[Toppings] = List()) - r0estir0bbe

0

如果您正在构建一个不需要通过方法签名传递的较小对象,则可以使用Scala部分应用。 如果这些假设中的任何一个都不适用,则建议使用可变生成器来构建不可变对象。 由于这是Scala,因此您可以使用带有伴生对象作为生成器的案例类来实现生成器模式。

鉴于最终结果是一个构造好的不可变对象,我认为这并没有违背任何Scala原则。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接