Scala 與設計模式(二):Builder 建立者模式

ScalaCool發表於2017-07-21

本文由 Prefert 發表在 ScalaCool 團隊部落格。

在 Java 開發中,你是否寫過這樣像蛇一樣長的建構函式:

Robot robot = new Robot(1, true, true, false, false, false, false, false, false) // Boolean 型別的參數列示 computer 是否含有對應韌體複製程式碼

剛寫完時回頭看發現能看懂,一天後回頭看時已經忘記大半了,一個星期後:What The Fu*k?
當然有強(lan)迫(duo)症的同學肯定不能忍 ——— 他們會創造各種各樣的便捷版!

本文會通過 Builder Pattern 來一步步解決上述以及更復雜的一些情況。

文章結構大致如下:

  • builder pattern 的概念
  • 問題分解
    • Java 例項
    • Scala 例項
  • 總結

概念

建立者模式與單例模式一樣,也是「四人幫」設計模式中的一種,一般也譯作「生成器模式」,定義如下:

Separate the construction of a complex object from its representation so that the same construction process can create different representations.
將一個複雜物件的構建與它的表示分離,使得同樣的構建過程可以建立不同的表示。

它解決了什麼問題

當大量引數遇上建構函式

我們都知道在 Java 中,每個類都至少有一個建構函式,如果我們沒有明確宣告建構函式,編譯器會預設幫我們生成一個無參的建構函式。當然我們也可以根據引數寫不同的建構函式。

在實際專案開發中,物件中的屬性一般都是比較多的。當物件中有大量可選引數或者引數型別一致時(正如文章開頭的例子),通常情況下建立前我們需要了解這個類的內部結構,然後我們忽略掉為空的引數或者用所需的引數寫一個新的建構函式。

我們以「機器人」類為例:

public class Robot {
    private String code;
    private String name;
    private int type;
    private int battery;
    private int ability;
    private double weight;
    private double height;

    // 通常我們會生成一個含有全部引數的建構函式
    public Robot(String code, String name, int type, int battery, int ability, double weight, double height) {
        this.code = code;
        this.name = name;
        this.type = type;
        this.battery = battery;
        this.ability = ability;
        this.weight = weight;
        this.height = height;
    }

    @Override
    public String toString() {
        return "Robot {" +
                "code = " + code + '\'' +
                ", name = '" + name + '\'' +
                ... // 省略部分
                ", height = '" + height +
                '}';
    }

// Test
Robot robot1 = new Robot("89757", "火星一號", 1, 99, 250, 180, 180);
System.out.println(robot1);複製程式碼

我們假設 code name type 是必填的引數,其他引數是可選的,我們想要的寫法可能是下面這樣的:

Robot robot2 = new Robot("89757", "火星一號", 1);複製程式碼

奈何編譯器可沒那麼智慧,這樣肯定會給出引數不匹配的 error 。我們只能老實的根據引數再去寫一個建構函式:

public Robot(String code, String name, int type) {
    this.code = code;
    this.name = name;
    this.type = type;
}複製程式碼

當使用者型別不同時,引數組合情況就會很多,難道還要每種都寫一個嗎?就算這樣寫了,也意味著構建時有多種物件狀態,擴充套件起來也不方便,該怎麼辦呢? Builder 模式虎軀一震:是時候展現真正的技術了。

Java —— 變種版

為了應對可選引數過多的情況,我們可以將 Robot.java 改進成下面這樣:

public Robot(RobotBuilder robotBuilder) {
       this.code = robotBuilder.code;
       this.name = robotBuilder.name;
       ...
       this.height = robotBuilder.height;
   }

   public static class RobotBuilder {
       private String code;
       private String name;
       ...
       private double height;

       // 必填引數
       public RobotBuilder(String code, String name, int type) {
           this.code = code;
           this.name = name;
           this.type = type;
       }

       //選填引數
       public RobotBuilder withOptionalBattery(int battery) {
           this.battery = battery;
           return this;
       }

       ... // 省略部分選填引數

       public Robot buildRobot() {
           ValidateRobotData();
           return new Robot(this);
       }

