TypeScript泛型 关于C++ TpeScript系列的泛型
李宇 人气:0前言:
我在面试的时候,通常喜欢问候选人一些莫名其妙的问题。比如这样的问题,假如你是某个库的作者,你如何实现某个功能。这类问题一般没有正确的答案,主要意图是考察一下候选人对这个库有没有更深入的理解,次要意图是觉得这样挺好玩。玩归玩,但该严肃的时候也要严肃起来。有一次,我面试到一位用过TypeScript
的同学,这让人眼前一亮(从我的经验看,国内偶尔有大厂会用,小厂基本没有)。随后,我问了句,你是怎么理解泛型的呢?问了之后,我就后悔了,因为我也不知道答案。但随后的答案让我没有后悔,因为候选人回了我一句,我不知道什么是泛型……
这件事对候选人的影响可大可小,但对我的影响挺大的。它致使我一定要写出一篇关于泛型的文章。但自从种下这个种子后,我就开始后悔了。因为越接触TS中的泛型,越觉得这个题材没什么好写的。一来呢,TS中的泛型犹如空气,经常使用却难以描述。二者呢,它太过宽泛,难以面面俱到。
今天的这篇文章将不同于这个系列的以往。这篇文章将从C++模版要解决的问题出发,引出TS泛型要解决的问题,并简答介绍一些稍微高级的使用场景。
一、模版
说起泛型,不得不提一下泛型的鼻祖,模版。C++中的模版以烧脑壳和强大著称,并被各类大牛津津乐道多年。就现在而言,Java、.NET或TS中的泛型都可以被认为是实现了C++模版的子集。对于子集的说法,我不敢苟同。因为就存在的目的而言,TS和C++模版完全不一样。
C++模版的出现是为了产生类型安全的通用容器。我们先来说一下通用容器,比如我写了个链表或者数组,这个数据结构不太关心存在里面的具体数据是什么类型,它都可以实现对应的操作。但js本身不关注类型和大小,所以js中的数组本来就是通用容器。对于TS而言,泛型的出现就可以解决这个问题。另一个值得对比的是产生,C++模版最终产出的是对应的类或函数,但对于TS而言,TS无法产生任何东西。有的同学可能要问了,TS不是最终产生JS代码吗?这样说有点不严谨,因为TS最终是分离出了JS代码,而没有对原有逻辑做任何处理。
C++模版的另一个目的就是元编程。这个元编程相当地强大,它主要通过编译时的程序设计构造来优化程序的执行。就TS而言,目前它只做了一处类似的优化,就是const enum可以内联在执行的地方,仅此而已。关于这类优化,上篇结束的位置也提到了基于类型推导的优化,但目前而言,TS还没有这个功能。倘若这类简单的优化都不支持,那对于更为复杂的元编程而言,就更不可能了(元编程需要对泛型参数进行逻辑推导,并最终内联到使用到的地方)。
关于C++模版,就说这么多吧,毕竟这不是一篇关于模版元编程的文章,而且我也不是专家,更多关于模版的问题,可以去问问轮子哥。说这么多模版,主要还是想说,TS中的泛型和模版是非常不一样的!如果你是从C++
或Java
转来做前端,仍然需要重新认识一下TS中的泛型。
二、泛型
我认为TS中的泛型主要有3个主要用途:
- 声明泛型容器或组件。比如:各种容器类
Map
、Array
、Set等;各种组件,比如React.Component
。 - 对类型进行约束。比如:使用
extends
约束传入参数符合某种特定结构。 - 生成新的类型
关于第二、三点,因为之前文章已经很清楚地提到过,这里不再赘述。关于第一点,我这里举两个例子:
第一个例子是关于泛型容器,假如我想实现一个简单的泛型链表,代码如下:
class LinkedList<T> { // 泛型类 value: T; next?: LinkedList<T>; // 可以使用自身进行类型声明 constructor(value: T, next?: LinkedList<T>) { this.value = value; this.next = next; } log() { if (this.next) { this.next.log(); } console.log(this.value); } } let list: LinkedList<number>; // 泛型特化为number [1, 2, 3].forEach(value => { list = new LinkedList(value, list); }); list.log(); // 1 2 3
第二个是泛型组件,假如我想实现一个通用的表单组件,可以这样写:
function Form<T extends { [key: string]: any }>({ data }: { data: T }) { return ( <form> {data.map((value, key) => <input name={key} value={value} />)} </form> ) }
这个例子不止演示了泛型组件,也演示了如何使用extends定义泛型约束。现实中的泛型表单组件可能比这个更为复杂,上面只是演示一下思路。
到此为止,TS的泛型就讲完了!但这个文章还没完,下面我们来看一下泛型的一些高级使用技巧。
三、泛型递归
递归简单来说就是函数的输出可以继续作为输入来进行逻辑演算的一类解决问题的思路。举个简单的例子,比如我们要算加法,定义了一个add
函数,它只能求两个数的和,但现在我们有1,2,3等三个数需要计算,那我们如何用现有的工具解决这个问题呢?答案很简单,首先算add(1, 2)是3,然后add(3, 3)是6。这就是递归的思路。
在现实生活中,递归是如此的常见,以至于我们经常忽略它的存在。程序的世界也是如此。这里举个例子,并用这个例子来说明TS中的递归如何实现。比如,我现在有个泛型类型ReturnType<T>,它可以返回一个函数的返回类型。但我现在有个调用层级很深的函数,而且我不知道它的层级有多深,我该如何做呢?
思路一:
type DeepReturnType<T extends (...args: any) => any> = ReturnType<T> extends ( ...args: any ) => any ? DeepReturnType<ReturnType<T>> // 这里引用自身 : ReturnType<T>;
上面代码的说明:这里定义了一个DeepReturnType
的泛型类型,类型约束为接受任意参数、返回任意类型的函数。若它的返回类型是个函数,则继续用返回类型调用自身,否则返回函数的返回类型。
任何直观、简洁的方案背后都有一个但是。但是,这个是无法通过编译的。主要原因是,TS暂时不支持。以后支不支持我不知道,但,官方给的理由很明确:
- 这个有着环形的意图不可能构成对象图,除非你以某种方式推迟(通过惰性或状态)。
- 真的没有办法知道类型推导是否结束。
- 我们可以在编译器中使用有限类型的递归,但问题不在于类型是否终止,而是计算密集程度和内存分配律如何。
- 一个元问题:我们是否希望人们编写这样的代码?这种使用场景是存在的,但这样实现的类型不一定适合库的消费者。
- 结论:我们还没有为这种件事做好准备。
所以,我们该如何实现这类需求呢?方法是有的,如官方给出的思路,我们可以使用有限次数的递归。下面给出我的思路:
// 两层泛型类型 type ReturnType1<T extends (...args: any) => any> = ReturnType<T> extends ( ...args: any ) => any ? ReturnType<ReturnType<T>> : ReturnType<T>; // 三层泛型类型 type ReturnType2<T extends (...args: any) => any> = ReturnType<T> extends ( ...args: any ) => any ? ReturnType1<ReturnType<T>> : ReturnType<T>; // 四层泛型类型,可以满足绝大多数情况 type DeepReturnType<T extends (...args: any) => any> = ReturnType<T> extends ( ...args: any ) => any ? ReturnType2<ReturnType<T>> : ReturnType<T>; // 测试 const deep3Fn = () => () => () => () => "flag is win" as const; // 四层函数 type Returned = DeepReturnType<typeof deep3Fn>; // type Returned = "flag is win" const deep1Fn = () => "flag is win" as const; // 一层函数 type Returned = DeepReturnType<typeof deep1Fn>; // type Returned = "flag is win"
这种技巧可以推广到定义深层结构的Exclude
、Optional
或Required
等等。
四、默认泛型参数
有时候我们很喜欢泛型,但有时候我们又不希望类或函数的消费者每次都指定泛型的类型,这时候,我们可以使用默认的泛型参数。这个在很多第三方库中广泛使用,比如:
// 接收P S C的泛型组件 class Component<P,S,C> { props: P; state: S; context:C .... } // 需要这样使用 class MyComponent extends Component<{}, {}, {}>{} // 但如果我的组件是个很纯粹的组件,并不需要props、state和context呢 // 可以这样定义 class Component<P = {}, S = {}, C = {}> { props: P; state: S; context:C .... } // 然后可以这么使用 class MyComponent extends Component {}
我觉得这个特性非常实用,它以一种js中很自然的方式实现了C++模版中的partial instantiation
。
五、泛型重载
泛型重载在官方文档上提过几嘴,这种重载依赖于函数重载的一些机制,因此,我们先来看一下TS中的函数重载吧。这里,我用lodash
里面的map
函数来举例。map函数的第二个参数可以接受一个string
或是function
,比如官网的例子:
const square = (n) => n * n; // 接收函数的map map({ 'a': 4, 'b': 8 }, square); // => [16, 64] (iteration order is not guaranteed) const users = [ { 'user': 'barney' }, { 'user': 'fred' } ]; // 接收string的map map(users, 'user'); // => ['barney', 'fred']
那么,这样的类型声明如何在TS中表达呢?我可以使用函数重载,比如这样:
// 这里只做演示,不保证正确性。真实场景下这里需要填充正确的类型,而不是any interface MapFn { (obj: any, prop: string): any; // 当接收string时的情况,情景一 (obj: any, fn: (value: any) => any): any; // 当接收函数时的情况,情景二 } const map: MapFn = () => ({}); map(users, 'user'); // 重载情景一 map({ 'a': 4, 'b': 8 }, square); // 重载情景二
上面这段代码使用了TS中比较奇特的一种机制,也就是函数、new等 类函数的定义可以写在interface
中。这个特性的出现主要是为了支持js中可调用的对象,比如,在jQuery
中,我们可以直接执行$("#banner-message"),
或者调用其方法 $.ajax()。
当然,也可以使用另一种更为传统的做法,比如下面这样:
function map(obj: any, prop: string): any; function map(obj: any, fn: (value: any) => any): any; function map(obj, secondary): any {}
这里,基本讲清楚了函数重载。推广到泛型,基本上是一样的。这里举一个知友提的问题的例子,对于这个问题,这里不再赘述。解决思路大概是这样的:
interface FN { (obj: { value: string; onChange: () => {} }): void; <T extends {[P in keyof T]: never}>(obj: T): void; // ,对于obj的类型T而言,它始终不接收其它的key。 } const fn: FN = () => {}; fn({}); // 正确 fn({ value: "Hi" }); // 错误 fn({ onChange: () => {} }); // 错误 fn({ value: "Hi", onChange: () => ({}) }); // 正确
对于React生态,这里有一个比较值得阅读的泛型重载的实例,那就是connect
函数,大家可以移步到它的源码以便了解更多。
整体而言,我不太喜欢这篇文章。究其原因,TS中的泛型使用广泛,因其设计初衷的原因,可玩性较差。但我对这种设计理念是支持的,首先,它能够满足我们定义类型的要求,其次,它做到了比C++模版更为简单易用。
加载全部内容