浅谈 TypeScript 中的类型变化

· Technology

TypeScript 是 JavaScript 的超集,其最显著特征是在 JavaScript 原有的语言模式上加入了类型强制,即静态类型系统。以此保证代码中变量处于类型安全状态,即只能赋同类型的值,而对象只能访问其自身所拥有的属性、方法。

类型安全 & 型变

类型安全是指同一段内存在不同的地方,会被强制要求使用相同的办法来解释,使开发者可以及早在编译时期就捕捉到潜藏的错误

let num: number = 1;
let bool: boolean = num; // Error: Type 'number' is not assignable to type 'boolean'.

在上述情况中,number 类型与 boolean 类型是完全不同的,它们不构成继承关系,因此他们不能相互赋值。

那么,当我们需要将一个 `number` 存储的数字转换为 boolean 变量时,我们该如何改变类型呢?

let num: number = 1;
let bool: boolean = Boolean(num);

显然,因为 Booleanconstructor 具备转换 numberboolean 的功能,我们只需要把数字放到其参数中进行转换即可。TypeScript 不会认为这是类型错误,你可以在 TypeScript 内置的类型信息中看到这一转换的定义。

// typescript/lib/lib.es5.d.ts
interface BooleanConstructor {
  new(value?: any): Boolean;

  <T>(value?: T): boolean;

  readonly prototype: Boolean;
}

通过这一点,我们可以得知:

  • 在强类型语言中若两个类型没有继承关系,则他们不能直接相互赋值。
  • 不能相互赋值的语言可以通过定义方法来进行类型转换,即型变。

那么,当我们遇到使用集成类型的时候,如何处理子类和父类之间的转换呢?

此时,我们就会用到以下两种型变方法:

  • 协变 (Covariant):协变表示 Comp<T> 类型兼容和 T 的一致。
  • 逆变 (Contravariant):逆变表示 Comp<T> 类型兼容和 T 相反。
  • 双向协变 (Bivariant):双向协变表示 Comp<T> 类型与 T 类型双向兼容。
  • 不变 (Invariant):不变表示 Comp<T> 类型与 T 类型双向都不兼容。

显然,上文中举的 numberstring 的例子,就是不变性的体现。

类型继承与多态(参考)

类型继承和多态都是计算机理论基础中的重要一部分,因其过于偏向理论且较为复杂,在此不做过多讲解。

子类型

在编程语言理论中,子类型(subtyping)是一种类型多态的形式。这种形式下,子类型可以替换另一种相关的数据类型(超类型,英语:supertype)。也就是说,针对超类型元素进行操作的子程序、函数等程序元素,也可以操作相应的子类型。如果 S 是 T 的子类型,这种子类型关系通常写作 S <: T,意思是在任何需要使用 T 类型对象的_环境中,都可以安全地使用_ S 类型的对象。子类型的准确语义取决于具体的编程语言中“X 环境中,可以安全地使用 Y”的意义。编程语言的类型系统定义了各自不同的子类型关系。

参考:https://en.wikipedia.org/wiki/Subtyping

多态

在编程语言和类型论中,多态(polymorphism)指为不同数据类型的实体提供统一的接口,或使用一个单一的符号来表示多个不同的类型。

参考:https://en.wikipedia.org/wiki/Polymorphism_(computer_science)

协变

协变是很好理解的,子类型继承了父类型,那子类型所包含的信息就一定等于或多于父类型,即父类型所需要的信息子类型必定包含。

比如我们有以下两个 Interface :

export interface Animal {
  age: number;
  bornAt: Date;
}

export interface Dog extends Animal {
  name: string;
}

在这一实例中我们定义了 AnimalDog 两个类型,其中 DogAnimal 的继承。因为 Dog 继承了 Animal 的所有信息,那么 Dog 的实际类型就等同与这个:

export interface Dog {
  name: string;
  age: number; // extends Animal
  bornAt: Date; // extends Animal
}

协变很容易理解,如果子类型还不能赋值给父类型,说明这个家庭关系有问题类型系统有问题。

