Scala 與設計模式(三):Prototype 原型模式

ScalaCool發表於2017-07-31

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

第一個生物是怎麼誕生的? 從科學角度推測:是由第一個細胞從核糖核酸(RNA)不斷的新陳代謝演變而來的。

第一個細胞其實是非常孤獨的,但幸好它掌握了「分裂」與「分化」的本領,一定條件下可以一分為二,由此才能快速演變,出現現在的人類。

在開發過程中,我們也常有類似的場景,本文將以細胞分裂為例來介紹原型模式。

定義

「四人幫」設計模式中提及的 原型模式 定義如下:

用原型例項指向建立物件的種類,並且通過拷貝這些原型建立新的物件。

從定義中我們可以知道,原型模式中核心點就是 原型類拷貝

看到拷貝,有些同學腦中可能會浮現下面這張圖:

Scala 與設計模式(三):Prototype 原型模式

可事實並沒有這麼簡單。

Java 實現

回到開頭的例子,假設細胞沒有分裂能力,每個細胞產生的過程和時間是一樣的,這無疑是費時的。

這也是「原型模式」第一個要解決的問題 — 通過拷貝加速效率

在 Java 中所有的 class 都繼承自 java.lang.Object 類,Object 提供了一個 clone() 方法,通過它,就能實現物件的拷貝。

淺拷貝

我們利用 Cloneable 介面,來實現細胞的克隆:

public class Cell implements Cloneable {
    private String dna;
    private Organelle organelle; // 細胞器

    ... // 省略 get set 與 建構函式

    @Override
    public String toString() {
        return "Cell: {" +
                "DNA = " + dna + '\'' +
                "Organelle = " + organelle.toString() +
                '}';
    }

    @Override
    public Cell clone() {
        Cell cellCopy = null;
        try {
            cellCopy = (Cell) super.clone();
        } catch (CloneNotSupportedException e) {
            e.printStackTrace();
        }
        return cellCopy;
    }
}

public class Organelle {
    private String cytoplasm; // 細胞質
    private String nucleus; // 細胞核

    ...// 省略get、set、toString() 與建構函式
}複製程式碼

以上我們便能呼叫 clone() 方法對複雜物件進行拷貝,以此來實現分裂的功能。

測試:

Cell cellA, cellB;

cellA = new Cell("AAAGTCTGAC", new Organelle("細胞質", "細胞核"));
System.out.println(cellA);

cellB = cellA.clone();
System.out.println(cellB);

System.out.println("cellA == cellB ? " + (cellA == cellB));
System.out.println("cellA-class == cellB-class? :" + (cellA.getClass() == cellB.getClass()));複製程式碼

看起來不錯!但問題出現了:這裡的 clone 只能拷貝到細胞本身資訊,但不拷貝細胞的引用,不同細胞中包含的細胞器是一樣的。

這其實是「淺拷貝」和「深拷貝」的問題。看看它們的區別:

  • 淺拷貝
    僅僅複製原有物件的值,而不復制它對其他物件的引用。

  • 深拷貝
    原有物件的值和引用都被複制。

驗證:

System.out.println("cellA.Organelle == cellB.Organelle ? " + (cellA.getOrganelle() == cellB.getOrganelle()));複製程式碼

輸出:

cellA.Organelle == cellB.Organelle ? true複製程式碼

可見,當前 clone() 方法執行的是淺拷貝,Java 中所有的物件都儲存在全域性共享的堆中。

只要能拿到某個物件的引用,引用者就可以隨意修改物件,這顯然是不好的。

接下來我為大家介紹一下深拷貝如何實現。

深拷貝

說到深拷貝,一般有兩種實現方案:

1. 改變 clone 方法

既然問題出在細胞器(Organelle)的引用沒有被複制,為其手動新增上即可。

首先修改引用類,使其支援 clone

public class Organelle implements Cloneable { 
  ... // 省略相同程式碼

  @Override
   protected Object clone() throws CloneNotSupportedException {
       Object object = null;
       try {
           object = super.clone();
       } catch (CloneNotSupportedException e) {
           e.printStackTrace();
       }
       return object;
   }複製程式碼

其次在 Cell 類的 clone() 方法中複製細胞器的引用:

