Scala 特質全面解析


要點如下:

  1. Scala中類只能繼承一個超類, 可以擴展任意數量的特質
  2. 特質可以要求實現它們的類具備特定的字段, 方法和超類
  3. 與Java接口不同, Scala特質可以提供方法和字段的實現
  4. 當將多個特質疊加使用的時候, 順序很重要

1. Scala類沒有多繼承

如果只是把毫不相關的類組裝在一起, 多繼承不會出現問題, 不過像下面這個簡單例子就能讓問題就浮出水面了;

class Student {
    val id: Int = 10
    }
class Teacher {
    val id: Int = 100
    }

假設可以有:

class TeacherAssistant extends Student, Teacher { ... }

要求返回id時, 該返回哪一個呢?

菱形繼承


對於 class A 中的字段, class D 從 B 和 C 都得到了一份, 這兩個字段怎么得到和被構造呢?
C++ 中通過虛擬基類解決它, 這是個脆弱而復雜的解決方法, Java設計者對這些復雜性心生畏懼, 采取了強硬的限制措施, 類只能繼承一個超類, 能實現任意數量的接口.

同樣, Scala 中類只能繼承一個超類, 可以擴展任意數量的特質,與Java接口相比, Scala 的特質可以有具體方法和抽象方法; Java 的抽象基類中也有具體方法和抽象方法, 不過如果子類需要多個抽象基類的方法時, Java 就做不到了(沒法多繼承), Scala 中類可以擴展任意數量的特質.

2. 帶有特質的類

2.1 當做接口使用的特質

  1. Scala可以完全像Java接口一樣工作, 你不需要將抽象方法聲明為 abstract, 特質中未被實現的方法默認就是抽象方法;
  2. 類可以通過 extends 關鍵字繼承特質, 如果需要的特質不止一個, 通過 with 關鍵字添加額外特質
  3. 重寫特質的抽象方法時, 不需要 override 關鍵字
  4. 所有 Java 接口都可以當做 Scala 特質使用

比起Java接口, 特質和類更為相似

trait Logger {
   def log(msg: String)  // 抽象方法
}

class ConsoleLogger extends Logger with Serializable {  // 使用extends
  def log(msg: String): Unit = {  // 不需要override關鍵字
    println("ConsoleLogger: " + msg)
  }
}

object LoggerTest extends App{
  val logger = new ConsoleLogger
  logger.log("hi")
}

/*輸出
ConsoleLogger: hi
*/

2.2 帶有具體實現的特質

trait Logger {
  def log(msg: String)  // 抽象方法
  def printAny(k: Any) { // 具體方法
    println("具體實現")
    }
}

讓特質混有具體行為有一個弊端. 當特質改變時, 所有混入該特質的類都必須重新編譯.

2.3 繼承類的特質

特質繼承另一特質是一種常見的用法, 而特質繼承類卻不常見.
特質繼承類, 這個類會自動成為所有混入該特質的超類

trait Logger extends Exception { }

class Mylogger extends Logger { } // Exception 自動成為 Mylogger 的超類

如果我們的類已經繼承了另一個類怎么辦?
沒關系只要這個類是特質超類的子類就好了;

//IOException 是 Exception 的子類
class Mylogger extends IOException with Logger { } 

不過如果我們的類繼承了一個和特質超類不相關的類, 那么這個類就沒法混入這個特質了.

3. 帶有特質的對象

在構造單個對象時, 你可以為它添加特質;

特質可以將對象原本沒有的方法與字段加入對象中
如果特質和對象改寫了同一超類的方法, 則排在右邊的先被執行.

// Feline 貓科動物
abstract class Feline {
  def say()
}

trait Tiger extends Feline {
  // 在特質中重寫抽象方法, 需要在方法前添加 abstract override 2個關鍵字
  abstract override def say() = println("嗷嗷嗷")
  def king() = println("I'm king of here")
}

class Cat extends Feline {
  override def say() = println("喵喵喵")
}

object Test extends App {
  val feline = new Cat with Tiger
  feline.say  // Cat 和 Tiger 都與 say 方法, 調用時從右往左調用, 是 Tiger 在叫
  feline.king // 可以看到即使沒有 cat 中沒有 king 方法, Tiger 特質也能將自己的方法混入 Cat 中
}

/*output
  嗷嗷嗷
  I'm king of here
*/ 

4. 特質的疊加

可以為類和對象添加多個相互調用的特質 時, 從最后一個開始調用. 這對於需要分階段加工處理某個值的場景很有用.

下面展示一個char數組的例子, 展示混入的順序很重要

定義一個抽象類CharBuffer, 提供兩種方法

  • put 在數組中加入字符
  • get 從數組頭部取出字符
