如何理解Scala>:迷之翻转喵 —— 协变逆变全解析

原创
2017/05/29 07:43
阅读数 1.4K

At first, 我想谈的并不是这只喵🐱 ~ 👇👇

小雨同学赠送的龙猫

一、背景回顾

热爱 Scala 的童鞋们,可能都曾见识过这只 迷之翻转喵,令人怅然若失,而又神魂颠倒!~

abstract class Cat[-T, +U] {
    def meow[W-](volume: T-, listener: Cat[U+, T-]-)
        : Cat[Cat[U+, T-]-, U+]+
}

说实话,一年前在读到这段的时候,我并没有真正搞懂,尤其是协/逆变类型翻转。书上说:这部分你完全可以跳过,因为在实际编程实践中,编译器会帮你检查是否写错。因此便没去深究。

最近打算全面进军 Scala 技术栈,在回顾这门语言的时候,发现唯有这个喵是一直没有搞懂的地方,这对我这个完美主义者、技术洁癖者来讲是个遗憾!

重新翻开原文(中文电子书,其实有买英文原版,为了向 Martin Odersky 博士致敬),翻译的有些凌乱:

为了核实变化型注解的正确性,Scala 编译器会把类或特质结构体的所有位置分类为正、负,或中立。所谓的“位置”是指类(或特质,但从此开始我们只用“类”代表)的结构体内可能会用到类型参数的地方。例如,任何方法的值参数都是这种位置,因为方法值参数具有类型,所以类型参数可以出现在这个位置上。编译器检查类的类型参数的每一个用法。注解了+号的类型参数只能被用在正的位置上,而注解了-号的类型参数只能用在负的位置上。没有变化型注解的类型参数可以用于任何位置,因此它是唯一能被用在类结构体的中性位置上的类型参数。
为了对这些位置分类,编译器首先从类型参数的声明开始,然后进入更深的内嵌层。处于声明类的最顶层被划为正的位置。默认情况下,更深的内嵌层的位置的分类会与它的外层一致,不过仍有屈指可数的几种例外会改变具体的分类。方法值参数位置是方法外部的位置的翻转类别,这里正的位置翻转为负的,负的位置翻转为正的,而中性位置仍然保持中立。
除了方法值参数位置外,方法的类型参数的当前类别也会同时被翻转。而类型的类型参数位置,如 C[Arg] 中的 Arg, 也有可能被翻转,这取决于对应类型参数的变化型。如果 C 的类型参数标注了+号,那么类别保持不变。如果 C 的类型参数标注了-号,那么当前类别被翻转。如果C的类型参数没有变化型注解那么当前类别将改为中性。
下面是个显得有点儿生编硬造的例子,我们考虑如下的类型定义,其中若干位置的变化型被标注了+(正的) 或-(负的):

abstract class Cat[-T, +U] {
    def meow[W-](volume: T-, listener: Cat[U+, T-]-)
        : Cat[Cat[U+, T-]-, U+]+
}

类型参数W, 以及两个值参数,volume 和 listener 的位置都是负的。注意 meow 的结果类型,第一个 Cat[U, T] 参数的位置是负的,因为 Cat 的第一个类型参数 T 被标注了-号。这个参数中的类型 U 重新转为正的位置(两次翻转),而参数中的类型 T 仍然是负的位置。

