TypeScript 协变与逆变详解(二十五)

在 TypeScript 类型系统中,协变与逆变是两个非常重要的概念。正确理解和运用这两个概念,可以帮助我们编写更加类型安全的代码,避免潜在的运行时错误。本文将详细介绍协变与逆变的基本概念、应用场景以及如何在实际开发中使用它们。

什么是协变与逆变?

协变与逆变描述了泛型类型在父子类型关系中的行为。具体来说:

  • 协变(Covariant):子类型可以赋值给父类型。适用于输出类型,如函数返回值。
  • 逆变(Contravariant):父类型可以赋值给子类型。适用于输入类型,如函数参数。
  • 不变(Invariant):既不是协变也不是逆变,不能相互赋值。

为什么需要协变与逆变?

在使用泛型类或函数时,类型参数的行为并不总是直观的。例如,将 Dog 赋值给 Animal 是安全的,但将处理 Animal 的函数赋值给处理 Dog 的函数可能不安全。协变与逆变规则帮助 TypeScript 捕获这些潜在的类型错误,确保代码的类型安全。

协变(Covariant)

输出类型的安全性

协变指的是子类型可以赋值给父类型。对于输出类型(如函数返回值),这是安全的。因为返回的子类型对象必然符合父类型的要求。

示例

class Animal {
  name: string = "动物";
}

class Dog extends Animal {
  breed: string = "田园犬";
}

function getAnimal(): Animal {
  return new Dog(); // 返回值协变:安全
}

const animal = getAnimal();
console.log("动物名称: " + animal.name);

在这个例子中,getAnimal 函数返回一个 Dog 对象,但其类型声明为 Animal。由于 Dog 是 Animal 的子类型,这种赋值是安全的。

泛型类的协变

泛型类的属性默认是协变的。这意味着子类型的泛型类可以赋值给父类型的泛型类。

示例

class Cage
<T> {
  animal: T;

  constructor(animal: T) {
    this.animal = animal;
  }
}

const dogCage = new Cage(new Dog());
const animalCage: Cage
<Animal> = dogCage; // 协变:安全

console.log("动物名称: " + animalCage.animal.name);

在这个例子中,Cage<Dog> 可以赋值给 Cage<Animal>,因为 Dog 是 Animal 的子类型。

数组的协变

TypeScript 中数组是协变的,但需要注意可变性带来的问题。虽然类型上安全,但实际上可以添加其他类型的元素,导致运行时错误。

示例

const dogs: Dog[] = [
  { name: "旺财", breed: "哈士奇" },
  { name: "小白", breed: "萨摩耶" }
];

const animals: Animal[] = dogs; // 协变:安全

// 问题:虽然类型上安全,但实际上可以添加其他动物
// animals.push({ name: "猫咪", breed: "猫" }); // 运行时可能出问题!

console.log("动物数量: " + animals.length);

在这个例子中,Dog[] 可以赋值给 Animal[],但后续对 animals 数组的操作可能会引入类型不匹配的问题。

逆变(Contravariant)

输入类型的反转

逆变指的是对于输入类型(如函数参数),父类型可以赋值给子类型。这是因为接受更具体类型的函数无法处理更宽泛的类型。

示例

class Animal {
  name: string = "动物";
}

class Dog extends Animal {
  breed: string = "田园犬";
}

type DogConsumer = (dog: Dog) => void;
type AnimalConsumer = (animal: Animal) => void;

const consumeAnimal: AnimalConsumer = (animal) => {
  console.log("处理动物: " + animal.name);
};

const consumeDog: DogConsumer = consumeAnimal; // 逆变:安全

const dog = new Dog();
dog.breed = "哈士奇";
consumeDog(dog);

在这个例子中,consumeAnimal 函数可以赋值给 consumeDog,因为 Dog 是 Animal 的子类型。

启用严格函数类型

TypeScript 默认对函数参数进行逆变检查。启用 strictFunctionTypes 会强制执行此规则,提高类型安全性。

示例

interface Animal {
  readonly name: string;
}

interface Dog extends Animal {
  readonly breed: string;
}

type GetName = (animal: Animal) => string;
type GetDogBreed = (dog: Dog) => string;

const getDogBreed: GetDogBreed = (dog) => dog.breed;

function printAnimalName(animal: Animal): string {
  return animal.name;
}

// const getSpecific: GetDogBreed = printAnimalName; // 错误!
console.log("犬种: " + getDogBreed({ name: "旺财", breed: "哈士奇" }));

在这个例子中,尝试将 printAnimalName 赋值给 getSpecific 会报错,因为 Animal 不是 Dog 的子类型。

使用 extends 实现安全赋值

了解协变与逆变后,可以安全地设计泛型接口。通过 extends 关键字,我们可以限制泛型参数的类型范围,从而实现更安全的类型赋值。

示例

interface Producer
<T> {
  produce(): T; // 生产方法:返回值是协变的
}

interface Consumer
<T> {
  consume(value: T): void; // 消费方法:参数是逆变的
}

class DogProducer implements Producer
<Dog> {
  produce(): Dog {
    return { name: "旺财", breed: "哈士奇" };
  }
}

class AnimalConsumer implements Consumer
<Animal> {
  consume(animal: Animal): void {
    console.log("消费动物: " + animal.name);
  }
}

const animalProducer: Producer
<Animal> = new DogProducer(); // 协变:安全
const dogConsumer: Consumer
<Dog> = new AnimalConsumer(); // 逆变:安全

const animal = animalProducer.produce();
console.log("生产: " + animal.name);

dogConsumer.consume({ name: "旺财", breed: "哈士奇" });

在这个例子中,Producer<Dog> 可以赋值给 Producer<Animal>,而 Consumer<Animal> 可以赋值给 Consumer<Dog>。

注意事项

  • 返回值协变: 函数返回子类型是安全的。
  • 参数逆变: 函数参数使用父类型是安全的。
  • 启用严格模式: 使用 strictFunctionTypes 获得更严格的类型检查。
  • 数组协变: 注意可变性带来的潜在问题。

总结

协变与逆变是 TypeScript 类型系统的核心概念,帮助我们编写更加类型安全的代码。通过理解这些概念,我们可以设计出更安全的泛型 API,避免运行时错误。

  • 协变: 子类型 → 父类型,用于输出类型。
  • 逆变: 父类型 → 子类型,用于输入类型。
  • 不变: 不能相互赋值。
  • strictFunctionTypes: 启用严格函数类型检查。

建议在设计泛型 API 时考虑协变与逆变,编写更安全的类型代码。


希望本文能帮助你更好地理解 TypeScript 中的协变与逆变。如果你有任何疑问或建议,欢迎在评论区留言交流。