Loading [MathJax]/jax/output/CommonHTML/config.js
前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
圈层
工具
发布
首页
学习
活动
专区
圈层
工具
MCP广场
社区首页 >专栏 >协变、逆变与不变

协变、逆变与不变

作者头像
zhiruili
发布于 2021-08-10 03:11:14
发布于 2021-08-10 03:11:14
2K00
代码可运行
举报
文章被收录于专栏:简易现代魔法简易现代魔法
运行总次数:0
代码可运行

型变

型变(variance)是类型系统里的概念,包括协变(covariance)、逆变(contravariance)和不变(invariance)。这组术语的目的是描述泛型情况下类型参数的父子类关系如何影响参数化类型的父子类关系。也就是说,假设有一个接收一个类型参数的参数化类型 T 和两个类 AB,且 BA 的子类,那么 T[A]T[B] 的关系是什么?如果 T[B]T[A] 的子类,那么这种型变就是「协变」,因为参数化类型 T 的父子类关系与其类型参数的父子类关系是「同一个方向的」。如果 T[A]T[B] 的子类,则这种关系是「逆变」,因为参数化类型 T 的父子类关系与类型参数的父子类关系是「相反方向的」。类似地,如果 T[A]T[B] 之间不存在父子类关系,那么这种型变就是「不变」1

协变

Java 中,数组是协变的,也就是说,假设有一个基类 Person 和一个 Person 的子类 Student。因为 Student 类型是 Person 类型的子类,所以 Student[] 类型是 Person[] 类型的子类,这个设计似乎相当符合直觉,一个学生(Student)是一个人(Person),那一个存放着学生的数组当然也应该是一个存放着人的数组了。

然而这是错误的。

假设 Person 有另一个子类 Teacher,考虑如下代码:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
Student[] students = { new Student() }
students[0].study();
Person[] persons = students;
persons[0] = new Teacher();
students[0].study();  // Oops!

这段代码显然错了,看一下刚刚做了什么。我们在 Student 数组里存放了一个 Student 实例,紧接着调用了这个对象的 study 方法,这个显然没错;然后将这个数组赋值给一个 Person 数组,由于数组是协变的,所以这步没问题;然后,向 Person 数组里添加一个 Teacher 的实例,这步也没问题,因为一个 Teacher 是一个 Person;接下来是获取 Student 数组里的对象,调用 Student 类的 study 方法,这似乎也是合理的。那问题在哪呢?

事实上,这段代码可以编译通过,Java 并不会因此报编译错误,而是在运行 persons[0] = new Teacher(); 时抛出一个 java.lang.ArrayStoreException。也就是说,给协变的数组的单元赋值的时候出错了。这个错误本来应该由编译器发现并指出,但 Java 将对这一错误的防止延后到了运行时期,错过了编译期的检查。编译器没有做正确的事情,这显然是一个设计错误,但这个错误是有其历史原因的 2

在 Java 的早期版本中,工程师们因为时间紧迫而选择暂时不添加泛型在 Java 的语法中,这导致 Java 的数组没法使用泛型,在这种情况下,如果数组的型变是不变,那么要写一些通用的数组操作方法就变得困难,解决方案就是将数组设计为协变的,这样,就可以用操作 Object[] 的方法来操作所有引用类型的数组了。比如你可以写类似这样的方法来对数组里的对象进行排序:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
public static void sort(Object[] objs, Comparator cmp) { ... }

这显然是一个妥协,后来又因为兼容性的考量,不得不维持了这个设计,这是 Java 的一个原罪。而 Scala 做了正确的事,在 Scala 中,数组的声明和别的类没有什么不同:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
final class Array[T] extends java.io.Serializable with java.lang.Cloneable

Scala 中的 Array 的实现就是 Java 的数组。在 Scala 中在类型参数前添加 + 代表参数化类型在该类型参数上协变,添加 - 则代表逆变,什么都不加就是不变。从 Array 的声明中可以看出,Scala 的 Array 是不变的,所以,以下代码是非法的:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
val students: Array[Student] = Array(new Student)
// Compiler error:
// Expression of type Array[Student] doesn't conform to expected type Array[Person]
val persons: Array[Person] = students

那么,怎样集合类型才是协变的呢?考虑刚刚的数组的例子,将 Student[] 类型的实例赋值给 Person[] 类型的对象是没错的,当我们去修改 Person[] 对象的元素时,错误才产生。也就是说,不可变的集合才是协变的。

在 Scala 的 scala.collection.immutable 包中有许多不可变的集合,例如不可变的链表 List,它的声明大概如下(原声明很长,此处有所省略):

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
abstract class List[+A] extends AbstractSeq[A] with LinearSeq[A]

这个声明表明 List 在其类型参数 A 上是协变的。也就是说,如下代码是合法的:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
val students: List[Student] = List(new Student)
val persons: List[Person] = students

类似于 Scala 的不变语义,在 Java 中,如下的代码也是错误的:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
List<Student> students = new ArrayList<>();
students.add(new Student());
// Compiler error:
// Incompatible types,
//   Required: List <test.Person>
//   Found:    List <test.Student>
List<Person> persons = students;

这次 Java 的类型系统做了正确的事情,防止了出现刚刚数组那样的问题。那么在 Java 中又该如何表示协变这样的语义呢?

Java 并没有提供类型的协变声明,取而代之的是在使用时的类型限制,形式大概是这样:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
List<Student> students = new ArrayList<>();
students.add(new Student());
List<? extends Person> persons = students;
Person person =  persons.get(0);
// Compiler error:
// Wrong 2nd argument type. Found: 'test.Teacher', required: '? extends test.Person'
// set(int, capture<? extends test.Person>) in List cannot be applied
// to (int, test.Teacher)
persons.set(0, new Teacher());

这段代码无法编译通过,List<? extends Person> persons 是 Java 的协变声明,它大概表达了这样的语义: List 的类型参数是 Person 的「某个」子类,而具体是什么类型并不知道,既然不知道是什么类型,也就自然无法将其中的元素替换为其他值了。但由于已经知道了其元素类型是 Person 的「某个」子类,所以可以将其元素当作 Person 类型的对象取出。这就保证了协变集合的要求。也就是说,Java 选择不在参数化类型声明的时候去声明该类型的型变关系,而是选择在这个类型被使用的时候去进行限定。从语义上也可以看出,这个方式掩盖了协变本身的概念,是一个较为工程化的思路。但是,型变应该是一个类型本身的特性,Scala 的处理方式能在类型声明上更加清晰地表意,个人更偏向于 Scala 的处理方式。

逆变

相对于协变,逆变显得非常不符合直觉,它表明,如果 BA 的子类,那么 T[B] 反而是 T[A] 的父类。很难想象什么地方会出现逆变的情况,而事实上,函数类型相对于其参数类型就是逆变的,Scala 中接受一个参数的函数类型声明如下:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
trait Function1[-T1, +R] extends AnyRef

其中,T1 是其参数类型,R 是其返回值类型,可以看出,函数在其参数类型上是逆变的。这件事仔细想想就会明白这是合理的,考虑如下代码:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
val student = new Student
val getStudentName: (Student => String) => String = (f) => f(student)
val personNameReader: Person => String = (p) => p.name
getStudentName(personNameReader)

在 Scala 中 A => B 表示一个接受一个 A 类型参数的对象,返回一个 B 类型的对象的函数类型。这段代码中 getStudentName 要求一个 Student => String 类型的函数作为参数,而 personNameReader 函数的类型是 Person => String。由于函数相对于其参数的类型是逆变的,所以可以将 getStudentName 应用于 personNameReader 上。从这个例子来说,personNameReader 要求一个 Person 类型的对象作为参数,而当 getStudentName 对其进行调用时,传入了一个类型更为「详细」的 Student 自然是合法的,由此就能理解为什么函数类型相对于其参数的类型是逆变的了。

在 Java 中,类似于协变,逆变也是在应用的时候才去对其进行约束,例如:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
List<Person> persons = new ArrayList<>();
List<? super Student> students = persons;
students.add(new Student());
// Compiler error:
// Incompatible types:
//   Required: test.Person
//   Found:    capture<? super test.Student>
Person p = students.get(0);
// Compiler error:
// Incompatible types:
//   Required: test.Student
//   Found:    capture<? super test.Student>
Student student = students.get(0);

也就是说,如果你进行了逆变的约束,那么 Java 将要求你只能向 List 里添加元素而不能将其取出来。语义与协变的情况是类似的。

于是,Scala 和 Java 的型变标记可以进行如下总结 3

Scala

Java

解释

+T

? extends T

协变(即:X[Tsub] 是 X[T] 的一个子类)

-T

? super T

逆变(即:Y[Tsup] 是 Y[T] 的一个子类)

T

T

不变(即:无法用 Z[Tsub] 或 Z[Tsup] 替代 Z[T])

规律

现在可以回头再看看之前的讨论,会发现其实只有一个规律,就是函数类型在其返回值的类型上协变,在其参数类型上逆变。

为什么数组是不变的?因为数组上的每个单元都相当于包含了两个方法,当写下 T value = arr[3] 这样的代码时,概念上可以理解为 T value = arr3.get()。而 get 方法的类型显然是 () => T。所以从单元中获取元素这个操作上来看,数组在其元素的类型上协变。而当写下 arr[3] = value 的时候,概念上则可以理解为 arr3.set(value)。而 set 方法的类型则为 T => UnitUnit 相当于 Java 的 void)。所以从给数组单元赋值这个操作上看,数组又在其元素的类型上逆变。因此,数组在其元素类型上不变。

为什么可以写 val person: Person = new Student 呢?因为每个对象都可以看作是一个只带有一个方法的对象,相当于 value.get()。而 get 方法的类型是 () => T。所以我们可以写这样的代码,它是协变的。这么说感觉有点怪,但是,在 Scala 的语法糖加持下,这么说其实挺自然的,因为 Scala 允许在函数不需要参数的情况下省略括号,且如果调用的方法是 apply 的话,不需要写 value.apply() 直接写成 value() 即可。也就是 def t() = new Tval t = new T 相比,虽然前者每次都会创建一个新的实例,但是在使用者看来,都可以写为 t,并不会有区别。

在 Scala 中,如果进行了协变或者逆变的标记,编译器就会对这个类型参数的使用进行检查,如果它出现在了错误的位置上,编译器就会提示错误,防止了开发者因此而犯错。例如:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
trait Test[+T] {
  def get(): T
  // Compiler error:
  // Covariant type T occurs in contravariant position in type T of value v
  def set(v: T): Unit
}

类型声明是很好的文档,更精确的类型声明就是更清晰的文档,Scala 的设计在这方面可以说是更胜一筹。

参考资料

  1. Covariance and contravariance (computer science) - Wikipedia
  2. The Origins of Scala
  3. Dean Wampler, Alex Payne - Programming Scala 2nd

