2019独角兽企业重金招聘Python工程师标准>>>

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 Xclass Y extends Xval 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: Intval 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]]}

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

(未完待续)

转载于:https://my.oschina.net/weichou/blog/910979

如何理解Scala:迷之翻转喵 —— 协变逆变全解析相关推荐

  1. 协变逆变java_Java中的逆变与协变

    什么是逆变与协变 协变(Covariance) 如果B是A的子类,并且F(B)也是F(A)的子类,那么F即为协变 逆变(Contravariance) 如果B是A的子类,并且F(B)成了F(A)的父类 ...

  2. C#泛谈 —— 变体(协变/逆变)

    有如下四个类. public class Animal{}public class Mammal : Animal{}public class Dog : Mammal{public void Eat ...

  3. 协变逆变java_Java中的协变与逆变

    Java作为面向对象的典型语言,相比于C++而言,对类的继承和派生有着更简洁的设计(比如单根继承). 在继承派生的过程中,是符合Liskov替换原则(LSP)的.LSP总结起来,就一句话: 所有引用基 ...

  4. 12:设计模式、泛型、上下界、视图界定、上下文界定、协变逆变不变

    经典的 WordCount 的讲解 示例代码如下: package com.atguigu.chapter14.homework.wordcount/*val lines = List("a ...

  5. 大数据技术之_16_Scala学习_12_设计模式+泛型、上下界、视图界定、上下文界定、协变逆变不变

    大数据技术之_16_Scala学习_12 第十七章 设计模式 17.1 学习设计模式的必要性 17.2 掌握设计模式的层次 17.3 设计模式的介绍 17.4 设计模式的类型 17.5 简单工厂模式( ...

  6. 10天学会kotlin DAY7 接口 泛型 协变 逆变

    kotlin 接口 泛型 协变 逆变 前言 1.接口的定义 2.抽象类 3.定义泛型类 4.泛型函数 5.泛型变换 6.泛型类型约束 7.vararg 关键字(动态参数) 8.[] 操作符 9.out ...

  7. 泛型型协变逆变_Java泛型类型简介:协变和逆变

    泛型型协变逆变 by Fabian Terh 由Fabian Terh Java泛型类型简介:协变和逆变 (An introduction to generic types in Java: cova ...

  8. Scala语言学习笔记——泛型、上下界、视图界定、上下文界定、协变逆变不变、闭包、柯里化

    1.Scala泛型 应用案例1 /*** @author huleikai* @create 2019-05-27 11:23*/ object TestFanXing {def main(args: ...

  9. c#: 协变和逆变深度解析

    环境: window 10 .netcore 3.1 vs2019 16.5.1 一.为什么要有协变? 首先看下面的代码: 还有下面的: 其实上面报错的是同一个问题,就是你无法用List<Fru ...

  10. Scala入门到精通——第二十一节 类型参数(三)-协变与逆变

    本节主要内容 协变 逆变 类型通匹符 1. 协变 协变定义形式如:trait List[+T] {} .当类型S是类型A的子类型时,则List[S]也可以认为是List[A}的子类型,即List[S] ...

最新文章

  1. MySQL如何从开源中获利
  2. php str splice,php中array_replace、array_splice与str_replace函数的比较
  3. c语言给bmp图片加滤镜,关于BMP位图透明通道的详解制作教程, 教你输出透明的BMP位图...
  4. 【线段树】FREQUENT - Frequent values(luogu-SP1684 / poj 3368)
  5. 转载:8个让程序员追悔莫及的职业建议
  6. 26-[Boostrap]-全局css样式,组件,控件
  7. 向下兼容性格什么意思_向下兼容是什么意思
  8. bin文件转换成html,bin如何改成mp4
  9. #相关系数r值比较(matlab)
  10. FFmpeg进阶:给视频添加文字水印
  11. k2.第一章 基于kubeadm安装kubernetes v1.20 -- 集群部署(二)
  12. 使用racoon setkey搭建IPsec VPN环境
  13. 爱了,这18个 Python 高效编程技巧真香
  14. google map 看经度和纬度
  15. Technical support(技术支持)
  16. Fleaphp 数组辅助文件中 array_to_tree 的bug修正
  17. C++ 算法篇 广度(宽度)优先搜索(BFS)
  18. 几款自制SDR的USB耗电测试
  19. 如何基于共享服务器模式shared server mode配置大池large pool
  20. 如何让多个视频同时倒放,并重新添加背景音乐

热门文章

  1. python绘制闭合多边形_python – 从边界点创建闭合多边形
  2. PLC_自动化控制系统_1_简说自动化控制系统
  3. Linux 系统中的用户管理
  4. 我的VSTO之路(三):Word基本知识
  5. php实战搭建博客,利用laravel搭建一个迷你博客实战教程
  6. 迷你HTTP服务器+小型博客
  7. ubuntu流量监控_ubuntu 流量监控
  8. ipod nano 无法添加mp4视频 电影失败解决方法
  9. 3.字体样式,分隔线与段落
  10. python cv2统一缩放图片尺寸,将透明背景填充白色