從 Java 到 Scala(四):Traits
本文由 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
。這是 interface
和 trait
的一點區別。但是再仔細觀察思考這一點區別, 更好更靈活的欄位設計,是否使得 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
。這裡就說到了 interface
和 trait
的第二個區別, 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) } } 複製程式碼
在以上程式碼中我們定義了一個抽象的佇列,有 put
和 get
方法,在類BasicIntQueue中提供了相應的實現方法。同時又定義了三個特質 Doubling
、 Incrementing
、 Filtering
,它們都繼承了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
。
首先,你可以觀察到,以上的兩段程式碼整體相似,卻得到不同的執行結果,原因只是因為特質 Filtering
和 Incrementing
混入的順序不同。我們仔細檢視一下特質中的方法實現,可以發現在特質中都通過 super
關鍵字呼叫了父類的方法。而以上情況的產生原因就在於此。 trait
中的 super
是支援 stackable modifications
的根本關鍵。
在 trait
中的 super
是動態繫結的,並且 super
呼叫的是另一個特質中的方法,具體哪個特質中的方法被呼叫需要取決於特質被混入的順序。對於一般的序列,我們可以採用"從後往前"的順序來推斷 super
的呼叫順序。
就拿以上的程式碼而言。
new BasicIntQueue with Incrementing with Filtering 複製程式碼
程式碼的super的執行順序按照從後往前的規則依次是
Filtering -> Incrementing -> BasicIntQueue 複製程式碼
舉個具體的例子
例如這個時候我執行了 put(1)
的程式碼,那麼按照上面的執行順序,
先執行 Filtering
的 put
方法判斷值是否大於1,發現合法,將值1傳給 Incrementing
中的 put
方法, Incrementing
中的 put
方法將值加1之後傳給 BasicIntQueue
然後將最終的值2放入佇列中。
以上程式碼的執行過程就是 stackable modifications
的核心。因此到這裡,你或許也能理解以上因為混入順序不同而出現的不同結果了吧。
另外,說到動態性,我們在這裡也可以簡單地聊幾句。在Java中, super
的靜態性與 trait
中 super
的動態性形成了鮮明的對比。而動態性所帶來的種種優勢與強大,我們也已經在這一小節的內容中見識了一二。其實動態性抽離出來是一種設計思想,而它也早已在我們的身邊大展拳腳。例如我們熟知的IOC/">IOC依賴注入,AOP面向切面程式設計,以及前端的動態壓縮技術等等,能夠列舉的還有很多,而它們的背後就是動態性的思想,你越是靈活,能夠做的事也就越多。
Traits 探索
Traits構造順序
trait Test { val name:String = "hello" //特質構造器的一部分 println(name);// 特質構造器的一部分 } 複製程式碼
正如你在以上程式碼中所見的,在特質大括號中包裹的執行語句均屬於特質構造器的一部分。
特質構造器的順序如下:(參考自《快學Scala》)
extends
舉個例子
class SavingAccount extends Account with FileLogger with ShortLogger trait ShortLogger extends Logger trait FileLogger extends Logger 複製程式碼
以上構造器將按如下順序執行
-
Account
(超類) -
Logger
(第一個特質的父特質) -
FileLogger
(第一個特質) -
ShortLogger
(從左往右第二個特質,它的父特質Logger
已經被構造,不再重複構造) -
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
稍微高階一點的用法,自身型別和結構型別。
