浅谈Typescript中的逆变、协变、双变与不变

坑爹的面试题

继上次小🐟面试心脏跳动之后,久久没有收到反馈,小🐟很是着急,于是主动向HR问起结果,HR很遗憾的跟她说已经招满了,不过隔壁组有个HC,你要不要试一下,小🐟心有不甘,就跟HR说今天下午可以面试吗?HR拍拍脸,说:安排~

下午三点,面试官准时上线,看到小🐟简历写到精通 Typescript ,拥有多年体操经验,曾获国家体操三等奖, 瞬间来了兴趣,兴奋地忘记流程,拿出珍藏已久的你面试题来考考她,就跟小🐟说:看我文档的这道题,说一下哪个类型不通过,为什么,以及怎么将类型改写可以达到相同的效果。

typescript 复制代码
interface StudentArrayA<T> {
    push(...items: T[]):  number;
}

interface StudentArrayB<T> {
    push: (...items: T[]) => number;
}

class Person {
  getName() {}
}

class Student extends Person {
  getScore () {

  }
}

declare let StudentArrayA1: StudentArrayA<Student>
declare let StudentArrayA2: StudentArrayA<Person>

StudentArrayA1 = StudentArrayA2
StudentArrayA2 = StudentArrayA1

declare let StudentArrayB1: StudentArrayB<Student>
declare let StudentArrayB2: StudentArrayB<Person>

StudentArrayB1 = StudentArrayB2
StudentArrayB2 = StudentArrayB1

小🐟看到这道题当时的表情是这样的:

内心是这样的:

想了三十分钟之后,跟面试官说:这道题应该没问题吧?面试官很有耐心地打开了ts playground展示了一下,跟小🐟说:回去等消息吧。

ts 复制代码
interface StudentArrayA<T> {
    push(...items: T[]):  number;
}

interface StudentArrayB<T> {
    push: (...items: T[]) => number;
}

class Persion {
  getName() {}
}

class Student extends Persion {
  getScore () {

  }
}

declare let StudentArrayA1: StudentArrayA<Student>
declare let StudentArrayA2: StudentArrayA<Persion>

StudentArrayA1 = StudentArrayA2
StudentArrayA2 = StudentArrayA1

declare let StudentArrayB1: StudentArrayB<Student>
declare let StudentArrayB2: StudentArrayB<Persion>

StudentArrayB1 = StudentArrayB2
StudentArrayB2 = StudentArrayB1 

小🐟很是郁闷,但她秉持着在哪里跌倒就在哪里站起来的原则,开始请教了ChatGPT老师。

两个🌰

我们先看第一个例子:有AnimalDog两个类,其中Dog继承Animal,所以Dog≤Animal的关系。

ts 复制代码
class Animal {
  asPet() {}
}

class Dog extends Animal {
  bark() {}
}

// 简单场景
type Getter<T> = () => T;
type Setter<T> = (value: T) => void;

declare let A1: Getter<Animal>
declare let A2: Getter<Dog>

A1 = A2
A2 = A1

declare let B1: Setter<Animal>
declare let B2: Setter<Dog>

B1 = B2
B2 = B1

我们来拆解一下上面的Getter和Setter赋值情况:

  1. Getter的情况

我们将上面A1、A2改写成类型,根据下面的例子我们可以看到当函数的返回类型是Dog的时候是可以赋值给返回类型是Animal,但返回类型Animal是不能赋值给返回类型是Dog的。

scss 复制代码
() => Animal = () => Dog //
() => Dog != () => Animal
  1. Setter的情况

同样,我们将上面的B1、B2改写成类型,根据下面的例子我们可以看到当函数的参数类型是Animal的时候是可以赋值给函数参数类型是Dog,但参数类型是Dog是不能复制给参数类型是Animal的。

javascript 复制代码
(value: Animal) => void != (value: Dog) => void
 (value: Dog) => void = (value: Animal) => void

是不是已经找到规律了,那我们下面看一个复杂点的例子,在这里,我们有三个的类,每个类在上一个类的基础上添加了一个独特的方法。我们使用 符号表达子类型关系,A ≼ B 意为 A 是 B 的子类型,在这里的例子中,易得 Corgi ≼ Dog ≼ Animal。现在我们有一个新的函数,它接收一个函数作为参数,其类型为 Dog -> Dog(即参数类型与返回值均为 Dog)。由于我们的类型是Dog -> Dog,所以我们需要考虑Dog -> Animal/CorgiAnimal/Corgi -> DogCorgi/Animal -> Corgi/Animal 这样的类型,来排列组合一下几种情况:

