Scala Tutorial(7)加強版的 Interface -- Trait by brianhsu | CodeData
top

Scala Tutorial(7)加強版的 Interface -- Trait

分享:

Scala Tutorial(6)Scala 物件導向基礎之二 << 前情

Java 7 中的介面

話說由於多重繼承可能產生許多混餚(一個典型的問題是「鑽石問題」),所以在 Java 中並沒有多重繼承這樣的功能,一個類別只能繼承自一個父類別。但相對的,在 Java 中提供了 interface 的功能,顧名思義,interface 只定義了「介面」,而實作則需要由使用該 interface 的類別來實作。

但這產生了一個問題--當 A 和 B 兩個不同的類別使用同一個 interface 時,就算這個 interface 裡的某個函數,在 A 和 B 這兩個類別裡的行為都相同時,我們仍然需要在 A 和 B 兩個類別中分別實作這個函式。[註一]

舉例而言,假設今天我們開發了一個玩具鯊魚,他會依照真正的鯊魚游動的方式來游,但他因為不是魚,所以他不應該繼承 Fish 這個類別。但另一方面,我們也知道不只有魚類會游泳,很多哺乳類也會游泳,所以「游泳」這件事不應該被歸類在「魚」的行為,而是一種通用的介面。

所以在 Java 中要表現這樣的關係,我們可能會這麼寫:

interface Swimming {
    public void swim();
}

class Fish {}       // 我是魚
class Mammal {}     // 我是哺乳類

class Dog extends Mammal implements Swimming {
    public void swim() {
        System.out.println("I'm swimming by using my four legs");
    }
}

class Shark extends Fish implements Swimming {
    public void swim() {
        System.out.println("I'm swimming like a shark");
    }
}

這看起來沒什麼問題,因為雖然鯊魚和狗都會游泳,但兩者使用的方法並不相同,所以我們使用不同的實作是合理的。

但如果加入了玩具鯊魚呢?由於他不是魚,所以他不應該繼承自 Fish 類別,但他游泳的方式又和 Shark 相同,所以我們可能會這樣寫:

class Toy {}    // 我是玩具

class ToyShark extends Toy implements Swimming {
    public void swim() {
        System.out.println("I'm swimming like a shark");
    }
}

但你有沒有發現,ToyShark 和 Shark 的 swim() 其實是同樣一件事?為什麼我們要撰寫同樣的程式碼兩次呢?所幸,Scala 提供了 Trait (特徵)這個功能,讓我們可以把程式碼的重覆程度降低,但同時又保留 interface 的好處。

[註一] Java 8 中的 Interface 提供了 Default Method 功能,同樣也可以解決這個問題喲!

最簡單的型式的 Trait

在 Scala 中,最簡單的 Trait 型式和 Java 中的 interface 相同,我們僅定義其名稱與介面,在這個情況下,trait 關鍵字的語義和 Java 的 interface 是相同,實際上也是將其編譯成 Java 中的 interface。

trait Swimming {
  def swim(): Unit
}

trait Playing {
  def play(): Unit
}

不過要注意的是,在使用方式上和 Java 不太一樣,在 Scala 中不管某個類別是繼承自父類別,或是引入 Trait,第一個關鍵字都是 extends,但第二個 Trait 開始,要使用 with 關鍵字。

但另一方面,由於 Scala 和 Java 一樣只有單一繼承,一個類別只能有一個父類別,所以類別最多只會出現一個 extends 關鍵字。

class Fish
class Mammal

// A 類別繼承 Fish 類別
class A extends Fish {       
  def swim() = println("Swimming") 
}

// B 類別引用了 Swimming
class B extends Swimming {
  def swim() = println("Swimming") 
}

// C 類別繼承了 Fish 類別並且引入了 Swimming 特徵
class C extends Fish with Swimming {
  def swim() = println("Swimming") 
}

