协变与逆变:前端工程师也要懂的型变知识

2024-07-04 前端

"协变"(Covariance)和"逆变"(Contravariance)听起来像学术黑话,但作为 TypeScript 开发者,你在日常编码中其实每天都在和它们打交道——只是你可能不知道它们叫什么。本文将用实际代码讲清楚这两个概念,以及它们如何影响你写的每一行类型标注。

一、什么是型变(Variance)?

型变描述的是:当类型之间有父子关系时,由这些类型复合而成的更复杂类型之间是否保持同样的父子关系。

简单来说,如果 Cat 是 Animal 的子类型,那么:

  • Array<Cat> 是 Array<Animal> 的子类型吗?(协变问题)
  • (cat: Cat) => void 是 (animal: Animal) => void 的子类型吗?(逆变问题)

二、协变(Covariance):保持父子关系方向

定义:如果 A 是 B 的子类型,那么 F<A> 也是 F<B> 的子类型。方向不变,这叫协变。

2.1 数组是协变的

class Animal {
  name: string = 'animal';
  eat() { console.log('eating...'); }
}

class Cat extends Animal {
  meow() { console.log('meow!'); }
}

class Dog extends Animal {
  bark() { console.log('woof!'); }
}

// Cat 是 Animal 的子类型
// Array<Cat> 也是 Array<Animal> 的子类型(协变)
const cats: Cat[] = [new Cat(), new Cat()];
const animals: Animal[] = cats;  // ✅ OK:Array 是协变的

// 你可以遍历 animals 调用 Animal 上存在的方法
animals.forEach(animal => animal.eat());  // ✅ OK

// ⚠️ 但注意:协变数组也有安全隐患
// animals 实际指向 cats 数组,但你通过 animals 可以放入 Dog
animals.push(new Dog());  // TypeScript 不会报错,但运行时 cats[2].meow() 会出错!

为什么 TypeScript 允许这样? 这是为了兼容 JavaScript 的灵活性。TypeScript 选择了"结构类型系统"(Structural Typing),数组和属性默认是协变的。可以通过配置 strictFunctionTypes 部分缓解,但数组协变是刻意的设计权衡。

2.2 对象属性是协变的

interface AnimalShelter {
  resident: Animal;
}

interface CatShelter {
  resident: Cat;
}

// CatShelter 是 AnimalShelter 的子类型(协变)
// 因为 read 操作总是安全的:你期望得到 Animal,实际得到 Cat(也是 Animal)
const shelter: AnimalShelter = {
  resident: new Cat()
}; // ✅ Cat 可以赋值给 Animal 类型的位置

2.3 返回值是协变的

// 返回值位置:协变
// 如果 Cat 是 Animal 的子类型,那么 () => Cat 是 () => Animal 的子类型
type AnimalFactory = () => Animal;
type CatFactory = () => Cat;

const getCat: CatFactory = () => new Cat();
const getAnimal: AnimalFactory = getCat;  // ✅ OK:返回值协变

// 调用 getAnimal,期望返回 Animal,实际返回 Cat —— 完全没问题
const pet: Animal = getAnimal();

三、逆变(Contravariance):反转父子关系方向

定义:如果 A 是 B 的子类型,那么 F<B> 是 F<A> 的子类型。方向反转,这叫逆变。

逆变最常出现在函数参数位置。

3.1 函数参数是逆变的

// 函数参数位置:逆变
// 如果 Cat 是 Animal 的子类型,那么 (animal: Animal) => void 是 (cat: Cat) => void 的子类型
type AnimalHandler = (animal: Animal) => void;
type CatHandler = (cat: Cat) => void;

// 处理 Animal 的函数,可以处理 Cat(因为 Cat 也是 Animal)
const handleAnimal: AnimalHandler = (animal: Animal) => {
  console.log(`Handling: ${animal.name}`);
  animal.eat();  // 只调用 Animal 上存在的方法
};

// ✅ OK:(animal: Animal) => void 可以赋值给 (cat: Cat) => void
const handleCat: CatHandler = handleAnimal;

// 使用时:传入 Cat,handleAnimal 只访问了 Animal 上的属性/方法 → 完全安全
handleCat(new Cat());  // 输出:Handling: animal / eating...

逆变直觉理解:

  • 一个能处理任何动物的处理函数,当然也能处理猫(猫也是动物)
  • 所以 Animal → void 是 Cat → void 的子类型
  • 参数类型越宽泛,函数越安全(作为被赋值的对象)

3.2 TypeScript 的 strictFunctionTypes

默认情况下,TypeScript 通过 --strictFunctionTypes(或 --strict)启用严格的函数参数逆变检查:

// strictFunctionTypes: true(推荐)
type Logger = (item: Animal) => void;

const catLogger: (item: Cat) => void = (cat: Cat) => {
  cat.meow();  // 假设 Cat 有 meow 方法
};

