在很多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
方法对于数据进行显示转换:
1 | val converToInt:Int = 3.6.toInt |
但是, 当一段代码的很多处都存在对这种数据转换的需求,我们就不得不在每一处都附带上 toXXX
。 这也是引入隐式转换机制的一大原因:编译器在编译期间能够自动地识别出需要隐式转换的代码,而程序开发者需要提供对应的转换方式:隐式转换函数,隐式类。
隐式转换函数实现转换数据类型
继续上个例子来讨论, 我们希望 Double
到 Int
的数据转换能够让编译器自行处理(即隐式转换), 而不需要每一次都手动地通过 .toInt
来实现。 这里要引入一个全新的关键字 implicit
:
1 | implicit def convertDoubleToInt(doubleNumber: Double):Int = doubleNumber.toInt |
其中,函数名可以自行定义, 能够体现功能即可, 因为编译器仅依赖函数签名来搜索合适的隐式函数, 签名指函数的参数列表和返回值。 隐式转换函数是典型的 Function<T, R>
类型, 传入需要被处理(或称被转换)的 T
类型参数, 并生产(或称提供)出被转换后的R
类型的值。
需要注意的是: 隐式转换函数在声明它的当前作用域以及子域内自动生效。因此在声明一个隐式转换函数时, 尤其要注意要向上的作用域中是否定义了签名重复的隐式转换函数。
如果在主函数内声明该隐式转换函数,则主函数内所有由 Double
到 Int
的赋值都将由编译器通过 convertDoubleToInt
函数来自动替换。
1 | object ImplicitTest { |
注意,在开发中, 我们应尽可能在最小的适用范围内定义隐式转换函数, 避免隐式转换函数对其他作用域也造成影响。
利用隐式转换实现Map映射
隐式转换是典型的Function<T, R>
型函数,我们可以稍加利用, 并实现 Map映射的功能。 举个例子:举个例子:有具备 age
和 name
属性的学生类 Student
。现在希望当用字符串引用studentInfo
去指向 Student
实例的时候,编译器能优雅地做如下处理:将这个学生的信息拼接成字符串并返回给studentInfo
引用,而不是报错。
1 | case class Student(name: String, age: Int) |
隐式转换函数的 OCP 哲学
OCP 原则,即开闭原则,对修改关闭,对拓展开放。
用一个例子来说明:假设现在有 JDBC
的原生组件,我们希望在不修改原本的核心代码的基础上,对 JDBC
进行一些额外功能拓展。在 Scala 中,这样的需求可以使用隐式函数来实现。给定不可修改的模拟 JDBC
类:
1 | final class JDBC { |
我们将新功能 “外挂” 到一个功能更强大的类 Mybatis
上。它不仅具备 JDBC
的原有内容,还新增了自己的属性和方法:
1 | class MyBatis(val url : String){ |
在主函数中声明一个将 JDBC
升级为 Mybatis
的隐式转换函数:
1 | implicit def JDBC_(jDBC: JDBC) :MyBatis =new MyBatis(jDBC.url) |
随后,在主函数域内定义的所有 JDBC
实例便可以使用 Mybatis
带来的新功能和新属性了。
1 | jdbc.Pool() |
从视觉效果来看,JDBC
好像是直接获得了 Mybatis
的功能,实则是编译器通过隐式转换将 JDBC
“调包” 成了 Mybatis
组件。
1 | JDBC_(jdbc).Pool() |
如此一来,便很容易理解了:又是 Scala 惯用的 “移花接木” (换个文雅的称呼,即 “装饰者模式“ )的伎俩。
回忆之前所学的动态混入特质,它其实也可以实现类似的目的:
1 | trait enhancedJDBC{ |
对于需要拓展功能的 JDBC
实例,只需要动态混入该特质即可。我个人的理解是:特质更偏向于动态插拔灵活组装,而隐式函数转换则偏向于对外隐匿繁琐的转换细节。不过注意,过度使用隐式转换会大大降低代码的可读性。
注:针对这个例子,我们还可以使用隐式类来实现。
隐式转换函数总结要点
隐式函数的名字不会到影响编译器的定位,它只依赖函数签名来匹配合适的隐式转换函数。
隐式函数之间的签名要区别开,不要产生二义性。
利用隐式函数可以实现映射或者装饰者功能。
隐式函数不能递归调用自身。
隐式类
Scala 2.10 版本后还可以用 implicit
关键字声明类,则这个类称之为隐式类。隐式类从用法,及其设计思想来看,和隐式函数没有太大区别,只不过它是将针对某一个数据类型的一系列增强方法和属性封装到了另一个结构体当中(OOP 思想)。
隐式类的特点:
- 构造方法的参数列表内有且只有一个参数,该参数的类型决定了此隐式类要增强的目标类型
- 隐式类不能是顶级的类(top-level objects), 它总是作为内部类或局部成员出现
- 隐式类不可以做模板类(模板类和模式匹配有关)
- 作用域内不能有同名方法,同名内部类
- 它能实现的功能用隐式转换函数也可以去实现
从底层编译的角度观察隐式类
我们使用隐式类重新实现 JDBC
升级为 MyBatis
的例子。
1 | //隐式类必须有且只有一个参数,该参数类型是要被拓展的类。 |
刚才提到,我们无法在.scala
文件中对隐式类做顶级声明(指将它当作是一个独立的类来声明)。因为在底层,隐式类总会被当作是一个内部类被编译。
1 | package scalaTest; |
我们无需纠结 .class
文件中大量的 $
符号,仅需知道编译器会在隐式类生效的作用域内声明一个 JDBC -> Mybatis
的隐式转换函数。当 JDBC
对象调用了 Mybatis
的成员时,则自动使用该隐式转换函数,使用 Mybatis
实例替换掉 JDBC
。
1 | package scalaTest; |
隐式转换函数和隐式类总结
隐式转换的时机
- 当引用类型和实际指向的对象不属于同一个类,也不能转换成上转型对象时。
- 一个类使用了自身不存在的属性或方法。
- 使用了视图界定或者是上下文界定(它和泛型有关系,泛型会在后续的blog中分析,因为它有一些概念要比 Java 更加复杂)。
隐式转换的运行机制
如果形如 S = T
的赋值发生了隐式转换:
- 编译器会在首先在上下文环境下查找可用的隐式类,隐式函数。
- 如果没有在上下文中找到,则会深入到
T
类型内部寻找可用的隐式转换规则 ,且情况更加复杂:- 如果类型
T
混入了特质,则在隐式解析T
的过程中,编译器会将这些特质也全部搜索一遍。 - 如果
T
包含了类型参数,比如List[String]
,则隐式转换时List
和String
都会被编译器搜索。 - 如果
T
是一个路径依赖类型instance.T
,则编译器会搜索对象instance
和内部类T
。 - 如果
T
是一个使用类型投影的内部类Clazz#T
,则编译器会搜索Clazz
类和内部类T
。
- 如果类型
一般情况下,都应该尽可能让编译器通过第一种方式就可以找到合适的转换规则。否则,不仅会增大编译器的工作负担,也会让后续的代码维护者难以定位到隐式转换的具体声明位置。
隐式值和隐式参数
定义隐式值
隐式值用于自定义某个数据类型的默认赋值,并配合隐式参数来使用。定义隐式值需要在前面加上 implicit
关键字。
1 | //绝大部分情况,隐式值都是不允许被篡改的,因此我们使用 val 而非 var。 |
现定义一个新的隐式函数,然后在参数列表中开头同样加上 implicit
关键字表示:这个参数列表里所有的参数全部为隐式参数,换句话说,string
和 int
全都是隐式参数。
1 | def usingImplicitValue(implicit string: String,int: Int): Unit = { |
隐式参数意味着当调用该函数且没有显式地传入形参时,其值由上下文环境中定义的隐式值来提供。隐式变量同样可以声明默认参数值,类似这种写法:
1 | def usingImplicitValue(implicit string: String = "null String" ,int: Int = -1): Unit = { |
这样,当编译器没有在上下文找到可用的隐式转换时,就会使用默认参数值。
区别不同的概念
不要和将隐式值和类声明内部的默认值相混淆。
1 | class Clazz{ |
默认值和隐式值的用途并不一样:
- 默认值在初始化值时使用,它的值都是由 Scala 给定的:如
Int
的默认值固定为0
,引用类型的默认值默认为null
。 - 隐式值用于程序开发者在某个上下文中规定某种数据类型的默认值,换句话说,开发者可以通过隐式值规定
Int
的默认值为-1
,而非0
。
同样的,隐式值和默认参数值也不同。比如说下面的 int
仅具备默认参数值:
1 | //此为默认参数值。 |
它和隐式值的区别是:
- 默认参数值仅在调用此函数,且没有为指定参数显式赋值时才生效。
- 隐式值可用在作用域内任何一个声明了隐式参数且类型匹配的函数入参中。
使用隐式值和隐式参数细节
隐式值和隐式参数的使用细节比较繁琐:
和隐式转换函数类似,同一个域及其子域内只允许存在一种数据类型的隐式值。当程序编译时报出 ambigouous implicit values
错误时,说明同一个数据类型的隐式值存在多个。因此当在小作用域内声明隐式值时,也要注意向上的大作用域内是否已经存在同类型的隐式值。
包含隐式参数的形参列表,在调用函数时可以省略不写,表示其隐式参数全部使用上下文提供的隐式值。
1 | //如果所有参数均使用隐式参数自动赋值,则不带括号。 |
但是如果想要让隐式参数的值由默认参数值来提供,则需要带上空括号 ()
,前提是隐式参数具备默认参数值。
1 | //--------------修改函数----------------// |
另极力建议,若参数列表仅部分参数有默认值,则赋值的时候应通过 name = value
的写法明确表明将哪个值 value
赋值给哪个变量 name
。
1 | def usingImplicitValue(explicitInt : Int = 100, explicitDouble : Double)( implicit double : Double, int: Int =12): Unit = { |
如上述代码块所示,如果一个函数既存在普通参数,又存在隐式参数,则应该用分开的参数列表来表示,并且隐式参数的参数列表总是在最后一个位置。同一个参数列表里不能同时存在隐式参数和非隐式参数。如果某个参数列表的开头出现了 implicit
关键字,则说明该列表内的所有参数都是隐式参数。
这样的函数在调用时需要使用多个小括号 ()
表示的参数列表分别进行赋值。
1 | def usingImplicitValue(explicitInt : Int = 100)( implicit double : Double =10.00, int: Int=100): Unit = { |
总结三条
- 当某个参数列表内部以
implicit
关键字开头时,表示该列表内部都是隐式参数。在调用函数时不需要使用()
为包含隐式参数的参数列表再单独赋值,除非你要显式地覆盖掉它们。上下文必须要声明对应每一个隐式参数的隐式值,或者隐式参数具有参数默认值。否则会提示错误:could not find implicit value for parameter
。 - 编译器优先在上下文环境中寻找匹配的隐式值,然后才会尝试寻找默认参数值。如果隐式参数既没有对应的隐式值,也没有行内的默认值,调用函数也没有
()
主动传参时,则编译器会报错。然而,不建议隐式参数和默认参数值混用,因为这样的代码会非常的混乱。 - 其它没有使用
implicit
关键字开头的参数列表(即通常意义上的参数列表),则在调用时要么主动为参数赋值,要么参数具有行内的默认参数值。若两者都不存在,则报错:not enough arguments for method xxx
。
自定义操作符
Scala 的特点是允许使用字母外的符号作为函数标识符。我们可以选择对 +
,-
,*
,\
进行操作符重载,或者是重现 ++
,--
等其它语言中常使用的 “自增”,”自减” 功能,或者是实现简单的内部 DSL 特性。在后续的 blog 中会通过解析器组合子进一步阐述 DSL 的基本实现,它将是隐式转换和函数式编程的综合应用。
自定义中置运算符
对于参数列表仅有一个参数的函数,可以将该函数的标识符理解成一个双目运算符 (或称中置运算符) :第一个参数是调用此函数的对象本身 ( this
) ,第二个参数是参数列表中的那个唯一参数。
举个例子,下面有一个 Wallet
类,它重载了 +
符号,该函数接收另一个 Wallet
实例作为参数。现在 +
的语义是:将当前的 Wallet
钱包对象和另一个 Wallet
钱包对象的余额相加,并返回一个新的 Wallet
对象。
1 | object OperatorOverride { |
在之前,我们通常需要使用这种方式实现调用:
1 | val wallet3 : Wallet = wallet1.+(wallet2) |
不过,Scala 还留下了一个有趣的语法糖。我们可以用中缀表达式写法来替换掉 .
访问符的方式:
1 | //实际上是相当于 wallet1.+(wallet2) |
这对于下文的后置运算符,前置运算符也同理。
自定义后置运算符
如果我们定义的函数没有参数,则可以将该函数的标识符理解成不需要另一个操作数的后置运算符(单目运算符),典型的是我们在其它语言中常用的 ++
,或者是 --
等。
1 | //Wallet内新增方法: |
不过,编译器可能会将 wallet ++
理解成是一个没有写完的中缀表达式,并将下一行语句当作是入参而引发错误(因为 ++
方法不需要其它参数)。为了避免这种误解,使用后置运算符之后在行末尾最好加上 ;
收尾。
自定义前置运算符
同理,我们也可以重写一些典型的前置运算符,比如取反操作符号 !
。我们可以在某个类中重新定义前置运算符 !
表示的实际含义:比如声明!wallet
代表将这个钱包的余额清空。注意声明前置运算符时,函数标识符前面还需要额外加一个前缀 unary_
。
其它可以用于充当前置运算符的还有:+
, -
, ~
。和中置运算符,后置运算符不同的是,除了此四个符号以外的其它符号/英文标识符不可以用于前置运算符。
1 | //Wallet内新增方法: |
案例:实现隐式地单位转换
对于大部分工具而言,它们设定的时间参数都是以 “毫秒” 为单位的。比如让当前线程睡眠 3 秒钟 ,需要换算成以毫秒为单位的 3000
作为参数传递进去:
1 | Thread.sleep(3000) |
现在尝试实现这样的语法糖:用 3 second
这种 “数值 + 单位” 的写法来替换掉 3000
,让程序变得更具有可读性。
时间的数值部分使用 Int
类型来表示,因此我们可以创建一个隐式类(或者隐式函数),它能够接收表示时间值的 Int
数据,当调用其 second
, minute
等后置运算符( 我们从习惯上称它们是 “单位” ),让程序自动根据单位将其转化为对应的毫秒数值。下面给出代码的实现:
1 | implicit class TimeDuration(millis_ : Int) { |
现在我们想要表达 3 秒钟 ,仅需要用这样的替代表示:3 second
。而想要表达 1 分钟,仅需要用 1 minute
来表述,而不是 60 * 1000
。我们只需要对 millis_
本身进行进制转换,而不依赖外部的任何其它变量,因此定义的 millis
, seconds
等函数都是不需要括号的无参数函数。
1 | // 数值 + 单位 的表示法更符合人们理解的逻辑。 |
至于为什么有选择无参数函数,有时却又选择空括号函数,这其实取决于该函数本身会不会产生副作用。