       private boolean ValidateRobotData() {
           // 引數格式檢查
           return true;
       }
   }複製程式碼

通過這種寫法,可以減少物件建立過程中引入的多個建構函式、可選引數以及多個 setter 過度使用導致的不必要的複雜性。

測試:

Robot robot = new Robot.RobotBuilder("89757", "火星一號", 1)
        .withOptionalBattery(99)
        .withOptionalAbility(250)
        .withOptionalWeight(180)
        .withOptionalHeight(180)
        .buildRobot();

System.out.println(robot);複製程式碼

這樣的鏈式呼叫看起來比較優雅,同時對於可選引數也有語義化的引入方式。但是實際的情況可能會更糟糕一些:Robot 類中可能還會包含其他複雜物件,並且這些物件之間還存在一些構造順序,下面將介紹傳統的 Buidler 模式是如何解決這個問題的。

Java —— 傳統版

在寫實際的例子之前,讓我們先看一下 「四人幫」 提出的 Builder 模式的組成(推薦新手先看例子再回過頭來看)

Builder 模式的構成
  1. 建造者(Builder):
    • Builder 為建立一個 Product 物件(對應文中 Robot)的各個部件指定抽象介面。
  2. 抽象建造者(ConcreteBuilder):
    • 實現 Builder 的介面以構造和裝配該產品的各個部件。
    • 定義並明確它所建立的表示。
    • 提供一個檢索產品的介面
  3. 導演類(Director)
    • 構造一個使用 Builder 介面的物件。
  4. 產品類(Product)

    • 表示被構造的複雜物件,ConcreteBuilder 建立該產品的內部表示並定義它的裝配過程。
    • 包含定義組成部件的類,包括將這些部件裝配成最終產品的介面。

    看著是不是會有一點繞?還是先直接進入實際的場景部分吧!

如何讓構造的物件有不同表示

不知道大家有沒有看過「西部世界」,這部電影中的機器人展現出了高度智慧(沒看過的話多啦A夢也可以吧),相信大家都想拿一個過來研究一下。

如果我們能夠購買到這樣的機器人,過程應該是這樣的:

  1. 我們(Client)和出廠商(Director)聯絡,告訴出廠商需要什麼型別的機器人(Product)
  2. 出廠商接單後,設計師將我們需要的機器人的部件(Builder)進行分類篩選,發出構造指令;
  3. 不同生產人員(ConcreteBuilder)收到對應部件的構造命令;
  4. 各個元件被組裝起來變成我們需要的機器人(Product)。
程式碼實現

有了一個過程的概念,讓我們看看程式碼是如何實現的(模擬的側重點不同所以將 Robot 的引數改變):

  1. 廠家決定機器人有哪些結構

    public class Robot {
     private String sensor;
     private String control;
     private String drive;
     private String shell;
    
     ... //省略引數的 set 函式
    
     @Override
     public String toString() {
         return "Robot {" +
                 "  Sensor = '" + sensor + '\'' +
                 ", Control = '" + control + '\'' +
                 ", Drive = '" + drive + '\'' +
                 ", Shell = '" + shell + '\'' +
                 '}';
     }
    }複製程式碼
  2. 定義組裝機器人的過程

    abstract class Builder {
    
     public abstract void BuildSensor();  // 構建感測器模組
     public abstract void BuildControl(); // 構建控制模組
     public abstract void BuildDrive();   // 構建驅動模組
     public abstract void BuildShell();   // 構建外殼
    
     public abstract Robot getRobot();
    }複製程式碼
  3. 實現生產工創造並組裝元件的具體方式,返回拼裝好的機器人

    public class ConcreteBuilder extends Builder {
     //建立機器人例項
     Robot robot = new Robot();
    
     // 生產並組裝部件
     @Override
     public void BuildSensor() {
       robot.setSensor("建立並組裝感測器");
     }
    
     ... // 省略部分 Build 函式
    
     @Override
     public Robot getRobot() {
         return robot;
     }
    }複製程式碼
  4. 下達指定給機器人生產與組裝人員

    public class Director {
    
