likes
comments
collection
share

TypeScript 类型兼容——逆变、协变、双向协变和不变在 TypeScript 中,类型系统支持“逆变(Contr

作者站长头像
站长
· 阅读数 23

在 TypeScript 中,类型系统支持“逆变(Contravariance)”、“协变(Covariance)”、“双向协变(Bivariance)”和“不变(Invariance)”的概念,这些概念主要用于理解类型之间的兼容性,尤其是在函数参数和返回值之间的关系。

类型安全和型变

首先,TypeScript 通过在 JavaScript 上添加静态类型系统来实现类型安全。类型安全意味着我们可以在编译时检测到可能的类型错误,防止它们在运行时导致程序崩溃。例如,TypeScript 不允许将一个 number 类型的值赋给一个 boolean 类型的变量,也不允许调用某个对象上不存在的方法。

let isDone: boolean = true;
isDone = 1; // Error: Type '1' is not assignable to type 'boolean'.

let currentDate: Date = new Date();
currentDate.exec(); // Error: Property 'exec' does not exist on type 'Date'.

在这两个例子中,TypeScript 的类型检查机制会在编译时报告错误,从而保证了类型的正确性和代码的可靠性。

然而,完全严格的类型安全有时会导致不便。例如,当我们处理子类型和父类型时,TypeScript 允许一些 变通,以便在保证类型安全的前提下提供更大的灵活性。这种 变通 被称为型变(Variance)。

在类型系统中,当类型 A 和类型 B 存在继承关系时,使用 A 的地方是否也能使用 B。型变在函数的参数和返回值中表现尤为明显。型变通常包括以下四种类型:

  1. 协变(Covariance):如果 A 是 B 的子类型,那么 F<A> 也是 F<B> 的子类型。换句话说,F<A> 可以赋值给 F<B>。协变通常用于函数的返回值类型。

  2. 逆变(Contravariance):如果 A 是 B 的子类型,那么 F<B>F<A> 的子类型。换句话说,F<B> 可以赋值给 F<A>。逆变通常用于函数的参数类型。

双向协变(Bivariance):TypeScript 中的一种特殊情况,允许函数参数类型既可以协变也可以逆变,虽然这种情况理论上不安全,但它是为了保持实际代码的兼容性。

不变(Invariance):F<A> 既不是 F<B> 的子类型,也不是其超类型。换句话说,F<A>F<B> 是完全不兼容的类型。

协变

在 TypeScript 中,协变 主要是指在处理泛型、函数类型、数组等结构时,允许子类型被视为其父类型。简言之,协变允许子类型可以赋值给父类型,这是最常见的类型兼容性规则。

泛型默认是协变的,这就以为着如果 B 是 A 的子类型,那么 T<B> 也是 T<A> 的子类型。

例如,假设我们有一个泛型接口 Box<T>

class Animal {}
class Dog extends Animal {}

interface Box<T> {
  value: T;
}

let animalBox: Box<Animal>;
let dogBox: Box<Dog> = { value: new Dog() };

animalBox = dogBox; // 合法,协变

他们相互赋值是不会错的,虽然这俩类型不一样,但是依然是类型安全的。

TypeScript 类型兼容——逆变、协变、双向协变和不变在 TypeScript 中,类型系统支持“逆变(Contr

在这个例子中,Box<Dog> 可以赋值给 Box<Animal>,因为 Dog 是 Animal 的子类型。这种关系在 TypeScript 的类型系统中是允许的,因为它符合协变的规则。

除了在类中,函数和数组中也都是可以协变的:

class Animal {}
class Dog extends Animal {}

type AnimalFactory = () => Animal;
type DogFactory = () => Dog;

let createAnimal: AnimalFactory;
let createDog: DogFactory = () => new Dog();

createAnimal = createDog; // 合法,协变

class Animal {}
class Dog extends Animal {}

let dogs: Dog[] = [new Dog(), new Dog()];
let animals: Animal[];

animals = dogs; // 合法,协变

协变在 TypeScript 中非常重要,因为它允许类型系统具有更大的灵活性。协变允许你在不违反类型安全的情况下,灵活地使用继承结构中的子类型和父类型。

在使用泛型数据结构时,协变允许我们在不影响类型安全的情况下,处理更广泛的类型。例如,List<Dog> 可以在 List<Animal> 的上下文中使用,因为 Dog 是 Animal 的子类型。协变使得函数可以返回更具体的类型而不影响函数的兼容性,这有助于设计灵活且类型安全的接口。例如,你可以在接口的实现中返回一个更具体的类型,而不需要更改接口本身的签名。

虽然协变提供了很多灵活性,但是在某些情况下也可能会带来问题,例如数组的协变可能会导致运行时错误,例如在使用 push 操作的时候,它可能会破坏数组的类型一致性,从而导致运行时报错:

class Animal {}
class Dog extends Animal {}
class Cat extends Animal {}

let animals: Animal[] = [new Dog()];
animals.push(new Cat()); // 合法,但可能不是预期的行为

在这个例子中,animals 原本是 Dog[] 类型,但由于数组的协变,Cat 实例被推入了这个数组,这可能会导致一些不一致的问题。

逆变

在 TypeScript 中,逆变 是指,如果类型 A 是类型 B 的父类型(A <: B),那么在函数参数的位置上,类型 B 可以赋值给类型 A。换句话说,逆变允许父类型的函数参数赋值给子类型的函数参数。

在函数参数中的逆变,我们可以考虑以下类继承结构:

class Animal {
  speak() {
    console.log("Animal sound");
  }
}

class Dog extends Animal {
  speak() {
    console.log("Bark");
  }
}

假设我们有两个函数类型,一个接受 Animal 作为参数,另一个接受 Dog 作为参数:

type AnimalHandler = (animal: Animal) => void;
type DogHandler = (dog: Dog) => void;

在逆变的情况下,AnimalHandler 可以赋值给 DogHandler。这是因为 Dog 是 Animal 的子类型,因此处理 Animal 的函数也可以处理 Dog。

let handleAnimal: AnimalHandler = (animal: Animal) => {
  animal.speak();
};

let handleDog: DogHandler;

handleDog = handleAnimal; // 合法,逆变

在这个例子中,handleAnimal 可以处理任何 Animal,包括 Dog。因此,我们可以将 handleAnimal 赋值给 handleDog,这在类型系统中是安全的。

逆变的直观理解是,你可以将处理较宽泛类型的函数赋值给处理更具体类型的函数。如果一个函数能够处理 Animal,那么它肯定能够处理 Dog,因为 Dog 是 Animal 的一种特殊化形式。

在 TypeScript 中,型变通常用于描述类型在不同上下文中的传递方式。逆变与协变是相对的:

  1. 协变:子类型可以赋值给父类型。例如,Dog 是 Animal 的子类型,那么 Dog 类型的值可以赋值给 Animal 类型的变量。

  2. 逆变:父类型可以赋值给子类型。例如,Animal 是 Dog 的父类型,那么接受 Animal 参数的函数可以赋值给接受 Dog 参数的函数。

在 React 中,事件处理函数是逆变的一种典型应用。当你在一个通用的事件处理器中使用更广泛的事件类型时,逆变可以确保你的事件处理器适用于子类型事件。

例如,考虑一个处理 MouseEvent 的事件处理器:

type MouseEventHandler = (event: React.MouseEvent<HTMLButtonElement>) => void;

如果我们有一个更通用的事件处理函数,它可以处理任意的 DOM 事件,那么它可以被安全地用作处理特定的 MouseEvent:

type AnyEventHandler = (event: React.SyntheticEvent) => void;

const handleEvent: AnyEventHandler = (event) => {
  console.log(event);
};

const handleMouseEvent: MouseEventHandler = handleEvent; // 合法,逆变

在这个例子中,handleEvent 可以处理任何 SyntheticEvent,其中包括 MouseEvent,所以它可以被赋值给 handleMouseEvent。这就是逆变的一个实际应用:更广泛的处理函数可以用于处理更具体的事件类型。

在使用高阶组件时,逆变也可能发挥作用。高阶组件(HOC)是接受一个组件并返回一个新组件的函数。在处理高阶组件时,如果传递给 HOC 的组件的属性是某种类型的父类型,那么 HOC 返回的组件可以安全地接受更具体的子类型属性。

假设你有一个高阶组件 withLogging,它可以将日志功能添加到任何组件中:

function withLogging<P>(Component: React.ComponentType<P>): React.FC<P> {
  return (props: P) => {
    console.log("Rendering", Component.name);
    return <Component {...props} />;
  };
}

如果你有一个组件 DogComponent 接受 Dog 类型的属性,那么你可以将 withLogging 应用于 DogComponent,并返回一个同样接受 Dog 属性的组件:

interface Dog {
  name: string;
  breed: string;
}

const DogComponent: React.FC<Dog> = ({ name, breed }) => (
  <div>
    {name} is a {breed}
  </div>
);

const LoggedDogComponent = withLogging(DogComponent);

// 使用 LoggedDogComponent
<LoggedDogComponent name="Rex" breed="Labrador" />;

在这里,withLogging 的参数类型是泛型 P,而 Dog 是 P 的一个具体实例。由于 TypeScript 支持逆变,因此即使 P 是父类型,它也可以接受子类型 Dog 的参数,这使得 LoggedDogComponent 可以安全地使用 Dog 类型的属性。

逆变 是 TypeScript 中的一种型变,允许父类型的函数参数赋值给子类型的函数参数。它保证了类型安全,因为处理父类型的函数可以适用于子类型,而不会引发类型错误。这通常用于函数参数类型的兼容性处理,确保函数的灵活性和扩展性。

双向协变

在类型系统中,协变允许子类型赋值给父类型,逆变则允许父类型赋值给子类型。双向协变是 TypeScript 中的一种特殊行为,它允许函数参数的类型既可以协变,也可以逆变。

简单来说,双向协变允许你在处理函数参数类型时,既可以将子类型赋值给父类型,也可以将父类型赋值给子类型。

假设我们有两个类 Animal 和 Dog,Dog 继承自 Animal:

class Animal {
  speak() {
    console.log("Animal sound");
  }
}

class Dog extends Animal {
  speak() {
    console.log("Bark");
  }
}

现在我们定义两个函数类型,一个接受 Dog 作为参数,另一个接受 Animal 作为参数:

type DogHandler = (dog: Dog) => void;
type AnimalHandler = (animal: Animal) => void;

根据双向协变的规则,TypeScript 允许我们将 DogHandler 赋值给 AnimalHandler,也允许我们将 AnimalHandler 赋值给 DogHandler:

let handleDog: DogHandler = (dog: Dog) => {
  dog.speak();
};

let handleAnimal: AnimalHandler = (animal: Animal) => {
  animal.speak();
};

handleAnimal = handleDog; // 合法,协变
handleDog = handleAnimal; // 合法,逆变

在这个例子中,handleAnimal 可以处理 Animal 类型的参数,而 handleDog 只处理 Dog 类型的参数。尽管从理论上来说,将 handleAnimal 赋值给 handleDog 可能存在类型安全问题(handleAnimal 可能接受 Cat,而 handleDog 只能处理 Dog),但 TypeScript 允许这种赋值操作。

考虑一个更复杂的例子,其中我们有一个函数接受另一个函数作为参数:

function processAnimal(handler: (a: Animal) => void): void {
  const animal = new Animal();
  handler(animal);
}

function processDog(handler: (d: Dog) => void): void {
  const dog = new Dog();
  handler(dog);
}

根据双向协变的规则,processDog 可以接受 processAnimal 中的函数作为参数,反之亦然:

processDog((dog: Dog) => {
  console.log(dog.speak());
}); // 合法

processAnimal((animal: Animal) => {
  console.log(animal.speak());
}); // 合法

在这里,尽管 processAnimal 接受的是 Animal 类型的参数,而 processDog 只处理 Dog 类型,但 TypeScript 允许这种相互赋值。

虽然双向协变使得代码更灵活,但它也可能引发类型安全问题。特别是在处理继承关系较为复杂的情况下,可能会导致运行时错误。

function handleAnyAnimal(animal: Animal): void {
  console.log("Handling an animal");
}

function handleOnlyDog(dog: Dog): void {
  console.log("Handling a dog");
}

let dogHandler: (d: Dog) => void = handleAnyAnimal; // 合法,但可能不安全
dogHandler(new Dog()); // 正常
dogHandler(new Animal()); // 运行时错误,因为 Animal 不是 Dog

在这个例子中,将 handleAnyAnimal 赋值给 dogHandler 是合法的,因为 TypeScript 允许这种双向协变。然而,当我们尝试传递一个 Animal(而不是 Dog)给 dogHandler 时,可能会引发运行时错误。

不变

在类型系统中,不变 是指某个类型不能在子类型和父类型之间相互替换。具体来说,如果你有一个泛型类型 T<A>T<B>,即使 A 是 B 的子类型,T<A> 也不能赋值给 T<B>,反之亦然。这种严格的类型匹配规则被称为不变。

考虑一个简单的泛型类:

class Animal {
  name: string;
}

class Dog extends Animal {
  breed: string;
}

class Cat extends Animal {
  color: string;
}

interface Box<T> {
  content: T;
}

let animalBox: Box<Animal> = { content: new Animal() };
let dogBox: Box<Dog> = { content: new Dog() };
let catBox: Box<Cat> = { content: new Cat() };

在不变的规则下,尽管 Dog 是 Animal 的子类型,但 Box<Dog> 不能赋值给 Box<Animal>,同样 Box<Animal> 也不能赋值给 Box<Dog>

animalBox = dogBox; // 错误,Box<Dog> 不能赋值给 Box<Animal>
dogBox = animalBox; // 错误,Box<Animal> 不能赋值给 Box<Dog>

这就是不变的体现。TypeScript 要求 Box<Animal>Box<Dog> 必须是完全一致的类型,任何尝试在这些类型之间进行赋值都会导致类型错误。

不变是一种型变规则,要求类型在所有上下文中必须严格匹配。比如我想要一只鸭子,你不能给我一个碗对吧,碗又不能吃。

总结

本文详细介绍了 TypeScript 类型系统中的四种型变概念:协变、逆变、双向协变 和 不变,以及它们在类型安全和灵活性方面的作用。

  • 协变:允许子类型赋值给父类型,常见于函数的返回值和数组类型。协变使得类型系统更加灵活,例如,List<Dog> 可以在需要 List<Animal> 的地方使用。

  • 逆变:允许父类型赋值给子类型,常见于函数的参数类型。逆变确保了类型系统的灵活性和安全性,比如将处理 Animal 的函数赋值给处理 Dog 的函数。

  • 双向协变:一种特殊情况,允许函数参数类型既可以协变也可以逆变,尽管这可能引发类型安全问题。双向协变在 TypeScript 中存在主要是为了保持与 JavaScript 的兼容性。

  • 不变:要求类型在所有上下文中严格匹配,不能在子类型和父类型之间相互替换。这种严格的类型检查确保了类型系统的安全性,但也减少了灵活性。

理解这些型变概念能够帮助开发者在 TypeScript 中编写既灵活又安全的代码,尤其是在处理复杂的类型关系和函数参数时。它们为我们提供了一个平衡类型安全性和代码灵活性的工具。

转载自:https://juejin.cn/post/7402248021093007423
评论
请登录