// D 類別繼承了 Fish 類別,並且引用了 Swimming 和 Playing 特徵
class D extends Fish with Swimming with Playing {
  def swim() = println("Swimming") 
  def play() = println("Playing")
}

// 編譯錯誤,和 Java 相同,如果沒實作 Trait 中的函式,那麼這個
// 類別要被宣告成 abstract,並且也不能用 new 關鍵字產生實例
/*
class E extends Swimming {
}
*/

在這邊要特別注意的是 B 類別,雖然語法上看起來像是 B 類別「繼承」了 Swimming 這個 Trait,但實際上 Trait 並不是類別,是不算在物件的繼承關係的,B 類別的父類別仍會是 java.lang.Object 這個類別,他的語義若轉成 Java 來看,實際上會是 class B implements Swimming 這個樣子。

具有實作的 Trait

在 Scala 中的 Trait 裡的函式是可以有實作的,若 Trait 裡的函式有實作,那麼類別在引入 Trait 之後若沒有將該函式覆寫掉,那麼呼叫該函式的時候就會使用 Trait 中的實作,例如上述的玩具鯊魚的例子我們可以寫成像下面這樣:

trait Swimming {
  def swim() = println("I'm swimming like a Shark")
}

trait Charging {
  def charge() = println("I'm charging my battery...")
}

class Fish
class Toy

class Shark extends Fish with Swimming
class ToyShark extends Toy with Swimming with Charging
class Dog extends Swimming {
  override def swim() = println("I'm swimming using my four legs!")
}

val shark = new Shark
val toy = new ToyShark
val dog = new Dog

shark.swim()        // 真的鯊魚可以遊泳
//shark.charge()    // 但不能充電
toy.swim()          // 玩具鯊魚可以充電
dog.swim()          // 狗也會泳遊,但游的方法和鯊魚不太一枇

透過這樣子提供了具有實作的 Trait,我們可以讓程式碼的重用性提高。

限制 Trait 可以被誰使用

除了做為「有實作的預設介面」外,Trait 可以宣告自己只能被用在哪些情況,或者限制這個 Trait 只能被某些類別引用。

第一個限制的情況是這個 Trait 的某些函式必須依賴在特定的變數,但這個變數卻應該由子類別自己來定義實際的值。這個時候我們可以在 Trait 中宣告這個變數的名稱和型別,但不用等號賦與其值。

這個時候,若引入該 Trait 的類別沒有定義 name 欄位的話,那麼和沒有定義 Trait 中未實作的函式的情況相同,該類別必須要被宣告為 abstract 類別,並且一直到其子類別定義該欄位為止。

trait SelfIntroduce {
  val name: String
  def sayHello() = println("Hey, I'm " + name)
}

class Person(val name: String) extends SelfIntroduce
val joe = new Person("Joe")
joe.sayHello()

// 編譯失敗,Fish 必須要有 name 欄位,否則要宣告成 abstract class,
// 讓子類別來定義 name 欄位
// class Fish extends SelfIntroduce

// 沒問題,把 name 欄位的問題留給下一層的類別再來處理
abstract class Fish extends SelfIntroduce

// MyFish 繼承了 Fish 類別,也定義了 name 欄位,所以可以使用
// SelfIntroduce 裡的 sayHello 函式了
class MyFish(val name: String) extends Fish
val myFish = new MyFish("Fissy")
myFish.sayHello()

除了使用未定義的欄位來限制外,也可以使用 trait TraitA extends BaseClass 這樣的語法指定 TraitA 只能被 BaseClass 類別和其子類別引入。但這邊要注意的是,這裡的 extends 並不是繼承關係,而只是代表 TraitA 可以被引入的限制範圍。

在這個情況下,TraitA 裡面可以參照到 BaseClass 的成員函式和成員變數。

class BaseClass {
  def sayHello(content: String) = println(content)
}

class SubClass extends BaseClass {
  override def sayHello(content: String) = {
    println("Inside SubClass:" + content)
  }
}

class AnotherClass