     public void Construct(Builder builder){
         // 按一定順序組裝機器人
         builder.BuildSensor();
         builder.BuildControl();
         builder.BuildDrive();
         builder.BuildShell();
     }
    }複製程式碼
  5. 測試機器人
    ```Java
    Director director = new Director();
    Builder builder = new ConcreteBuilder();

director.Construct(builder); // 發出組裝機器人的指令
Robot robot = builder.getRobot(); // 拿來拼裝好的機器人
System.out.println(robot); // 展示機器人


##### 總結
從上面的例子中看出我們只關心機器人是否正常運作,但是並不知道機器人拼裝的過程。即這種模式的封裝性很好。使用該模式可以有效的封裝變化,在使用場景中,一般產品類(`Product`)和建造者(`Builder`)類是比較穩定的,因此,將主要的業務邏輯封裝在導演類(`Director`)中對整體而言可以取得比較好的穩定性。

其次,建造者模式很容易進行擴充套件。如果有新的需求,通過實現一個新的建造者(`ConcreteBuilder`)類就可以完成,基本上不用修改之前的程式碼,因此對原有程式碼影響很小。

那麼,在 Scala 中是否也存在 Java 的問題呢?

## Scala 實現  

#### 仿 Java 版
問題存在是毋庸置疑的,但我們最關心的應該是解決方法,Java 能幹的 Scala 肯定也是能做的。在 Scala 中也有類似上文中 「Java —— 變種版」 的實現方式,我們還是採用 `Robot` 作為例子(因篇幅有限省略引數):
```Scala
class Robot(builder: RobotBuilder) {
    val name = builder.name
    val nickname = builder.nickname
    val age = builder.age
  }複製程式碼

然後定義一個 Buidler 類:

class RobotBuilder {
  var name = ""
  var code = ""
  var battery = 0

  def setName(name: String): RobotBuilder = {
    this.name = name
    this // 返回 this 鏈式呼叫
  }

  ... // 省略兩個 set 函式

  def build() = {
    new Robot(this)
  }
}複製程式碼

測試:

val robot: Robot = new RobotBuilder()
  .setCode("89757")
  .setName("Bat-Man")
  .setBattery(88)
  .build()
System.out.println(s"Robot: $robot }")複製程式碼

這個與上方 Java 版本基本無異,當為 Robot 類新增新的欄位也不必再建立新的構造器。僅需要通過 RobotBuilder 類進行相容即可。

case class 版

但是我們可能忽略了一個問題:Scala 作為 「Object-Oriented Meets Functional」 的一門語言,推崇函數語言程式設計和併發,比 Java 更加強調不變性。上文中的 setXXX 已經違背了這個特點,會帶來副作用,這並不符合最佳實踐。

好在 Scala 擁有樣例類,這使得創造者模式的實現變得更加簡單:

case class Robot(
                   name: String = "",
                   code:  String = "",
                   battery :Int = 0
                  )複製程式碼

測試:

val robot1 = Robot(
  code = "89757",
  name = "Bat-Man",
  battery = 99
)

val robot2 = Robot(name = "prefert")

System.out.println(s"Robot 1: $robot1")
System.out.println(s"Robot 2: $robot2")複製程式碼

這種實現要比第一種實現更加簡潔並且也更易維護,同時解決了第一種中不夠 Pure 的缺點。

型別安全(type-safe) 版

在建立物件的過程中,引數的初始化順序可能是嚴格要求的(比如機器人遵循從裡到外,從小到大的構造方式)。回顧前面兩種方式,我們並不能完全控制引數的初始化順序。

這裡我們給code name 欄位設定非空約束。為了確保這些引數都被設定,我們可以結合 sealed 關鍵字,利用 ADT 來達到這個目的(對 ADT 不熟悉的同學可以參考一下這篇文章如何在 Scala 中利用 ADT 良好地組織業務),同時對 Robot 類做一些修改:

case class Robot(
                  code: String,
                  name: String,
                  battery: Int
                )

//  抽象型別定義了構建過程的不同步驟
// sealed 關鍵字要求我們要列舉所有的情況,被sealed 宣告的 trait僅能被同一檔案的的類繼承
sealed trait BuildStep
sealed trait HasCodeStep extends BuildStep
sealed trait HasNameStep extends BuildStep複製程式碼

然後我們改變一下 RobotBuilder 類 :

// <: 為型別上界符號,即 PassedStep 必須是 BuildStep 的子類
class RobotBuilder[PassedStep <: BuildStep] private(
                                                     var code: String,
                                                     var name: String,
                                                     var battery: Int
                                                   ) {

  // 按實際需求過載構造器
  protected def this() = this("", "", 0)

  protected def this(pb: RobotBuilder[_]) = this(
    pb.code,
    pb.name,
    pb.battery
  )

  def setCode(code: String): RobotBuilder[HasCodeStep] = {
    this.code = code
    new RobotBuilder[HasCodeStep](this)
  }

  def setName(name: String)(implicit ev: PassedStep =:= HasCodeStep): RobotBuilder[HasNameStep] = {
    this.name = name
    new RobotBuilder[HasNameStep](this)
  }


  def setBattery(battery: Int): RobotBuilder[PassedStep] = {
    this.battery = battery
    this
  }


  // =:= 要求 ev 等於 HasAgeStep 型別
  def build()(implicit ev: PassedStep =:= HasNameStep): Robot = Robot(code, name, battery)
}複製程式碼

這裡將 builder 構造器設為 private 型別,即我們不可再使用 new 來建立 builder 了。並且返回型別變成了 RobotBuilder[PassedStep]

另外我們給需要的方法新增了泛化型別約束,以 build() 函式為例,它限制 HasNameStep 步驟完成後構造器才能成功呼叫。

現在已經實現構造器已經對外不可見了,我們還需要提供一個命令入口。

object RobotBuilder {
  def apply() = new RobotBuilder[BuildStep]()
}複製程式碼

object 在上一篇單例模式中提到過,單獨出現時即單例物件(Singleton Object),當與同名 Class 同時出現時,被稱為 class 的伴生物件(companion object),其中的 apply() 方法用於例項化伴生類。

測試:

val robot = RobotBuilder()
  .setName("tyl")
  .setCode("89757")
  .setBattery(99)
  .build()
System.out.println(s"Robot: $robot")複製程式碼

如果我們少寫了 setNamesetCode 函式,或者顛倒了順序,編譯器都會給出類似下方的錯誤:

Error:(8, 13) Cannot prove that Builder.Scala.typesafe.BuildStep =:= Builder.Scala.typesafe.HasCodeStep.
    .setName("tyl")

