返回介绍

数学基础

统计学习

深度学习

工具

Scala

八、类型参数化

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

8.1 类型参数化

  1. 类型参数化用于编写泛型的类和泛型的特质。泛型的意思是:我们用一个泛化的类或者特质来定义许多具体的类型。

    
    
    xxxxxxxxxx
    trait Queue[T]{ def head: T def tail: Queue[T] def enqueue(x: T): Queue[T] }

    这里的 Queue 是泛型的, 是一个泛型的特质,其定义为 Queue[T]

    事实上 Queue 并不能当作一个类型来用,因此我们不能创建类型为 Queue 的变量:

    
    
    xxxxxxxxxx
    def f(q: Queue) = {}

    我们必须参数化 Queue ,指定参数化类型T 。如:Queue[Int],Queue[String],...

    
    
    xxxxxxxxxx
    def f(q: Queue[Int]) = {}

    因此 Queue 也被称作类型构造方法,因为我们可以通过指定类型参数来构造一个类型。类型构造方法 Queue 能够“生成”一组类型,如:Queue[Int],Queue[String],...

8.2 型变注解

  1. 泛型和子类型这两个概念放在一起,会产生一些非常有趣的问题。如:Queue[String] 应不应该被当作 Queue[AnyRef] 的子类型?

    更通俗的讲:如果 S 是类型 T 的子类型,那么 Queue[S] 是否应该作为 Queue[T] 的子类型?

    如果 Queue[S] 可以作为 Queue[T] 的子类型,则可以说特质 Queue 在类型参数 T 上是协变的 convariant 。由于 Queue 只有一个类型参数,因此可以简单的说 Queue 是协变的。这意味着以下的调用是允许的:

    
    
    xxxxxxxxxx
    def f(q: Queue[AnyRef]) = {} // 函数参数类型: Queue[AnyRef] val q1: Queue[String] = .... f(q1) // 实参类型: Queue[String]
  2. Scala 中,泛型类型默认的子类型规则是非协变的nonvariant 。也就是说,Queue[String] 不能当作 Queue[AnyRef] 来使用。

  3. 可以通过在类型参数前面加上 + 来表示协变的:

    
    
    xxxxxxxxxx
    trait Queue[+T]{ ... }

    通过+,我们告诉Scala 我们要的效果是:Queue[String]Queue[AnyRef] 的子类型。编译器会检查Queue 的定义符合这种子类型关系的要求。

  4. 也可以通过在类型参数前面加上 - 来表示逆协变的contravariance

    
    
    xxxxxxxxxx
    trait MyTrait[-T]{ ... }

    如果 T 是类型 S 的子类型,则 MyTrait[S]MyTrait[T] 的子类型。也就是说,MyTrait[AnyRef]MyTrait[String] 的子类型。

  5. 类型参数是协变的、逆变的、还是不变的,这被称作类型参数的形变variance 。可以放在类型参数旁边的 +- 被称作类型注解 variance annotation

  6. 在纯函数式的世界中,许多类型自然而然的是协变的。但是引入可变数据之后,情况就会发生变化。

    
    
    xxxxxxxxxx
    class Cell[+T](init: T) { // 实际上无法通过编译 private[this] var current = init def get = current def set(x: T) = { current = x } }

    假设Cell 是协变的(实际上这段代码无法通过编译器的检查),则可以构建如下语句:

    
    
    xxxxxxxxxx
    val c1 = new Cell[String]("hello") val c2: Cell[Any] = c1 // Cell[String] 是 Cell[Any] 的子类型 c2.set(1) // Int 是 Any 的子类型 val s: String = c1.get // current 现在是整数 1

    这四行代码的效果是:将整数 1 赋值给了字符串 s 。这显然违背了类型约束。

  7. 并不仅仅只有可变字段能让协变类型变得不可靠,泛型参数类型作为方法参数类型出现时,协变类型也不可靠。

    
    
    xxxxxxxxxx
    class Cell[+T](init: T) { // 实际上无法通过编译 private[this] var current = init def get = current def set(x: T) = { current = x } } ​ class MyCell extends Cell[Int]{ override def set(x: Int) ={ println(math.sqrt(x)) super.set(x) } }

    因为 Cell[Int]Cell[Any] 的子类,而 MyCell 又是 Cell[Int] 的子类,因此现在我们可以这么做:

    
    
    xxxxxxxxxx
    val c: Cell[Any] = new MyCell c.set("hello")

    c.set 会执行 MyCellset 方法,而该方法要求的参数类型是 Int

    事实上,可变字段能让协变类型变得不可靠只是如下规则的特例:用 + 注解的类型参数不允许应用于方法的参数类型。

  8. Java 中的数组是被当作协变来处理的。Java 在运行时会保存数组的元素类型。每当数组元素被更新时,都会检查新元素值是否满足类型要求。如果新元素不满足类型要求,则抛出 ArrayStoreException 异常。

    
    
    xxxxxxxxxx
    // Java 代码,能够编译成功,但是运行异常 String[] a1 = { "abc" }; Object[] a2 = a1; a2[0] = new Integer(17); String s = a1[0]

    Java 的这种设计是为了用一种简单的手段来泛化地处理数组。

    而在Scala 中,数组是不变的。因此 Array[String] 并不会被当作 Array[Any] 的子类来处理。

    
    
    xxxxxxxxxx
    val a1 = Array("abc") val a2: Array[Any] = a1 // 编译失败

    但是 Scala 允许我们将元素类型为 T 的数组,经过类型转换成 T 的任意超类型的数组:

    
    
    xxxxxxxxxx
    val a1 = Array("abc") val a2: Array[Any] = a1.asInstanceOf[Array[Any]]

    这个类型转换在编译时永远合法,且在运行时也永远成功。因为 JVM 的底层runtime 模型对数组的处理都是协变的,跟 Java 语言一样。但是你可能在这之后遇到 ArrayStoreException,就跟 Java 一样:

    
    
    xxxxxxxxxx
    val a1 = Array("abc") val a2: Array[Any] = a1.asInstanceOf[Array[Any]] a2(0) = 17 // 抛出 ArrayStoreException

8.3 逆变

  1. 类型系统设计的一个通用原则:如果在任何需要类型 U 的值的地方,都能够用类型 T 的值替代,则可以安全的假定类型 T 是类型 U 的子类型。这称作李氏替换原则。

    如果 T 支持跟 U 一样的操作,而 T 的所有操作跟 U 中对应的操作相比,要求更少且提供更多的话,该原则就成立。

  2. 有些场景需要逆变。

    
    
    xxxxxxxxxx
    trait OutputChannel[-T]{ def write(x: T) }

    这里 OutputChannel 被定义为以 T 逆变,因此一个 OutputChannel[AnyRef]OutputChannel[String] 的子类。

    这是因为 OutputChannel[AnyRef]OutputChannel[String] 都支持 write 操作,而这个操作在 OutputChannel[AnyRef] 中的要求比 OutputChannel[String] 更少。更少的意思是:前者只要求入参是 AnyRef,后者要求入参是 String

  3. 有时候逆变和协变会同时出现。一个典型的例子是ScalaFunction 特质。

    当我们写下函数类型 A => B 时,Scala 会将其展开成 Function1[A, B]。标准类库中的 Function1 同时使用了协变和逆变:函数入参类型 A 上进行逆变,函数结果类型 B 上进行协变。

    
    
    xxxxxxxxxx
    trait Function1[-A, +B]{ def apply(x: A): B }

    这是因为对于函数调用,我们可以传入 A 的子类对象;而函数的返回值可以传递给B 的超类对象。

8.3 检查型变注解

  1. Scala 编译器会检查你添加在类型参数上的任何型变注解。如:如果你尝试声明一个类型参数为协变的(添加一个 + ),但是有可能引发潜在的运行时错误,则你的程序将无法通过编译。

  2. 为了验证型变注解的正确性,Scala 编译器会对类或者特质定义中的所有能够出现类型参数的地点进行归类,归类为:协变的positive、逆变的 negative 和不变的 neutral

    • 所谓的“地点”指的是:类或特质中,任何一个可以用到类型参数的地方。

    • 编译器会检查类型参数的每一次使用:

      • 使用 + 注解的类型参数只能用在协变点。
      • 使用 - 注解的类型参数只能用在逆变点。
      • 没有型变注解的类型参数能够用在任何能够出现类型参数的点,因此这也是唯一的能用在不变点的类型参数。
  3. 为了对类型参数点进行归类,编译器从类型参数声明开始,逐步深入到更深的嵌套层次。

    • 声明该类型参数的类的顶层的点被归类为协变点。

    • 更深的嵌套层次默认为跟包含它的层次相同,不过有一些例外情况归类会发生变化:

      • 值函数的参数的点被归类为:方法外的翻转:

        • 协变点的翻转是逆变点。
        • 逆变点的翻转是协变点。
        • 不变点的翻转仍然是不变点。
      • 当前的归类在方法的类型参数上也会翻转。

        
        
        xxxxxxxxxx
        class C[-T,+U] { // T,U 在顶层都是协变点 def func(t:T, u:U){} // func 为方法,因此翻转:T,U 都为逆变点 }
      • 当前的归类在类的类型参数上也会翻转。

        
        
        xxxxxxxxxx
        class C[-T,+U] { // W 在顶层是协变点 def func[W](){} // W 为类型,因此翻转:W 为逆变点 }
    • 要想跟踪型变点相当不容易。不过不用担心,Scala 编译器会帮助你做这个检查。

    • 一旦归类被计算出来,编译器会检查每个类型参数只被用在了正确的归类点。

  4. Scala 的型变检查对于对象私有定义 private[this] 有一个特殊规则:在检查带有 +- 的类型参数必须匹配相同型变归类点时,会忽略掉对象私有的定义。

8.4 上界/下界

  1. 对于例子中:

    
    
    xxxxxxxxxx
    trait Queue[T]{ def head: T def tail: Queue[T] def enqueue(x: T): Queue[T] }

    由于 T 是以 enqueue 方法的参数出现,因此 T 不能是协变的。

    事实上可以通过给enqueue 一个类型参数,并对这个类型参数使用下界来实现多态:

    
    
    xxxxxxxxxx
    trait Queue[T]{ def head: T def tail: Queue[T] def enqueue[U >: T](x: U): Queue[U] }

    新的定义给enqueue 添加了一个类型参数 U,并且用 U >: T 这样的语法定义了 U 的下界为 T 。这样一来 U 必须是 T 的超类。

    现在 enqueue 的参数类型为 U 而不是 T,方法的返回值是 Queue[U] 而不是 Queue[T]

  2. 超类和子类关系是反身的。即:一个类型同时是自己的超类和子类。对于 U >: T,尽管TU 的下界,你仍然可以将一个 T 传入 enqueue

  3. 上界的指定方式跟下界类似,只是不再采用表示下界的 >: 符号,而是采用 <: 符号。

    对于 U <: T ,要求类型参数 UT 的子类型。

  4. 型变注解和上、下界配合得很好。它们是类型驱动设计得绝佳范例。

8.5 类型推断

  1. 对于方法调用 func(args),类型推断算法首先检查 func 的类型是否已知。

    • 如果已知,则这个类型信息就被用于推断入参的预期类型。

      如:List(1,2,3,4).sortWith( _ > _) 中,调用对象的类型为 List[Int],因此类型推断算法知道 sortWith 的参数类型为:(Int,Int) => Boolean ,并且返回类型为 List[Int]

      由于该参数类型已知,所以并不需要显式写出来。

    • 如果未知,则类型推断算法无法自动推断入参类型。

      
      
      xxxxxxxxxx
      def msort[T](less:(T,T) => Boolean)(xs: List[T]): List[T] ={ ... } msort ( _ > _)(List(1,2,3,4)) // 错误 msort[Int]( _ > _)(List(1,2,3,4)) // 正确 msort[Int]( (x:Int, y:Int) => x > y )List(1,2,3,4)) // 正确

      msort 是一个经过科里化的、多态的方法类型,它接收一个类型为 (T, T) => Boolean 的入参,产出一个从 List[T]List[T] 的函数,其中 T 是当前未知的某个类型。msort 需要先用一个类型参数实例化之后才能应用到它的入参上。

      由于 msort 确切实例类型未知,因此类型推断算法无法推断首个入参类型。此时类型推断算法尝试从入参类型来决定方法的正确实例类型。但是当它检查 _ > _ 时,无法得到任何类型信息。

      • 一种方案是:显式传入类型参数,如msort[Int]( _ > _)(List(1,2,3,4))

      • 另一种方案是:交换两个参数列表的位置:

        
        
        xxxxxxxxxx
        def msort[T](xs: List[T])(less: (T,T) => Boolean): List[T] ={ ... }

        当类型推断算法需要推断一个多态方法的类型参数时,它会考虑第一个参数列表里的所有入参的类型,但是不会考虑第二个、第三个等等参数列表的入参。

        因此,当我们设计一个接收非函数的入参和接受函数入参时,将函数入参单独放在最后一个参数列表中。这样一来,方法的正确实例类型可以从非函数入参推断而来,而这个类型又可以继续用于对函数入参进行类型检查。这样的效果是:编写函数字面量作为入参时可以更简洁。

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

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

发布评论

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