Scala中的Implicit詳解

Scala中的implicit關鍵字對於咱們初學者像是一個謎同樣的存在,一邊驚訝於代碼的簡潔,html

一邊像在迷宮裏打轉同樣地去找隱式的代碼,所以咱們團隊結合目前的開發工做,將implicit
做爲一個專題進行研究,了一些心得。
前端

在研究的過程中,咱們注重三方面:java

  1. 爲何須要implicit?
  2. implicit 包含什麼,有什麼內在規則?
  3. implicit 的應用模式有哪些?

爲何須要Implicit?

Scala在面對編譯出現類型錯誤時,提供了一個由編譯器自我修復的機制,編譯器試圖去尋找
一個隱式implicit的轉換方法,轉換出正確的類型,完成編譯。這就是implicit的意義。git

咱們正在作Guardian系統的升級,Guardian是公司內部的核心繫統,提供統一權限管控、
操做審計、單點登陸等服務。系統已經有4年多的歷史了,已經難以知足目前的須要,好比:
當時僅提供了RESTFul的服務接口,而隨着性能需求的提升,有些服務使用Tcp消息完成遠程
調用;另外,在RESTFull接口的協議方面,咱們也想作一些優化。github

而現狀是公司內部系統已經所有接入Guardian,要接入新版,不可能一次所有遷移,甚至
要花很長一段時間才能完成遷移工做,所以新版接口必須同時支持新老兩個版本接口協議。編程

所以咱們必須解決兩個問題:併發

  1. 兼容老版本協議, 以便可以平滑升級
  2. 支持多種協議,以知足不一樣業務系統的需求

咱們但願對接口層提供一個穩定的Service接口,以避免業務的變更影響前端接口代碼,常規
的作法是咱們在Service接口上定義多種版本的方法(重載),好比鑑權服務:app

trait AuthService { // 兼容老版本的鑑權業務方法 def auth(p: V1HttpAuthParam): Future[V1HttpAuthResult] // 新版本的鑑權業務方法 def auth(p: V2HttpAuthParam): Future[V2HttpAuthResult] // 新版本中支持的對Tcp消息鑑權的業務方法 def auth(p: V2TcpMsg): Future[V2TcpMsg] } 

這種作法的問題在於一旦業務發生變化,出現了新的參數,勢必要修改AuthService接口,
添加新的接口方法,接口不穩定。框架

假若有一個通用的auth方法就行了:ide

trait AuthParam {} trait StableAuthService{ // 穩定的鑑權接口 def auth(p: AuthParam) } 

這樣,咱們就能夠按照下面的方式調用:

//在老版本的REST WS接口層: val result = authService auth V1HttpAuthParam response(result) //在新版本的REST WS接口層: val result = authService auth V2HttpAuthParam response(result) // .... 在更多的服務接口層,任意的傳入參數,得到結果 

很明顯,這樣的代碼編譯出錯。 由於在authService中沒有這樣的方法簽名。

再舉個簡單的例子, 咱們想在打印字符串時,添加一些分隔符,下面是最天然的調用方式:

"hello,world" printWithSeperator "*" 

很明顯,這樣的代碼編譯出錯。 由於String 沒有這樣的方法。

Scala在面對編譯出現類型錯誤時,提供了一個由編譯器自我修復的機制,編譯器試圖去尋找
一個隱式implicit的轉換方法,轉換出正確的類型,完成編譯。這就是implicit 的意義。

Implicit包含什麼,有什麼內在規則?

Scala 中的implicit包含兩個方面:

