boxboxs
技术

ts逆变与协变

浏览 16最近编辑于
ts逆变与协变

子类型

在讲逆变协变之前,先讲讲子类型。在类型系统中,如果类型 B 可以安全地用在任何需要类型 A 的地方,我们就说 B 是 A 的子类型

例如:"hello" 是 string 的子类型,string 是 string | number 的子类型。

按照逻辑理解上来说,类型更加具体,包含了一个”宽泛“类型的所有”特征‘。那么这个更加具体的类型,就是这个“宽泛”类型的子类型。

即判断规则通常基于结构:B 拥有 A 的所有属性且类型兼容。

class Animal {
  name: string
  constructor(name: string) {
    this.name = name
  }
}
class Dog extends Animal {
  bark() {
    console.log('Woof!')
  }
}

在上面代码中,Dog有Animal的所有类型,并且自己拓展了bark,所以我们可以说Dog是Animal的子类型。

那么就可以适用子类型规则,在需要使用Animal地方,通常都可以放一个Dog

let a: Animal
let d: Dog = new Dog()

a = d // ok

let c: Dog
let b: Animal = new Animal('Buddy')

c = b // 报错

更加复杂的情况-协变

基于上面的情况,我们拓展下,比如在泛型中。如果我们有一个泛型Handle<T>。我们是否也可以认为,Handle<Dog> 也是 Handle<Animal>的子类型?是的,可以这样认为,他和子类型的情况是一致的、协调的。称之为协变

class Animal {
  name: string
  constructor(name: string) {
    this.name = name
  }
}
class Dog extends Animal {
  bark() {
    console.log('Woof!')
  }
}

interface ReadonlyBox<T> {
  get(): T
}

let dogBox: ReadonlyBox<Dog> = {
  get() {
    return new Dog('Buddy')
  }
}
let animalBox: ReadonlyBox<Animal>

animalBox = dogBox

逆变

逆变就是方向反过来。如果Dog是Animal的子类型。那么反过来Handler<Animal>反而是Handler<Dog>的子类型。

type Handler<T> = (value: T) => void

let handleAnimal: Handler<Animal> = (a) => {
  console.log(a.name)
}

let handleDog: Handler<Dog>

handleDog = handleAnimal // 可以

上面的代码,其实逻辑上也是可以理解的

handleDog的要求是一个能处理Dog的函数

handleAnimal能处理所有的Animal,Dog是Animal的子类型,包含Animal的所有“特征”,自然也能处理dog。所以把handleAnimal赋值给handleDog没有问题。

但是根据子类型的定义,反过来Handler<Animal>反而是Handler<Dog>的子类型了。这样的情况就是称之为逆变。

如果还用协变的逻辑赋值

type Handler<T> = (value: T) => void

let onlyDog: Handler<Dog> = (dog) => {
  dog.bark()
}

let onlyAnimal: Handler<Animal> = onlyDog // 不可以

onlyAnimal 只能处理Animal, 但是如果根据协变规则,传入onlyDog函数。如果传入一个没有dog.bark的Animal,这样就不安全了。

逆变协变的规律

在上面逆变、协变的的描述中。我们可以总结出一个规律:如果在泛型中,类型参数T,处于返回值位置中,应用协变规则;如果T处于参数位置中,应用逆变规则。

  • 返回值位置:如果A是B的子类型,那么() => A 可以赋值给 () => B (需要返回B,但是我返回一个更加具体的子类型A,没有问题),协变。
  • 参数位置:如果A是B的子类型,那么(B) => void 可以赋值给 (A) => void(需要处理A类型的参数, 但是我给你一个能处理更加宽泛的B的函数,依然可以安全的处理A),逆变

既处于返回值、又处于参数的位置-不变

interface Box<T> {
  get(): T
  set(value: T): void
}
let dogBox: Box<Dog> = {
  get() { return new Dog(); },
  set(value: Dog) {}
};
let animalBox: Box<Animal> = dogBox;   // ❌ 错误:Type 'Box<Dog>' is not assignable to type 'Box<Animal>'

let animalBox2: Box<Animal> = {
  get() { return new Animal(); },
  set(value: Animal) {}
};
let dogBox2: Box<Dog> = animalBox2;    // ❌ 错误:同样不兼容

上面代码的情况get是返回值位置,应用协变;set是参数位置,应用逆变。两种冲突了,所以保持了严格一致,也就是不变。虽然Dog是Animal的子类型。但是Box<Dog>和Box<Animal>没有任何关系,不能替代。

双变

双变指的是:如果A是B的子类型,那么(B) =>void 可以赋值给(A) => void(这是逆变),但是(A) => void也可以赋值给(B) => void(协变)。理论上来说函数参数应该遵守逆变才对。

但是Typescript不只是停留在理论上的产物,还要考虑到实用和兼容性。

JavaScript / DOM / 事件回调风格代码里,很多 API 都默认允许“写一个更具体一点的回调参数类型”。

如果 TypeScript 把所有地方都严格按逆变来查,很多旧代码、常见回调写法都会报错,开发体验会变差很多

所以在某些”方法参数“的位置,它不会严格按照逆变检查,而是采用更宽松的双变策略。

interface Handler {
    // 方法签名:handle 的参数是 Event
    handle(event: Event): void;
}

// 协变方向(子类型参数)—— 不安全但双变允许 ✅
const mouseHandler: Handler = {
    handle(e: MouseEvent) {
        console.log(e.clientX);
    }
}

// 逆变方向(父类型参数)—— 安全,双变也允许 ✅
const anyHandler: Handler = {
    handle(e: UIEvent) {
        console.log(e.view);
    }
};

评论 (0)

为了减少垃圾评论,请先使用 GitHub 登录后再发表评论。

使用 GitHub 登录评论

暂无评论,成为第一个评论者!