浅谈 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.