伍.1.4 彻底搞懂泛型
因为JAVA的OOP特征实现得实在太经典了,以至于后来很多OOP语言都会借鉴它,所以我会先借用JAVA语言来阐释泛型这个概念,本文最后再用TypeScript来讲前端领域的泛型。
01.为什么要有泛型
没有泛型的年代,JAVA里面要定义好各种class,并为变量指明明确的类型,所以给变量赋值的时候经常会用到强制转换:
这种强制转换在编译时不报错,但是在运行时可能抛出ClassCastException
异常。说个形象点的例子:
这段代码的意思是:我们在map中放了一杯水(Water),又放了一杯酒(Wine),当我们想从map中取出酒的时候,却一不留神把水取了出来。 这段代码编译时是没有问题的,但运行的时候就会报ClassCastException
(水毕竟不是酒,不然岂不乱套了?):
这个异常可能是程序员粗心造成的(实在太容易犯这个错了),但是影响是致命的(重则程序崩溃)。所以为了消灭这种异常,泛型出现了。当然,这只是重要原因之一,更多的还是在于让工程里的代码更灵活、可复用。
02.定义
所谓“泛型”,就是“宽泛的数据类型”。
泛型是这样一种特殊类型——它把类型明确的工作推迟到创建对象或调用方法的时候才去明确。说白了就是提供一种更高层次的OOP抽象/灵活性:
把类型当作参数一样传递;
这种类型参数只能用来表示引用类型,不能用来表示基本类型如 int、double、char 等。但是传递基本类型也不会报错,因为它们会自动装箱成对应的引用类型。
相关术语:
ArrayList<T>
中的T称为类型参数变量;ArrayList<Integer>
中的Integer称为实际类型参数;整个
ArrayList<T>
称为泛型类型;整个
ArrayList<Integer>
称为已参数化的类型(Parameterized Type)。
JAVA泛型设计原则:只要在编译时期没有出现警告,那么运行时期就不会出现ClassCastException
异常。
注:在Java中,经常用T、E、K、V等形式的参数来表示泛型参数。
T:代表一般的任何类。 E:代表 Element 的意思,或者 Exception 异常的意思。 K:代表 Key 的意思。 V:代表 Value 的意思,通常与 K 一起配合使用。
如果以上的“概念”不好理解,那我来说点通俗的。现在有一只玻璃杯,你可以让它盛一杯水,也可以盛一杯酒——泛型的概念就在于此:制造这只杯子的时候,没必要在说明书上定义死指明它只能盛水而不能盛酒。可以在说明书上指明它用来盛装液体,但最好也不要这样,弄不好用户想用它来盛几块冰糖呢 😁 。这么一说,你是不是感觉不那么抽象了?泛型其实就是在定义类、方法、接口的时候不局限地指定某一种特定类型参数,而让类、方法、接口的调用者来决定具体使用哪一种类型参数。就好比,玻璃杯的制造者说,我不知道使用者用这只玻璃杯来干嘛,所以我只负责造这么一只杯子;玻璃杯的使用者说,这就对了,我来决定这只玻璃杯是盛水还是酒,或者冰糖。
03.泛型类、泛型方法、泛型接口
1).泛型类
泛型类就是把泛型定义在类上,用户使用该类的时候,才把类型参数变量明确下来。这样的话,用户明确了什么类型参数变量,该类就代表着什么类型。用户在使用的时候就不用担心转换异常ClassCastException
的问题了。
用户最终想要使用哪种类型,就在创建的时候指定类型参数变量T,后续使用该类的时候,该类就会自动被转换成用户想要的类型了。
2).泛型方法
某一个方法上需要使用泛型,外界仅仅是关心该方法,不关心类其他的属性。这样的话,我们在整个类上定义泛型,未免就有些大题小作了,这时候就需要用到泛型方法。
用户传递进来的是什么类型参数变量,返回值就是什么类型了。
3).泛型接口
在Java中也可以定义泛型接口,这里不再赘述,仅仅给出示例代码:
04.通配符"?"
用一个问号?
代表匹配任意类型。
?和T的区别
?
和T
都表示不确定的类型,区别在于我们可以对 T 进行操作,但是对 ?不行,比如如下这种 :
T 是一个 确定的类型,通常用于泛型类和泛型方法的定义。 ?是一个 不确定的类型,通常用于泛型方法的调用代码和形参,不能用于定义类和泛型方法。
Class<T>
和 <Class<?>
又有什么区别呢?
Class<T>
和 <Class<?>
又有什么区别呢?最常见的是在反射场景下的使用,这里以用一段发射的代码来说明下。
对于上述代码,在运行期,如果反射的类型不是 B ,那么一定会报 java.lang.ClassCastException 错误。对于这种情况,则可以使用下面的代码来代替,使得在在编译期就能直接 检查到类型的问题:
从上面的代码可以发现,Class<T>
在实例化的时候,T 要替换成具体类。
Class<?>
它是个通配泛型,? 可以代表任何类型,所以主要用于声明时的限制情况。比如,我们可以这样做声明:
如果想要上面的第5行不报错,就必须让当前的类也指定 T,可以这样写:
05.泛型约束:PECS
PECS全称是 Producer Extends, Consumer Super。
上面这段代码演示了PECS的基本特性。使用? extends T
之后,类型参数变量都被限定为T的子类(包含T)了;而使用? super T
的话,类型参数变量都会被限定为T的父类(含T),所以才有上面代码的表现。
Producer意指生产者,生产者用extends
关键字来限制类型参数变量的范围,这样类型参数变量的上限是指定的,也即约束是严格的;Consumer意指消费者,消费者用super
关键字来指定类型参数变量的下限,这样类型参数变量的上限是约束宽松的。
为什么这么设计?这是因为我们生产软件的时候,相对更容易知道软件的生产者具体是谁;但是不太容易明确到底有哪些具体的消费者使用这个软件。因此在Sun设计JAVA的时候采用了这种朴素的思想,那就是针对生产者Producer具体化(约束严格),针对消费者Consumer抽象化(约束宽松)。而生产者只产出不进,消费者只进不产出,所以为方便读者记忆理解,我总结为 “ 宽进严出 ” 。
熟悉OOP之后我们又都知道:JAVA是单继承,所有继承的类构成一棵树。子类可以强制转换为父类,父类强制转换为子类(向下转型)会有风险。具体的可以抽象化,抽象的不一定能具体化,这就是自然规律的映射。
如上面代码所示,在JAVA中进行类型转换,向下转型存在着风险,而且编译期间不容易发现,只有在运行期间才会抛出异常,十分隐蔽,所以要尽量避免使用向下转型。接着往下看代码:
看来作为Producer的extendsList,我们不能往里插入对象,但是可以取出里面的项(也即只出不进)。为什么呢?如果上面第5行可以通过编译,则extendsList应该是具体的类型List<A>
,那么后面第6行就无法通过了,因为B和A不是同一个类型,所以前后矛盾,那么第5行一开始就不能通过编译;至于第7行,C是B的父类,前面提到了“父类转换为子类有风险”,也即可能报ClassCastException错误,而泛型的目的就是要在编译阶段消灭ClassCastException,所以编译器也必须、不能让编译通过了。
其实,从另外一个角度而言,extendsList里放的类型是? extends B
,也即可能是B,也可能是B的子类A,但是具体是哪个类,并不清楚,所以如果往里添加任何B和A的实例,则会发生强制转换,促使extendsList就变成了具体的List<A>或List<B>,而这不是我们想要的。综上,编译器一定会禁止插入,对任何插入必须要报错 😎 。
至于第9行,从extendsList里面取出来的项,一定是B或者B的子类的实例,子类强制转换为父类是可行的,所以第9行可以安全地被通过编译。
同理,我们再看看下面的代码:
可以发现,作为Consumer的superList,我们不能取出里面的任何项,但是可以往里面插入对象(也即只进不出)。这又是为什么呢?superList里放的类型是? super B
,也即可能是B,也可能是B的父类C。因为前面说到了“子类可以强制转换为父类”,所以第5、6行放一个子类进去,子类可以强制转换为父类,因此编译通过没毛病。第7行还是老毛病,superList里面的项可能为B类型,而C是B的父类,前面说到了“父类转换为子类有风险”,也即可能报ClassCastException错误,而泛型的目的就是要在编译阶段消灭ClassCastException,所以编译器也必须不能让编译通过了。
至于第9行为什么也报错?由于只规定了superList里的项必须是B或者B的超类,导致superList里的项没有明确统一的“根”(除了Object这个必然的根),所以除了把具体的项强制转成Object,这个泛型你其实无法使用它,对吧!所以,对于把类型参数变量写成这样形态的泛型,无法读取,只能对这个泛型做插入操作,也即只进不出。
06.TypeScript的泛型
1).与JAVA的语法差别
TypeScript的泛型类、泛型方法、泛型接口语法上与JAVA有明显的区别:
2).类型推导
TypeScript编译器会根据传入的参数自动地帮助我们确定T的类型:
类型推论帮助我们保持代码精简和高可读性。如果编译器不能够自动地推断出类型的话,只能像上面第2行那样明确的传入T的类型,在一些复杂的情况下是必须这么做的。
3).泛型约束
另外,JAVA的接口里只能放方法(和static/final关键字修饰的属性),而TypeScript的接口里能额外地放各种属性。比如现在有这样一个需求:上面的genericMethod
,我想传入一个数组,然后打印出数组的长度,如果这么写,编译器会报错。代码如下:
为了解决这个问题,我们可以使用接口,在接口里约定必须具备length
属性:
现在这个泛型函数的参数arg
必须包含属性length
,因此这个函数它不再适用于任意类型,正所谓有得必有失。
4).约束嵌套
约束嵌套意思就是说泛型extends了另外一个泛型、或者类。这个和JAVA里的PECS有点区别,笔者发现TypeScript还没有引入JAVA的PECS那种深层次的约束能力,参见TypeScript官方Github页的issues#4704。
在TypeScript里,我们可以让一个类型参数被另外一个类型参数约束。比如,我要从一个Map对象里面获取某一个Key的值,并且要确保这个Key一定在Map里,在使用泛型之前我们可能会通过if来判断:
使用泛型约束嵌套之后,在代码运行前编译器就能帮我们执行好这个约束,十分方便:
//todo
5).用TypeScript+Vue实现泛型组件
什么是泛型组件(Generic Components)?当组件支持传递泛型这种参数的时候,这个组件也具备了泛型的特征,而成为了泛型组件。
前端常用的框架React和Vue都有组件的概念,本篇以Vue为例,React+Typescript实现泛型组件可以看这篇网文。
//todo
参考文献
最后更新于