返回介绍

数学基础

统计学习

深度学习

工具

Scala

五、隐式类型转换

发布于 2023-07-17 23:38:23 字数 21296 浏览 0 评论 0 收藏 0

  1. Scala 支持隐式类型转换:这是一个定义为implicit 的方法。

    
    
    xxxxxxxxxx
    class C(n:Int,s:String) { val num:Int = n val name:String = s } implicit def CtoInt(c:C) = c.num // 隐式类型转换 val c = new C(123,"Hello") println(1+c) // 输出:124

    为了让隐式类型转换能够工作,它需要定义在作用域内。

  2. 由于隐式类型转换是由编译器隐式的作用在代码上,而不是在代码中显式的给出。因此对于使用方程序员,究竟哪些地方隐式转换起了作用并不是那么直观。因此这会阻碍代码的可读性。

  3. 隐式定义指的是我们允许编译器插入程序以解决类型错误的定义。例如:如果 x + y 无法通过编译,则编译器可能将它修改为 convert(x) + y ,其中 convert 是某种可用的隐式转换。

    如果 convert 是一种简单的转换函数,则不应该在代码里显式写出从而有助于澄清程序的主要逻辑。

  4. 隐式转换规则:

    • 标记规则:只有标记为 implicit 的定义才可用。

      关键字 implicit 用于标记哪些声明可以被编译器用作隐式定义。可以用 implicit 来标记任何变量、函数或者对象的定义。编译器只会从那些显式标记为 implicit 的定义中选择。

    • 作用域规则:被插入的隐式转换必须是当前作用域的单个标识符,或者跟隐式转换的源类型或者目标类型有关联。

      Scala 编译器只会考虑那些在当前作用域内的隐式转换,因此必须以某种方式将隐式转换定义引入到当前作用域。

      除了一个例外,隐式转换在当前作用域中必须是单个标识符。如对于 x + y,编译器不会插入someVar.convert(x) 这种形式的转换,你必须显式导入 import someVar.convert 来使用单个标识符 convert 。事实上对于类库而言,通常提供一个包含了一些有用的隐式转换的 Preamble 对象,这样使用这个类库的代码就可以通过 import Preamble._ 来访问该类库的隐式类型转换。

      但是这个规则有一个例外:编译器还会在隐式转换的源类型或者目标类型的伴生对象中查找隐式定义。我们不需要在程序中import 伴生对象。

    • 每次一个规则:每次只能有一个隐式定义被插入。

      如:编译器绝对不会将 x + y 重写为 convert1(convert2(x)) + y

      可以通过让隐式定义包含隐式参数的方式绕过这个限制。

    • 显式优先原则:只要代码按照编写的样子能够通过类型检查,就不要尝试隐式定义。

      编译器不会对已经可以工作的代码做修改。

      这个规则必然可以得出结论:我们总是可以将隐式标识符替代成显式的,代码会更长但是歧义更少。这是一种折衷:

      • 如果代码看上去重复啰嗦,则隐式转换可以减少这种繁琐的代码
      • 如果代码看上去生硬晦涩,则可以显式的插入转换

      究竟是否采用隐式转换,这是代码风格问题。

  5. 隐式转换可以使用任何名称。隐式转换的名称只有两种情况下重要:

    • 当你希望显式给出转换时
    • 当决定程序的哪些位置都有哪些隐式转换可用时。

    考虑一个带有两个隐式转换的对象:

    
    
    xxxxxxxxxx
    object MyConversions { implicit def stringWrapper(s :String) : IndexedSeq[Char] = ... implicit def intToString(x :Int): String = ... }

    如果你只是希望使用 stringWrapper 转换,并不希望使用 intToString 转换,则可以通过仅仅引入其中一个来实现:

    
    
    xxxxxxxxxx
    import MyConversions.stringWrapper

    在这里,隐式转换的名字很重要,因为只有这样才可以有选择的引入一个而不引入另外一个。

  6. Scala 会在三个地方使用隐式定义:

    • 转换到一个预期的类型:在预期不同类型的上下文中使用某个类型。如你有一个String,但是你要将它传递给一个要求 IndexedSeq[Char] 的方法。

    • 对某个(成员)选择接收端(即字段、方法调用)的转换:适配接收端的类型。

      "abc".exists 将转换为 stringWrapper("abc").exists ,因为 exists 方法在 String 上不可用,但是在 IndexedSeq 上是可用的。

    • 隐式参数:用于给被调用函数提供更多关于调用者诉求的信息。

      隐式参数对于泛型函数尤其有用,被调用的函数可能完全不知道某个或某些入参的类型。