ts 复制代码
class Animal {
  asPet() {}
}

class Dog extends Animal {
  bark() {}
}

class Corgi extends Dog {
  cute() {}
}

type DogFactory = (args: Dog) => Dog;

function transformDogAndBark(dogFactory: DogFactory) {
  const dog = dogFactory(new Dog());
  dog.bark();
}
// 情况1
const CorgiAndCorgi: (args: Corgi) => Corgi = (args) => args
transformDogAndBark(CorgiAndCorgi)
// 情况2
const AnimalAndAnimal: (args: Animal) => Animal = (args) => args
transformDogAndBark(AnimalAndAnimal)
// 情况3
const CorgiAndAnimal: (args: Corgi) => Animal = (args) => new Animal()
transformDogAndBark(CorgiAndAnimal)
// 情况4
const AnimalAndCorgi: (args: Animal) => Corgi = (args) => new Corgi()
transformDogAndBark(AnimalAndCorgi)

// 情况5
const DogAndCorgi: (args: Dog) => Corgi = (args) => new Corgi()
transformDogAndBark(DogAndCorgi)
// 情况6
const CorgiAndDog: (args: Corgi) => Dog = (args) => new Dog()
transformDogAndBark(CorgiAndDog)

// 情况7
const DogAndAnimal: (args: Dog) => Animal = (args) => new Animal()
transformDogAndBark(DogAndAnimal)
// 情况8
const AnimalAndDog: (args: Animal) => Dog = (args) => new Dog()
transformDogAndBark(AnimalAndDog)
  • Corgi -> Corgi:我们在 transformDogAndBark 中,会传入一只狗,并让返回的狗狗叫两声听听,看起来好像没问题,但是 Corgi -> Corgi 函数只能接受柯基,内部可能调用了柯基才有的逻辑,如果我们传了个柴犬,那程序可能就崩溃了。但返回值没问题,因为不管是柯基还是柴犬都能叫嘛。
  • Animal -> Animal:有了上一点的经验我们一看就知道不行,因为它的返回值可能是任何动物,但不是任何动物都会狗叫。
  • Corgi -> Animal:第一点是参数类型有问题,第二点是返回值类有问题,这一点则是参数类型和返回值都有问题。
  • Animal -> Corgi:只剩下这一个正确答案了,如果还不行的话就离谱了。还是先来分析一波,首先我们会传入一只狗,好的,没问题,Animal 有的方法 Dog 都有。接着我们会让返回的物种叫两声,这里返回的是柯基!它可以叫!所以没问题!

归纳一下上面的情况,我们会发现,作为参数的函数,它的入参允许是函数入参类型的父类型(实际入参 Animal,类型入参Dog),不允许为子类型(实际入参Corgi,类型入参Dog),而它的返回值允许是函数返回类型的子类型(实际返回值Corgi,类型返回值Dog),不允许是父类型(实际返回值Animal,类型返回值 Dog)。

根据上面两个例子我们可以总结出:如果把(Animal → Corgi)记为A,把Dog → Dog记为B,则A ≼ B,也就是AB的子集。

ts 复制代码
class Animal {
  asPet() {}
}

class Dog extends Animal {
  bark() {}
}

class Corgi extends Dog {
  cute() {}
}

declare let B: (args: Dog) => Dog;
declare let A: (args: Animal) => Corgi;

B = A

验证一下,没问题,那为什么会出现这种情况?

协变与逆变

这个时候我们可以引入协变(covariance )与逆变(contravariance)的概念了。

协变和逆变都是术语,前者指能够使用比原始指定的派生类型的派生程度更大(更具体的)的类型,后者指能够使用比原始指定的派生类型的派生程度更小(不太具体的)的类型 。 泛型类型参数支持协变和逆变,可在分配和使用泛型类型方面提供更大的灵活性。

---- .Net基础知识

根据上面的两个例子,我们可以发现函数的参数只能接受自己的子类,返回值只能接受自己的父类,借用上面的概念:协变是比原始指定的派生类型的派生程度更大的类型, 逆变是比原始指定的派生类型的派生程度更小的类型, 所以可以得出结论 :函数返回值为协变,函数参数为逆变