    @Override
    public Cell clone() throws CloneNotSupportedException {
        Cell cellCopy = null;

        try {
            cellCopy = (Cell) super.clone();
        } catch (CloneNotSupportedException e) {
            e.printStackTrace();
        }

        if (cellCopy != null) {
            cellCopy.organelle = (Organelle) organelle.clone();
        }
        return cellCopy;
    }複製程式碼

測試結果:

cellA.organelle == cellB.organelle ? false複製程式碼

雖然功能是實現了,但是每個引用物件都要重寫 clone(),太糟糕了!

2. 序列化物件

序列化是一個將物件寫到流的過程,寫到流中的物件是原有物件的一個拷貝,而原物件仍然存在於記憶體中。

Cloneable 實現類似,需要序列化的類要求實現序列化介面。

public class Organelle implements Serializable { ... }
public class Cell implements Serializable {
  ... // 省略部分程式碼

  // 序列化實現深拷貝
  public Cell deepClone() throws CloneNotSupportedException, IOException, ClassNotFoundException {
    // 序列化(將物件寫入流中)
    ByteArrayOutputStream bos=new  ByteArrayOutputStream();
    ObjectOutputStream oos=new  ObjectOutputStream(bos);
    oos.writeObject(this);

    // 反序列化(將物件從流中取出)
    ByteArrayInputStream bis=new  ByteArrayInputStream(bos.toByteArray());
    ObjectInputStream ois=new  ObjectInputStream(bis);
    return  (Cell)ois.readObject();
  }

}複製程式碼

注意:Cloneable 與 Serializable 介面都是 「marker Interface」,即它們只是標識介面,沒有定義任何方法。

對比而言,序列化的實現方式不需要重寫多個類的 clone() 方法,比第一種更加簡便。

接下去看看 Scala 中如何實現原型模式。

Scala 實現

在 Scala 中,你用類似 Java 的方式來實現(Scala 提供了呼叫 Java 中 CloneableSerializable 的特質)

trait Cloneable extends java.lang.Cloneable

trait Serializable extends Any with java.io.Serializable複製程式碼

當然,Scala 中每個 case class 都擁有一個 copy() 方法,它會返回拷貝自原有例項的新例項,並且可以在拷貝的過程中改變一些值。

同樣以細胞為例:

case class Cell(dna: String, organelle: Organelle)

case class Organelle(cytoplasm: String, nucleus: String)複製程式碼

測試一下:

val initialCell = Cell("AAAGTCTGAC", Organelle("細胞質", "細胞核"))
val cell1 = initialCell.copy()
val cell2 = initialCell.copy()
val cell3 = initialCell.copy(dna = "1234") // 可以在拷貝的時候重新賦值
System.out.println(s"cell1: ${cell1}")
System.out.println(s"cell2: ${cell2}")
System.out.println(s"cell3: ${cell3}")
System.out.println(s"cell1 and cell2 are equal: ${cell1 == cell2}")

// 輸出
Cell 1: Cell(AAAGTCTGAC,Organelle(細胞質,細胞核))
Cell 2: Cell(AAAGTCTGAC,Organelle(細胞質,細胞核))
Cell 3: Cell(1234,Organelle(細胞質,細胞核))
cell1 and cell2 are equal: true複製程式碼

對比 Scala 和 Java 的實現程式碼,有沒有發現 Scala 是如此的簡潔。

誒? 為什麼 cell1cell1 相等? 這會不會導致上面淺拷貝的問題呢?不存在的。

由於 case class 引數預設為 val,兩個 case class 物件持有相同引用,但也不允許修改

總結

通過以上內容,我們對原型模式已有一些瞭解,一般來說原型模式中參與者有以下三類:

  • 抽象原型類:宣告克隆方法的介面,是所有具體原型類的公共父類,可以是抽象類、介面、甚至具體實現類(對應上面的 CloneableSerializable 介面)。
  • 具體原型類:實現抽象原型類宣告的克隆方法,返回自己的一個克隆物件(Cell.class | Cell.class)。
  • 客戶類:建立物件並克隆(Test.class)。

以下為 Java 與 Scala 中的實現方式對比:

拷貝方式 Java Scala
淺拷貝 具體原型類實現 Cloneable 具體原型類實現 Cloneable 或 具體原型類為 case class
深拷貝 具體原型類 + 引用類實現 CloneableSerializable 具體原型類 + 引用類實現 CloneableSerializable

當然原型模式通常還可以解決以下問題:

  • 建立新物件成本較大(如初始化需要佔用較長的時間,佔用太多的 CPU 資源或網路資源),新的物件可以通過原型模式對已有物件進行復制來獲得,如果是相似物件,則可以對其成員變數稍作修改。
  • 如果系統要儲存物件的狀態,而物件的狀態變化很小,或者物件本身佔用記憶體較少時,可以使用原型模式配合備忘錄模式來實現。

原始碼連結

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

相關文章