浅谈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
相关推荐
程序猿小D1 小时前
第二百六十七节 JPA教程 - JPA查询AND条件示例
java·开发语言·前端·数据库·windows·python·jpa
奔跑吧邓邓子1 小时前
npm包管理深度探索:从基础到进阶全面教程!
前端·npm·node.js
前端李易安2 小时前
ajax的原理,使用场景以及如何实现
前端·ajax·okhttp
汪子熙2 小时前
Angular 服务器端应用 ng-state tag 的作用介绍
前端·javascript·angular.js
Envyᥫᩣ2 小时前
《ASP.NET Web Forms 实现视频点赞功能的完整示例》
前端·asp.net·音视频·视频点赞
applebomb3 小时前
【2024】uniapp 接入声网音频RTC【H5+Android】Unibest模板下Vue3+Typescript
typescript·uniapp·rtc·声网·unibest·agora
Мартин.6 小时前
[Meachines] [Easy] Sea WonderCMS-XSS-RCE+System Monitor 命令注入
前端·xss
昨天;明天。今天。8 小时前
案例-表白墙简单实现
前端·javascript·css
数云界8 小时前
如何在 DAX 中计算多个周期的移动平均线
java·服务器·前端
风清扬_jd8 小时前
Chromium 如何定义一个chrome.settingsPrivate接口给前端调用c++
前端·c++·chrome