Scala 隐式转换

1,094 阅读12分钟

在 Scala 高级应用中,隐式转换是常用操作。它本质上以 OCP 开闭原则为核心思想,其用法也非常多样,我们可以利用它来实现装饰者模式,或者实现映射功能,或者选择将繁琐,晦涩,对于代码调用者而言无需深入理解的代码部分隐藏掉。

此外,由于 Scala 支持使用符号作为函数标识符,结合隐式转换可以实现相当多的内部语法特性(也可以称它是内部 DSL )。尤其对于集合以及元素操作而言,Scala 便用大量精简的符号替代掉了 concat , add , remove 等相对冗长的英文函数名,而我们仅需要像写简单算式一样就能实现集合操作。比如,对于 Scala 的 Map 集合,我们可以使用 key -> value 的写法来生动地表示存储了一个键值对。然而 -> 并不是 Scala 本身支持的语法或者是符号,而是利用隐式转换包装出的一个 “语法糖” 。为什么要这样做?在大部分情况下,函数标识符使用简单符号作为助记符要比一大串的英文单词直观得多。这对于追求简洁,精巧的 Scala 而言最合适不过了。再比如说我们更倾向于使用 1 + 1 来表达 "1 加 1",而不是 1 add 1 或者 1.add(1)

在本章,笔者会同时介绍隐式转换和自定义操作符的相关概念,并通过一个自实现 "单位转换" 的实例来感受隐式转换的妙用。隐式转换为 Scala 提供灵活性的同时,还有不可言状的 "神秘感",以至于在你很难直接理解一些 Scala 库的 "魔术" 代码,原因就是库内部存在着大量的隐式转换。

隐式转换函数

首先从一个最基本的例子开始说起。Scala 的所有数据类型都提供了 toXXX 方法用于对数据进行显式转换:

val double2Int: Int = 3.5.toInt
println(s"double2Int = ${double2Int}")

但是,当一段代码的很多处都存在对这种数据转换的需求,我们就不得不在每一处都附带上 toXXX 方法。这也是引入隐式转换机制的一大原因:编译器在编译期间能够自动地识别出需要隐式转换的代码,而程序开发者需要提供对应的转换方式:隐式转换函数,隐式类。

对于发生了隐式转换的代码,IDEA 编译器会使用 下划线 标注出来。

隐式转换函数实现转换数据类型

继续上个例子来讨论,我们希望 DoubleInt 的数据转换能够让编译器自行处理(即隐式转换),而不需要每一次都手动地通过 .toInt 来实现。这里要引入一个全新的关键字implicit

implicit def putInt(in : Double):Int= in.toInt

其中,函数名可以自行定义,能够体现功能即可,因为编译器仅依赖函数签名来搜索合适的隐式函数,签名指函数的参数列表,和返回值。隐式转换函数是典型的 Function <T,R> 类型,传入需要被消费(或称被转换)的 T 类型参数 ,并生产(或称提供)出被转换后的 R 类型的值。这里的签名不同指代 TR 有任意一种不同。

需要注意的一点是,隐式转换函数在声明它的当前作用域以及子域内自动生效。因此在声明一个隐式转换函数时,尤其要注意向上的作用域中是否定义了签名重复的隐式转换函数。

如果在主函数内声明该隐式转换函数,则主函数内所有由 Double Int 的赋值都将由编译器通过 putInt 函数来自动替换。

  def main(args: Array[String]): Unit = {

    implicit def putInt(in : Double):Int= in.toInt
	
    //没有报错,是因为发生了隐式转换。
    val int: Int = 3.5
    println(s"double2Int = $int")
  }

注意,在开发中,我们应尽可能在最小的适用范围内定义隐式转换函数,避免隐式转换函数对其它作用域也造成影响。

从反编译文件中观察隐式转换函数

在不同的作用域下(一个在类内,一个在函数内)定义两个重名的 putInt 隐式转换函数,编译该 .scala 文件,然后使用 Jd-gui 反编译工具查看这两个方法是如何定义的:

package scalaTest

object ImplicitTransform {

  def main(args: Array[String]): Unit = {
    //在函数内再声明一个函数。
    implicit def putInt(in : Double):Int= in.toInt

    val int: Int = 3.5
    println(s"double2Int = $int")
  }
  //在类内声明一个函数。
  implicit def putInt(in:Float) : Int = in.toInt
}

下面贴出 ImplicitTransform$.class 文件的部分内容:

public final class ImplicitTransform$
{
  //...忽略MODULE$部分
  public void main(String[] args)
  {
    int int = putInt$1(3.5D);
    //...打印数据
  }
  public int putInt(float in) {
    return (int)in;
  }

  private final int putInt$1(double in)
  {
    return (int)in;
  }
}

其主函数内部的隐式转换函数 putInt 在编译时被移动到了 main 方法外部,并声明为了 putInt$1 (避免和类内的 putInt 方法冲突),使用 final 关键字保护了起来。编译器会在 main 方法内寻找需要使用强制转换的代码,并使用 putInt$1 函数进行替换。

利用隐式转换实现 map 映射

隐式转换是典型的 Function<T,R> 型函数,我们可以稍加利用,并实现 map 映射的功能。举个例子:有具备 agename 属性的学生类 Student。现在希望当用字符串引用studentInfo去指向 Student 实例的时候,编译器能优雅地做如下处理:将这个学生的信息拼接成字符串并返回给studentInfo引用,而不是报错。

implicit def printStudent(student: Student) = student.name + "-" + student.age

//用字符串去接受一个Student对象。此时编译器会调用printStudent隐式转换函数自动处理。
val studentInfo: String = new Student(age = 23, name = "Wang Fang")
println(s"studentInfo = ${studentInfo}")

控制台最后会显示:studentInfo = Wang Fang-23

隐式转换函数中的 OCP 哲学

OCP 原则,即开闭原则,对修改关闭,对拓展开放。

直接用一个例子来说明:假设现在有 JDBC 的原生组件,我们希望在不修改原本的核心代码的基础上,对 JDBC 进行一些额外功能拓展。在 Scala 中,这样的需求可以使用隐式函数来实现。给定不可修改的模拟 JDBC 类:

final class JDBC{
  val url : String = "127.0.0.1"
}

我们将新功能 "外挂" 到一个功能更强大的类 Mybatis 上。它不仅具备 JDBC 的原有内容,还新增了自己的属性和方法:

class MyBatis(val url : String){

  val maxConnection : Int = 10

  def Pool():Unit={
      print(s"获取连接池信息:$url")
  }
}

在主函数中声明一个将 JDBC 升级为 Mybatis 的隐式转换函数:

implicit def JDBC_(jDBC: JDBC) :MyBatis =new MyBatis(jDBC.url)

随后,在主函数域内定义的所有 JDBC 实例便可以使用 Mybatis 带来的新功能和新属性了。

jdbc.Pool()
println(jdbc.maxConnection)

从视觉效果来看,JDBC 好像是直接获得了 Mybatis 的功能,实则是编译器通过隐式转换将 JDBC "调包" 成了 Mybatis 组件。

JDBC_(jdbc).Pool()

如此一来,便很容易理解了:又是 Scala 惯用的 "移花接木" (换个文雅的称呼,即 "装饰者模式" )的伎俩。

回忆之前所学的动态混入特质,它其实也可以实现类似的目的:

trait enhancedJDBC{

  this:JDBC=>
  def Pool() : Unit ={
    print(s"获取连接池信息:$url")
  }
  val maxConnection : Int = 10
}

对于需要拓展功能的 JDBC 实例,只需要动态混入该特质即可。笔者的理解是:特质更偏向于动态插拔灵活组装,而隐式函数转换则偏向于对外隐匿繁琐的转换细节。不过注意,过度使用隐式转换会大大降低代码的可读性。

注:针对这个例子,我们还可以使用隐式类来实现。

