TypeScript 背后的结构化类型系统原理详解
草帽Plasticine 人气:0前言
你能说清楚类型、类型系统、类型检查这三个的区别吗?在理解TypeScript的结构化类型系统之前,我们首先要搞清楚这三个概念和它们之间的关系
- 类型:即对变量的访问限制与赋值限制。如 TypeScript 中的原始类型、对象类型、函数类型和字面量类型等类型,当一个变量类型确定后,你不能访问这个类型中不存在的属性或方法,也不能将其他不兼容的类型的变量赋值给该变量
- 类型系统:本质上是一组规则,其描述如何为变量、函数等结构分配和实施类型。同时还定义了如何判断类型之间的兼容性,也正是我们今天主要讨论的概念
- 类型检查:是一种能力,一种确保类型遵循类型系统下的类型兼容性的能力
理解它们对理解我们今天要讨论的TypeScript的结构化类型系统很有帮助
类型系统分为结构化类型系统和标称类型系统,首先我们来看看它们分别都是什么
什么是结构化类型系统?
基于类型结构进行类型兼容性判断
关键体现在两个类型的比较当中,当两个类型比较时,如果是按照属性和方法是否相同来比较的话就称为结构化类型系统,也叫鸭子类型。
比如下面这个例子:
class Dog { say() { console.log('wang wang wang!') } } class Cat { say() { console.log('miao miao miao!') } } const invokeSay = (dog: Dog) => { dog.say() } const dog = new Dog() const cat = new Cat() invokeSay(dog) // wang wang wang! invokeSay(cat) // miao miao miao!
虽然invokeSay函数接收的参数类型是Dog,但是由于Cat类型的结构和Dog是一样的(都是只有一个 say 方法),因此会被认为是同一种类型,这就是结构化类型的特点,基于类型结构进行类型兼容性判断
代表语言:C#、Python、Objective-C
什么是标称类型系统?
基于类型名进行兼容性判断
与结构化类型系统相对的叫标称类型系统,它在判断两个类型是否相同时,只看它们的名称是否相同,即便内部结构完全相同也不能认为是同一种类型,比如下面这个例子:
/** @description 人民币 */ type CNY = number /** @description 美元 */ type USD = number const CNYCount: CNY = 666 const USDCount: USD = 333 const addCount = (source: CNY, input: CNY) => source + input addCount(CNYCount, USDCount)
在标称类型系统中,这里对于addCount的调用是错误的,尽管CNY和USD类型都是number类型,但由于它们的名称不同,因此被视为是不同类型
代表语言:C++、Java、Rust
结构化类型系统等价于鸭子类型系统吗?
严格意义上两者并不等同
- 结构化类型系统基于完全的类型结构来判断类型兼容性
- 鸭子类型系统基于运行时访问的部分来判断类型兼容性
TypeScript 本身并不是在运行时进行类型检查,因此并不严格等价于鸭子类型系统
如何在 TypeScript 中模拟标称类型系统?
由于 TypeScript 的类型系统是结构化类型系统,所以刚刚那个例子在 TypeScript 中是可以正常运行的:
/** @description 人民币 */ type CNY = number /** @description 美元 */ type USD = number const CNYCount: CNY = 666 const USDCount: USD = 333 const addCount = (source: CNY, input: CNY) => source + input addCount(CNYCount, USDCount)
这里我们的意图应当是让人民币 CNY 和美元 USD 作为两种不同的类型,但是由于 TypeScript 结构化类型系统的特性,两个类型本质上都是number类型,因此会被认为是同一个类型
而如果是标称类型系统就不会有这个问题,如果我们能在 TypeScript 中模拟实现标称类型系统就符合预期了,那么要怎么模拟呢?
对比结构化类型系统和标称类型系统的特点,只要我们能够在两个完全兼容的结构化类型中加入一个标识符,那么即便这两个类型的结构是兼容的,但由于这个标识符并不相同,因而会被认为是两个不同的类型
利用这个特点我们就可以在 TypeScript 中模拟标称类型系统
以下有两种方式模拟实现标称类型系统:
- 通过交叉类型实现 -- 只能在类型层面上实现,无法在运行时逻辑上实现
- 通过类实现 -- 能兼顾类型层面和运行时逻辑层面
交叉类型实现
我们可以实现一个工具类型Nominal,对传入的泛型参数进行处理,将其和一个特殊的类型进行交叉类型操作,上面提到的标识符就是由这个特殊的类型提供的
nominal.ts
class TagProtector<T extends string> { protected __tag__: T } export type Nominal<T, U extends string> = T & TagProtector<U> index.ts import { Nominal } from './nominal' /** @description 人民币 */ type CNY = Nominal<number, 'CNY'> /** @description 美元 */ type USD = Nominal<number, 'USD'> const CNYCount = 666 as CNY const USDCount = 333 as USD const addCount = (source: CNY, input: CNY) => source + input // 类型“USD”的参数不能赋给类型“CNY”的参数。 // 不能将类型“USD”分配给类型“TagProtector<"CNY">”。 // 属性“__tag__”的类型不兼容。 // 不能将类型“"USD"”分配给类型“"CNY"”。ts(2345) addCount(CNYCount, USDCount)
由于 TypeScript 实际运行时还是以 JavaScript 的方式运行的,所以类型代码会被抹除,抹除类型后这个代码仍然能够正常执行
这是因为通过这种方式模拟实现标称类型系统只能在类型层面模拟,实际的运行时并不能起到检查的作用,这时候就要用下面的方案 -- 用类模拟实现
类实现
/** @description 人民币 */ class CNY { private __tag__!: void constructor(public value: number) {} } /** @description 美元 */ class USD { private __tag__!: void constructor(public value: number) {} } const CNYCount = new CNY(666) const USDCount = new USD(666) const addCount = (source: CNY, input: CNY) => source.value + input.value // 类型“USD”的参数不能赋给类型“CNY”的参数。 // 类型具有私有属性“__tag__”的单独声明。 addCount(CNYCount, USDCount)
以上两种方式本质都是通过一个非公开的额外属性对类型添加了额外的标识符,从而能够让结构化类型系统将它们判断为不同的类型
总结
相信现在你能够理解什么是结构化类型系统了,正如开头介绍的类型系统的概念中所说,它定义了如何判断类型之间的兼容性
而结构化类型系统对于类型之间兼容性的判断则是基于类型的结构来判断的,只要两个类型的结构上可兼容(如一个类型中的所有属性和方法在另一个类型中都存在),则可以将两个类型视为是兼容的
除此之外,我们还了解了与结构化类型系统对应的标称类型系统,并且了解到如何在TypeScript中模拟实现标称类型系统,让我们对结构化类型系统有更深刻的理解
加载全部内容