这段话的陈述,不知道你有没有读懂,反正我是读了无数遍,仍不知所云。尤其是最后一句的“翻转两次”和“类型 T 仍然是负的位置”,说明作者已经把自己绕晕了。其实并不是那个“位置”(Cat[Cat[U+)的 U 因为翻转了两次又变回来,而是由于 内层 Cat 占用了外层-号标注的位置而必须翻转,使得内层本来是负的位置翻转变成了正的,然后正的位置只能使用+号标注的参数(即协变),只能用 U, 所以那里出现了 U。

二、重走丝路(重温类型系统)

既然教材和网文都说不清楚这个 Cat 的来龙去脉,我们不妨自己试着从设计者的角度去思考探索这个规则是如何建立起来的。不管怎么说,能够把一个 全功能的 图灵完备 的类型系统的 类型安全推断模型,高度概括并简化到如此精练的程度,非智者所能为也。让我们去一探究竟!

  abstract class Cat[-T, +U, -X, +Y, A] {
    def meow[W](volume: T, listener: Cat[U, T, Y, Cat[U, T, Cat[T,
                Cat[A, U, Cat[U, T, A, A, A], Y, A], X, Y, A], X, A], A])
    : Cat[T, U,
        Cat[Cat[T, U, X, Y, A], Cat[U, T, Y, X, A], U, T, A],
        Cat[T, U, X, Y, A],
        Cat[A, A, A, A, A]]
  }

关于翻转的问题,或许用复杂一点的代码更容易找到规律。这里先贴一段已经编译通过的原文 code 的变种,先睹为快,留到最后再详解。

1. 基本公理

我们都知道,子类对象父类型变量 赋值是天经地义的 —— 因为这样做只能导致 父类型变量 使用更少的、子类型必然都有的功能,这没有任何逻辑冲突而导致运行错误,因此是合理的 —— 我们称之为对象的 上转型对象

先贤们设计定义了 继承赋值 这些基本概念,并推导出了 上转型对象 的合理性,这些概念如同:

顺序分支循环 三种结构可以构建一切 过程

一样基础,是程序世界的 基本思想、世界观、准则共识,是宪法,在任何时候都是合理的。在这里我将它们称为 基本公理

2. 程序逻辑与公理

之所以称为 基本公理,是因为在多年的编程实践中,我发现:

任何对 对错 的判定,都是以是否违背这些公理为准则。

这个判定的执行者,包括编译器、运行时等。即:这些环境的设计者同样是遵循基本公理的。

3. 参数化类型

参数化类型 是 Scala 特有的概念,类似 Java 的泛型,包括 协变(用 + 号标记,如:class A[+T])、逆变(用 - 号标记,如:class A[-T]) 和 无变化型(无标记,如:class A[T]。事实上,Java 泛型是 Scala 参数化类型 的一个子集,即:无变化型。可能有人会问:Java 的 A<? super B> 难道不是变化型吗?还真不是,这是 上界下界,等同于 Scala 的 A[T >: B])。

参数化类型 的实例也需要赋值,那么类型之间应该具备怎样的关系才可以赋值,而什么关系不可以,这是个问题。如果按照 上转型对象 的理论来界定,问题最终转换为:

到底 的子类。

“这还用问吗?父类就是父类,子类就是子类!” —— 这是初学编程的伙伴们的第一反应。

“父类还是父类,子类还是子类,但只有泛型参数类型 相同 的才可以赋值!” —— 这是 Java 转型过来的伙伴的第一反应。

我要告诉你的是:到底 谁是谁的子类 这个问题,还真不是一眼就能看出来的。 先来看个例子:

  class A[+T]
  class B[T] extends A[T]
  class C[-T]
  class D[-T] extends C[T]
  class X
  class Y extends X

  val aaxx: A[X] = new A[X]
  val aaxy: A[X] = new A[Y]
  val abxx: A[X] = new B[X]
  val abxy: A[X] = new B[Y]
  val aayx: A[Y] = new A[X] // 报错
  val aayy: A[Y] = new A[Y]
  val abyx: A[Y] = new B[X] // 报错
  val abyy: A[Y] = new B[Y]

  val dcxx: D[X] = new C[X] // 报错
  val dcxy: D[X] = new C[Y] // 报错
  val dcyx: D[Y] = new C[X] // 报错
  val dcyy: D[Y] = new C[Y] // 报错
  val ddxx: D[X] = new D[X]
  val ddxy: D[X] = new D[Y] // 报错
  val ddyx: D[Y] = new D[X]
  val ddyy: D[Y] = new D[Y]

这段代码中,我穷举出了 赋值 的所有情况,显然有些是合理的,有些不可以。为避免话题战线拉得太长,先来总结一句话以说明 在参数化类型中,关于 谁是谁的子类 这个问题到底是怎么定义的(虽然有点 事后诸葛 的嫌疑):

假如一个 变量(或常量)可以被某 实例 合法的(编译通过)赋值,那么这个 实例 类型就是该 变量 定义类型的子类型(或本身)。

B[Y]A[X] 的子类型(很正常)、D[X]D[Y] 的子类型(要开始 颠覆 了)。简述一下相关定义:

  • 由于定义了 A[+T]T协变 的,即:

同时是 AT 的子类(或本身)的参数化类型,才是 A[+T] 的子类。

所以 B[Y]A[X] 的子类型。

  • 由于定义了 D[-T]T逆变 的,即:

同时是 D子类(或本身)且是 T父类(或本身)的参数化类型,才是 D[-T] 的子类。

所以 D[X]D[Y] 的子类型。

但从这个例子中,我们无法看出 协变逆变 概念的设计到底有何意义,难道仅仅是为了多样性吗?当然不是!

三、用途决定变化型

参数化类型的设计,是为了在具有严格、完备、强制的类型检查环境下,同时提供更多的灵活性,让我们开发者具有更多的选择,使得程序变得更加丰富和有趣,同时让一些本需要绕道而行的写法有了 捷径。如何定义变化型,应该视具体应用场景而定。相信在读完下面的场景化分析之后,你会对之前产生的问题有一个清晰的答案。

1. 集合类用途

  • 假设 List 是协变的即 List[+T] ,会发生什么?(这里先以 java.util.List 为例,下同)

    class Animal
    class Cat extends Animal {
      def run(): Unit
    }
    class Bird extends Animal {
      def fly(): Unit
    }
    
    val cats: util.List[Cat] = new util.ArrayList[Cat]()
    cats.add(new Cat)
    val list: util.List[Animal] = cats  // 对于协变的 List 来说,合法。
    addAnimals(list)
    list.foreach { t =>
      t.as[Cat].run() // 类型转换错误
    }
    
    def addAnimals(list: util.List[Animal]) {
      list.add(new Bird)  // 悖论
    }
    

    如果 List 是协变的,则 list 变量的赋值合法,但后面的某些操作就有问题了,虽然从变量定义的角度来看,似乎并没有问题:

    add(t: T) 方法接受 T 类型的变量,在定义上是 Animal 类型,此时 add Bird 类型实例,即:将 Bird 实例赋值给 Animal,符合前边讲到的 基本公理,所以没问题。

    但由于在内存中运行的是 ArrayList[Cat] 的实例,即任何操作都会当做 Cat 来处理,虽然逻辑上是把 Bird 实例赋值给 Animal,实际上都是当做 Cat 进行处理,其中必然存在着诸多强制类型转换,后续操作也会调用 Cat 的相关方法,显然把实际塞进去的 Bird 强转为 Cat 是不合理的,违背了 基本公理,而 Bird 也没有 run() 方法。我们应该阻止这种事情发生,怎么阻止?因为 问题的根源是协变,因此应该将其改为逆变或不变。

  • 假设 List 是逆变的,会发生什么?

    class Animal
    class Cat extends Animal {
      def run(): Unit
    }
    class Bird extends Animal {
      def fly(): Unit
    }
    
    val anims: util.List[Animal] = new util.ArrayList[Animal]()
    anims.add(new Cat)
    val list: util.List[Bird] = anims  // 对于逆变的 List 来说,合法。
    addBirds(list)
    list.foreach { t =>
      t.fly()  // 叫 add 进去的 Cat 怎么想?
    }
    
    def addBirds(list: util.List[Bird]) {
      list.add(new Bird)
    }
    

    对于逆变的 List来说,也存在着类似协变的问题。因此也需要阻止违背 基本公理 的事情发生。

总结:变化型会导致集合类的相关操作出现违背 基本公理 的情况,因此集合类通常必须是无变化型的。

从源码中可以看到,java.util.List[E] 是没有变化型的(本来就不支持协/逆变),但 Scala 的 scala.collection.mutable.ListBuffer[A] 也是没有变化型的,其它如 mutable.HashMap[A, B] 等也都是。

可能你要问了,为什么 immutable.List[+A] 是协变的?这个 List 其实是链表的一个 元素,而不是同上面例子一样的 真正的列表。而为了让元素具备 Elem[Sub] 可以给 Elem[Super] 赋值这样的能力,才为其定义了协变。

2. 其它类用途

前两个月重构技术栈,打算全面运用 Scala 开发 Android, 包括彻底扔掉 gradle 构建工具。sbt-android 能够为我们自动生成很多代码,例如所有在 layout xml 中定义了 android:idView 都会自动生成在 TypedViewHolder[V <: View] 里面(题外话:生成这么多东西会不会导致臃肿多余呢,Proguard 是干嘛的,顺便推荐我的工具集 Annoguard这里)。

话说,TypedViewHolder[V <: View] 虽然很棒,但导致了一个 intellij-idea 语法不兼容的问题,虽然编译没问题,但看着碍眼。我有一个 implicit 工具可以 fix 这类问题,但是需要这个自动生成的类是协变的:

trait TypedViewHolder[+T <: View] {
  val rootViewId: Int
  val rootView: T
}

如果协变,则 val tvg: TypedViewHolder[ViewGroup] = new TypedViewHolder[LinearLayout] { //... } 这个赋值是合法的,也导致了其中变量 rootView 的类型由 LinearLayout 变成了 ViewGroup,但这显然没有任何问题。而同时也使得我的另一个工具起作用了。

可以看到,在这个应用场景下,这个增加协变能力的更改,完美的 fix 了几个问题,不仅增强了功能,而且非常和谐。 事实上,前一节提到的 immutable.List[+A] 与本应用场景类似,而它就是协变的。

总结:在合适的场景下合理的运用 变化型 会发挥意想不到的效果,往往事半功倍。

四、变化型翻转

相信到这里,我们对参数化类型的变化型有了全新的认识,那么回到最初的问题 —— 迷之翻转喵~

  abstract class Cat[-T, +U, -X, +Y, A] {
    def meow[W](volume: T, listener: Cat[U, T, Y, Cat[U, T, Cat[T,
                Cat[A, U, Cat[U, T, A, A, A], Y, A], X, Y, A], X, A], A])
    : Cat[T, U,
        Cat[Cat[T, U, X, Y, A], Cat[U, T, Y, X, A], U, T, A],
        Cat[T, U, X, Y, A],
        Cat[A, A, A, A, A]]
  }

略过教材版本,直接上手这个复杂版。

(未完待续)

展开阅读全文
加载中

作者的其它热门文章

打赏
0
0 收藏
分享
打赏
0 评论
0 收藏
0
分享
返回顶部
顶部