协变、逆变、双变,这些概念你真的都懂了吗?

258 阅读8分钟

理解 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-workercooperate都源于此。
  • 翻译解析: 协变 中的  字,意为“协同”、“协助”。
  • 内在含义: 因此,协变 (Covariance) 的字面意思就是“协同变化”。它描述的是,当基础类型通过类型构造器生成新类型后,它们的子类型关系被原封不动地保留了下来,方向保持一致,共同进退。

Contravariance (逆变)

  • 词源分析: Contravariance 由前缀 Contra- 和 variance 组成。
  • 前缀 Contra-: 源自拉丁语,意为“相反”、“对抗”。我们熟悉的 contradictcontrast都源于此。
  • 翻译解析: 逆变 中的  字,意为“反向”、“倒转”。
  • 内在含义: 因此,逆变 (Contravariance) 的字面意思就是“反向变化”。它描述的是,当基础类型通过类型构造器生成新类型后,它们的子类型关系被完全反转了。

Bivariance (双变)

  • 词源分析: Bivariance 由前缀 Bi- 和 variance 组成。
  • 前缀 Bi-: 源自拉丁语,意为“双”、“二”。我们熟悉的 bicyclebilingual都源于此。
  • 翻译解析: 双变 中的  字,意为“双向”、“两者皆可”。
  • 内在含义: 因此,双变 (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 所带来的健壮与优雅,编写出不仅正确,而且深思熟虑的优秀代码。