5.1 转换到一个预期的类型

  1. 每当编译器看到一个 X 而它需要一个 Y 的时候,他就会查找一个能将 X 转换为 Y 的隐式转换。

  2. Double 隐式转换成Int 可能并不是一个好主意,因为这会悄悄的丢掉精度。我们更推荐从一个受限的类型转换成更通用的类型。

    如:scala.Predef ,它是每个 Scala 程序都会隐式导入的对象,它定义了一些从 “更小” 的数值类型到 “更大” 的数值类型的隐式转换。如 scala.Predef 定义了:

    
    
    xxxxxxxxxx
    implicit def int2double(x: Int): Double = x.toDouble

    Scala 中并没有什么强制类型转换,所有的类型转换都是通过这种隐式转换或者显式转换来实现的。

5.2 转换接收端

  1. 隐式转换还能应用于方法调用的接收端,也就是调用方法的那个对象。这种隐式转换有两个用途:

    • 允许我们更平滑的将新类集成到已有的类继承关系图谱当中。
    • 支持在语言中编写(原生的)领域特定语言 DSL
  2. 即如你写了 obj.doIt,而 obj 并没有一个 doIt 的成员,编译器会在放弃之前尝试插入转换。

    这里转换需要应用于接收端,也就是 obj。编译器会装作 obj 的预期“类型”为“拥有“名字为 doIt 的成员。这个”类型“ 并不是一个普通的 scala 类型,不过从概念上讲它是存在的。

  3. 接收端转换的一个主要用途是:让新类型和已有类型的集成更为平滑。

    如定义了新类型:

    
    
    xxxxxxxxxx
    class MyCls(n: Int, d: Int){ ... def + (that: MyCls): MyCls = ... def + (that: Int): Mycls = ... }

    则我们可以通过以下调用:

    
    
    xxxxxxxxxx
    val obj = new MyCls(1,2) obj + 1

    但是无法通过以下调用:

    
    
    xxxxxxxxxx
    1 + obj

    因为作为接收端的 1 并没有一个合适的方法 + (MyCls) 。为了允许这样的操作,我们可以定义一个 IntMyCls 的隐式转换:

    
    
    xxxxxxxxxx
    implicit def intToMyCls(x: Int) = new MyCls(x, 1) 1 + obj

    背后的原理:Scala 编译器首先尝试对表达式 1 + obj 进行类型检查。虽然 Int 有多个 + 方法,但是没有一个是接收参数为 MyCls 类型的,因此类型检查失败。接着编译器检查一个从 Int 到另一个拥有可以应用 MyCls 参数的 + 方法的类型的隐式转换,编译器将会找到intToMyCls 隐式转换并执行:

    
    
    xxxxxxxxxx
    intToMyCls(1) + obj
  4. 隐式转换的另一个主要用途是模拟添加新的语法。

    考虑创建一个 Map

    
    
    xxxxxxxxxx
    Map("a" -> 1, "b" -> 2)

    这里的 -> 并不是 scala 的语法特性,而是 ArrowAssoc 类的方法。ArrowAssoc 是一个定义在 scala.Predef 对象这个 scala 标准前导preamble 代码里的类。当写下 "a" -> 1 时,编译器将插入一个从 "a"ArrowAssoc 的转换。

    下面是相关定义:

    
    
    xxxxxxxxxx
    package scala object Predef{ class ArrowAssoc[A](x: A){ def -> [B](y: B): Tuple2[A,B] = Tuple2(x,y) } implicit def any2ArrowAssoc[A](x: A): ArrowAssoc[A] = new ArrowAssoc(x) ... }

    这种“富包装类”模式在给编程语言提供 syntax-like 的扩展的类库中非常常见。

    • 只要你看到有人调用了接受类中不存在的方法,那么很可能使用了隐式转换。
    • 如果你看到名为 RichSomething 的类(如:RichInt,RichBoolean),那么这个类很可能对 Something 类型增加了 syntax-like 的方法。

    富包装类的应用场景广泛,它可以让你做出以类库形式定义的内部 DSL

  5. Scala 2.10 引入隐式类来简化富包装类的编写。隐式类是一个以 implicit 关键字开始的类。对这样的类,编译器会生成一个从类的构造方法参数到类本身的隐式转换。

    如:

    
    
    xxxxxxxxxx
    case class Rect(width: Int, height: Int) implicit class RectMaker(width: Int){ def x(height: Int) = Rect(width, height) } val myRect = 3 x 4

    其调用过程为:

    • 由于 Int 类型没有一个名为 x 的方法,因此编译器会查找一个从 Int 到某个有该方法的类型的隐式转换。
    • 编译器将找到类 RectMaker并执行转换,然后调用 RectMakerx 方法。

    并不是所有的类都可以作为隐式类。

    • 隐式类不能是样例类。

    • 隐式类的构造方法必须有且仅有一个参数。

    • 隐式类必须存在于另一个对象、类或者特质里。

      在实际应用中,只要是用隐式类作为富包装类来给某个已有的类添加方法,该限制不是问题。

