2.4 协变、逆变与不变


2.4 协变、逆变与不变

这节是第二章里最反直觉的一块,也是地图里"变型规则"节点。它解释一个现象:为什么 () => Dog 能赋给 () => Animal,但 (a: Animal) => void 不能赋给 (a: Dog) => void?搞懂变型(variance),数组、回调函数、泛型容器的报错才不再像玄学。

三个词先翻译成人话

  • 协变(covariant):方向一致。子类型 Dog <: Animal,那么 F<Dog> <: F<Animal> 也成立,F 就是协变的。
  • 逆变(contravariant):方向相反。F<Animal> <: F<Dog> 反而成立,F 是逆变的。
  • 不变(invariant):两者都不成立,必须类型完全相等。

符号 <: 读作"可赋给"。

返回类型协变

函数返回什么,调用方拿去当更宽的类型用,没问题——这就是协变。

type GetAnimal = () => Animal; type GetDog = () => Dog; let fn: GetAnimal = (): Dog => new Dog("狗"); // OK:返回 Dog 可当 Animal 用

输入输出:把返回 Dog 的函数赋给"期望返回 Animal"的变量,合法。因为调用方只想要 Animal,你多给 Dog 的能力它用不用都行。

参数类型逆变

参数方向反过来:一个"能吃 Animal"的函数,能不能当作"能吃 Dog"的函数用?

type ConsumeAnimal = (a: Animal) => void; type ConsumeDog = (d: Dog) => void; // 严格模式下:ConsumeAnimal 可赋给 ConsumeDog(逆变) let handler: ConsumeDog = (a: Animal) => { console.log(a.name); }; handler(new Dog("狗")); // 传入 Dog,函数只用到 Animal 的部分,安全

背景:直觉上觉得"能处理 Animal 的函数更能处理 Dog(Dog 是 Animal 的子集)",于是方向反了。操作:把消费 Animal 的函数赋给消费 Dog 的插槽。结果:合法。解读:因为函数内部只依赖 Animal 的字段,给它更具体的 Dog 不会出错。变式:若开启 strictFunctionTypes,方法参数按逆变检查;老模式(bivariant)对方法宽松,对新手更宽容但更易藏 bug。

不变:数组与可写容器

Array<T> 既能读又要写,读要协变、写要逆变,两头一卡就变成不变——Dog[] 不能赋给 Animal[]

let animals: Animal[] = []; let dogs: Dog[] = [new Dog("狗")]; // animals = dogs; // 若允许:往 animals 里 push 一只 Cat 就污染了 dogs // 因此 TS 把数组当作不变,禁止这种赋值(除非只读)

若容器只读(只出不进),就只需协变,可以放宽:

let ro: ReadonlyArray<Animal> = dogs; // OK:只读数组协变 // ro.push(...) // 编译期就没有 push,天然安全

下图标出函数与数组的变型方向差异:

02-04-fig01-2

泛型默认协变,但位置决定变型

interface Box<T> { value: T }T 只出现在输出位置(字段),所以 BoxT 协变。若 T 出现在参数位置,就逆变。同时出现则不变。

interface Reader<T> { read(): T; } // T 在返回位 → 协变 interface Writer<T> { write(v: T): void; } // T 在参数位 → 逆变 interface Cell<T> { get(): T; set(v: T): void; } // 双向 → 不变

实战:回调参数的逆变坑

事件监听常栽在逆变上。监听器期望接收具体事件,你却传了处理宽泛事件的版本——逆变恰恰允许,但要小心你"没用到"的字段其实在别处被需要。

type ClickHandler = (e: MouseEvent) => void; type AnyHandler = (e: Event) => void; const onAny: AnyHandler = (e) => console.log(e.type); const onClick: ClickHandler = onAny; // OK:逆变,处理 Event 的可处理 MouseEvent

键值容器 Map 的变型

Map<K, V>K 只用于读取键(查询时给定的是 K 本身),V 既是读也是写,所以 MapK 逆变、对 V 不变。这点常被忽略。

const dogMap: Map<string, Dog> = new Map(); // 查询时给 string 键,存的是 Dog 值 dogMap.set("a", new Dog("狗")); const d = dogMap.get("a"); // Dog | undefined // 若把 Map<string, Dog> 当 Map<string, Animal> 用会怎样? const asAnimal: Map<string, Animal> = dogMap; // 报错:V 是不变 // 因为往 asAnimal 里 set 一只 Cat,dogMap 就混入了非 Dog

背景:Map<number, T> 在缓存、索引表里极常见。操作:记住值是"不变"的,键的变型几乎用不到。结果:别指望 Map<string, Dog> 能当 Map<string, Animal> 传。解读:容器变型取决于值的可写性,写入面越开放,越趋向不变。

协变在 Promise 链里的体现

Promise<T>T 只出现在"解析结果"这个输出位置,所以对 T 协变。async 函数返回更具体的子类型,可以赋给返回更宽类型的变量。

async function makeDog(): Promise<Dog> { return new Dog("狗"); } // 协变:返回 Promise<Dog> 可赋给期望 Promise<Animal> 的位置 const makeAnimal: () => Promise<Animal> = makeDog; makeAnimal().then((a) => console.log(a.name)); // a 是 Animal

背景:服务层常返回 Promise<具体实体>,调用方只要 Promise<基类>。操作:直接赋值无需断言。结果:链式 .then 里拿到基类字段。解读:协变让"逐层返回更具体"的写法一路通畅,是 async/await 与多态配合时少踩坑的关键。

工程取舍:方法双变(bivariance)的代价

TS 历史上对方法参数采用双变(协变逆变都放行),为了兼容大量 JS 类库写法。这更宽容,但也让"参数类型其实不兼容"的 bug 溜过去。strictFunctionTypes 只对函数类型启用严格逆变,不对方法启用——知道这点,你遇到"为什么方法不报错但函数报错"就不会懵。

本节要点回顾

  • 协变:返回位置,方向一致
  • 逆变:参数位置,方向相反
  • 不变:既读又写(数组、Cell),必须类型相同
  • 只读容器回到协变,是安全的放宽

⚠️ 别把逆变理解成"反过来就错"。逆变是类型安全的需要:能处理父类型的函数,喂给它子类型永远安全。它不是规则的例外,而是规则的另一半。

💡 定义数据容器时,若只想往外取,优先暴露 ReadonlyArray/readonly 字段,既能享受协变放宽,又从类型上杜绝外部写入污染。


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