Typescript的逆变和协变

661 阅读6分钟

TypeScript 给 JavaScript 添加了一套静态类型系统,是为了保证类型安全的,也就是保证变量只能赋同类型的值,对象只能访问它有的属性、方法。

比如 number 类型的值不能赋值给 boolean 类型的变量,Date 类型的对象就不能调用 exec 方法。 这是类型检查做的事情,遇到类型安全问题会在编译时报错。

但是这种类型安全的限制也不能太死板,有的时候需要一些变通,比如子类型是可以赋值给父类型的变量的,可以完全当成父类型来使用,也就是“型变(variant)”(类型改变)。 这种“型变”分为两种,一种是子类型可以赋值给父类型,叫做协变(covariant),一种是父类型可以赋值给子类型,叫做逆变(contravariant)。

子类型(subtype)

子类型的作用:可以定义不依赖于细节的行为。换句话说,可以创建接受父类型作为参数的函数,然后您可以使用子类型调用该函数。

function getName(person: Person) : string {
  return person.name;
}

// 父类型
const person: Person = {
  name: 'lily',
  age: 0,
}

// 子类型
const student: Student = {
  name: 'xiaoming',
  age: 8,
  stuNo: 1234,
}

getName(person); // 'lily';
getName(student); // 'xiaoming';
// 定义的时候用父类, 调用的时候可以传子类

很明显,子类型让代码的复用性变得更高,不必为父类型和子类型各自写一个 getName 方法。

可赋值性(assignable)

assignable 是类型系统中很重要的一个概念,当你把一个变量赋值给另一个变量时,就要检查这两个变量的类型之间是否可以相互赋值。

// 父类型
interface Person {
  name: string;
  age: number;
}

// 子类型
interface Student {
  name: string;
  age: number;
  stuNo: number;
}

let person: Person = {
  name: 'lei',
  age: 25
}

let student: Student = {
  name: 'xiao ming',
  age: 8,
  stuNo: 11024
}

例如上面声明的两个类型,父类型 Person 和子类型 Student。

// 可赋值性比较
person = student; // ok
student = person; // error

首先,为什么 Person 是父类,明明它的属性更少?在集合论中,属性多的才是父集,属性少的是子集;但是在类型编程中,属性更少的是父类,属性多的是子类。在类型体操中经常会看到 T extends {}这种代码,其实就是因为 {} 是父类型,而 T 是 子类型。{}就类似于顶级类型了。 然后,为什么 person = student 是可以的?而 student = person 又不可以呢?下面以一个函数的声明和调用为例子:

// 这里定义了一个 log 函数,
// 函数参数是 Person 类型
function log(p: Person): void {
  console.log(p.name);
  console.log(p.age);
}

// 此时,传入一个 Student 类型可以吗?
log(student);
// 打印 'xiaoming'
// 打印 8

可以看到,定义一个函数的时候,正常情况下参数里面的所有属性和值都会用得上【比如这里的 name 和 age】,如果传进来的参数类型包含定义这个函数时的参数类型,那么这个函数一定是可以正常运行的,但是如果情况相反,定义函数时,用的是属性比较多的类型【子类型】,而调用时传入的是属性比较少的类型【父类型】,那么可能会导致,函数内部想要调用某个子类型的属性而调用不到的问题,如下所示:

// 这里定义了一个 log 函数,
// 函数参数是 Student 类型
function log(s: Student): void {
  console.log(s.name);
  console.log(s.age);
  console.log(s.stuNo);  // 定义时不报错
}

// 此时,传入一个 Person 类型可以吗?
log(person);
// 打印 'xiaoming'
// 打印 8
// 运行时报错

为什么 person = student 这样赋值是可以的呢?因为此时 Student 类型的属性是完全包含 Person 类型的属性的。这样赋值后,person可以调用他的 name, age 属性并且不会产生类型安全问题。 【但是调用 stuNo 这个属性的话,ts会报错,然而转换为js后,这个person对象上面确实会存在 stuNo 这个属性】。

辅助类型

为了更好的区分两个类型是否是父子类型,这里提供一个 IsSubtypeOf 辅助类型:

type IsSubtypeOf<SubType, ParentType> = SubType extends ParentType ? true : false;

IsSubTypeOf 用于判断一个类型是否是另外一个类型的子类型。

type T11 = IsSubtypeOf<Student, Person>;
// type T11 = true

type T12 = IsSubtypeOf<'hello', string>;
// type T12 = true

type T13 = IsSubtypeOf<42, number>;     
// type T13 = true

type T14 = IsSubtypeOf<Map<string, string>, Object>; 
// type T14 = true

协变(covariant)

协变的定义是:一个类型T协变的,如果有S <: P,则T<S> <: T<P>。这个定义比较难理解。还是以上面的 Person 类型 和 Student 类型为例。因为 Student 类型是 Person 类型的子类,记作: Studuent <: Person。请问 Promise<Student> 是否仍然是 Promise<Person> 的子类型?即:是否Promise<Student> <: Promise<Person>?如果满足这个条件,那么就可以说 Promise类型是协变的。

type T21 = IsSubtypeOf<Promise<Student>, Promise<Person>>
// type T21 = true
// 说明 Promise<Student> 是 Promise<Person> 的子类型 

由此:有 Student <: Person,则Promise<Student> <: Promise<Person>成立。这表明Promise类型是协变的。 再比如,我们写一个类型MakeArray

type MakeArray<T> = T[];

type Persons = MakeArray<Person>;

type Students = MakeArray<Student>;

type T31 = IsSubtypeOf<Students, Persons>;
// T31 = true
// 说明类型构造器 MakeArray<T> 是协变的。

逆变(contravariant)

逆变的定义是: 一个类型T是逆变的,如果有S <: P,则 T<P> <: T<S>。简而言之,逆变就是调换了父子类型的顺序。 以下面的类型为例:

type Func<Param> = (param: Param) => void;

Func<Param>创建了具有一个类型参数的函数类型。

请问:如果 Student <: Person,那么,下面两个表达式那个为真?

  1. Func<Person> <: Func<Student>
  2. Func<Student> <: Func<Person>

用上面的 IsSubtypeOf 辅助类型试试:

type T41 = IsSubtypeOf<Func<Person>, Func<Student>>
// T41 = true
type T42 = IsSubtypeOf<Func<Student>, Func<Person>>
// T42 = false

我们发现,T41 才是成立的,这表明:Func<Person>Func<Student> 的子类型,和原来的 Student 是 Person 的子类型正好相反

这说明 Func 类型的行为是逆变的。一般来说,函数类型在参数类型方面是逆变的。

接下来看一段代码:

type Func = (a: string) => void;

const func: Func = (a: 'hello') => undefined

这段代码会报错吗?答案是:会。并且在函数参数的位置是会报错的,而返回值的位置并不会报错。原因是 返回值的位置是协变的,而 undefined 是 void 的子类型,所以并不会报错,同时函数参数的位置是逆变的,而 'hello' 类型string类型的子类型而不是父类型,所以这里会报错。

函数类型的参数类型是逆变的,而返回类型协变的。

双向协变

在Typescript 2.6 版本之前,是支持双向协变的,即:既可以把父类型赋值给子类型,又可以把子类型赋值给父类型。这样很明显是有问题的【可能调用到不存在的属性或者方法】,可以把 tsconfig.json 中设置 strictFunctionTypes 为 true 来开启严格检查,这样Typescript就不支持双向协变了。

true:只支持逆变

false:支持双向协变

不变(invariant)

非父子类型之间不会发生型变,只要类型不一样就会报错。