所以我们假设存在 Corgi ≼ Dog的情况,如果它遵循协变,则有 (T → Corgi) ≼ (T → Dog),即 A、B 在被作为函数返回值类型以后仍然遵循一致的子类型关系。而对于参数,由于其遵循逆变,则有 (Dog → T) ≼ (Corgi → T),即 A、B 被作为函数参数类型以后其子类型关系发生逆转。

双变(bivariant or 双向协变? )与不变

还是先看一个例子:当我们写下这段代码的时候,并没有什么问题,但是我们尝试运行一下,但是发现报错了,为什么?

ts 复制代码
class Animal {
  asPet() {}
}

class Dog extends Animal {
  bark() {}
}

class Cat extends Animal{}

function trainDog(d: Dog) {  }
function cloneAnimal(source: Animal, done: (result: Animal) => void): void {  }
let c = new Cat();

// Runtime error here occurs because we end up invoking 'trainDog' with a 'Cat'
cloneAnimal(c, trainDog);
ts 复制代码
declare let A: (args: Animal) => void
declare let B: (args: Dog) => void

由于DogAnimal的子集,即Dog≼Animal,根据上面的结论,函数的参数是发生逆变,故等式AB子集,A≼B,根据typescript类型推断,A的类型是可以赋值给B,但B的类型是不可以赋值A的。那为什么还是能绕过typescript类型检测呢,想要理解这个问题,去思考两个问题:

  1. Dog[]Aniaml[]子类型吗?
  2. 在Typescript中,Dog[]应该是Aniaml[]子类型吗?

先回答第二个问题:

scss 复制代码
function checkIfAnimalsAreAwake(arr: Animal[]) { 
    //... 
}
let myPets: Dog[] = [new Dog('spot'), new Dog('fido')];
checkIfAnimalsAreAwake(myPets);

看上面的代码,如果答案是不是的话,就应该报错,对吧?但是上面的代码是100%正确的,只要checkIfAnimalsAreAwake不修改数组,这个程序永远是对的。

再看第二个问题,当类型系统决定Dog[]是否是Animal[]的子类型时,它会执行以下计算:

  • Dog[]可以分配给Animal[]吗?
  • Dog[]的每个成员都可以分配给Animal[]吗?
    • Dog[].push是否可分配给Animal[].push
      • 类型(x: Dog) => number可分配给(x: Animal) => number吗?
        • (x: Dog) => number中的第一个参数类型是否可分配给(x: Animal) => number中的第一个参数类型?
          • Dog是否可以分配给Animal
            • 可以分配

从这里看到在typescript类型检测中,类型系统必须询问类型(x: Dog) => number可分配给(x: Animal) => number吗?这正好与我们刚开始的问题相同,如果typescript强制参数发生逆变 (要求Animal可以分配给Dog),那么Dog[]将不能分配给Animal[],那正好与我们第二个问题冲突了,所以我们可以得出结论:双变就是在typescript检查函数参数类型时,既可以发生协变,也可以发生逆变。 用公式推导就是当Dog ≼ Animal 可以推导出 Dog -> void ≼ Animal -> voidAnimal -> void ≼ Dog -> void

如何避免

由于函数参数的双变,在写代码的时候难免会出现不注意的情况,所以在typescript 2.6版本的时候新增strictFunctionTypes,当开启strictFunctionTypes选项时,会将函数参数类型做强制逆变检查。下面来看一下demo:

ts 复制代码
class Animal {
    asPet(){}
}
class Dog extends Animal {
    name(){}
}
class Cat extends Animal {
    age(){}
}

declare let f1: (x: Animal) => void;
declare let f2: (x: Dog) => void;
declare let f3: (x: Cat) => void;
f1 = f2;  // Error with --strictFunctionTypes
f2 = f1;  // Ok
f2 = f3;  // Error

再举一个例子:

ts 复制代码
class Animal {
    asPet(){}
}
class Dog extends Animal {
    name(){}
}
interface Comparer<T> {
    compare(a: T, b: T): number;
}

declare let animalComparer: Comparer<Animal>;
declare let dogComparer: Comparer<Dog>;

animalComparer = dogComparer;  // Ok because of bivariance
dogComparer = animalComparer;  // Ok

我们发现刚刚发现的规则又失效了,第一个赋值仍然是允许的,为什么呢?因为compare被声明为方法。实际上, TComparer<T>中是双变的,因为它只用于方法参数位置。那怎么能修复这个问题呢?

  1. compare改写成具有函数类型的属性
