牧童的思恋

博客园 首页 新随笔 联系 订阅 管理

Scala中的implicit关键字对于我们初学者像是一个谜一样的存在,一边惊讶于代码的简洁,

一边像在迷宫里打转一样地去找隐式的代码,因此我们团队结合目前的开发工作,将implicit
作为一个专题进行研究,了一些心得。

在研究的过程当中,我们注重三方面:

  1. 为什么需要implicit?
  2. implicit 包含什么,有什么内在规则?
  3. implicit 的应用模式有哪些?

为什么需要Implicit?

Scala在面对编译出现类型错误时,提供了一个由编译器自我修复的机制,编译器试图去寻找
一个隐式implicit的转换方法,转换出正确的类型,完成编译。这就是implicit的意义。

我们正在做Guardian系统的升级,Guardian是公司内部的核心系统,提供统一权限管控、
操作审计、单点登录等服务。系统已经有4年多的历史了,已经难以满足目前的需要,比如:
当时仅提供了RESTFul的服务接口,而随着性能需求的提高,有些服务使用Tcp消息完成远程
调用;另外,在RESTFull接口的协议方面,我们也想做一些优化。

而现状是公司内部系统已经全部接入Guardian,要接入新版,不可能一次全部迁移,甚至
要花很长一段时间才能完成迁移工作,因此新版接口必须同时支持新老两个版本接口协议。

因此我们必须解决两个问题:

  1. 兼容老版本协议, 以便能够平滑升级
  2. 支持多种协议,以满足不同业务系统的需求

我们希望对接口层提供一个稳定的Service接口,以免业务的变动影响前端接口代码,常规
的做法是我们在Service接口上定义多种版本的方法(重载),比如鉴权服务:

trait AuthService {
    // 兼容老版本的鉴权业务方法
    def auth(p: V1HttpAuthParam): Future[V1HttpAuthResult]

    // 新版本的鉴权业务方法
    def auth(p: V2HttpAuthParam): Future[V2HttpAuthResult]

    // 新版本中支持的对Tcp消息鉴权的业务方法
    def auth(p: V2TcpMsg): Future[V2TcpMsg]
}

这种做法的问题在于一旦业务发生变化,出现了新的参数,势必要修改AuthService接口,
添加新的接口方法,接口不稳定。

假如有一个通用的auth方法就好了:

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/
posted on 2019-04-18 17:07  牧童的思恋  阅读(937)  评论(0编辑  收藏  举报