  1. 隱式參數(implicit parameters)
  2. 隱式轉換(implicit conversion)

隱式參數(implicit parameters)

隱式參數一樣是編譯器在找不到函數須要某種類型的參數時的一種修復機制,咱們能夠採用顯式的柯里化式
的隱式參數申明,也能夠進一步省略,採用implicitly方法來獲取所須要的隱式變量。

隱式參數相對比較簡單,Scala中的函數申明提供了隱式參數的語法,在函數的最後的柯里化參數
列表中能夠添加隱式implicit關鍵字進行標記, 標記爲implicit的參數在調用中能夠省略,
Scala編譯器會從當前做用域中尋找一個相同類型的隱式變量,做爲調用參數。

在Scala的併發庫中就大量使用了隱式參數,好比Future:

// Future 須要一個隱式的ExecutionContext // 引入一個默認的隱式ExecutionContext, 不然編譯不經過 import scala.concurrent.ExecutionContext.Implicits.default Future { sleep(1000) println("I'm in future") } 

對於一些常量類的,可共用的一些對象,咱們能夠用隱式參數來簡化咱們的代碼,好比,咱們的應用
通常都須要一個配置對象:

object SomeApp extends App { //這是咱們的全局配置類 class Setting(config: Config) { def host: String = config.getString("app.host") } // 申明一個隱式的配置對象 implicit val setting = new Setting(ConfigFactory.load) // 申明隱式參數 def startServer()(implicit setting: Setting): Unit = { val host = setting.host println(s"server listening on $host") } // 無需傳入隱式參數 startServer() } 

甚至,Scala爲了更進一步減小隱式參數的申明代碼,咱們均可以不須要再函數參數上顯示的申明,在scala.Predef包中,提供了一個implicitly的函數,幫助咱們找到當前上下文中所須要類型的
隱式變量:

@inline def implicitly[T](implicit e: T) = e // for summoning implicit values from the nether world 

所以上面的startServer函數咱們能夠簡化爲:

// 省略隱式參數申明 def startServer(): Unit = { val host = implicitly[Setting].host println(s"server listening on $host") } 

須要注意的是,進一步簡化以後,代碼的可讀性有所損失,調用方並不知道startServer須要一個隱式的
配置對象,要麼增強文檔說明,要麼選用顯式的申明,這種權衡須要團隊達成一致。

隱式轉換(implicit conversion)

回顧一下前面說到的小例子,讓字符串可以帶分隔符打印:

"hello,world" printWithSeperator "*" 

此時,Scala編譯器嘗試從當前的表達式做用域範圍中尋找可以將String轉換成一個具備printWithSeperator
函數的對象。

爲此,咱們提供一個PrintOpstrait,有一個printWithSeperator函數:

trait PrintOps { val value: String def printWithSepeator(sep: String): Unit = { println(value.split("").mkString(sep)) } } 

此時,編譯仍然不經過,由於Scala編譯器並無找到一個能夠將String轉換爲PrintOps的方法!那咱們申明一個:

def stringToPrintOps(str: String): PrintOps = new PrintOps { override val value: String = str } 

OK, 咱們能夠顯示地調用stringToPrintOps了:

stringToPrintOps("hello,world") printWithSepeator "*" 

離咱們的最終目標只有一步之遙了,只須要將stringToPrintOps方法標記爲implicit便可,除了爲String
添加stringToPrintOps的能力,還能夠爲其餘類型添加,完整代碼以下:

object StringOpsTest extends App { // 定義打印操做Trait trait PrintOps { val value: String def printWithSeperator(sep: String): Unit = { println(value.split("").mkString(sep)) } } // 定義針對String的隱式轉換方法 implicit def stringToPrintOps(str: String): PrintOps = new PrintOps { override val value: String = str } // 定義針對Int的隱式轉換方法 implicit def intToPrintOps(i: Int): PrintOps = new PrintOps { override val value: String = i.toString } // String 和 Int 都擁有 printWithSeperator 函數 "hello,world" printWithSeperator "*" 1234 printWithSeperator "*" } 

隱式轉換的規則 -- 如何尋找隱式轉換方法

Scala編譯器是按照怎樣的套路來尋找一個能夠應用的隱式轉換方法呢? 在Martin Odersky的Programming in Scala, First Edition中總結了如下幾條原則:

  1. 標記規則:只會去尋找帶有implicit標記的方法,這點很好理解,在上面的代碼也有演示,若是不申明爲implicit
    只能手工去調用。
  2. 做用域範圍規則:
    1. 只會在當前表達式的做用範圍以內查找,並且只會查找單一標識符的函數,上述代碼中,
      若是stringToPrintOps方法封裝在其餘對象(加入叫Test)中,雖然Test對象也在做用域範圍以內,但編譯器不會嘗試使用Test.stringToPrintOps進行轉換,這就是單一標識符的概念。
    2. 單一標識符有一個例外,若是stringToPrintOps方法在PrintOps的伴生對象中申明也是有效的,Scala
      編譯器也會在源類型或目標類型的伴生對象內查找隱式轉換方法,本規則只會在轉型有效。而通常的慣例,會將隱式轉換方法封裝在伴生對象中
    3. 當前做用域上下文的隱式轉換方法優先級高於伴生對象內的隱式方法
  3. 不能有歧義原則:在相同優先級的位置只能有一個隱式的轉型方法,不然Scala編譯器沒法選擇適當的進行轉型,編譯出錯。
  4. 只應用轉型方法一次原則:Scala編譯器不會進行屢次隱式方法的調用,好比須要C類型參數,而實際類型爲A,做用域內
    存在A => B,B => C的隱式方法,Scala編譯器不會嘗試先調用A => B ,再調用B => C
  5. 顯示方法優先原則:若是方法被重載,能夠接受多種類型,而做用域中存在轉型爲另外一個可接受的參數類型的隱式方法,則不會
    被調用,Scala編譯器優先選擇無需轉型的顯式方法,例如:
    def m(a: A): Unit = ??? def m(b: B): Unit = ??? val b: B = new B //存在一個隱式的轉換方法 B => A implicit def b2a(b: B): A = ??? m(b) //隱式方法不會被調用,優先使用顯式的 m(b: B): Unit 

Implicit的應用模式有哪些?

隱式轉換的核心在於將錯誤的類型經過查找隱式方法,轉換爲正確的類型。基於Scala編譯器的這種隱式轉換機制,一般有兩種應用
模式:Magnet PatternMethod Injection

Magnet Pattern

Magnet Pattern模式暫且翻譯爲磁鐵模式, 解決的是方法參數類型的不匹配問題,可以優雅地解決本文開頭所提出的問題,
用一個通用的Service方法簽名來屏蔽不一樣版本、不一樣類型服務的差別。

磁鐵模式的核心在於,將函數的調用參數和返回結果封裝爲一個磁鐵參數,這樣方法的簽名就統一爲一個了,不須要函數重載;再
定義不一樣參數到磁鐵參數的隱式轉換函數,利用Scala的隱式轉換機制,達到相似於函數重載的效果。

磁鐵模式普遍運用於Spray Http 框架,該框架已經遷移到Akka Http中。

下面,咱們一步步來實現一個磁鐵模式,來解決本文開頭提出的問題。

  1. 定義Magnet參數和使用Magnet參數的通用鑑權服務方法

    // Auth Magnet參數 trait AuthMagnet { type Result def apply(): Result } // Auth Service 方法 trait AuthService { def auth(am: AuthMagnet): am.Result = am() } 
  2. 實現不一樣版本的AuthService

    //v1 auth service trait V1AuthService extends AuthService //v2 auth service trait V2AuthService extends AuthService 
  3. 實現不一樣版本AuthService的伴生對象,添加適當的隱式轉換方法

    //V1 版本的服務實現 object V1AuthService { case class V1AuthRequest() case class V1AuthResponse() implicit def toAuthMagnet(p: V1AuthRequest): AuthMagnet {type Result = V1AuthResponse} = new AuthMagnet { override def apply(): Result = { // v1 版本的auth 業務委託到magnet的apply中實現 println("這是V1 Auth Service") V1AuthResponse() } override type Result = V1AuthResponse } } //V2 版本的服務實現 object V2AuthService { case class V2AuthRequest() case class V2AuthResponse() implicit def toAuthMagnet(p: V2AuthRequest): AuthMagnet {type Result = V2AuthResponse} = new AuthMagnet { override def apply(): Result = { // v2 版本的auth 業務委託到magnet的apply中實現 println("這是V2 Auth Service") V2AuthResponse() } override type Result = V2AuthResponse } } 
  4. 編寫兩個版本的資源接口(demo)