ts 复制代码
class Animal {
    asPet(){}
}
class Dog extends Animal {
    name(){}
}
interface Comparer<T> {
    compare: (a: T, b: T) => number;
}

declare let animalComparer: Comparer<Animal>;
declare let dogComparer: Comparer<Dog>;

animalComparer = dogComparer;  // Error
dogComparer = animalComparer;  // Ok
  1. in/out表明要发生协变还是逆变
ts 复制代码
class Animal {
    asPet(){}
}
class Dog extends Animal {
    name(){}
}
interface Comparer<in T> {
    compare(a: T, b: T): number;
}

declare let animalComparer: Comparer<Animal>;
declare let dogComparer: Comparer<Dog>;

animalComparer = dogComparer;  // Ok because of bivariance
dogComparer = animalComparer;  // Ok

了解了双变之后,我们再来看一下不变:

ts 复制代码
class Animal {
    asPet(){}
}
class Dog extends Animal {
    wang(){}
}
class Cat extends Animal {
    miao() {}
}
declare let f1: (x: Animal) => void;
declare let f2: (x: Dog) => void;
declare let f3: (x: Cat) => void;

f2 = f3
f3 = f2

不变的概念就比较简单了,两种类型既不会发生协变,也不会逆变,完全没有关系的两种类型。有一个需要注意的点

当一个T同时用于输出和输入位置时,它就会变得不变。两个不同的State<T>不能互换,除非它们的T相同。换句话说,State<Dog>State<Animal>不能相互替代。所以我们可以使用in out表明不变。

csharp 复制代码
interface State<in out T> {
    get: () => T;
    set: (value: T) => void;
}

这时候有同学会提问,我就是不写呢,其实在大多是的场景下是不需要的,但还是例外场景:

ts 复制代码
type Foo<T> = {
    x: T;
    f: Bar<T>;
}
type Bar<U> = (x: Baz<U[]>) => void;
type Baz<V> = {
    value: Foo<V[]>;
}
declare let foo1: Foo<unknown>;
declare let foo2: Foo<string>;
foo1 = foo2;  // Should be an error but isn't ❌
foo2 = foo1;  // Error - correct ✅

结论

我们现在知道什么是协变,逆变,双变以及不变,那我们再看面试题:

typescript 复制代码
interface StudentArrayA<T> {
    push(...items: T[]):  number;
}

interface StudentArrayB<T> {
    push: (...items: T[]) => number;
}

class Person {
  getName() {}
}

class Student extends Person {
  getScore () {

  }
}

declare let StudentArrayA1: StudentArrayA<Student>
declare let StudentArrayA2: StudentArrayA<Person>

StudentArrayA1 = StudentArrayA2
StudentArrayA2 = StudentArrayA1

declare let StudentArrayB1: StudentArrayB<Student>
declare let StudentArrayB2: StudentArrayB<Person>

StudentArrayB1 = StudentArrayB2
StudentArrayB2 = StudentArrayB1

我们知道函数作为属性会进行更严格的检查,由于Student是Person的子集(Student ≼ Person),如果发生协变等式(T → Student) ≼ (T → Person)成立,如果发生逆变等式(Person → T) ≼ (Student → T)成立,22/23行代码发生双变,故没问题,28/29发生逆变,必有一个有问题,我们套入公式,由于(Person → T)(Student → T)子集,在typescript是可以将父集赋值给子集,故28行代码没问题,那29行必然有问题。

ts 复制代码
interface StudentArrayA<T> {
    push(...items: T[]):  number;
}

interface StudentArrayB<T> {
    push: (...items: T[]) => number;
}

class Person {
  getName() {}
}

class Student extends Person {
  getScore () {

  }
}

declare let StudentArrayA1: StudentArrayA<Student>
declare let StudentArrayA2: StudentArrayA<Person>

StudentArrayA1 = StudentArrayA2
StudentArrayA2 = StudentArrayA1

declare let StudentArrayB1: StudentArrayB<Student>
declare let StudentArrayB2: StudentArrayB<Person>

StudentArrayB1 = StudentArrayB2
StudentArrayB2 = StudentArrayB1
相关推荐
崔庆才丨静觅3 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60614 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了4 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅4 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅5 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅5 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment5 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅5 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊6 小时前
jwt介绍
前端
爱敲代码的小鱼6 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax