基於 Scala Trait 的設計模式

張_逸發表於2017-04-24

在《作為Scala語法糖的設計模式》博文中,我重點介紹了那些已經融入Scala語法的設計模式。今天要介紹的兩個模式,則主要與Scala的trait有關。

Decorator Pattern

在GoF 23種設計模式中,Decorator Pattern算是一個比較特殊的模式。它充分利用了繼承和組合(或者委派)各自的優勢,將它們混合起來,不僅讓優勢擴大,還讓各自的缺點得到了抵消。Decorator模式的核心思想其實是“職責分離”,即將要裝飾的職責與裝飾的職責分離,從而使得它們可以在各自的繼承體系下獨立演化,然後通過傳遞物件(組合)的形式完成對被裝飾職責的重用

從某種角度來講,裝飾職責與被裝飾職責之間的分離與各自抽象,不妨可以看做是Bridge模式的變種。但不同之處在於Decorator模式又額外地引入了繼承,但不是為了重用,而是為了多型,使得裝飾者因為繼承自被裝飾者,從而擁有了被裝飾的能力。所以說,繼承的引入真真算得上是點睛之筆了。

理解Decorator模式,一定要理解繼承與組合各自扮演的角色。簡而言之,就是:

  • 繼承:裝飾者的多型
  • 組合:被裝飾者的重用

正因為此,在Java程式碼中實現Decorator模式,要注意裝飾器類在重寫被裝飾器的業務行為時,一定要通過傳入的物件來呼叫被裝飾者的行為。假設傳入的被裝飾者物件為decoratee,則呼叫時就一定是decoratee,而不是super(由於繼承的關係,裝飾類是可以訪問super的)。

例如BufferedOutputStream類作為裝飾類,要裝飾OutputStream的write行為,就必須這樣實現:

public interface OutputStream {
    void write(byte b);
    void write(byte[] b);
}
public class FileOutputStream implements OutputStream { /* ... */ }
public class BufferedOutputStream extends OutputStream {
    //這裡是組合的被裝飾者    
    protected final OutputStream decoratee;
    public BufferedOutputStream(OutputStream decoratee) {
        this.decoratee = decoratee;
    }

    public void write(byte b) {
        //這裡應該是呼叫decoratee, 而非super,雖然你可以訪問super    
        decoratee.write(buffer)
    }
}複製程式碼

然而,在Scala中實現Decorator模式,情況卻有些不同了。Scala的trait既體現了Java Interface的語義,卻又可以提供實現邏輯(相當於Java 8的default interface),並在編譯時採用mixin方式完成程式碼的重用。換言之,trait已經完美地融合了繼承與組合的各自優勢。因此,在Scala中若要實現Decorator模式,只需要定義trait去實現裝飾者的功能即可:

trait OutputStream {
  def write(b: Byte)
  def write(b: Array[Byte])
}
class FileOutputStream(path: String) extends OutputStream { /* ... */ }
trait Buffering extends OutputStream {
  abstract override def write(b: Byte) {
    // ...
    super.write(buffer)
  }
}複製程式碼

在Buffering的定義中,根本看不到組合的影子,且在對write方法進行重寫時,呼叫的是super,這與我前面講到的內容背道而馳啊!

區別在於組合(delegation)的時機。在Java(原諒我,因為使用Scala的緣故,我對Java 8的default interface沒有研究,不知道是否與scala的trait完全相同)語言中,組合是通過傳遞物件方式完成的職責委派與重用,也就是說,組合是在執行時發生的。Scala的實現則不然,在trait中利用abstract override關鍵字來完成一種stackable modifications,這種方式被稱之為Stackable Trait Pattern。這種語法僅能用於trait,它表示trait會將某個具體類針對該方法提供的實現混入(mixin)到trait中。裝飾的客戶端程式碼如下:

new FileOutputStream("foo.txt") with Buffering複製程式碼

FileOutputStream的write方法實現在編譯時就被混入到Buffering中。所以可以稱這種組合為靜態組合。

Dependency Injection

Dependency Injection(依賴注入或者稱為IoC,即控制反轉)其實應該與依賴倒置原則結合起來理解,首先應該保證不依賴於實現細節,而是依賴於抽象(介面),然後,再考慮將具體依賴從類的內部轉移到外面,並在執行時將依賴注入到類的內部。這也是Dependency Injection的得名由來。

在Java世界,多數情況下我們會引入框架如Spring、Guice來完成依賴注入(這並不是說依賴注入一定需要框架,嚴格意義上,只要將依賴轉移到外面,然後通過set或者構造器注入依賴,都可以認為是實現了依賴注入),無論是基於xml配置,還是annotation,或者Groovy,核心思想都是將物件之間的依賴設定(裝配)轉交給框架來完成。Scala也有類似的IoC框架。但是,多數情況下,Scala程式設計師會充分利用trait與self type來實現所謂的依賴注入。這種設計模式在Scala中常常被暱稱為Cake Pattern

一個典型的案例就是將一個Repository的實現注入到Service中。在Scala中,就應該將Repository的抽象定義為trait,然後在具體的Service實現中,通過Self Type引入Repository:

trait Repository {
  def save(user: User)
}
trait DatabaseRepository extends Repository { /* ... */ }
trait UserService { 
  self: Repository => 
  def create(user: User) {
    //這裡呼叫的是Repository的save方法
    //呼叫Self Type的方法就像呼叫自己的方法一般
    save(user)
  }
}

//這裡的with完成了對DatabaseRepository依賴的注入
new UserService with DatabaseRepository複製程式碼

Cake Pattern遵循了Dependency Inject的要求,只是它沒有像Spring或者Guice那樣徹底將注入依賴的職責轉移給外部框架,而是將注入的權利交到了呼叫者手裡。這樣會導致呼叫端程式碼並沒有完全與具體依賴解耦,但在大多數情況下,這種輕量級的依賴注入方式,反而更討人喜歡。

在Scala開發中,我們常常會使用Cake Pattern。在我的一篇部落格《一次設計演進之旅》中,就引入了Cake Pattern來完成將ReportMetadata依賴的注入。

說明:文中示例程式碼主要來自Pavel Fatin的部落格Design Patterns in Scala

相關文章