/* * * CONFIGURATION VARIABLES: EDIT BEFORE PASTING INTO YOUR WEBPAGE * * */ var disqus_shortname = 'ZhiruiLi'; // required: replace example with your forum shortname /* * * DON'T EDIT BELOW THIS LINE * * */ (function() { var dsq = document.createElement('script'); dsq.type = 'text/javascript'; dsq.async = true; dsq.src = 'https://' + disqus_shortname + '.disqus.com/embed.js'; (document.getElementsByTagName('head')[0] || document.getElementsByTagName('body')[0]).appendChild(dsq); })(); /* * * DON'T EDIT BELOW THIS LINE * * */ (function () { var s = document.createElement('script'); s.async = true; s.type = 'text/javascript'; s.src = 'https://' + disqus_shortname + '.disqus.com/count.js'; (document.getElementsByTagName('HEAD')[0] || document.getElementsByTagName('BODY')[0]).appendChild(s); }()); comments powered by Disqus

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
暂无评论
推荐阅读
编辑精选文章
换一批
认真CS☀️协变、逆变 & 不变
上面这段代码,dog是派生自Animal类,它是可以直接赋值给Animal类的,但此代码却产生错误,这是因为委托也是类型,Factory<Dog>和Factory<Animal>都派生自delegate,他们是平级关系,不是父子关系,自然他们定义的变量无法相互赋值,即使它们的变量引用的对象是父子关系,可以赋值的,它们的变量也不可以赋值
星河造梦坊官方
2024/08/14
1620
认真CS☀️协变、逆变 & 不变
Scala教程之:深入理解协变和逆变
在之前的文章中我们简单的介绍过scala中的协变和逆变,我们使用+ 来表示协变类型;使用-表示逆变类型;非转化类型不需要添加标记。
程序那些事
2020/07/08
9210
2021年大数据常用语言Scala(三十六):scala高级用法 泛型
scala和Java一样,类和特质、方法都可以支持泛型。我们在学习集合的时候,一般都会涉及到泛型。
Lansonli
2021/10/11
7970
c# 协变和逆变的理解
1. 是什么 1.1 协变 协变指能够使用比原始指定的派生类型的派生程度更小(不太具体的)的类型。如 string 到 object 的转换。多见于类型参数用作方法的返回值。 1.2 逆变 逆变指能够使用比原始指定的派生类型的派生程度更大(更具体的)的类型。如 object 到 string 的转换。多见于类型参数用作方法的输入值。 泛型类型参数支持协变和逆变,可在分配和使用泛型类型方面提供更大的灵活性。 2. 怎么理解 假如有一个 sub 子类和 parent 父类,我们可以很轻易地将 sub 转换成 p
潘成涛
2018/01/18
1.5K0
Kotlin 范型之协变、逆变
如果 Dog 是 Animal 的子类,但 List<Dog> 并不是 List<Animal> 的子类。 下面的代码会在编译时报错:
fengzhizi715
2019/06/24
1.4K0
Kotlin 范型之协变、逆变
五分钟看完,彻底理解C#的协变逆变
其实这是c#的老知识点了,但是今天发现同事对这个竟然还一知半解,就和他们讲解了下,顺便也回顾了下,同事我也把我对这个的全部理解,融化成几分钟的讲解,保证大家5分钟内全部理解,看不懂来打我。
郑子铭
2023/08/30
4130
五分钟看完,彻底理解C#的协变逆变
C#进阶-协变与逆变
我们知道子类转换到父类,在C#中是能够隐式转换的。这种子类到父类的转换就是协变。而另外一种类似于父类转向子类的变换,可以简单的理解为逆变。逆变协变可以用于泛型委托和泛型接口,本篇文章我们将讲解C#里逆变和协变的使用。逆变和协变的语法第一次接触难免感到陌生,最好的学习方式就是在项目中多去使用,相信会有很多感悟。
Damon小智
2024/02/03
1750
谈谈我对C#协变和逆变的理解
在 C# 中,协变和逆变能够实现数组类型、委托类型和泛型类型参数的隐式引用转换。简单点说,协变和逆变有一个基本的公式:
郑子铭
2025/05/23
1030
谈谈我对C#协变和逆变的理解
Java泛型的协变与逆变
泛型是Java最基础的语法之一,众所周知:出于安全原因,泛型默认不能支持型变(否则会引入危险),因此Java提供了通配符上限和通配符下限来支持型变,其中通配符上限就泛型协变,通配符下限就是泛型逆变。
疯狂软件李刚
2020/06/24
1.4K0
Effective Kotlin 译文:Chapter3-Item24-泛型的型变
以下文章翻译自《Effective Kotlin: Best practices》 中的 *Chapter 3 - Item24 -
GeeJoe
2022/03/26
7810
Effective Kotlin 译文:Chapter3-Item24-泛型的型变
了解C#的协变和逆变
在引用类型系统时,协变、逆变和不变性具有如下定义。 这些示例假定一个名为 Base 的基类和一个名为 Derived的派生类。
ryzenWzd
2022/05/07
1K0
03.Scala:样例类、模式匹配、Option、偏函数、泛型
样例类是一种特殊类,它可以用来快速定义一个用于保存数据的类(类似于Java POJO类),在后续要学习并发编程和spark、flink这些框架也都会经常使用它。
Maynor
2021/04/09
2.2K0
From Java To Kotlin 2:Kotlin 类型系统与泛型终于懂了
上期主要分享了 From Java To Kotlin 1 :空安全、扩展、函数、Lambda。
Seachal
2023/06/06
5550
From Java To Kotlin 2:Kotlin 类型系统与泛型终于懂了
深入理解泛型
泛型类:泛型类最常见的用途就是作为容纳不同类型数据的容器类,比如 Java 集合容器类。
用户7353950
2022/05/10
5070
Spark基础-scala学习(七、类型参数)
类型参数是什么 类似于java泛型,泛型类 泛型函数 上边界Bounds 下边界 View Bounds Context Bounds Manifest Context Bounds 协变和逆变 Existential Type 泛型类 scala> :paste // Entering paste mode (ctrl-D to finish) class Student[T](val localId:T){ def getSchoolId(hukouId:T) = "S-"+hukouId+"-"+
老梁
2019/09/10
7420
一文详解scala泛型及类型限定
今天知识星球球友,微信问浪尖了一个spark源码阅读中的类型限定问题。这个在spark源码很多处出现,所以今天浪尖就整理一下scala类型限定的内容。希望对大家有帮助。
Spark学习技巧
2018/09/25
2.7K0
一文详解scala泛型及类型限定
scala快速入门系列【泛型】
本篇作为scala快速入门系列的第三十五篇博客,为大家带来的是关于泛型的内容。
大数据梦想家
2021/01/26
8040
scala快速入门系列【泛型】
快速理解 TypeScript 的逆变和协变
深入学习 TypeScript 类型系统的话,逆变、协变、双向协变、不变是绕不过去的概念。
神说要有光zxg
2022/06/06
1.8K1
快速理解 TypeScript 的逆变和协变
Null 值及其处理方式
Null 值由来已久,它最早是由 Tony Hoare 图方便而创造的,后来被证明这是个错误,而他本人也对此进行了道歉,并称之为「十亿美金错误」1。
zhiruili
2021/08/10
1.3K0
【JAVA冷知识】什么是逆变(contravariant)&协变(covariant)?数组支持协变&逆变吗?泛型呢?
生活不能等待别人来安排,要自己去争取和奋斗;而不论其结果是喜是悲,但可以慰藉的是,你总不枉在这世界上活了一场。有了这样的认识,你就会珍重生活,而不会玩世不恭;同时,也会给人自身注入一种强大的内在力量。 ——路遥《平凡的世界》
山河已无恙
2023/03/02
7550
相关推荐
认真CS☀️协变、逆变 & 不变
更多 >
LV.1
西安云睿网络科技有限公司Java开发
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档
本文部分代码块支持一键运行,欢迎体验
本文部分代码块支持一键运行,欢迎体验