    trait V1Resource extends V1AuthService { def serv(): Unit = { val p = V1AuthRequest() val response = auth(p) println(s"v1 resource response: $response") } } trait V2Resource extends V2AuthService { def serv(): Unit = { val p = V2AuthRequest() val response = auth(p) println(s"v2 resource response: $response") } } val res1 = new V1Resource {} val res2 = new V2Resource {} res1.serv() res2.serv() 

    控制檯輸出結果爲:

    這是V1 Auth Service
    v1 resource response: V1AuthResponse()
    這是V2 Auth Service
    v2 resource response: V2AuthResponse()

Method Injection

Method Injection 暫且翻譯爲方法注入,意思是給一個類型添加沒有定義的方法,實際上也是經過隱式轉換來實現的,
這種技術在Scalaz中普遍使用,Scalaz爲咱們提供了和Haskell相似的函數式編程庫。

本文中的關於printWithSeperator方法的例子其實就是Method Injection的應用,從表面上看,便是給String
Int類型添加了printWithSeperator方法。

Magnet Pattern不一樣的是轉型所針對的對象,Magnet Pattern是針對方法參數進行轉型,
Method Injection是針對調用對象進行轉型。

舉個簡單的例子,Scala中的集合都是一個Functor,均可以進行map操做,可是Java的集合框架卻沒有,
若是須要對java.util.ArrayList等進行map操做則須要先轉換爲Scala對應的類型,很是麻煩,藉助Method Injection,咱們能夠提供這樣的輔助工具,讓Java的集合框架也成爲一種Functor,具有map能力:

  1. 首先定義一個Functor
    trait Functor[F[_]] { def map[A, B](fa: F[A])(f: A ⇒ B): F[B] } 
  2. 再定義一個FunctorOps
    final class FunctorOps[F[_], A](l: F[A])(implicit functor: Functor[F]) { def map[A, B](f: A ⇒ B): F[B] = functor.map(l)(f) } 
  3. 在FunctorOps的伴生對象中定義針對java.util.List[E]的隱式Funcotr實例和針對java.util.List[E]到
    FunctorOps的隱式轉換方法
    object FunctorOps { // 針對List[E]的functor implicit val jlistFunctor: Functor[JList] = new Functor[JList] { override def map[A, B](fa: JList[A])(f: (A) => B): JList[B] = { val fb = new JLinkList[B]() val it = fa.iterator() while(it.hasNext) fb.add(f(it.next)) fb } } // 將List[E]轉換爲FunctorOps的隱式轉換方法 implicit def jlistToFunctorOps[E](jl: JList[E]): FunctorOps[JList, E] = new FunctorOps[JList, E](jl) } 
  4. 愉快滴使用map啦
    val jlist = new util.ArrayList[Int]() jlist.add(1) jlist.add(2) jlist.add(3) jlist.add(4) import FunctorOps._ val jlist2 = jlist map (_ * 3) println(jlist2) // [3, 6, 9, 12] 

總結

Implicit 是Scala語言中處理編譯類型錯誤的一種修復機制,利用該機制,咱們能夠編寫出任意參數和返回值的多態方法(這種多
態也被稱爲Ad-hoc polymorphism -- 任意多態),實現任意多態,咱們一般使用Magnet Pattern磁鐵模式;同時還能夠
給其餘類庫的類型添加方法來對其餘類庫進行擴展,一般將這種技術稱之爲Method Injection

參考資料

  1. 《Programming in Scala》中關於隱式轉換和隱式參數章節: http://www.artima.com/pins1ed/implicit-conversions-and-parameters.html
  2. 《The Magnet Pattern》http://spray.io/blog/2012-12-13-the-magnet-pattern/
相關文章
相關標籤/搜索