Scala implicit 隐式转换安全驾驶指南

1,513 阅读4分钟

这篇短文将结合实例对隐式转换的各种场景进行解释和总结,希望看完的人能够安全驶过隐式转换这个大坑。

隐式转换函数

隐式转换函数有两种作用场景。

  • 1 转换为期望类型:就是指一旦编译器看到X,但需要Y,就会检查从X到Y的隐式转换函数。
  • 2 转换方法的调用者:简单来说,如obj.f(),如果obj对象没有f方法,则尝试将obj转换为拥有f方法的类型。
object ImpFunction extends App {

  class Dog(val name: String) {
    def bark(): Unit = println(s"$name say: Wang !")
  }

  implicit def double2int(d: Double): Int = d.toInt

  implicit def string2Dog(s: String): Dog = new Dog(s)

  val f: Int = 1.1 //转换为期望类型,1.1通过double2int转成了Int类型

  println(f)

  "Teddy".bark() // 转换方法的调用者,字符串通过string2Dog转成了Dog, 于是有了bark方法

}
// output
// 1
// Teddy say: Wang !

val f: Int = 1.1 因为类型不匹配,这段本来是无法通过编译的,但是编译器发现存在一个Double至Int的隐式转换函数,所以进行了隐式转换。

"Teddy".bark() String类型本来是没有bark方法的,但是编译器发现了隐式转换string2Dog可以使得String转成一种拥有bark方法的类型,相当于进行了这样的转换:string2Dog("Teddy").bark()

注意事项

需要注意的是,编译器只关心隐式转换函数的输入输出类型,不关心函数名,为避免歧义,同一个作用域中不能有输入输出类型相同的两个隐式转换函数,不然编译器会报错。

隐式类

Scala 2.10引入了一种叫做隐式类的新特性。隐式类指的是用implicit关键字修饰的类。使用情况与隐式转换函数类似,可以看做将类的构造函数定义为隐式转换函数,返回类型就是这个类。

package io.github.liam8.impl

object ImpClass extends App {

  implicit class Dog(val name: String) {
    def bark(): Unit = println(s"$name say: Wang !")
  }

  "Teddy".bark()

}

注意事项

这段来自官网IMPLICIT CLASSES隐式类有以下限制条件:

  • 1 只能在别的trait/类/对象内部定义。
    object Helpers {
       implicit class RichInt(x: Int) // 正确!
    }
    implicit class RichDouble(x: Double) // 错误!

  • 2 构造函数只能携带一个非隐式参数。
    implicit class RichDate(date: java.util.Date) // 正确!
    implicit class Indexer[T](collecton: Seq[T], index: Int) // 错误!
    implicit class Indexer[T](collecton: Seq[T])(implicit index: Index) // 正确!

虽然我们可以创建带有多个非隐式参数的隐式类,但这些类无法用于隐式转换。

  • 3 在同一作用域内,不能有任何方法、成员或对象与隐式类同名。
    注意:这意味着隐式类不能是case class。
    object Bar
    implicit class Bar(x: Int) // 错误!

    val x = 5
    implicit class x(y: Int) // 错误!

    implicit case class Baz(x: Int) // 错误!

隐式参数 & 隐式值

package io.github.liam8.impl

object ImpParam extends App {

  def bark(implicit name: String): Unit = println(s"$name say: Wang !")

  implicit val t: String = "Hot Dog"

  bark

}

参数加上implicit就成了隐式参数,需要与隐式值(变量定义加上implicit)搭配使用,最后一行的bark缺少了一个String类型的参数,编译器找到了String类型的隐式值,便将其传入,相当于执行了bark(t)

implicit关键字会作用于函数列表中的的所有参数,如def test(implicit x:Int, y: Double)这样定义函数,x和y就都成了隐式函数。但是通常我们只希望部分参数为隐式参数,就好比通常会给部分参数提供默认值而不是全部都指定默认值,于是隐式参数常常与柯里化函数一起使用,这样可以使得只有最后一个参数为隐式参数,例如def test(x: Int)(implicit y: Double)

