Scala 與設計模式(一):Singleton 單例模式

ScalaCool發表於2019-02-26

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

二十年前,軟體設計領域的四位大師( GoF ,”四人幫”,又稱 Gang of Four,即Erich Gamma, Richard Helm, Ralph Johnson & John Vlissides)通過論著《設計模式:可複用物件導向軟體的基礎》闡述了設計模式領域的開創性成果。設計模式(Design Pattern)是一套被反覆使用、多數人知曉的、經過分類的、程式碼設計經驗的總結。

在 2017 年的今天,雖然一些傳統的設計模式仍然適用,但部分設計已經發生改變,甚至被全新的語言特徵所取代。
本系列文章首先會介紹傳統的設計模式在 Java 與 Scala 中的實現,之後會介紹 Scala 可以實現的 “新” 的設計模式。

本文將會簡單介紹單例模式在 Java 中的實現方式,以及如何將單例模式應用在 Scala 中,通過比較來闡述單例模式。

概念

單例模式最初的定義出現於《設計模式》(艾迪生維斯理, 1994):

保證一個類僅有一個例項,並提供一個訪問它的全域性訪問點。

通俗一點單例類就是:全域性可以訪問的唯一例項。

Why Singleton

什麼時候需要使用單例模式呢? 如果某個類建立時需要消耗很多資源,即建立出這個類的代價很大時我們就需要使用單例模式。通俗的講,我們可以將單例物件比作地球,因為很難建立出第二顆這樣的星球,這時我們就需要共用地球。

在編寫程式的時候,很多操作都會佔用大量的資源,如:日誌類、配置類、共享資源類等等,我們倡導節能減排,高效利用資源。所以,對於這些操作我們需要一個全域性的可訪問介面的實現(也可能是懶載入)。

但是我們如何才能保證一個類只有一個例項並且這個例項易於被訪問呢?一個全域性變數使得一個物件可以被訪問,但是它並不可以防止我們例項化多個物件。一個更有效的方法是,讓類自身負責儲存它的唯一例項。這個類可以保證沒有其他例項可以被建立(通過擷取建立新物件的請求),並且它可以提供一個訪問該例項的方法。這就是單例模式。

Java 實現

單例模式應該是 Java 中最出名的設計模式,雖然 Java 語言中包含了靜態關鍵詞( static ),但是靜態成員與任何物件都不存在直接聯絡,並且靜態成員類不能實現介面。因此,靜態方法的概念違背了 OOP 的前提:所有東西都是物件。

一般來說在 Java 中單例模式有兩種形式:餓漢模式(eager)懶漢模式(lazy)

餓漢 —— 基礎版

對於一個初學者來說,寫出的第一個單例類應該是類似下面這個樣子的:

public class HungrySingleton {
    //類載入時就初始化
    private static HungrySingleton instance = new HungrySingleton();

    private HungrySingleton() {
        System.out.println(("HungrySingleton is created"));
    }

    public static void run() {
        System.out.println(("HungrySingleton is running"));
    }

    public static HungrySingleton getInstance() {
        return instance;
    }

    public static void main(String[] args) {
        HungrySingleton.run();
    }
}複製程式碼

以上程式碼中有了全域性訪問點,同時單例類也是靜態的,結構也比較清晰。

執行測試程式碼後控制檯輸出:

HungrySingleton is created
HungrySingleton is running複製程式碼

從以上結果我們可以發現這種模式有一個缺點: 不是惰性初始化(lazy initialization),即單例會在 JVM 載入單例類後一開始就被初始化,如果此時單例類在系統中還扮演其他角色,不管是否用到都會初始化這個單例變數。因為這種寫法下單例會被立即初始化,所以我們稱這種單例為 餓漢 (eager)

懶漢 —— 基礎版

為了解決上述的問題,我們就需要引入延遲載入。比較容易想到的做法是:在獲取例項的時候判斷例項是否存在,不存在則建立。程式碼如下:

public class LazySingletonOne {
    private static LazySingletonOne instance;