abstract class CharBuffer {
  def get: Char
  def put(c: Char)
}

class Overlay extends CharBuffer{
  val buf = new ArrayBuffer[Char]
  
  override def get: Char = {
    if (buf.length != 0) buf(0) else '@'
  }
  override def put(c: Char): Unit = {
    buf.append(c)
  }
}

定義兩種對輸入字符進行操作的特質:

  • ToUpper 將輸入字符變為大寫
  • ToLower 將輸入字符變為小寫

因為上面兩個特質改變了原始隊列類的行為而並非定義了全新的隊列類, 所以這2種特質是可堆疊的,你可以選擇它們混入類中,獲得所需改動的全新的類。


trait ToUpper extends CharBuffer {

// 特質中重寫抽象方法  abstract override
 abstract override def put(c: Char) = super.put(c.toUpper)
  
  // abstract override def put(c: Char): Unit = put(c.toUpper)
  // java.lang.StackOverflowError, 由於put相當於 this.put, 在特質層級中一直調用自己, 死循環
}

trait ToLower extends CharBuffer {
  abstract override def put(c: Char) = super.put(c.toLower)
  }

特質中 super 的含義和類中 super 含義並不相同, 如果具有相同含義, 這里super.put調用時超類的 put 方法, 它是一個抽象方法, 則會報錯, 下面會詳細介紹 super.put 的含義

測試

object TestOverlay extends App {
  val cb1 = new Overlay with ToLower with ToUpper
  val cb2 = new Overlay with ToUpper with ToLower

  cb1.put('A')
  println(cb1.get)

  cb2.put('a')
  println(cb2.get)

}

/*output
a
A
*/

上面代碼的一些說明:

  1. 上面的特質繼承了超類charBuffer, 意味着這兩個特質只能混入繼承了charBuffer的類中

  2. 上面每一個put方法都將修改過的消息傳遞給 super.put, 對於特質來說, super.put 調用的是特質層級的下一個特質(下面說), 具體是哪一個根據特質添加的順序來決定. 一般來說, 特質從最后一個開始被處理.

  3. 在特質中,由於繼承的是抽象類,super調用時非法的。這里必須使用abstract override 這兩個關鍵字,在這里表示特質要求它們混入的對象(或者實現它們的類)具備 put 的具體實現, 這種定義僅在特質定義中使用。

  4. 混入的順序很重要,越靠近右側的特質越先起作用。當你調用帶混入的類的方法時,最右側特質的方法首先被調用。如果那個方法調用了super,它調用其左側特質的方法,以此類推。

如果要控制具體哪個特質的方法被調用, 則可以在方括號中給出名稱: super[超類].put(...), 這里給出的必須是直接超類型, 無法使用繼承層級中更遠的特質或者類; 不過在本例中不行, 由於兩個特質的超類是抽象類, 沒有具體方法, 編譯器報錯

5. 特質的構造順序

特質也可以有構造器,由字段的初始化和其他特質體中的語句構成。這些語句在任何混入該特質的對象在構造時都會被執行。
構造器的執行順序:

  1. 調用超類的構造器;
  2. 特質構造器在超類構造器之后、類構造器之前執行;
  3. 特質由左到右被構造;
  4. 每個特質當中,父特質先被構造;
  5. 如果多個特質共有一個父特質,父特質不會被重復構造
  6. 所有特質被構造完畢,子類被構造。

線性化細節

  1. 線性化是描述某個類型的所有超類型的一種技術規格;
  2. 構造器的順序是線性化順序的反向;
  3. 線性化給出了在特質中super被解析的順序,

如果 C extends c1 with c2 with c3, 則:

lin(C) = C >> lin(c3) >> lin(c2) >> lin(c1)

這里>>意思是 "串接並去掉重復項, 右側勝出"

下面例子中:

class Cat extends Animal with Furry with FourLegged

lin(Cat) = Cat>>lin(FourLegged)>>lin(Furry)>>lin(Animal)
= Cat>>(FourLegged>>HasLegs)>>(Furry>>Animal)>>(Animal)
= Cat>>FourLegged>>HasLegs>>Furry>>Animal

線性化給出了在特質中super被解析的順序, 舉例來說就是
FourLegged中調用super會執行HasLegs的方法
HasLegs中調用super會執行Furry的方法

例子

Scala 的線性化的主要屬性可以用下面的例子演示:假設你有一個類 Cat,繼承自超類 Animal 以及兩個特質 Furry 和 FourLegged。 FourLegged 又擴展了另一個特質 HasLegs:

class Animal
trait Furry extends Animal
trait HasLegs extends Animal
trait FourLegged extends HasLegs
class Cat extends Animal with Furry with FourLegged

類 Cat 的繼承層級和線性化次序展示在下圖。繼承次序使用傳統的 UML 標注指明:白色箭頭表明繼承,箭頭指向超類型。黑色箭頭說明線性化次序,箭頭指向 super 調用解決的方向。

6. 特質中的字段

特質中的字段可以是具體的也可以是抽象的. 如果給出了初始值那么字段就是具體的.

6.1 具體字段

trait  Ability {
  val run = "running" // 具體字段
  def log(msg: String) = {}
}

class Cat extends Ability {
    val name = "cat"
    }

1.混入Ability特質的類自動獲得一個run字段.
2.通常對於特質中每一個具體字段, 使用該特質的類都會獲得一個字段與之對應.
3.這些字段不是被繼承的, 他們只是簡單的加到了子類中.任何通過這種方式被混入的字段都會自動成為該類自己的字段, 這是個細微的區別, 卻很重要
scalatrait

JVM中, 一個類只能繼承一個超類, 因此來自特質的字段不能以相同的方式繼承. 由於這個限制, run 被直接加到Cat類中, 和name字段排在子類字段中.

6.2 初始化特質抽象字段

在類中初始化

特質中未被初始化的字段在具體的子類中必須被重寫

trait  Ability {
  val swim: String // 具體字段
  def ability(msg: String) = println(msg + swim)  // 方法用了swim字段    
}

class Cat extends Ability {
    val swim = "swimming" // 不需要 override
}

這種提供特質參數的方式在零時構造某種對象很有利, 很靈活,按需定制.

創建對象時初始化

特質不能有構造器參數. 每個特質都有一個無參構造器. 值得一提的是, 缺少構造器參數是特質與類唯一不相同的技術差別. 除此之外, 特質可以具有類的所有特性, 比如具體的和抽象的字段, 以及超類.

這種局限對於那些需要定制才有用的特質來說會是一個問題, 這個問題具體就表現在一個帶有特質的對象身上. 我們先來看下面的代碼, 然后在分析一下, 就能一目了然了.


/**
  * Created by wangbin on 2017/7/11.
  */
trait Fruit {
    val name: String

    // 由於是字段, 構造時就輸出
    val valPrint = println("valPrint: " + name)
    // lazy 定義法, 由於是lazy字段, 第一次使用時輸出
    lazy val lazyPrint = println("lazyPrint: " + name)
    // def 定義法,  方法, 每次調用時輸出
    def defPrint = println("defPrint: " + name)

}

object  TestFruit extends App {

    // 方法1. lazy定義法
    println("** lazy定義法 構造輸出 **")
    val apple1 = new Fruit {
        val name = "Apple"
    }

    println("\n** lazy定義法 調用輸出 **")
    apple1.lazyPrint
    apple1.defPrint

    // 方法2. 提前定義法
    println("\n** 提前定義法 構造輸出 **")
    val apple2= new {
        val name = "Apple"
    } with Fruit

    println("\n** 提前定義法 調用輸出 **")
    apple2.lazyPrint
    apple2.defPrint
}

/*
** lazy定義法 構造輸出 **
valPrint: null

** lazy定義法 調用輸出 **
lazyPrint: Apple
defPrint: Apple

** 提前定義法 構造輸出 **
valPrint: Apple

** 提前定義法 調用輸出 **
lazyPrint: Apple
defPrint: Apple

*/

為了便於觀察, 先把輸出整理成表格

方法 valPrint lazyPrint defPrint
lazy定義法 null Apple Apple
提前定義法 Apple Apple Apple

我們先來看一下 lazy定義法 和 提前定義法 的構造輸出, 即 valPrint, lazy定義法輸出為 null, 提前定義法輸出為 "Apple"; 問題出在構造順序上, Fruit 構造器(特質的構造順序)先與子類構造器執行. 這里的子類並不那么明顯, new 語句構造的其實是一個 Fruit 的匿名子類的實例. 也就是說 Fruit 先初始化, 子類的 name 還沒來得及初始化, Fruit 的 valPrint 在構造時就立即求值了, 所以輸出為 null.

由於lazy值每次使用都會檢查是否已經初始化, 用起來並不是那么高效.

關於 val, lazy val, def 的關系可以看看 lazy

特質背后的實現: Scala通過將 trait 翻譯成 JVM 的類和接口 , 關於通過反編譯的方式查看 Scala 特質的背后工作方式可以參照 Scala 令人着迷的類設計中介紹的方法, 有興趣的可以看看.



參考:

  1. 特質與特質線性化 Jason Ding


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM