先说我自己的经历。有次模拟面试,面试官问了一个特别基础的问题:"Cat 是 Animal 的子类,那 List 和 List 是什么关系?"我脱口而出"协变",结果对方紧接着追问:"那我把一只 Dog 塞进这个 List 里,你手里的 List 还安全吗?"我当场卡壳。协变和逆变这两个词,几乎所有写代码的人都背过定义,可真到用的时候,该用 extends 还是 super、为什么这里编译不让过,还是得翻资料。这篇我准备用最容易理解的盒子模型把这件事彻底讲透,最后给你一句可以直接抄走的总结。
先给出全文的核心结论,省得你看到一半没耐心:协变是"父子关系顺着类型参数的方向走",逆变是"父子关系反着走"。判断标准不是背关键字,而是想清楚这个类型参数是"往外给东西"还是"往里收东西"。能往外给(生产/返回值),协变;只往里收(消费/参数),逆变;又给又收,只能不变。
1. 为什么这个破概念记了八遍还是忘
1.1 一个让人当场懵掉的提问场景
上面那个问题,我后来复盘了很久。它其实是一套连环追问:先问 List 是不是 List 的子类型,再问如果只读不改是否安全,再问如果允许写入会发生什么,最后问数组为什么又是协变的。一环扣一环,每一环都在逼你用"安全规则"去思考,而不是背结论。
我当时的问题出在哪?我把"子类型"理解成了一种静态的、绝对的关系,以为 Cat <: Animal 就万事大吉,所有出现 Cat 的位置都自动能换成 Animal 或者反过来。但容器和函数一出现,事情就变了:容器有读和写两种操作,各自的安全约束完全不同。绝大多数人记不住协变逆变,不是因为笨,而是因为只在背"方向",没真正理解"为什么要有方向"。
1.2 问题出在"方向"两个字上
先理清楚子类型到底是什么意思。日常开发里,子类型关系就一句话:凡是需要父类型的地方,你都能放心地把子类型丢过去。函数想要一个 Animal,你传一只 Cat,没问题;变量声明成 Animal,你赋一个 Cat 的值,也没问题。这套规则叫里氏替换原则,说白了就是子类型不能拆父类型的台。
但这条规则碰到泛型就麻烦了。泛型容器有"读"和"写"两个方向,这两个方向的安全要求是相反的。如果你有一个 List 的引用,别人可能往里塞一只 Dog;而真正传给你的对象其实是 List ,你按照 Animal 去读,读出来的却是 Dog,类型安全当场破功。所以,泛型能不能协变、能不能逆变,核心不是数学推导,而是"这个位置到底能不能安全读写"。
理解到这一层,后面所有问题就都顺了。你不需要记"协变 = 同向、逆变 = 反向",你只需要判断:这里是在产出还是在消费。
2. 从"能装能取"的盒子模型看懂三种变化
2.1 协变:顺着继承方向,只有"只读"才安全
假设我们有 Cat <: Animal,再假设有一个特殊的盒子,它只有一个操作:往外拿,而且保证每次拿出来的都是 Cat。我们可以叫它 Producer 。现在代码里需要一个 Producer ,你递过来一个 Producer ,安全吗?
完全安全。因为我从这个盒子里拿出来的每一只动物,实际都是猫,猫就是动物,我按动物的方式处理它没有任何问题。这种只读、只生产、永远不往里写的容器,它的子类型关系顺着类型参数走:
Producer <: Producer
这就是协变。写成 Java 通配符就是 List<? extends Animal> 可以被 List 赋值,因为 ? extends Animal 保证了"读出来的一定是 Animal 或者 Animal 的子类",读操作永远安全。
反过来就崩了:我需要的明明是 Producer ,你给我 Producer 的引用去读,没问题;但如果代码里期望能掏出各种动物,而实际底层是个 Producer ,某一刻需要的是 Dog,就当场出事故。所以协变只对读操作成立,一旦容器允许写入,协变立刻失效。
2.2 逆变:方向反过来,因为"只写"需要更广的能力
再看另一个盒子:它只有一个写入操作,每次往里放的都是 Cat。我们叫它 Consumer 。现在代码里需要的是一个 Consumer ,你递过来一个 Consumer ,也就是"什么动物都能往里放"的盒子,安全吗?
安全。因为能装下所有动物的盒子,装一只猫当然绰绰有余。这种只写、只消费的容器,它的子类型关系跟类型参数的方向是反的:
Consumer <: Consumer
这就是逆变。用 Java 通配符表示就是 List<? super Cat> 可以被 List 赋值,因为 ? super Cat 保证了"往里写 Cat 是安全的,这个列表至少能装下 Cat 及其父类型"。
反过来的方向就危险了:我需要一个"能放 Animal 的盒子",你塞给我一个"只能放 Cat 的盒子",我往里丢一只 Dog,盒子直接爆。所以消费者对能力的要求是"越广越好",而不是"越具体越好"。这个反直觉的方向不是设计者故意刁难,是安全规则逼出来的。
生活里也有完全对应的场景:你要找一个"能喂猫的人",来了一个"能喂所有动物的人",那是降维打击;但你要找一个"能喂所有动物的人",来了一个"只会喂猫的人",那就接不住活儿了。
2.3 不变:又读又写的默认选择
现在考虑最普通的盒子:既能往外拿 Cat,也能往里放 Cat,两个操作都有。它跟"既能读又能写 Animal 的盒子"之间有父子关系吗?
没有,两者互不兼容。这就是第三种情况:不变(invariance)。
原因从两个方向各推一次。如果"能读写 Animal 的盒子"是"能读写 Cat 的盒子"的子类型,那我用 Cat 引用去读,可能掏出 Dog,崩。反过来,如果"能读写 Cat 的盒子"是"能读写 Animal 的盒子"的子类型,那我用 Animal 引用去写,可能塞进 Dog,也崩。两头都堵死,所以只能互不认账。
这就解释了为什么大多数语言的泛型默认都是不变的:Java 的 List 、C# 的 List 、Python 的 typing.List[T],全都默认不变。不是因为设计者偷懒,而是因为默认情况下你不知道使用方是读还是写,不变是最安全的起点。想放开协变或逆变,必须显式声明,把"这里只读"或"这里只写"作为契约交给编译器。
3. 函数类型才是真正的考点:参数的逆变与返回值的协变
3.1 参数逆变、返回值协变的完整推导
面试题喜欢考容器,但真正难倒人的往往是函数类型。假设有两个函数类型:
F = (Animal) -> Cat,意思是"接收 Animal,返回 Cat" G = (Cat) -> Animal,意思是"接收 Cat,返回 Animal"
问题是:F 能不能安全顶替 G?分两块看。
先看参数位。G 的调用方会往里传 Cat,而 F 的形参是 Animal,Animal 能接住 Cat,安全。这说明参数位上的要求是"越宽越好",你能处理的输入类型越广,越能顶替那些只需要处理更窄输入的函数。这是逆变:接受 Animal 的函数,可以安全放在期望接受 Cat 的函数的位置上。
再看返回值位。G 的调用方期望拿到 Animal,而 F 实际返回 Cat,猫本身就是动物,安全。这说明返回值位上的要求是"给得越具体越好",你能返回 Cat,当然能满足任何期望 Animal 的位置。这是协变:返回 Cat 的函数,可以安全放在期望返回 Animal 的函数位置上。
把两部分合起来,就是函数子类型最经典的结论:
(Animal) -> Cat <: (Cat) -> Animal
左边能顶替右边,因为它在入参上更宽容、在出参上更慷慨。放到 C# 或者 Java 里就是:
// C# 示例:Func<in T, out TResult> Func<Animal, Cat> f = /* 某个实现 */; Func<Cat, Animal> g = f; // 编译通过,参数逆变 + 返回值协变// Java 示例:java.util.function.Function Function<Animal, Cat> f = /* 某个实现 */; Function<Cat, Animal> g = f; // 编译通过我自己的理解口径是:函数就像一个外包合同。你让我处理一件事,我的处理能力越广(参数越泛),越不容易掉链子;我交付给你的东西越具体(返回值越细),你越不容易踩坑。一个输入更宽容、输出更精细的服务商,当然能顶替要求更低的服务商。
顺带一提,Java 在方法重写这件事上也是这个逻辑:重写时返回类型可以收窄(协变返回类型),但参数类型不允许变宽。原因一模一样——重写的方法要能被父类调用点安全使用,参数上你只能保持原样或者更宽松,返回值上可以更具体。
3.2 那句可以抄走的一句话总结
到这里,标题要的"一句话"可以正式给了。我把它拆成三个版本,按使用场景挑选:
- 看定义:协变是子类型关系顺着类型参数方向走,逆变是反着走,不变则是完全不认父子关系。
- 做判断:只读、生产者、返回值的位置用协变思维;只写、消费者、参数的位置用逆变思维;又读又写只能不变。
- 记公式:extends 配协变,super 配逆变;out 配协变,in 配逆变;Producer Extends, Consumer Super。
这三句话是同一件事的三个角度。第一句是定义,第二句是判断逻辑,第三句是落地到各语言的关键字。面试时把三句按顺序说出来,基本就是标准满分答案。
4. 不同语言里的真实面孔:各自动了哪些手脚
4.1 Java:数组协变与 PECS 通配符
Java 是理解协变最好的教材,也是最好的反面教材。先说数组:Java 的数组是协变的,String[] 可以直接赋值给 Object[]。编译期很美好,运行时就出了岔子:
String[] strs = new String[10]; Object[] objs = strs; // 编译通过,数组协变 objs[0] = 1; // 编译通过,运行时抛 ArrayStoreException数组既能读又能写,却硬要设计成协变,所以只能靠运行时检查来兜底。泛型吸取了这个教训,默认完全不变,List 和 List
那 Java 怎么表达协变和逆变?靠通配符。这里最实用的口诀就是 PECS:Producer Extends, Consumer Super。
// 只读场景:方法只需要从集合里取元素 void printAll(List<? extends Animal> animals) { for (Animal a : animals) { /* 安全读取 */ } } // 只写场景:方法只需要往集合里添加元素 void addCat(List<? super Cat> cats) { cats.add(new Cat()); // 安全写入 } // 经典复制:从通配符上界读,往通配符下界写 void copy(List<? extends T> src, List<? super T> dst) { for (T item : src) dst.add(item); }用的时候最容易犯的错,是拿到 List<? extends Animal> 就想往里 add。编译器保证的是"读出来的一定是 Animal 或它的子类",但不知道具体是哪一个,所以任何写操作都不合法。反过来,List<? super Cat> 只能保证"能写 Cat",读出来的东西最多当 Object 用,想读回 Cat 也是做梦。
4.2 C# 的 in/out 与 TypeScript 的双向放水
C# 走的是声明处变体,在泛型接口声明时就标好方向,使用方不用每次写通配符:
// T 只出不进:协变 interface IEnumerable<out T> { T Current { get; } } // T 只进不出:逆变 interface IComparer<in T> { int Compare(T x, T y); }声明了 out 的接口,IEnumerable 可以直接赋给 IEnumerable ;声明了 in 的接口,IComparer 可以直接赋给 IComparer 。编译器在声明处已经确认了安全,不需要使用方再操心。这是我看过的最舒服的变体设计。
TypeScript 则是另一个极端。它是结构化类型,对象属性默认协变;方法参数默认是 bivariant,也就是既允许逆变也允许协变。这在类型安全上是放水的,换来的是对日常 JavaScript 写法的兼容。开启 strictFunctionTypes 之后,独立的函数类型参数会收紧成逆变,但对象方法参数仍然保持双向,目的是不砸碎老代码。
// 未开 strictFunctionTypes 时:参数双向,编译通过 type Handler = (x: Cat) => Animal; let h: Handler = (x: Animal) => new Cat(); // 开启 strictFunctionTypes 后:函数类型参数逆变,上面这种双向赋值被限制4.3 Python 与其他语言的变体机制
Python 的 typing 模块里,List[T]、Dict[K, V] 默认都是不变的。但你在定义自己的泛型协议时,可以用 TypeVar 显式声明方向:
from typing import TypeVar, Protocol T_co = TypeVar("T_co", covariant=True) # 协变:只读 T_contra = TypeVar("T_contra", contravariant=True) # 逆变:只写 class SequenceLike(Protocol[T_co]): def __getitem__(self, index: int) -> T_co: ... class Sink(Protocol[T_contra]): def send(self, item: T_contra) -> None: ...这样的声明配合静态检查工具,就能获得和 C# 类似的变体保障。Kotlin 和 Scala 也支持声明处变体,分别用 out/in 和 +T/-T 来表示。Rust 因为基本没有继承式的子类型,平时不太讨论容器协变,更多是在生命周期推导里出现 variance 的概念。
4.4 语言行为对比速查表
| 语言 | 数组 | 泛型默认 | 显式声明方式 | 备注 |
|---|---|---|---|---|
| Java | 协变 | 不变 | ? extends / ? super 通配符 | 数组协变有运行时风险 |
| C# | 不变 | 不变 | out / in 接口声明 | 声明处变体,使用最舒服 |
| TypeScript | 结构化协变 | 属性协变,方法参数双向 | strictFunctionTypes 收缩函数参数 | 兼顾兼容性与安全性 |
| Python | — | 不变 | TypeVar(covariant/contravariant) | 配合静态检查才生效 |
| Kotlin | — | 不变 | out / in 类型参数 | 类似 C# |
| Scala | — | 声明处 | +T / -T | 表达力最强 |
表格只是速查。真正重要的还是那句老话:先判断类型参数是"往外给"还是"往里收",再决定用哪种写法。
5. 最容易踩的坑,以及我的记忆锚点
5.1 Java 数组协变的运行时炸雷
我早期写过一个工具方法,接收 Object[] 然后统一处理,心想"String[] 是 Object[] 的子类型,传什么都行"。结果某条分支里传进来的其实是 Integer[],方法试图往里写一个 String,运行到一半直接抛 ArrayStoreException。那一次排查花了半天,最后发现是数组协变在作祟。
这个坑的本质,就是协变容器不能承担"写"的职责。数组在 Java 里读写都能干,却偏偏设计成协变,所以必须靠运行时检查兜底。泛型选择不变,正是吸取了这个教训:编译期就把危险操作拦下,不让问题拖到运行时才爆。以后面试被问"为什么数组能协变而泛型不能",答案可以简化为:数组年轻时候埋的坑,泛型学乖了。
5.2 "兔子管理员"与"动物管理员"的现场教学
在团队里给新人讲这个概念,我从来不用术语开场,而是用两个角色:兔子管理员和动物管理员。
需要"能给你一只动物的人"时,来一个"能给你一只兔子的人",没问题,兔子也是动物,方向顺着走,这是协变。需要"能把兔子安顿好的人"时,来一个"能安顿所有动物的人",更没问题,能力覆盖更广,方向反着走,这是逆变。需要"既能给你动物又能收留动物的全功能管理处"时,"兔子专营店"和"动物中心"谁也不能替代谁,因为它们各有对方的盲区,这就是不变。
这个例子的好处是彻底脱离了语法关键字,只用"给"和"收"两个动作判断方向。在代码里犹豫的时候,就把类型参数翻译成兔子/动物,问自己:这里是在给出去,还是在收进来?给出去就用协变的口子(extends/out),收进来就用逆变的口子(super/in)。
5.3 面试实战:三十秒讲完协变和逆变
最后分享一套我教新人用的三十秒话术,亲测在面试场合很稳:
"协变和逆变描述的是类型参数替换后,整个泛型类型的父子关系如何变化。关键看类型参数用于生产还是消费:只作为返回值往外提供时,子类型关系顺着参数走,是协变,Java 用 ? extends、C# 用 out 表达;只作为方法参数往里接收时,子类型关系反着走,是逆变,Java 用 ? super、C# 用 in 表达;如果生产消费都有,就只能是不变,这也是大多数泛型的默认选择。记忆法就是 PECS:Producer Extends, Consumer Super。"
如果面试官追问"为什么函数参数是逆变的",把前面那个外包合同的类比递上去:输入越宽容越能接住各种调用,输出越具体越能满足各种期望。面试官想听到的从来不是背得多熟,而是你清不清楚方向由读写安全决定。
我个人最后再留一个小技巧:设计通用接口时,如果明确知道某个类型参数只用于读或只用于写,就大胆在声明处标上协变或逆变;如果两种操作都有,宁可保持不变,也不要为了调用方便牺牲编译期安全。这个原则帮我少写了很多运行时才能发现的 bug,也推荐你试一下。