    Error:(8, 13) not enough arguments for method setName: (implicit ev: =:=[Builder.Scala.typesafe.BuildStep,Builder.Scala.typesafe.HasCodeStep])Builder.Scala.typesafe.RobotBuilder[Builder.Scala.typesafe.HasNameStep].
    Unspecified value parameter ev.
        .setName("tyl")複製程式碼

因為能夠支援在編譯期間檢查所編寫的程式碼,所以對於需要檢查型別的構造方式來說很可靠。

概括來說,type safe 版有如下的優缺點:

優點:

  • 對於嚴格按照順序(存在相互依賴)的構造場景十分合適
  • 泛化型別約束使得構造時不易出錯

缺點:

  • 對於不需要構造順序的構造場景來說畫蛇添足
  • 引數可變導致副作用
  • 不夠簡潔

Scala 是一門高可擴充套件性語言,同樣也提供了語法幫助我們緩解上述方法的缺點。

require 版

在 Scala 中我們可以使用 require 關鍵字進行函式引數限制,類似 Java 中的 assert

case class Robot(
                  code:  String = "",
                  name: String = "",
                  battery :Int = 0
                  ){
  require(code != "", "不可缺少 code 引數")
  require(name != "", "不可缺少 name 引數")
}複製程式碼

程式碼非常簡潔,並且也滿足我們的需求,這才像函式式風格啊(另外資料驗證我們也可以通過第三方類庫 refined 來實現,感興趣的同學可以看一看refined: simple refinement types for Scala

測試:

try {
  val robot2 = Robot(name = "Bat-Man")
}catch {
  case e :Throwable => e.printStackTrace()
}複製程式碼

如果我們在建立的時候少寫引數,或者任何不符合 require 條件的行為都會導致丟擲異常:

java.lang.IllegalArgumentException: requirement failed: 不可缺少 code 引數
    at scala.Predef$.require(Predef.scala:277)
    at Builder.Scala.require.Robot.<init>(Robot.scala:12)複製程式碼

總結

通過以上的例子我們可以得出 Builder 模式使用的場景大致如下:

  • 當物件具有大量可選引數時。
  • 當建立複雜物件的演算法應該獨立於該物件的組成部分以及它們的裝配方式時。
  • 當構造過程必須允許被構造的物件有不同的表示時。

另外,對比 Scala 和 Java 的實現,因為兩者設計的初衷不同,所以也鑄就了不同的語言特性。對於 Scala 而言,避免副作用是需要優先考慮的,當然 Scala 也有著很多語法糖來幫助開發者實現。
在 Java 和 Scala 中,實現 Buidler 模式的方式都很多,我們可以參考三種場景來選擇恰當的方式實現,最大程度的提高開發效率。

原始碼連結
如有錯誤和講述不恰當的地方還請指出,不勝感激!

相關文章