trait UpperCase extends BaseClass {
  def greet() {
    println("Greeting from UpperCase")
    sayHello("Hello!")
  }
}

// 沒問題,UpeerCase 可以被 BaseClass 類別引入
val base = new BaseClass with UpperCase
base.greet()

// 沒問題,UpperCase 可以被 BaseClass 的子類別引入
val sub = new SubClass with UpperCase
sub.greet()

// 編譯錯誤,AnotherClass 並不是 BaseClass 的子類別,
// 所以不能使用 UpperCase
// 
// val another = new AnotherClass with UpperCase

這算是多重繼承嗎?

讀者可能會有疑惑,如果 Trait 可以有實作,而一個類別又可以引入多個 Trait,那麼這算不算是多重繼承呢?會不會產生多重繼承中常出現的鑽石問題呢?

答案是否定的,因為在 Scala 中,類別的階層和 Trait 是不一樣的,兩者是不同的關係鍊。

而且當一個類別使用了多個 Trait,而這多個 Trait 又都有同名函式時,Scala 使用了一套固定的順序規範來執行這些同名的函式,所以並不會有混餚的情況產生,你也可以只透過程式碼就知道其執行的結果,並沒有任何的不確定性產生。

簡單來說,若只是單純擁有同名函式的 Trait,那麼被引入時會出現編譯錯誤,因為我們無法判斷要呼叫哪個函式。

trait Hello { 
  def hello() = println("Hello World")
}

trait CapitalHello {
  def hello() = println("HELLO WORLD")
}

// 編譯錯誤
// class A extends Hello with CapitalHello

但另一方面,若具有同名函式的 Trait 都使用了 extends 關鍵字定義了同一個基礎類別,那麼可以編譯過關,並且由最右邊的 Trait 贏得該函式的呼叫權。

舉例而言在像下面的程式碼中,由於 capital 這個變數最後引入的是 CapitalHello 這個 Trait,所以當我們使用 capital.hello() 時會呼叫到 CapitalHello 的 hello() 函式,而相對的 normal.hello() 因為 normal 最後引入的是 Hello 這個 Trait,所以由這個 Trait 贏得函式的呼叫權。

class Printable {
  def hello() = println("Hello World in printable")
}

trait Hello extends Printable {
  override def hello() = println("Hello World in Hello")
}

trait CapitalHello extends Printable {
  override def hello() = println("HELLO WORLD in CapitalHello")
}

val capital = new Printable with Hello with CapitalHello
val normal  = new Printable with CapitalHello with Hello

capital.hello()
normal.hello()

在這裡值得一提的,是在 Trait 中使用 super 這個關鍵字,當我們在 Trait 使用 super 這個關鍵字時,指的不是階層的關係,而是指在一連串 with XXX 的 Trait 引入順序的先後關係,而 super 會幫我們指到這串 Trait 列表的左邊的 Trait。

猜猜看,當我們在上面的程式碼裡的 Hello 和 CapitalHello 這兩個 Trait 中的 hello() 的程式碼裡加上 super.hello() 後,最後會印出什麼結果?

class Printable {
  def hello() = println("Hello World in printable")
}

trait Hello extends Printable {
  override def hello() = {
    println("Hello World in Hello")
    super.hello()
  }
}

trait CapitalHello extends Printable {
  override def hello() = {
    println("HELLO WORLD in CapitalHello")
    super.hello()
  }
}

val capital = new Printable with Hello with CapitalHello
capital.hello()

答案是會依序印出下面的文字:

HELLO WORLD in CapitalHello
Hello World in Hello
Hello World in printable

為什麼呢?因為我們的在產生 capital 物件時,他的順序從右到左是 CapitalHello / Hello / Printable,所以會先調用 CapitalHello 裡的 hello() 函式,而後又呼叫了 super.hello(),這個時候的 super 指的其實是 Hello 這個 Trait,因為在產生 capital 物件時他左 CapitalHello 的左邊,最後才又使用 super 關鍵字呼叫到 Printalbehello 函式,最後結束執行。