隐式转换函数总结要点

  1. 隐式函数的名字不会到影响编译器的定位,它只依赖函数签名来匹配合适的隐式转换函数。
  2. 隐式函数之间的签名要区别开,不要产生二义性。
  3. 利用隐式函数可以实现映射或者装饰者功能。
  4. 隐式函数不能递归调用自身。

隐式类

Scala 2.10 版本后还可以用 implicit 关键字声明类,则这个类称之为隐式类。隐式类从用法,及其设计思想来看,和隐式函数没有太大区别,只不过它是将针对某一个数据类型的一系列增强方法和属性封装到了另一个结构体当中(OOP 思想)。

隐式类的特点

  1. 构造方法的参数列表内有且只有一个参数,该参数的类型决定了此隐式类要增强的目标类型。

  2. 隐式类不能是顶级的类 ( top-level objects ),它总是作为内部类或局部成员出现。

  3. 隐式类不可以做模板类( 模板类和模式匹配有关 )。

  4. 作用域内不能有同名方法,同名内部类。

  5. 它能实现的功能用隐式转换函数也可以去实现。

从底层编译的角度观察隐式类

我们使用隐式类重新实现 JDBC 升级为 MyBatis 的例子。

//隐式类必须有且只有一个参数,该参数类型是要被拓展的类。
implicit class Mybatis(jDBC: JDBC){
	
  val url: String = jDBC.url
  val maxConnection : Int = 10

  //内部可以定定义拓展的功能。
  def Pool(): Unit ={
    println("获取连接池")
  }

  def delete():Unit ={
    println("删除数据")
  }
}
//------------主函数的调用部分-----------------//
val jdbc = new JDBC
jdbc.Pool()
println(jdbc.maxConnection)

刚才提到,我们无法在.scala文件中对隐式类做顶级声明(指将它当作是一个独立的类来声明)。因为在底层,隐式类总会被当作是一个内部类被编译。

package scalaTest;
//...
public class ImplicitTransform$Mybatis$2
{
  private final String url;
  private final int maxConnection;

  //对val变量只编译 get 方法。
  public String url(){return this.url; } 
  public int maxConnection(){ return this.maxConnection; }

  //Mybatis拓展的新方法  
  public void Pool(){//...
  }
  public void delete() {//... 
  }
  
  //构造函数
  public ImplicitTransform$Mybatis$2(JDBC jDBC)
  {
    this.url = jDBC.url();
    this.maxConnection = 10;
  }
}

我们无需纠结 .class 文件中大量的 $ 符号,仅需知道编译器会在隐式类生效的作用域内声明一个 JDBC -> Mybatis 的隐式转换函数。当 JDBC 对象调用了 Mybatis 的成员时,则自动使用该隐式转换函数,使用 Mybatis 实例替换掉 JDBC

package scalaTest;
//忽略部分import
public final class ImplicitTransform$
{
  //忽略MODULE$相关的代码
  public void main(String[] args)
  {
    JDBC jdbc = new JDBC();
    Mybatis$1(jdbc).Pool();
   	//忽略其它代码
  }
  //在使用隐式类的地方又声明了一个隐式转换函数。
  private final ImplicitTransform.Mybatis$2 Mybatis$1(JDBC jDBC)
  {return new ImplicitTransform.Mybatis$2(jDBC);}
}

综述:隐式转换函数和隐式类

隐式转换的时机

  1. 当引用类型和实际指向的对象不属于同一个类,也不能转换成上转型对象时。

  2. 一个类使用了自身不存在的属性或方法。

  3. 使用了视图界定或者是上下文界定(它和泛型有关系,泛型章节笔者会在 Scala 的最后几篇文章中给出,因为它有一些概念要比 Java 更加复杂)。

隐式转换的运行机制