5.3 隐式参数

  1. 编译器有时候将 f(a) 替换为 f(a)(b),或者将 new C(a) 替换成 new C(a)(b) ,通过追加一个参数列表的方式来完成某个函数调用。

    隐式参数调用提供的是整个最后一组柯里化的参数列表,而不仅仅是最后一个参数。如果f 缺失的最后一个参数列表有三个参数,那么编译器将 f(a) 替换成 f(a)(b,c,d)。此时,不仅被插入的标识符,如 b,c,d 需要在定义时标记为 implicitf 的最后一个参数列表在定义时也需要标记为 implicit

  2. 示例:

    
    
    xxxxxxxxxx
    class A(val name: String) { ... } ​ object O{ def f(s: String)(implicit a: A)={ println(s + " " + a.name) } }

    你可以显式调用:

    
    
    xxxxxxxxxx
    val a = new A("a1") O.f("hello")(a)

    也可以隐式调用,因为f 的最后一个参数列表标记为 implicit

    
    
    xxxxxxxxxx
    implicit val imp_a = new A("implicit a") O.f("hello")

    当调用 O.f("hello") 时,编译器自动填充最后一个参数列表。但是这里必须首先在作用域内找到一个符合要求的类型的 implicit 变量,这里为 imp_a

    注意 implict 变量必须为当前作用域内的单个标识符,如:

    
    
    xxxxxxxxxx
    object impObj{ implicit val imp_a = new A("implict a") } O.f("hello")

    这种调用在编译期间报错,必须将 imp_a 引入到O.f("hello") 的作用域。

  3. 示例:

    
    
    xxxxxxxxxx
    class A(val name: String) { ... } class B(val name: String) { ... } object O{ def f(s: String)(implicit a: A, b: B)={ println(s + " " + a.name + " " + b.name) } } object impObj{ implicit val imp_a = new A("implict a") implicit val imp_b = new B("implict b") } import impObj._ O.f("hello")

    这里 implicit 关键字是应用到整个参数列表而不是单个参数。

  4. 隐式参数通常都是采用那些非常“稀有”或者“特别”的类型,防止意外匹配。

  5. 隐式参数最常用的场景是:提供关于更靠前的那个参数列表中已经”显式“(与”隐式“相对应)提到的类型的信息。

    如:下面的函数给取出列表中的最大元素:

    
    
    xxxxxxxxxx
    def maxElement[T](elements: List[T])(implicit ordering: Ordering[T]): T = { elements match { case List() => throw new IllegalArgumentException("empty list!") case List(x) => x case x :: rest => val maxRest = maxElement(rest)(ordering) if(ordering.gt(x, maxRest)) x else maxRest } }

    这里隐式参数 ordering 用于提供关于前面提到的 elements 中的类型 T 的排序信息。由于调用 maxElements 时必须给出 elements,因此编译器在编译时就会知道 T 是什么,因此就能确定类型 Ordering[T] 的隐式定义是否可用。如果可用,则它就可以隐式的作为 ordering 传入第二个参数列表。

    
    
    xxxxxxxxxx
    maxElement(List(1,5,7,2)) // 编译器插入针对 Int 的 ordering maxLement(List("one","two","three")) // 编译器插入针对 String 的 ordering
  6. 从代码风格而言,最好是对隐式参数使用特殊的、定制化的类型。

    前面的例子也可以这样写:

    
    
    xxxxxxxxxx
    def maxElement[T](elements: List[T])(implicit ordering: (T, T) => Boolean): T = { ... }

    这个版本的隐式参数 ordering 的类型为 (T, T) => Boolean ,这是一个非常泛化的类型,覆盖了所有从两个 TBoolean 的函数。这个类型并没有给出任何关于 T 类型的信息,它可以是相等性测试、小于等于测试、大于等于测试.... 。

    而前面的例子:

    
    
    xxxxxxxxxx
    def maxElement[T](elements: List[T])(implicit ordering: Ordering[T]): T = { ... }

    它用一个类型为 Ordering[T] 的参数 ordering。这个类型中的单词 Ordering 明白无误的表达了这个隐式参数的作用:对类型为 T 的元素进行排序。由于这个类型更为特殊,因此在标准库中添加相关隐式定义不会带来什么麻烦。

    与此相反,如果我们在标准库里添加一个类型为 (T,T) => Boolean 的隐式定义,则编译器广泛的自动传播这个定义,这会带来很多稀奇古怪的行为。

    因此有这样的代码风格规则:在给隐式参数的类型命名时,使用一个能确定其职能的名字。