同裡的,若我們使用 new Printable with CapitalHello with Hello,那麼印出的東西的順序就會變:

Hello World in Hello
HELLO WORLD in CapitalHello
Hello World in printable

從這個角度上來看,Trait 的功用有有點像是多重繼承,但他又不是。特別是在 Scala 中要記住的,是 Trait 彼此間的關係和 Class 之間的繼承關係是兩個不一樣的東西,Scala 的類別仍然只能繼承自單一類別,而引入 Trait 的順序,會影響到 Trait 當中的 super 關鍵字的意義。

使用 Trait 來實現 Decorator Pattern

在上面的例子中我們可以看到,Trait 並不是傳統的多重繼承,而在其中 super 所指的東西會根據其引入的順序而改變。由於這個特性,使得 Trait 非常適合拿來實作設計模式中的裝飾者模式。

裝飾者模式指的是某一個類別只專注在一件事情之上,而後透過一層一層包裹的方式,讓其他的類別來提供其他進階的功能。在 Java 中我們最常使用到的裝飾者模式,大概就屬 java.io.BufferedReader 這類的類別了吧!我們使用 BufferedReader 將其他的 Reader 包裹進來,使其具有 Buffer 的功能,這就是裝飾者模式的一個例子。

在下面的程式碼中,我們要試著完成下列的工作:

  1. 寫一個 Printable 物件可以在螢幕上印出字串
  2. 寫出可以分別讓 Printable 物件印出「重覆三次」、「全部轉成大寫」、「全部轉成小寫」、「過慮關鍵字」的裝飾者物件

當我們使用 Scala 的 Trait 的特性時候,這就會變得很簡單了:

// 基礎類別
class Printable {
  def print(content: String) = println(content)
}

// 因為這些是裝飾者物件,我們只能讓他們可以被 Printable 類別引用
trait Repeat extends Printable {
  override def print(content: String) = {
    super.print(content + "\n" + content + "\n" + content)
  }
}

trait UpperCase extends Printable {
  override def print(content: String) = { 
    super.print(content.toUpperCase)
  }
}

trait LowerCase extends Printable {
  override def print(content: String) = { 
    super.print(content.toLowerCase)
  }
}

trait Filter extends Printable {
  override def print(content: String) = { 
    super.print(content.replace("shit", "XXXX"))
  }
}

有了這些程式碼之後,我們就可以透過不同的組合方式,來達到印出不同效果的自串的功能。

// 大寫並且印三次
val upperAndRepeat = new Printable with Repeat with UpperCase
upperAndRepeat.print("Hello World")

// 小寫並且過濾
val filterAndLower = new Printable with LowerCase with Filter
filterAndLower.print("It looks like a shit")

不過在這邊要注意的是引入 Trait 的順序會引響到哪個 Trait 裡的 println 先被呼叫,所以像下面的程式碼中,由於 UpperCase 裡的 println 優先權比較高,已經先把 content 轉成大寫了,所以 Filter 裡的 println 就會沒有辦法過濾掉 SHIT 這個字。

val upperAndFilter = new Printable with Filter with UpperCase

// 會印出 IT LOOKS LIKE A SHIT
upperAndFilter.print("It looks like a shit")

小結

在這一篇文章中,我們看到了 Scala 提供了 Trait 這樣的功能。在最簡單的情況下,他可以如同 Java 中的 interface 一樣來定義介面,但 Trait 也提供了許多額外的功能,特別是當我們在 Trait 中善用 extends 和 super 關鍵字時,將可以很簡單的實作出裝飾者模式。

後續 >> Scala Tutorial(8)Tuple 簡介

分享:
按讚!加入 CodeData Facebook 粉絲群

相關文章

留言

留言請先。還沒帳號註冊也可以使用FacebookGoogle+登錄留言

熱門論壇文章

熱門技術文章