协变与逆变
在 TypeScript 的类型系统中,**协变(Covariance)和逆变(Contravariance)**是描述类型之间"父子关系如何传递"的两个核心概念。理解它们能帮助你写出更安全、更灵活的泛型代码。
前置知识:子类型
在开始之前,我们需要先理解什么是子类型(Subtype)。
class Animal {
name: string = '';
}
class Dog extends Animal {
breed: string = '';
}
class Greyhound extends Dog {
speed: number = 0;
}
这里的继承关系是:
Greyhound → Dog → Animal
(子类型) (父类型)
在 TypeScript 中,子类型可以赋值给父类型,这叫做向上兼容:
let animal: Animal = new Dog(); // ✅ Dog 是 Animal 的子类型
let dog: Dog = new Animal(); // ❌ Animal 不是 Dog 的子类型
这就像说"狗是动物"成立,但"动物是狗"不成立。
什么是协变?
协变的意思是:如果 Dog 是 Animal 的子类型,那么 F<Dog> 也是 F<Animal> 的子类型。方向一致。
最直观的例子是数组:
let dogs: Dog[] = [new Dog()];
let animals: Animal[] = dogs; // ✅ 协变:Dog[] 可以赋值给 Animal[]
Dog 是 Animal 的子类型,所以 Dog[] 也是 Animal[] 的子类型——类型关系的方向保持不变,这就是协变。
协变的常见场景
1. 函数返回值是协变的
type Producer<T> = () => T;
let produceDog: Producer<Dog> = () => new Dog();
let produceAnimal: Producer<Animal> = produceDog; // ✅ 协变
函数"生产"数据时,返回子类型是安全的——你期望得到一个 Animal,实际得到一个 Dog,完全没问题。
2. 只读属性是协变的
type Box<T> = { readonly value: T };
let dogBox: Box<Dog> = { value: new Dog() };
let animalBox: Box<Animal> = dogBox; // ✅ 协变
什么是逆变?
逆变的意思是:如果 Dog 是 Animal 的子类型,那么 F<Animal> 反而是 F<Dog> 的子类型。方向相反。
最典型的例子是函数参数:
type Handler<T> = (value: T) => void;
let handleAnimal: Handler<Animal> = (animal) => {
console.log(animal.name);
};
let handleDog: Handler<Dog> = (dog) => {
console.log(dog.breed);
};
// 逆变:Handler<Animal> 可以赋值给 Handler<Dog>
let dogHandler: Handler<Dog> = handleAnimal; // ✅
let animalHandler: Handler<Animal> = handleDog; // ❌ 不安全!
为什么函数参数是逆变的?
用一个生活中的例子来理解:
假设你需要一个"遛狗师"(
Handler<Dog>),来了一个"动物护理员"(Handler<Animal>)。动物护理员能照顾所有动物,当然也能遛狗——安全。反过来,你需要一个"动物护理员"(
Handler<Animal>),来了一个"遛狗师"(Handler<Dog>)。如果让遛狗师去照顾一只猫,他不会——不安全。
用代码验证:
type Handler<T> = (value: T) => void;
let handleAnimal: Handler<Animal> = (animal) => {
console.log(animal.name); // 只访问 Animal 的属性,对任何动物都安全
};
let handleDog: Handler<Dog> = (dog) => {
console.log(dog.breed); // 访问 Dog 特有的属性
};
// ✅ 安全:handleAnimal 只用 name,Dog 肯定有 name
let test1: Handler<Dog> = handleAnimal;
// ❌ 危险:handleDog 要用 breed,但 Animal 不一定有 breed
let test2: Handler<Animal> = handleDog;
strictFunctionTypes 的作用
TypeScript 中有一个编译选项 strictFunctionTypes(包含在 strict 模式中),它控制函数参数的类型检查行为:
// strictFunctionTypes: true(默认 strict 模式)
type Handler<T> = (value: T) => void;
let animalHandler: Handler<Animal> = handleDog; // ❌ 报错,逆变检查
// strictFunctionTypes: false
let animalHandler: Handler<Animal> = handleDog; // ✅ 不报错,双变(不安全)
始终开启 strict 模式,让 TypeScript 帮你捕获类型不安全的赋值。
注意:方法简写语法(method shorthand)不受 strictFunctionTypes 约束,始终是双变的:
// 函数属性语法 → 逆变(严格检查)
interface Strict {
handle: (value: Dog) => void;
}
// 方法简写语法 → 双变(宽松检查)
interface Loose {
handle(value: Dog): void;
}
用 in / out 显式标注(TypeScript 4.7+)
TypeScript 4.7 引入了 in 和 out 关键字,让你可以显式声明泛型参数的型变:
// out = 协变(只出不进)
type Producer<out T> = () => T;
// in = 逆变(只进不出)
type Consumer<in T> = (value: T) => void;
// in out = 不变(既进又出)
type Processor<in out T> = (value: T) => T;
当实际使用违反声明时,TypeScript 会报错:
// ❌ 报错:T 被用在了输入位置,但声明为 out(协变)
type Wrong<out T> = (value: T) => void;
不变(Invariance)
当一个类型参数既出现在输入位置又出现在输出位置时,它既不是协变也不是逆变,而是不变的:
interface MutableBox<T> {
get(): T; // T 在输出位置 → 协变
set(v: T): void; // T 在输入位置 → 逆变
}
// 协变 + 逆变 = 不变
let dogBox: MutableBox<Dog> = /* ... */;
let animalBox: MutableBox<Animal> = dogBox; // ❌ 不安全
为什么不安全?如果允许赋值:
animalBox.set(new Cat()); // 通过 Animal 接口塞入一只 Cat
dogBox.get().breed; // 💥 运行时错误!实际是 Cat,没有 breed
总结速查表
| 型变 | 方向 | 关键字 | 典型场景 | 记忆口诀 |
|---|---|---|---|---|
| 协变 | Dog → Animal 则 F<Dog> → F<Animal> | out | 返回值、只读属性、Promise<T> | 只出不进,方向同 |
| 逆变 | Dog → Animal 则 F<Animal> → F<Dog> | in | 函数参数、回调 | 只进不出,方向反 |
| 不变 | 不能互相赋值 | in out | 可读写的容器 | 又进又出,锁死 |
| 双变 | 两个方向都可以 | — | 方法简写(不安全) | 都行(但危险) |
面试常见问题
Q1:什么是协变和逆变?用一句话解释
协变:子类型关系"顺着传"——Dog 是 Animal 的子类型,Dog[] 也是 Animal[] 的子类型。
逆变:子类型关系"反着传"——Dog 是 Animal 的子类型,但 Handler<Animal> 反而是 Handler<Dog> 的子类型。
Q2:为什么函数参数是逆变的,返回值是协变的?
- 参数(输入):函数要"消费"这个值。能处理
Animal的函数一定能处理Dog,反之不然 → 逆变 - 返回值(输出):函数要"生产"这个值。能生产
Dog的函数返回的值一定满足Animal的要求 → 协变
Q3:Array<Dog> 赋值给 Array<Animal> 安全吗?
严格来说不安全,因为数组是可变的(可读可写),应该是不变的。但 TypeScript 为了实用性,将数组设计为协变的。如果你需要严格安全,使用 ReadonlyArray<T>。
Q4:in 和 out 关键字有什么实际用途?
- 文档作用:明确告诉使用者这个泛型参数的用途
- 编译检查:TypeScript 会验证实际使用是否符合声明
- 性能优化:帮助编译器更快地判断类型兼容性