👇是完整的例子。

object ImpParamWithCurry extends App {

  def bark(name: String)(implicit word: String): Unit = println(s"$name say: $word !")

  implicit val w: String = "Wang"

  bark("Hot Dog")

}

注意事项

下面这段来自scala的隐式转换学习总结(详细)

  • 1)当函数没有柯里化时,implicit关键字会作用于函数列表中的的所有参数。
  • 2)隐式参数使用时要么全部不指定,要么全不指定,不能只指定部分。
  • 3)同类型的隐式值只能在作用域内出现一次,即不能在同一个作用域中定义多个相同类型的隐式值。
  • 4)在指定隐式参数时,implicit 关键字只能出现在参数开头。
  • 5)如果想要实现参数的部分隐式参数,只能使用函数的柯里化,
              如要实现这种形式的函数,def test(x:Int, implicit  y: Double)的形式,必须使用柯里化实现:def test(x: Int)(implicit y: Double).
  • 6)柯里化的函数, implicit 关键字只能作用于最后一个参数。否则,不合法。
  • 7)implicit 关键字在隐式参数中只能出现一次,柯里化的函数也不例外!

隐式对象

类似于隐式值, 要结合隐式参数使用。先看一个栗子(下面的代码需要认真体会)。

package io.github.liam8.impl

object ImpObject extends App {

  //定义一个`排序器`接口,能够比较两个相同类型的值的大小
  trait Ordering[T] {
    //如果x<y返回-1,x>y返回1,x==y则返回0.
    def compare(x: T, y: T): Int
  }

  //实现一个Int类型的排序器
  implicit object IntOrdering extends Ordering[Int] {
    override def compare(x: Int, y: Int): Int = {
      if (x < y) -1
      else if (x == y) 0
      else 1
    }
  }

  //实现一个String类型的排序器
  implicit object StringOrdering extends Ordering[String] {
    override def compare(x: String, y: String): Int = x.compareTo(y)
  }

  //一个通用的max函数
  def max[T](x: T, y: T)(implicit ord: Ordering[T]): T = {
      if (ord.compare(x, y) >= 0) x else y
  }

  println(max(1, 2))
  println(max("a", "b"))
}

//output: 
// 2
// b

max函数的作用显然是返回x和y中的最大值,但是x和y的值类型不是固定的,max不知道如何比较x和y的大型,于是定义了一个隐式参数implicit ord: Ordering[T],希望能传入一个Ordering[T]类型的排序器帮助进行x和y的比较。

在调用max(1, 2)的时候,编译器发现需要一个Ordering[Int]类型的参数,刚好`implicit object IntOrdering`定义了一个隐式对象符合要求,于是被用来传入max函数。

隐式对象跟上面的隐式值非常相似,只是类型特殊而已。

在Scala中scala.math.Ordering很常用的内置特质,如果你理解了这段代码,也就大致理解了Ordering的原理。

上下文界定(context bounds)

这是一种隐式参数的语法糖。

再看上面隐式对象的例子,如果要添加一个min函数,大致就是这样

  def min[T](x: T, y: T)(implicit ord: Ordering[T]): T = {
    if (ord.compare(x, y) >= 0) y else x
  }

但是max和min函数的参数都比较长,于是出现了一种简化的写法

  def min[T: Ordering](x: T, y: T): T = {
    val ord = implicitly[Ordering[T]]
    if (ord.compare(x, y) >= 0) y else x
  }

[T: Ordering]这种语法就叫上下文界定,含义是上下文中必须有一个Ordering[T]类型的隐式值,这个值会被传入min函数。但是由于这个隐式值并没有明确赋值给某个变量,没法直接使用它,所以需要一个implicitly函数把隐式值取出来。

implicitly函数的定义非常简单,作用就是将T类型的隐含值返回:

@inline def implicitly[T](implicit e: T) = e

视界

这个语法已经被废弃了,但是你还是可能会看到,简单解释下。

def min[T <% Ordered[T]](x: T, y: T): T = {
    if (x > y) y else x
}

视界的定义T <% Ordered[T]的含义是T可以被隐式转换成Ordered[T],这也是为什么`x > y`可以编译通过。

上面的写法其实等同于下面这样,所以视界的语法不能用了也不要紧。

  def min[T](x: T, y: T)(implicit c: T => Ordered[T]): T = {
    if (x > y) y else x
  }

隐式转换机制

隐式转换通用规则

  • 标记规则:只有标记为implicit的定义才是可用的。
  • 作用域规则:插入的隐式转换必须以单一标识符的形式处于作用域中,或与转换的源或目标类型关联在一起。

单一标识符意思是不能插入形式为someVariable.convert(x)的转换,只能是convert(x)。单一标识符规则有个例外,编译器还将在源类型或转换的期望目标类型的伴生对象中寻找隐式定义。

有点难理解?看个例子!

package io.github.liam8.impl

object ImpCompObject extends App {

  object Dog {
    implicit def dogToCat(d: Dog) = new Cat(d.name)
  }

  class Cat(val name: String) {
    def miao(): Unit = println(s"$name say: Miao !")
  }

  class Dog(val name: String) {
    def bark(): Unit = println(s"$name say: Wang !")
  }

  new Dog("Teddy").miao()

}
//Teddy say: Miao !

当前作用域中没有定义和引入隐式函数,但是在Dog的伴生对象中找到了,所以Dog可以被转成Cat,这个跟上下文没有关系,而是Dog自带技能。

  • 无歧义规则:隐式转换唯有不存在其他转换的前提下有效。
  • 单一调用规则:只会尝试一个隐式操作。
  • 显示操作先行规则:若编写的代码类型检查无误,则不会尝试隐式操作。

转换时机

  • 当类型与目标类型不一致时
  • 当对象调用类中不存在的方法或成员时
  • 缺少隐式参数时

也即是能用到隐式操作的有三个地方:转换为期望类型、指定(方法)调用者的转换、隐式参数。

转换机制

这段来自深入理解Scala的隐式转换

即编译器是如何查找到缺失信息的,解析具有以下两种规则:

  • 1.首先会在当前代码作用域下查找隐式实体(隐式方法 隐式类 隐式对象)
  • 2.如果第一条规则查找隐式实体失败,会继续在隐式参数的类型的作用域里查找
    类型的作用域是指与该类型相关联的全部伴生模块,一个隐式实体的类型T它的查找范围如下:
    • 1 如果T被定义为T with A with B with C,那么A,B,C都是T的部分,在T的隐式解析过程中,它们的伴生对象都会被搜索
    • 2 如果T是参数化类型,那么类型参数和与类型参数相关联的部分都算作T的部分,比如List[String]的隐式搜索会搜索List的伴生对象和String的伴生对象
    • 3 如果T是一个单例类型p.T,即T是属于某个p对象内,那么这个p对象也会被搜索
    • 4 如果T是个类型注入S#T,那么S和T都会被搜索

上路前的话

这段话来自《Scala编程》

隐式操作若过于频繁使用,会让代码变得晦涩难懂。因此,在考虑添加新的隐式转换之前,请首先自问是否能够通过其他手段,诸如继承、混入组合或方法重载,达到同样的目的。如果所有这些都不能成功,并且你感觉代码仍有一些繁复和冗余,那么隐式操作或许正好能帮到你。

所以。。。谨慎使用,小心翻车,good luck!

参考文献

IMPLICIT CLASSES

scala的隐式转换学习总结(详细)

《Scala编程》

深入理解Scala的隐式转换

本文代码

Github仓库

转载请注明原文地址:liam-blog.ml/2019/09/28/…