// ❌ 错误!如果关闭 strictFunctionTypes 则允许
// const logger: Logger = catLogger;
// 危险:logger(new Dog()) 时会调用不存在的 meow()

四、TypeScript 中的型变速查

类型位置 型变方向 规则 示例
数组元素 协变 Array<Cat> → Array<Animal> let a: Animal[] = cats
对象属性(只读) 协变 {v: Cat} → {v: Animal} let a: {v: Animal} = {v: cat}
函数返回值 协变 () => Cat → () => Animal let f: () => Animal = () => cat
函数参数 逆变 (a: Animal)=>void → (c: Cat)=>void let h: CatHandler = animalHandler
Promise 协变 Promise<Cat> → Promise<Animal> let p: Promise<Animal> = Promise.resolve(cat)
Map/Set 协变 Map<string, Cat> → Map<string, Animal> 值的部分是协变

五、实际场景

5.1 事件处理器中的逆变

// DOM 事件处理是逆变的最佳实践体现
type ClickHandler = (event: MouseEvent) => void;
type GenericHandler = (event: Event) => void;

// GenericHandler(参数更宽泛)可以赋值给 ClickHandler(参数更具体)
// 因为当用户点击时,传入的确实是 MouseEvent,而 GenericHandler 只要求 Event
const logEvent: GenericHandler = (event) => {
  console.log(event.type, event.timeStamp);
};

// ✅ 逆变使得这成为可能
button.addEventListener('click', logEvent);
// addEventListener('click', handler: (e: MouseEvent) => void)
// GenericHandler 是 (e: MouseEvent) => void 的子类型(参数逆变)

5.2 回调函数中的逆变

// 实际场景:通用的数组处理函数
function processItems<T>(
  items: T[],
  callback: (item: T) => void
): void {
  items.forEach(callback);
}

const logAny = (item: unknown) => {
  console.log('Processing:', item);
};

const numbers = [1, 2, 3];
// ✅ unknown 比 number 更宽泛,所以 (item: unknown) => void 是 (item: number) => void 的子类型
processItems(numbers, logAny);  // 逆变在起作用

// 同理,这也很安全
const strings = ['a', 'b', 'c'];
processItems(strings, logAny);  // ✅

5.3 Array 方法中的协变与逆变

// map:参数逆变 + 返回值协变
const animals: Animal[] = [new Cat(), new Dog(), new Cat()];

// map 签名:map<U>(callback: (value: T, ...) => U): U[]
// value 参数是逆变的,返回值是协变的

// ✅ callback 参数更宽泛(逆变)
const names: string[] = animals.map((animal: Animal) => animal.name);

// ✅ 返回值更具体(协变)
const cats: Cat[] = [new Cat(), new Cat()];
const pets: Animal[] = cats.map(cat => cat);  // Cat → Animal 协变

// filter:参数逆变
// filter(predicate: (value: T, ...) => boolean): T[]
const isAnimal = (x: { name: string }) => true;  // 参数更宽泛
const filtered = animals.filter(isAnimal);  // ✅ 逆变使得这可以工作

5.4 回调参数的双向协变(TypeScript 特殊设计)

// TypeScript 对回调参数的检查比一般函数参数更宽松
// 这是为了兼容常见 JavaScript 模式

interface AnimalEvent {
  onEat(callback: (animal: Animal) => void): void;
}

const handler = (cat: Cat) => {
  cat.meow();
};

// 在 strictFunctionTypes 为 true 时,以下代码的行为:
const shelter: AnimalEvent = {
  // ⚠️ 这里可能会有类型错误,取决于 TypeScript 版本和配置
  onEat(callback) {
    callback(new Cat());
  }
};

注意:strictFunctionTypes 只对非方法签名(属性形式的函数类型)生效。类方法中的参数仍然是双向协变的。

六、记住型变的实用法则

🍎 协变(Covariance):输出位置 → 方向不变
    "猫是动物 → 猫工厂也是动物工厂"
    适用于:返回值、数组元素、Promise 值、只读属性

🔃 逆变(Contravariance):输入位置 → 方向反转
    "能处理任何动物的函数 → 也能处理猫"
    适用于:函数参数

📦 不变(Invariance):输入+输出位置 → 没有子类型关系
    适用于:读写属性、mutable 字段

七、总结

  1. 协变保持方向:子类型在哪里都是子类型(返回值、数组、只读属性)
  2. 逆变反转方向:参数类型越宽泛,函数越"小"(作为子类型)
  3. TypeScript 的设计在类型安全和JavaScript 兼容性之间做了平衡
  4. 理解型变能帮你写出更准确的泛型约束,避免"为什么这里报类型错误"的困惑
  5. 开启 strictFunctionTypes 获得更安全的函数参数逆变检查

下次当你在 TypeScript 中疑惑"为什么这个函数能赋值给那个类型"时,想想是协变还是逆变在起作用,答案往往一目了然。