const dog: Dog = {
  name: 'AHdark',
  age: 3,
  bornAt: new Date("2007-04-28 18:00:00"),
};

const animal: Animal = dog; // OK

console.log(animal); // { age: 3, bornAt: 2007-04-28T18:00:00.000Z }

如上述代码,当我们把子类型赋值给父类型时,会出现以下情况:

  • 子类型从父类型继承来的,赋值后仍然存在。
  • 子类型独有的,赋值后不再存在。

所以型变是实现父子关系所必需的,它在保证类型安全的前提下,增加了类型系统的灵活性。

逆变

逆变,即父类型到子类型的转换。我们继续使用 AnimalDog 举例:

export interface Animal {
  age: number;
  bornAt: Date;
}

export interface Dog extends Animal {
  name: string;
}

const printAnimal = (animal: Animal) => {
  console.log(animal.age);
  console.log(animal.bornAt);
}

const printDog = (dog: Dog) => {
  printAnimal(dog);
  console.log(dog.name);
}

const dog: Dog = { age: 1, bornAt: new Date(), name: 'Fido' }
const animal: Animal = { age: 1, bornAt: new Date() }

printDog(dog); // works
printAnimal(dog); // works
printAnimal(animal); // works
printDog(animal); // error. argument of type 'Animal' is not assignable to parameter of type 'Dog'.

可见,上文中我们进行了四个涉及类型的操作:

  • 通过 printDog() 输出 Dog ,成功。
  • 通过 printAnimal() 输出 Dog ,成功。
  • 通过 printAnimal() 输出 Animal ,成功。
  • 通过 printDog() 输出 Dog ,出现类型错误。

或许听到这里你会有些混乱。我们知道, DogAnimal 的继承,即 Animal 拥有的 Dog 一定拥有,而 Dog 拥有的 Animal 不一定有。

在这里的 printDog 函数中,如果我们传入一个 Animal ,当访问 object.name 的时候就会因为 Animal 不具备这一属性而出现错误。所以在类型系统中,我们必须禁止这种未经处理的协变。只有我们在对传入数据进行处理以后,才能被称为协变。

interface ConvertAnimalToDog {
  (animal: Animal): Dog;
}

const convertAnimalToDog: ConvertAnimalToDog = (animal) => ({
  ...animal,
  name: 'Any animal'
})

const printAnimalAsDog = (animal: Animal) => {
  const dog: Dog = convertAnimalToDog(animal)
  printDog(dog);
}

printAnimalAsDog(animal);

比如在这种情况下,我们为 Animal 补全了 name ,使得它具有 Dog 所拥有的属性,因此可以被转换为 Dog 进而进行 printDog() 操作。这一过程,便被称为逆变。

双向协变

如上文逆变中所述,在当前的 TypeScript 是不允许父类型直接赋值给子类型这种反向协变的操作的。但是在 TS 2.x 之前支持这种赋值,也就是父类型可以赋值给子类型,子类型可以赋值给父类型,既逆变又协变,叫做 “双向协变”。

这明显是有问题的,不能保证类型安全,所以之后 TS 加了一个编译选项 strictFunctionTypes,设置为 true 就只支持函数参数的逆变,设置为 false 则是双向协变。

总结

在这一文章中我们简要介绍了 TypeScript 类型变化中协变、逆变、双向协变、不变的具体表现,以助于大家更好地理解 TypeScript 为什么是 JavaScript 的超集。

显然,其最主要的功绩还是在于讲一个弱类型语言变成静态强类型语言。在实际开发过程中,强类型语言有助于将错误从运行期转移到编译期,以此减小 Debug 过程消耗的时间。

作为一个强类型语言的坚定拥护者,我建议诸位在前端项目与 Node.js 项目使用 TypeScript,尤其是开源项目,以此达到更高的可维护性。

后续我会另写文章细讲一些 TypeScript 在类型变化中的特性。

Comments

Send Comments

Markdown supported. Please keep comments clean.