從 Java 到 Scala(四):Traits

ScalaCool發表於2018-11-28

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

Traits特質,一個我們既熟悉又陌生的特性。熟悉是因為你會發現它和你平時在Java中使用的interface介面有著很大的相似之處,而陌生又是因為Traits的新玩法會讓你打破對原有介面的認知,進入一個更具有挑戰性,玩法更高階的領域。所以,在一開始,我們可以對Traits有一個初步的認識:它是一個加強版的interface。之後,隨著你對它瞭解的深入,你就會發現相比Java介面,Traits跟類更為相似。再之後,你或許會覺察到,Traits在嘗試著將抽象更好地融為一個整體。

Traits 入門

在Java中為了避免多重繼承所帶來的昂貴代價(方法或欄位衝突、菱形繼承等問題),Java的設計者們使用了interface介面。而為了解決Java介面無法進行stackable modifications(即無法使用物件狀態進行迭代)、無法提供欄位等侷限,在Scala中,我們使用Traits特質而非介面。

定義一個trait

trait Animal {
  val typeOf: String = "哺乳動物" //  帶有預設值的欄位

  def move(): Unit = {  // 帶有預設實現的方法
    println("walk")
  }

  def eat() //未實現的抽象方法
}
複製程式碼

以上程式碼類似於以下的Java程式碼

public interface Animal {
    String typeOf = "哺乳動物";

    default void move() {
        System.out.println("walk");
    }

    void eat();
}
複製程式碼

在Scala中使用關鍵字trait而不interface,和Java介面一樣,trait也可以有預設方法的實現。也就是說Java介面有的,trait基本上也都有,而且實現起來要優雅許多。 之所以要說類似於以上的Java程式碼,原因在於trait擁有的是欄位typeOf,而interface擁有的是靜態屬性typeOf。這是interfacetrait的一點區別。但是再仔細觀察思考這一點區別,更好更靈活的欄位設計,是否使得trait更好地組織了抽象,使得它們成為了一個更好的整體。

mix in trait

和Java一樣,Scala只支援單繼承,但卻可以有任意數量的特質。在Scala中,我們不稱介面被implements實現了,而是traits被mix in混入了類中。

class Bird extends Animal {
  override val typeOf: String = "蛋生動物"

  override def eat(): Unit = {
    println("eat bugs")
  }

  override def move(): Unit = {
    println("fly")
  }
}
複製程式碼

以上程式碼中,Bird類混入了特質Animal。當類混入了多個特質時,需要使用with關鍵字

trait Egg

class Bird extends Animal with Egg{
  override val typeOf: String = "蛋生動物"

  override def eat(): Unit = {
    println("eat bugs")
  }

  override def move(): Unit = {
    println("fly")
  }
}
複製程式碼

在Scala中,我們將extends with的這種語法解讀為一個整體,例如在以上程式碼中,我們將extends Animal with Egg看做一個整體,然後被Bird類混入。從這裡你是否也能夠感受到 trait在嘗試著將抽象更好地融為一個整體。

到這裡,你或許能夠發現,相比Java interface,trait和類更加相似。而事實也確實如此,trait可以具備類的所有特性,除了缺少構造器引數。這一點trait可以使用構造器欄位來達到同樣的效果。也就是說你不能想給類傳入構造器引數那樣給特質傳入引數。具體程式碼這裡就不再演示。

其實在這裡我們可以簡單地思考一番,為什麼要把trait設計得這麼像一個class,是設計者們有意為之,還是無意間的巧合。其實,不管怎麼樣,個人認為,但從設計層面來講,class類的設計就比trait更加具備一致性,class產生的物件就可以被很好的管理,為什麼我們不像管理物件一樣來管理我們的抽象呢?

Traits的兩大基本應用

Traits最常見的兩種使用方式:一種是和Java介面類似,用於設計富介面,另一種是Traits獨有的stackable modifications。這裡就說到了interfacetrait的第二個區別,Traits支援stackable modificatio,使它能夠使用物件狀態,可以對物件狀態進行靈活地迭代。

rich interface

富介面的應用要歸功於interface中對預設方法這一特性的支援,一方面鬆綁了類和介面之間實現與被實現之間的強關係,另一方面為程式的可擴充套件性代入了很大的靈活性。trait在這一方面的應用和Java的沒有很大的區別。而trait中的預設方法的實現背後採用的也是interface中的default預設方法。

trait Hello {
  def hello(): Unit = {println("hello")
  }
}
複製程式碼
interface Hello2 {
    default void hello() {...}
}
複製程式碼

stackable modifications

關於stackable modifications,顧名思義,我們將modification儲存在了一個stack棧中。也就是說我們可以對運算的結果進行不斷的迭代處理,已達到我們想要的結果。這對於想要分佈處理並得到某一結果的需求來說是非常有用的。

這裡我們借用一下programming in scala中的例子

abstract class IntQueue {
  def get(): Int

  def put(x: Int)
}

import scala.collection.mutable.ArrayBuffer

class BasicIntQueue extends IntQueue {
  private val buf = new ArrayBuffer[Int]

  def get() = buf.remove(0)

  def put(x: Int) {
    buf += x
  }
}

trait Doubling extends IntQueue {
  abstract override def put(x: Int) {
    super.put(2 * x)
  }
}

trait Incrementing extends IntQueue {
  abstract override def put(x: Int) {
    super.put(x + 1)
  }
}

trait Filtering extends IntQueue {
  abstract override def put(x: Int) {
    if (x >= 0) super.put(x)
  }
}

複製程式碼

在以上程式碼中我們定義了一個抽象的佇列,有putget方法,在類BasicIntQueue中提供了相應的實現方法。同時又定義了三個特質DoublingIncrementingFiltering,它們都繼承了IntQueue抽象類(還記得之前講過的,trait可以具備類的所有特性),並重寫了其中的方法。Doubling將處理結果*2,Incrementing特質將處理結果做了+1處理,Filtering將過濾掉<0的值。

我們在來看以下的執行結果

scala> val queue = (new BasicIntQueue with Incrementing with Filtering)
queue: BasicIntQueue with Incrementing with Filtering...
scala> queue.put(-1); queue.put(0); queue.put(1)
scala> queue.get()
res15: Int = 1
scala> queue.get()
res16: Int = 2
複製程式碼
scala> val queue = (new BasicIntQueue with Filtering with Incrementing)
queue: BasicIntQueue with Filtering with Incrementing...
scala> queue.put(-1); queue.put(0); queue.put(1)
scala> queue.get()
res17: Int = 0
scala> queue.get()
res18: Int = 1
scala> queue.get()
res19: Int = 2
複製程式碼

仔細觀察以上的程式碼,瞭解了上面的程式碼,你基本也就瞭解了stackable modifications

首先,你可以觀察到,以上的兩段程式碼整體相似,卻得到不同的執行結果,原因只是因為特質FilteringIncrementing混入的順序不同。我們仔細檢視一下特質中的方法實現,可以發現在特質中都通過super關鍵字呼叫了父類的方法。而以上情況的產生原因就在於此。trait中的super是支援stackable modifications的根本關鍵。

trait中的super是動態繫結的,並且super呼叫的是另一個特質中的方法,具體哪個特質中的方法被呼叫需要取決於特質被混入的順序。對於一般的序列,我們可以採用"從後往前"的順序來推斷super的呼叫順序。

就拿以上的程式碼而言。

new BasicIntQueue with Incrementing with Filtering
複製程式碼

程式碼的super的執行順序按照從後往前的規則依次是

Filtering -> Incrementing -> BasicIntQueue 
複製程式碼

舉個具體的例子

例如這個時候我執行了put(1)的程式碼,那麼按照上面的執行順序,

先執行Filteringput方法判斷值是否大於1,發現合法,將值1傳給Incrementing中的put方法,Incrementing中的put方法將值加1之後傳給BasicIntQueue然後將最終的值2放入佇列中。

以上程式碼的執行過程就是stackable modifications的核心。因此到這裡,你或許也能理解以上因為混入順序不同而出現的不同結果了吧。

另外,說到動態性,我們在這裡也可以簡單地聊幾句。在Java中,super的靜態性與traitsuper的動態性形成了鮮明的對比。而動態性所帶來的種種優勢與強大,我們也已經在這一小節的內容中見識了一二。其實動態性抽離出來是一種設計思想,而它也早已在我們的身邊大展拳腳。例如我們熟知的IOC依賴注入,AOP面向切面程式設計,以及前端的動態壓縮技術等等,能夠列舉的還有很多,而它們的背後就是動態性的思想,你越是靈活,能夠做的事也就越多。

Traits 探索

Traits構造順序

trait Test {
    val name:String = "hello" //特質構造器的一部分
    println(name);  // 特質構造器的一部分
}
複製程式碼

正如你在以上程式碼中所見的,在特質大括號中包裹的執行語句均屬於特質構造器的一部分。

特質構造器的順序如下:(參考自《快學Scala》)

  1. 首先執行超類的構造器(也就是跟在extends之後的類)
  2. 特質構造器在超類構造器之後、類構造器之前執行
  3. 特質由左到右構造
  4. 父特質先構造
  5. 類構造器

舉個例子

class SavingAccount extends Account with FileLogger with ShortLogger

trait ShortLogger extends Logger

trait FileLogger extends Logger
複製程式碼

以上構造器將按如下順序執行

  1. Account(超類)
  2. Logger(第一個特質的父特質)
  3. FileLogger(第一個特質)
  4. ShortLogger(從左往右第二個特質,它的父特質Logger已經被構造,不再重複構造)
  5. SavingAccount(類構造器)

線性化

其實以上構造器順序實現的背後使用的是一種叫"線性化"的技術。

拿以上的程式碼作為例子

class SavingAccount extends Account with FileLogger with ShortLogger
複製程式碼

以上的程式碼將被線性化解析為

>>的意思是右側將先被構造

lin(SavingsAccount) = SavingsAccount >> lin(ShortLogger) >> lin(FileLogger) >> lin(Account)

= SavingsAccount >> (ShortLogger >> Logger) >> (FileLogger >> Logger) >> Account

= SavingsAccount >> ShortLogger >> FileLogger >> Logger >> Account
複製程式碼

仔細觀察以下線性化的結果,你會發現,以上的順序就是構造器執行的順序。同時,線性化也給出了super的執行順序,舉例來說,在ShortLogger中呼叫super將呼叫右側的FileLogger中的方法,而FileLogger中的super將呼叫右側Logger中的方法,依次類推。

特質欄位初始化

因此由於特質構造器的執行時間要早於類構造器的執行,因此在初始化特質中的欄位時要額外注意欄位的執行時間,避免出現空指標的情況。例如以下程式碼就會出現錯誤

trait Hello {
  val name:String
  val out = new PrintStream(name)
}

val test = new Test with Hello {
    val name = "Rhyme" // Error 類構造器晚於特質構造器
}
複製程式碼

解決方法有提前定義或者懶值

採用提前定義的程式碼如下所示

val test = new { 
    val name = "Rhyme" //先於所有的構造器執行
}Test with Hello 
複製程式碼

採用提前定義的方式使得程式碼不太雅觀,我們還可以使用懶值的方式

採用懶值的方式如下

trait Hello {
  val name:String
  lazy val out = new PrintStream(name) // 使用懶值,延遲name的初始化
}
複製程式碼

懶值在每次使用前都回去檢查欄位是否已經初始化,存在一定的使用開銷。使用前需要仔細考慮

由於篇幅限制,關於trait的探索,我們就到此為止。希望本文能夠對你學習和了解trait提供一點幫助。在下一章我們將介紹trait稍微高階一點的用法,自身型別和結構型別。

從 Java 到 Scala(四):Traits

相關文章