5.4 上下文界定

  1. 当我们使用隐式参数时,编译器不仅会给这个参数提供一个隐式值,它还会将这个参数作为一个可以在方法体中使用的隐式定义。

    如前面的例子:

    
    
    xxxxxxxxxx
    def maxElement[T](elements: List[T])(implicit ordering: Ordering[T]): T = { elements match { case List() => throw new IllegalArgumentException("empty list!") case List(x) => x case x :: rest => val maxRest = maxElement(rest) // 这里会隐式添加 (ordering) if(ordering.gt(x, maxRest)) x else maxRest // 这里必须显式给出 } }

    编译器检查 maxElement(rest) 时发现类型不匹配,由于第二个参数列表是隐式的,因此编译器并不会立即放弃类型检查。它会查找合适类型的隐式参数,在上述代码中这个类型是 Ordering[T]。编译器找到了这样一个隐式参数,并将方法调用重写为 maxList(rest)(ordering)

  2. 还有一种方法可以去掉对 ordering 的显式使用。这涉及标准类库中定义的方法:

    
    
    xxxxxxxxxx
    def implicitly[T](implicit t: T) = t

    调用 implicitly[Foo] 的作用是编译器会查找一个类型为 Foo 的隐式定义,然后编译器用这个对象来调用 implicitly 方法,该方法直接将这个隐式对象返回。

    因此,如果希望在当前作用域内找到类型为 Foo 的隐式对象,则可以直接写 implicitly[Foo]

    采用这个方式之后,上述例子改为:

    
    
    xxxxxxxxxx
    def maxElement[T](elements: List[T])(implicit ordering: Ordering[T]): T = { elements match { case List() => throw new IllegalArgumentException("empty list!") case List(x) => x case x :: rest => val maxRest = maxElement(rest) // 这里会隐式添加 (ordering) if(implicitly[Ordering[T]].gt(x, maxRest)) x else maxRest // 使用 implicitly } }

    在这个版本中,方法体内没有任何地方提到ordering参数。

    这个模式很常用,因此Scala 允许我们省掉这个参数并使用上下文界定 context bound 来缩短方法签名。采用上下文界定的方式为:

    
    
    xxxxxxxxxx
    def maxElement[T : Ordering](elements: List[T]): T = { // 使用上下文界定 elements match { case List() => throw new IllegalArgumentException("empty list!") case List(x) => x case x :: rest => val maxRest = maxElement(rest) // 这里会隐式添加 (ordering) if(implicitly[Ordering[T]].gt(x, maxRest)) x else maxRest // implicitly } }

    这里 [T: Ordering] 这样的语法是一个上下文界定,它完成两件事:

    • 首先,它像平常那样引入一个类型参数 T

    • 其次,它添加了一个类型为 Ordering[T] 的隐式参数。至于这个隐式参数叫什么名字,你完全不需要知道。

      你只需要知道这个隐式参数的类型,并通过 implicitly[Ordering[T] 获得该隐式参数即可。

  3. 直观上讲,可以将上下文界定想象为对类型参数做某种描述:

    • [T <: Ordering[T]] 表示 T 是一个 Ordered[T] 类型或者其子类
    • [T : Ordering] 并没有任何关于T 是什么类型的定义,仅仅表示 T 带有某种形式的排序。

5.5 多个转换可用

  1. 当作用域内存在多个隐式转换可用时,大部分场景Scala 编译器都会拒绝插入转换。

    Scala 2.8 中对这个规则有所放宽:如果所有可用的转换中,某个转换比其它更为具体more specific,那么编译器就会选择这个更为具体的转换。

    如果满足下面任何一条,则我们就说某个隐式转换比另一个转换更为具体:

    • 前者的入参类型是后者入参类型的子类型。
    • 两者都是方法,而前者所在的类扩展自后者所在的类。

    增加这个修改规则的动机是:改进Java 集合、Scala 集合、字符串之间的互操作。

    例如在 Scala 2.8 之前:

    
    
    xxxxxxxxxx
    "abc".reverse.reverse == "abc"

    这个表达式结果是 false 。原因是 cba 的类型是 StringScala 2.8 之前 String 没有 .reverse 操作,因此字符串被转换成了 Scala 的集合,而对集合的 reverse 返回的是另一个集合,因此表达式左侧返回一个集合,它不等于字符串。

    Scala 2.8 之后,Scala 提供了一个更具体的从 String 转换到 StringOps 的隐式转换。StringOps 有很多像 reverse 这样的方法,不过它们并不返回集合,而是返回字符串。

    StringOps 的隐式转换直接定义在 Predef 中。

5.6 调试

  1. 在调试时,可以将转换显式写出。如果显式写出还出错,你就能很快定位问题;如果显式写出不报错,则说明某个其它规则(如作用域规则)阻止了该隐式转换。
  2. 可以通过 -Xprint:typer 编译器选项查看编译器插入的隐式转换。
  3. 如果隐式转换被频繁使用,则会让代码变得难以阅读。因此在添加一个新的隐式转换之前,首先问自己能否通过其它手段达到相似的效果,比如继承、混入或者方法重载。

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据
    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文