如果形如 S = T 的赋值发生了隐式转换:

  1. 编译器会在首先在上下文环境下查找可用的隐式类,隐式函数。

  2. 如果没有在上下文中找到,则会深入到 T 类型内部寻找可用的隐式转换规则 ,且情况更加复杂:

    • 如果类型 T 混入了特质,则在隐式解析 T 的过程中,编译器会将这些特质也全部搜索一遍。
    • 如果 T 包含了类型参数,比如 List[String] ,则隐式转换时 ListString 都会被编译器搜索。
    • 如果 T 是一个路径依赖类型 instance.T ,则编译器会搜索对象 instance 和内部类 T
    • 如果 T 是一个使用类型投影的内部类 Clazz#T,则编译器会搜索 Clazz 类和内部类 T

一般情况下,都应该尽可能让编译器通过第一种方式就可以找到合适的转换规则。否则,不仅会增大编译器的工作负担,也会让后续的代码维护者难以定位到隐式转换的具体声明位置。

隐式值和隐式参数

定义隐式值

隐式值用于自定义某个数据类型的默认赋值,并配合隐式参数来使用。定义隐式值需要在前面加上 implicit 关键字。

  //绝大部分情况,隐式值都是不允许被篡改的,因此我们使用 val 而非 var。
  implicit val defaultString: String = "null string."
  implicit val defaultInt : Int = 200
  implicit val defaultDouble : Double = 200.00d

现定义一个新的隐式函数,然后在参数列表中开头同样加上 implicit 关键字表示:这个参数列表里所有的参数全部为隐式参数,换句话说,stringint 全都是隐式参数。

def usingImplicitValue(implicit string: String,int: Int): Unit = {
  println(string)
}

隐式参数意味着当调用该函数且没有显式地传入形参时,其值由上下文环境中定义的隐式值来提供。隐式变量同样可以声明默认参数值,类似这种写法:

def usingImplicitValue(implicit string: String = "null String" ,int: Int = -1): Unit = {
  println(string)
}

这样,当编译器没有在上下文找到可用的隐式转换时,就会使用默认参数值。

区别不同的概念

不要和将隐式值和类声明内部的默认值相混淆。

class Clazz{
  // _ 占位符表示赋默认值。
  val value : Int = _
}

默认值和隐式值的用途并不一样:

  1. 默认值在初始化值时使用,它的值都是由 Scala 给定的:如 Int 的默认值固定为 0 ,引用类型的默认值默认为 null
  2. 隐式值用于程序开发者在某个上下文中规定某种数据类型的默认值,换句话说,开发者可以通过隐式值规定 Int 的默认值为 -1 ,而非 0

同样的,隐式值和默认参数值也不同。比如说下面的 int 仅具备默认参数值:

//此为默认参数值。
def function(int : Int = 100): Int ={int * 2}

它和隐式值的区别是:

  1. 默认参数值仅在调用此函数,且没有为指定参数显式赋值时才生效。
  2. 隐式值可用在作用域内任何一个声明了隐式参数且类型匹配的函数入参中。

使用隐式值和隐式参数细节

隐式值和隐式参数的使用细节比较繁琐:

和隐式转换函数类似,同一个域及其子域内只允许存在一种数据类型的隐式值。当程序编译时报出 ambigouous implicit values 错误时,说明同一个数据类型的隐式值存在多个。因此当在小作用域内声明隐式值时,也要注意向上的大作用域内是否已经存在同类型的隐式值

包含隐式参数的形参列表,在调用函数时可以省略不写,表示其隐式参数全部使用上下文提供的隐式值。

//如果所有参数均使用隐式参数自动赋值,则不带括号。
usingImplicitValue

但是如果想要让隐式参数的值由默认参数值来提供,则需要带上空括号 () ,前提是隐式参数具备默认参数值。

//--------------修改函数----------------//
def usingImplicitValue(implicit string: String = "default value"): Unit = {
    println(string)
}
//----------------主函数----------------//
//这种写法表示参数列表内全部都采用隐式值,无论默认参数值是否存在。
usingImplicitValue

//这种写法会调用行内的默认参数值。
usingImplicitValue()

另笔者极力建议,若参数列表仅部分参数有默认值,则赋值的时候应通过 name = value 的写法明确表明将哪个值 value 赋值给哪个变量 name

