5.1 泛型与型变:协变逆变的第一课


5.1 泛型与型变:协变逆变的第一课

泛型让类型带参数;型变(variance) 规定"元素有子类型关系时,容器之间是什么关系"。+A 协变、-A 逆变、无标注不变——这一节是主峰的第一段岩缝,也是绝大多数人卡壳的地方。

先会写泛型

class Stack[A]: private var elems: List[A] = Nil def push(a: A): Unit = elems = a :: elems def pop(): Option[A] = elems match case Nil => None case h :: t => elems = t; Some(h) val s = Stack[Int]

方法也可以带类型参数:def first[A](xs: List[A]): Option[A] = xs.headOption

问题:Stack[Student] 是 Stack[Person] 吗

Student <: Person(学生是人)。直觉说"学生栈当然也是人栈",但类型系统必须看你怎么 A:

  • 只把 A 读出来(如 List 的 head):可以协变,List[Student]List[Person] 用安全。
  • 只把 A 写进去(如消费者):可以逆变。
  • 又读又写(如 Stack、ArrayBuffer):只能不变,否则一头不安全。
// 协变的正确性论证 val students: List[Student] = List(alice, bob) val people: List[Person] = students // OK,List 是 +A people.head.greet() // 取出来当 Person 用,安全 // 假设 Stack 可变且协变会发生什么 val stack: Stack[Person] = Stack[Student]() // 假设允许 stack.push(teacher: Person) // 装进了一个老师 val s2 = stack.asInstanceOf[Stack[Student]] s2.pop() // 取出的"学生"其实是老师 —— 类型谎言

Scala 的答案是把决定权交给声明者:class Box[+A]class Sink[-A]class Cell[A]。最经典的双重身份是函数:

trait Function1[-T1, +R]: def apply(v: T1): R

参数逆变(能接受更宽的输入)、返回值协变(能给出更窄的输出)——"宽进严出",这是所有类型系统共享的智慧,Java 的 ? super / ? extends 用通配符在调用点表达同一件事,Scala 把它挪到声明点,一次声明处处生效。

型变三种姿态一览

型变三种姿态一览

类型边界

def largest[A <: Ordered[A]](xs: List[A]): Option[A] = ??? // ^ 上界:A 必须是 Ordered[A] 的子类型 def enrich[A >: Empty](xs: List[A]): List[A] = ??? // ^ 下界:A 至少要这么宽

上界限定"能力"(必须可比较),下界常用于协变类的方法参数——List[+A] 上定义 def :: [B >: A](b: B): List[B],允许往 List[Student] 前面接一个 Person,结果类型自动放宽为 List[Person]。标准库大量使用这个技巧让协变与灵活性共存。

图 5-1 协变、逆变、不变三格对比

图 5-1 协变、逆变、不变三格对比

REPL 推演:编译器如何驳回错误的协变

声明 class Box[+A] 后在里面写一个"装进去"的方法,编译器立刻拦截。把这场对话完整走一遍:

class Box[+A](private var value: A): def put(a: A): Unit = value = a // 编译错误:covariant type A occurs in contravariant position // in type A of parameter a

报错翻译成人话:A 声明为协变(只产出),却在参数位置(消费位)出现,位置冲突。修复有两条正路——用下界把消费位放宽:

class Box2[+A](private var value: A | Null): def put[B >: A](b: B): Unit = value = b.asInstanceOf[A | Null] def get: A = value.asInstanceOf[A] // 标准库的做法:List[+A] 的 prepend 签名正是 def :: [B >: A](b: B): List[B] List(new Student("amy")).::(new Person("boss")) // 结果类型 List[Person]

推演结论:下界 B >: A 让方法"能接受的类型至少像 A 一样宽",消费的不再是 A 本身,协变性得以保全。这是标准库最重要的型变技巧,读懂它,List、Option、Vector 的签名全部透明。

协变逆变速判练习

给五个日常类型标注型变,答案附推理:List 是 +A(只读);Function1 是 -T1、+R(吃进宽、吐出窄);Set 是不变(contains 消费元素);Option 是 +A(同 List);Sink/Consumer 类是 -A(只写)。做错最多的通常是 Set——它看起来"像 List",但 contains(A) 这个方法让 A 出现在了消费位。

最后一个易混点是型变与子类型的区别:子类型描述两个类型的关系(Student 是 Person),型变描述两个"容器类型"的关系是否随之成立(List[Student] 是否算 List[Person])。前者是语言给定的,后者是设计者用标注立法的——所以型变标注错了编译器一定会拦,它拦的正是你立的法与你的用法打架。

本节要点回顾

  • 协变 +A 只产出、逆变 -A 只消费、又读又写不变
  • Function1 一逆一协,"宽进严出"是所有类型系统的共同智慧。
  • 上界约束能力,下界放宽输入,两者都是给泛型立法。
  • 判断型变别背口诀,问一句"A 在这个位置是被读还是被写"。

作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U