
先说我自己的经历。有次模拟面试面试官问了一个特别基础的问题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 的形参是 AnimalAnimal 能接住 Cat安全。这说明参数位上的要求是越宽越好你能处理的输入类型越广越能顶替那些只需要处理更窄输入的函数。这是逆变接受 Animal 的函数可以安全放在期望接受 Cat 的函数的位置上。再看返回值位。G 的调用方期望拿到 Animal而 F 实际返回 Cat猫本身就是动物安全。这说明返回值位上的要求是给得越具体越好你能返回 Cat当然能满足任何期望 Animal 的位置。这是协变返回 Cat 的函数可以安全放在期望返回 Animal 的函数位置上。把两部分合起来就是函数子类型最经典的结论(Animal) - Cat : (Cat) - Animal左边能顶替右边因为它在入参上更宽容、在出参上更慷慨。放到 C# 或者 Java 里就是// C# 示例Funcin T, out TResult FuncAnimal, Cat f /* 某个实现 */; FuncCat, Animal g f; // 编译通过参数逆变 返回值协变// Java 示例java.util.function.Function FunctionAnimal, Cat f /* 某个实现 */; FunctionCat, 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 怎么表达协变和逆变靠通配符。这里最实用的口诀就是 PECSProducer 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 IEnumerableout T { T Current { get; } } // T 只进不出逆变 interface IComparerin 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, covariantTrue) # 协变只读 T_contra TypeVar(T_contra, contravariantTrue) # 逆变只写 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 表达如果生产消费都有就只能是不变这也是大多数泛型的默认选择。记忆法就是 PECSProducer Extends, Consumer Super。如果面试官追问为什么函数参数是逆变的把前面那个外包合同的类比递上去输入越宽容越能接住各种调用输出越具体越能满足各种期望。面试官想听到的从来不是背得多熟而是你清不清楚方向由读写安全决定。我个人最后再留一个小技巧设计通用接口时如果明确知道某个类型参数只用于读或只用于写就大胆在声明处标上协变或逆变如果两种操作都有宁可保持不变也不要为了调用方便牺牲编译期安全。这个原则帮我少写了很多运行时才能发现的 bug也推荐你试一下。