def usingImplicitValue(explicitInt : Int = 100, explicitDouble : Double)( implicit double : Double,  int: Int =12): Unit = {
      println(double)
      println(int)
      println(explicitInt)
}
//使用 name = value 的格式指明赋值的参数和值。
usingImplicitValue(explicitDouble = 23)(double = 12)

如上述代码块所示,如果一个函数既存在普通参数,又存在隐式参数,则应该用分开的参数列表来表示,并且隐式参数的参数列表总是在最后一个位置。同一个参数列表里不能同时存在隐式参数和非隐式参数。如果某个参数列表的开头出现了 implicit 关键字,则说明该列表内的所有参数都是隐式参数。

这样的函数在调用时需要使用多个小括号 () 表示的参数列表分别进行赋值。

def usingImplicitValue(explicitInt : Int = 100)( implicit double : Double =10.00,  int: Int=100): Unit = {
    //第一个参数列表的所有参数都是非隐式参数。
	println(explicitInt)
    
    //第二个参数列表的所有参数都是隐式参数。
	println(double)
	println(int)    
}
//-------------表示全部采用显式赋值-------------------------//
usingImplicitValue(101)(21,23)
//-------------表示全部采用默认参数值-----------------------//
usingImplicitValue()()
//---表示前一个参数列表使用默认参数值,而隐式参数全采用隐式值---//
usingImplicitValue()
//-------------对部分参数进行指定赋值-----------------------//
usingImplicitValue(explicitInt = 101)(int =201)

总结三条

  1. 当某个参数列表内部以 implicit 关键字开头时,表示该列表内部都是隐式参数。在调用函数时不需要使用 () 为包含隐式参数的参数列表再单独赋值,除非你要显式地覆盖掉它们。上下文必须要声明对应每一个隐式参数的隐式值,或者隐式参数具有参数默认值。否则会提示错误: could not find implicit value for parameter
  2. 编译器优先在上下文环境中寻找匹配的隐式值,然后才会尝试寻找默认参数值。如果隐式参数既没有对应的隐式值,也没有行内的默认值,调用函数也没有 () 主动传参时,则编译器会报错。然而,不建议隐式参数和默认参数值混用,因为这样的代码会非常的混乱
  3. 其它没有使用 implicit 关键字开头的参数列表(即通常意义上的参数列表),则在调用时要么主动为参数赋值,要么参数具有行内的默认参数值。若两者都不存在,则报错:not enough arguments for method xxx

案例:实现隐式地单位转换

对于大部分工具而言,它们设定的时间参数都是以 "毫秒" 为单位的。比如让当前线程睡眠 3 秒钟 ,需要换算成以毫秒为单位的 3000 作为参数传递进去:

Thread.sleep(3000)

现在尝试实现这样的语法糖:用 3 second 这种 "数值 + 单位" 的写法来替换掉 3000,让程序变得更具有可读性。

时间的数值部分使用 Int 类型来表示,因此我们可以创建一个隐式类(或者隐式函数),它能够接收表示时间值的 Int 数据,当调用其 second , minute 等后置运算符( 我们从习惯上称它们是 "单位" ),让程序自动根据单位将其转化为对应的毫秒数值。下面给出代码的实现:

implicit class TimeDuration(millis_ : Int) {

    def millis : Int = millis_
    def second : Int = millis_ * 1000
    def minute : Int = millis_ * 60 * 1000

} 

现在我们想要表达 3 秒钟 ,仅需要用这样的替代表示:3 second 。而想要表达 1 分钟,仅需要用 1 minute 来表述,而不是 60 * 1000 。我们只需要对 millis_ 本身进行进制转换,而不依赖外部的任何其它变量,因此定义的 millis , seconds 等函数都是不需要括号的无参数函数

// 数值 + 单位 的表示法更符合人们理解的逻辑。
Thread.sleep(3 second)

至于为什么有时笔者选择无参数函数,有时却又选择空括号函数,这其实取决于该函数本身会不会产生副作用 —— 笔者会在后续的 Scala 函数章节给出具体的阐述。