理解 TypeScript 中的协变、逆变与双变:从词源开始
在学习 TypeScript 的类型系统时,经常会见到协变 (Covariance)、逆变 (Contravariance) 和双变 (Bivariance)。这些概念看似抽象,但实际上,它们的本质并不复杂。要真正揭开它们神秘的面纱,最好的方法不是一头扎进复杂的代码示例,而是回到源头,从一个更基本的问题开始:Variance (变型) 究竟是什么?以及这些精妙的中文翻译是如何诞生的?
Variance 究竟是什么
在编程中,我们经常处理类型之间的关系,最常见的就是子类型关系。例如,我们有一个基类 Animal 和一个继承它的子类 Dog。我们很清楚,Dog 是 Animal 的一种,所以 Dog 是 Animal 的子类型。
现在,让我们引入一个类型构造器 (Type Constructor)的概念,我们用泛型 F<T> 来表示。这个 F<T> 可以是任何更复杂的类型构造,比如一个数组 Array<T>或者一个函数 (arg: T) => void。
Variance 所要回答的核心问题就是:
如果我们已知
Dog是Animal的子类型,那么F<Dog>和F<Animal>之间存在什么样的关系?
答案不止一种:F<Dog> 可能仍是 F<Animal> 的子类型,也可能反过来成为父类型,甚至彼此毫无关系。描述这种“关系如何随类型构造器而变动”的规则,便称为 Variance(变型)。
词源之旅:理解协变、逆变与双变的翻译由来
理解了“变型”的含义后,这三个翻译得极其出色的术语就变得容易理解了。它们的奥秘隐藏在英文单词的拉丁词根中。
Covariance (协变)
- 词源分析:
Covariance由前缀Co-和variance组成。 - 前缀
Co-: 源自拉丁语,意为“共同”、“一起”、“协同”。我们熟悉的co-worker、cooperate都源于此。 - 翻译解析:
协变中的协字,意为“协同”、“协助”。 - 内在含义: 因此,协变 (Covariance) 的字面意思就是“协同变化”。它描述的是,当基础类型通过类型构造器生成新类型后,它们的子类型关系被原封不动地保留了下来,方向保持一致,共同进退。
Contravariance (逆变)
- 词源分析:
Contravariance由前缀Contra-和variance组成。 - 前缀
Contra-: 源自拉丁语,意为“相反”、“对抗”。我们熟悉的contradict、contrast都源于此。 - 翻译解析:
逆变中的逆字,意为“反向”、“倒转”。 - 内在含义: 因此,逆变 (Contravariance) 的字面意思就是“反向变化”。它描述的是,当基础类型通过类型构造器生成新类型后,它们的子类型关系被完全反转了。
Bivariance (双变)
- 词源分析:
Bivariance由前缀Bi-和variance组成。 - 前缀
Bi-: 源自拉丁语,意为“双”、“二”。我们熟悉的bicycle、bilingual都源于此。 - 翻译解析:
双变中的双字,意为“双向”、“两者皆可”。 - 内在含义: 因此,双变 (Bivariance) 的字面意思就是“双向变化”。它描述的是一种最灵活的关系,即子类型关系可以同时双向成立,既是协变的,又是逆变的。
理解了这些术语的来源,我们就有了一个坚实的起点。接下来,让我们深入探讨它们在 TypeScript 中是如何具体体现的。
变型在 TypeScript 中的实践
为了清晰地演示各种变型,我们需要先准备一个基类型和它的几个子类型。
这里我们选择使用 class 来定义它们,因为 class Dog extends Animal 的语法能非常直观地表达子类型关系,并且便于后续创建实例。
class Animal {
name: string = "animal";
eat() { console.log("animal is eating"); }
}
class Dog extends Animal {
name: string = "dog";
bark() { console.log("dog is barking"); }
}
class Cat extends Animal {
name: string = "cat";
meow() { console.log("cat is meowing"); }
}
在这个类型层级中,Dog 和 Cat 都是 Animal 的子类型。现在,让我们看看当这些类型被放入不同的“类型构造器”中时,会发生什么。
1.协变:直观的子类型关系
协变是最符合我们直觉的一种变型。它的规则是:如果 Dog 是 Animal 的子类型,那么 F<Dog> 也是 F<Animal> 的子类型。这种关系在“只读”或“生产数据”的场景下是完全类型安全的。
安全的协变:函数返回值
// 一个返回 Dog 的函数
let createDog: () => Dog = () => new Dog();
// 一个期望返回 Animal 的函数变量
let createAnimal: () => Animal;
// 赋值是类型安全的
createAnimal = createDog; // OK
// 为什么安全?
// 调用 createAnimal() 时,我们期望得到一个 Animal。
// 实际上它执行的是 createDog(),返回了一个 Dog 实例。
// 因为 Dog 本身就是一个 Animal,所以这完全符合预期。
const myAnimal = createAnimal();
myAnimal.eat(); // OK
console.log(myAnimal.name); // 输出 "dog"
不安全的协变:TypeScript 中的数组
当一个类型既可以“生产”数据(读取元素)又可以“消费”数据(写入元素)时,协变就可能导致类型不安全。
在 TypeScript 中,为了编程的便利性,数组(Array)被设计成协变的。这是一个出于实用性的权衡,但它牺牲了绝对的类型安全,可能在运行时引发错误。
const dogs: Dog[] = [new Dog()];
const animals: Animal[] = dogs; // 协变赋值 —— 编译通过
animals.push(new Cat()); // 运行时允许,但潜藏危险
dogs[1].bark(); // Runtime Error! Cat 没有 bark
Dog 是 Animal 的子类型;数组在 TypeScript 中协变,因此 整个 Dog[] 可以赋给 Animal[]。 此时 dogs 和 animals 指向 同一块数组内存。通过较宽的别名 animals 写入 Cat
编译器只检查 “Cat 是否能赋给 Animal”,答案是肯定的,所以放行;写入的数据落在那块共享内存里。仍以较窄的别名 dogs 读取索引 1,编译器认定它必是 Dog,于是允许调用专属方法 bark(); 运行时却拿到了 Cat,没有找到 bark,抛出异常。
2.逆变:反直觉但安全
逆变则恰恰相反。它的规则是:如果 Dog 是 Animal 的子类型,那么 F<Animal> 反而是 F<Dog> 的子类型。
这听起来很奇怪,但在函数参数中,这却是唯一类型安全的方式。
示例:函数参数
// 一个能处理任何 Animal 的函数
let processAnimal: (animal: Animal) => void = (a) => a.eat();
// 一个期望处理 Dog 的函数变量
let processDog: (dog: Dog) => void;
// 赋值是类型安全的
processDog = processAnimal; // OK
// 为什么安全?
// processDog 现在指向了 processAnimal。
// 当我们调用 processDog(new Dog()) 时,实际上是在调用 processAnimal(new Dog())。
// processAnimal 函数期望接收一个 Animal,而我们给它一个 Dog,这是完全可以的。
processDog(new Dog()); // 调用 processAnimal,传入一个 Dog 实例
// 反过来则不安全
let processDogOnly: (dog: Dog) => void = (d) => d.bark();
// processAnimal = processDogOnly; // Error!
// 如果允许赋值,调用 processAnimal(new Cat()) 就会在运行时崩溃,因为 Cat 没有 bark 方法。
3.双变:灵活但不安全
双变是协变和逆变的结合体,它允许类型关系双向传递。这在某些情况下提供了便利,但也牺牲了类型安全。
在 TypeScript 中,对象方法的参数默认是双变的,这是为了兼容一些早期的 JavaScript 编程模式。
示例:对象方法的参数
interface Handler<T> {
handle(arg: T): void;
}
let animalHandler: Handler<Animal> = {
handle(arg: Animal) {
console.log(`Handling ${arg.name}`);
}
};
let dogHandler: Handler<Dog> = {
handle(arg: Dog) {
arg.bark(); // 只能对 Dog 调用
}
};
// 1. 协变方向 (不安全!)
// 将一个只能处理 Dog 的处理器,赋值给一个声称能处理任何 Animal 的变量
animalHandler = dogHandler; // 在 strict 模式下会报错,但在某些情况下 OK
// 2. 逆变方向 (安全)
dogHandler = animalHandler; // OK
// 让我们看看不安全的情况是如何发生的
// animalHandler 现在实际上是 dogHandler,它期望一个 Dog
// 但 animalHandler 的类型签名允许我们传入任何 Animal
animalHandler.handle(new Cat()); // 编译通过,但运行时会崩溃!
// 内部调用 dogHandler.handle(new Cat()),但 Cat 没有 .bark() 方法。
这个例子清晰地展示了双变带来的风险:它可能让一个在编译时看起来完全正确的程序,在运行时因为类型不匹配而崩溃。
为了解决双变带来的类型安全问题,TypeScript 引入了 strictFunctionTypes 编译选项。
-
当
strictFunctionTypes: true时:- 独立函数和函数表达式的参数是逆变的(安全)。
- 对象方法的参数仍然是双变的(为了向后兼容)。
如何强制方法参数也逆变?将方法写成函数属性的形式即可。
interface StrictHandler<T> {
// 写成函数属性,参数会强制为逆变
handle: (arg: T) => void;
}
let strictAnimalHandler: StrictHandler<Animal>;
let strictDogHandler: StrictHandler<Dog>;
// strictAnimalHandler = strictDogHandler; // Error! (正确地阻止了不安全的协变赋值)
strictDogHandler = strictAnimalHandler; // OK (安全的逆变赋值)
总结:从理论到实践的飞跃
从协变、逆变到双变,这些源于类型理论的术语初看时或许令人望而生畏,但它们的本质,是确保我们代码在类型替换时不出差错的基石。当我们深入其核心,会发现它们共同遵循一个实用的原则:
对于消耗数据的位置(如函数参数),类型越宽泛就越安全,这就是逆变;对于生产数据的位置(如函数返回值或只读属性),类型越具体就越安全,这就是协变。
这个“输入宽,输出窄”的规则,是构建健壮、可预测的类型接口的黄金法则。它超越了函数本身,适用于任何存在数据流动和类型替换的场景。而双变,则是对这一规则的妥协,它为了灵活性而牺牲了部分的类型安全。
理解变型并非终点,而是一扇通往更深层次类型世界的大门。它揭示了 TypeScript 类型系统的严谨与广博,也时刻提醒着我们,技术的海洋浩瀚无垠。唯有怀着一颗谦逊和探索的心,我们才能真正领略并驾驭 TypeScript 所带来的健壮与优雅,编写出不仅正确,而且深思熟虑的优秀代码。