    private LazySingletonOne() {
        System.out.println(("LazySingletonOne is created"));
    }

    public static void run() {
        System.out.println(("LazySingletonOne is running"));
    }

    public static LazySingletonOne getInstance() {
        if (instance == null) {
            instance = new LazySingletonOne();
        }
        return instance;
    }

    public static void main(String[] args) {
        LazySingletonOne.run();
    }
}複製程式碼

執行後控制檯輸出:

LazySingletonOne is running複製程式碼

可見這種形式在這樣的環境下確實已經能滿足我們的需求。但是在多執行緒環境下,缺點就非常明顯:會出現建立出多個例項的情況(由於篇幅限制,測試程式碼見文末原始碼)。這時候通常的做法是在方法上加一個同步鎖
synchronized),但是僅僅這樣就夠了嗎?

懶漢 —— 雙重檢查鎖版(Double-checked locking)

getInstance 整個方法外加同步鎖(synchronized),每次訪會還是會造成很大的效能開銷。我們就只能在方法的臨界區做一些文章,Double-checked locking 應聲而至。
我們先看程式碼:

...
private static LazySingletonTwo instance;
...
public static LazySingletonTwo getInstance() {
      if (instance == null) {                         // 第一次檢查
          synchronized (LazySingletonTwo.class) {
              if (instance == null) {                 // 第二次檢查
                  instance = new LazySingletonTwo();
              }
          }
      }
      return instance ;
  }複製程式碼

為了避開多次同步鎖的開銷,我們先判斷單例實體是否存在再進行同步鎖操作。這樣雖然已經能應對大部分的問題,但是依然存在一個問題:其他執行緒可能會 read 初始化到一半的 instance。只有將 instance 設定為 volatile ,才能保證每次的 write 操作優先於 read 操作,即能確保每次引用到都是最新狀態。瞭解更多
只用將程式碼稍加改動:

// private static LazySingletonTwo instance;           ---- old

   private volatile static LazySingletonTwo instance;  ---- new複製程式碼

注意 :我們至少要建立一個 private 構造器,否則編譯器預設將為我們生成一個 friendly 的構造器,而非 private;其次,instance 成員變數和 getInstance() 方法必須是 static 的;如果單例類實現了 java.io.Serializable 介面,就可能被反序列化,從而產生新的例項。

靜態內部類版

當然,我們還能通過靜態內部類來實現:

final class StaticNestedSingleton {
    // 宣告為 final 能防止其在派生類中被 clone
    private StaticNestedSingleton(){
       System.out.println(("StaticNestedSingleton is created"));
   }

    public static StaticNestedSingleton getInstance()
    {
        return NestedClass.instance;
    }

    //在第一次被引用時被載入
    static class NestedClass
    {
        private static StaticNestedSingleton instance = new StaticNestedSingleton();
    }
}複製程式碼

上面的程式碼中,我們將單例類設定為 final 型別,這樣能夠禁止克隆的發生。同樣靜態內部類只有在第一次被引用時才載入,即隨著類的載入而產生(而不是隨著物件的產生)。

列舉類模式

上面的幾種是我們比較常見的單例類形式,可能有的同學會抱怨道:有沒有簡短易懂一點的?
當然我們還可以使用 Java 中的列舉類實現單例:

public enum  EnumSingleton {
    INSTANCE;
    private final String[] preference =
            { "intresting","nice","just so so" };

    public void printPreference() {
        System.out.println(Arrays.toString(preference));
    }
}複製程式碼

這是 《Effictive Java》 中所推薦的單例模式在 Java 中的最佳實現方式,同時也是 Stack Overflow 中 what-is-an-efficient-way-to-implement-a-singleton-pattern-in-java 最高票回答。

注意:Enumenum 是不同的。後者只是 Java 1.5 後增加的一個語法糖,不是新的型別。 我們可以反編譯 EnumSingleton.class 檢視一下內部程式碼:

$ javap EnumSingleton.class複製程式碼

編譯後:

public final class Singleton.Java.EnumSingleton extends java.lang.Enum<Singleton.Java.EnumSingleton>{
public static final Singleton.Java.EnumSingleton INSTANCE;
public static Singleton.Java.EnumSingleton[]values();
public static Singleton.Java.EnumSingleton valueOf(java.lang.String);
public void printPreference();
static {};
    }複製程式碼

簡單總結一下,選用 enum 原因如下:

  • enum 防止反序列化重新建立新的物件。
  • 類的修飾 abstract,所以沒法例項化,反射也無能為力。
  • 關於執行緒安全的保證,其實是通過類載入機制來保證的,我們看看 INSTANCE 的例項化時機,是在 static 塊中,JVM載入類的過程顯然是執行緒安全的。

Scala 實現

在 Scala 中並沒有 static 關鍵字,你不用糾結太多,我們用 object 便能實現單例,再也不用為你的選擇困難症煩惱!

object

object 在 Scala 中被稱作 「單例物件」 (Singleton Objects)。

object 關鍵字建立一個新的單例型別,就像一個 class 只有一個被命名的例項。如果你熟悉 Java, 在 Scala 中宣告一個 object 有些像建立了一個匿名類的例項。 ——引自 《Scala 函數語言程式設計》

舉個例子:

object Singleton2Scala {
  def sum(l: List[Int]): Int = l.sum
}複製程式碼

測試:

object Test {
  def main(args: Array[String]): Unit = {
    Singleton2Scala.sum(List)
  }
}複製程式碼

看起來是不是比 Java 優雅多了!

你問有沒有多執行緒問題?是否是惰性初始化?這些都不用你來處理。

Scala 被編譯後生成 `Singleton2Scala$.class`Singleton2Scala.class,我們可以對其進行反編譯:

$ javap  `Singleton2Scala$.class`
Compiled from "Singleton2Scala.scala"

public final class Singleton.Scala.Singleton2Scala$ {
  public static Singleton.Scala.Singleton2Scala$ MODULE$;
  public static {};
  public int sum(scala.collection.immutable.List<java.lang.Object>);
}


$ javap  Singleton2Scala.class
Compiled from "Singleton2Scala.scala"

public final class Singleton.Scala.Singleton2Scala {
  public static int sum(scala.collection.immutable.List<java.lang.Object>);
}複製程式碼

從上方程式碼我們能看到,所有的方法前都帶上了 static 關鍵字。

在實際專案開發的時候,我們還可以繼承其他 class(類) 與 trait(特質)。舉個例子:

object AppRegistry extends xxClass with xxtrait{
  println("Registry initialization block called.")
  private lazy val lazyXX  = ???
  private val users: scala.collection.mutable.HashMap[String, String] =  scala.collection.mutable.HashMap.empty

  def addUser(id: String, name: String): Unit = { users.put(id, name) }
  def removeUser(id: String): Unit = { users.remove(id) }
  def isUserRegistered(id: String): Boolean = users.contains(id)
  def getAllUserNames(): List[String] = users.map(_._2).toList
}複製程式碼

優點

  • static class 更易於理解
  • 語法簡潔
  • 按需初始化(lazy initialization)
  • 執行緒安全(Scala 中不用考慮 double-checked locking)

缺點

  • 缺乏對初始化行為的控制

總結

以下是對 Scala 和 Java 是實現單例模式的一個簡單比較:

場景 Java Scala
單執行緒 static object
多執行緒 synchronized + volatile object
延遲載入 enumdouble-checked locking object + lazy(引數延遲載入)

人們對單例模式的看法褒貶不一,甚至被稱為是 anti-pattern (反面模式)。如果你是一名 Java 開發者,可能 Spring 框架中 Dependency Injection 是你的 better choice 。但是單例模式你不能否認的是單例模式在 Android SDK 中得到了廣泛的應用。在 Scala 中,伴生物件出現的頻率更是非常之高。當你面對的業務場景需要用到單例模式的時候,請務必注意 多執行緒效能開銷 的問題。

原始碼連結
如有說錯的地方還請指出,不勝感激。

相關文章