伍.1.4 彻底搞懂泛型

因为JAVA的OOP特征实现得实在太经典了,以至于后来很多OOP语言都会借鉴它,所以我会先借用JAVA语言来阐释泛型这个概念,本文最后再用TypeScript来讲前端领域的泛型。

01.为什么要有泛型

没有泛型的年代,JAVA里面要定义好各种class,并为变量指明明确的类型,所以给变量赋值的时候经常会用到强制转换:

/* JAVA */

B b = (B) someObject;

这种强制转换在编译时不报错,但是在运行时可能抛出ClassCastException异常。说个形象点的例子:

/* JAVA */

public class Coffe1891{

    class Water{
    }

    class Wine{
    }

    public static void main(String[] args) {
        Coffe1891 coffe1891= new Coffe1891();
        Map map = new HashMap();
        map.put("water", coffe1891.new Water());
        map.put("wine", coffe1891.new Wine());

        Wine wine = (Wine) map.get("water");
        System.out.println(wine);
    }

}

这段代码的意思是:我们在map中放了一杯水(Water),又放了一杯酒(Wine),当我们想从map中取出酒的时候,却一不留神把水取了出来。 这段代码编译时是没有问题的,但运行的时候就会报ClassCastException(水毕竟不是酒,不然岂不乱套了?):

Exception in thread "main" java.lang.ClassCastException: com.coffe1891.java_demo.Coffe1891$Water cannot be cast to com.coffe1891.java_demo.Coffe1891$Wine
    at com.coffe1891.java_demo.Coffe1891.main(Coffe1891.java:12)

这个异常可能是程序员粗心造成的(实在太容易犯这个错了),但是影响是致命的(重则程序崩溃)。所以为了消灭这种异常,泛型出现了。当然,这只是重要原因之一,更多的还是在于让工程里的代码更灵活、可复用。

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的问题了。

/* JAVA */

public class Coffe1891<T> {
    private T obj;

    public T getObj() {
        return obj;
    }

    public void setObj(T obj) {
        this.obj = obj;
    }
}

用户最终想要使用哪种类型,就在创建的时候指定类型参数变量T,后续使用该类的时候,该类就会自动被转换成用户想要的类型了。

/* JAVA */
public static void main(String[] args) {
    //创建对象并指定元素类型
    Coffe1891<String> c = new Coffe1891<>();

    c.setObj(new String("coffe1891"));
    String s = c.getObj();
    System.out.println(s);//>> coffe1891


    //创建对象并指定元素类型
    Coffe1891<Integer> cc = new Coffe1891<>();
    /**
     * 如果在这个对象里传入的是String类型的,它在编译时期就通过不了.
     */
    cc.setObj(10);
    int i = cc.getObj();
    System.out.println(i);//>> 10
}

2).泛型方法

某一个方法上需要使用泛型,外界仅仅是关心该方法,不关心类其他的属性。这样的话,我们在整个类上定义泛型,未免就有些大题小作了,这时候就需要用到泛型方法。

//定义泛型方法..
public <T> void show(T t) {
    System.out.println(t);
}

用户传递进来的是什么类型参数变量,返回值就是什么类型了。

public static void main(String[] args) {
    //创建对象
    ObjectTool tool = new ObjectTool();

    //调用方法,传入的参数是什么类型,返回值就是什么类型
    tool.show("coffe1891");
    tool.show(1891);
}

3).泛型接口

在Java中也可以定义泛型接口,这里不再赘述,仅仅给出示例代码:

//定义泛型接口
interface Info<T> {
    public T getVar();
}

//实现接口
class InfoImp<T> implements Info<T> {
    private T var;

    // 定义泛型构造方法
    public InfoImp(T var) {
        this.setVar(var);
    }

    public void setVar(T var) {
        this.var = var;
    }

    public T getVar() {
        return this.var;
    }
}

04.通配符"?"

用一个问号?代表匹配任意类型。

?和T的区别

?T都表示不确定的类型,区别在于我们可以对 T 进行操作,但是对 ?不行,比如如下这种 :

/* JAVA */

// 可以
T t = operate();

// 不可以
? car = operate();

T 是一个 确定的类型,通常用于泛型类和泛型方法的定义。 ?是一个 不确定的类型,通常用于泛型方法的调用代码和形参,不能用于定义类和泛型方法。

Class<T><Class<?>又有什么区别呢?

最常见的是在反射场景下的使用,这里以用一段发射的代码来说明下。

/* JAVA */

// 通过反射的方式生成  multiLimit对象,这里比较明显的是,我们需要使用强制类型转换
B b = (B)Class.forName("com.coffe1891.generic.B").newInstance();

对于上述代码,在运行期,如果反射的类型不是 B ,那么一定会报 java.lang.ClassCastException 错误。对于这种情况,则可以使用下面的代码来代替,使得在在编译期就能直接 检查到类型的问题:

/* JAVA */

public static <T> T createInstance(Class<T> clazz) throws InstantiationException, IllegalAccessException {
  return clazz.newInstance();
}

public static void main(String[] args) throws IllegalAccessException, InstantiationException {
  B b=createInstance(B.class);
}

从上面的代码可以发现,Class<T>在实例化的时候,T 要替换成具体类。

Class<?>它是个通配泛型,? 可以代表任何类型,所以主要用于声明时的限制情况。比如,我们可以这样做声明:

/* JAVA */

class TEST{
  public Class<?> clazz;//可以
  public Class<T> clazzT;//报错,因为需要指定T
}

如果想要上面的第5行不报错,就必须让当前的类也指定 T,可以这样写:

/* JAVA */

class TEST<T>{
  public Class<?> clazz;
  public Class<T> clazzT;
}

05.泛型约束:PECS

PECS全称是 Producer Extends, Consumer Super

/* JAVA */

static class A extends B{} //子子类
static class B extends C{} //子类
static class C {} //父类


static class Example<T>{}

public static void main(String[] args) {
    {
        Example<? extends A> testAA = new Example<A>();
        Example<? extends A> testAB = new Example<B>();//报错
        Example<? extends A> testAC = new Example<C>();//报错
        Example<? extends B> testBA = new Example<A>();
        Example<? extends B> testBC = new Example<C>();//报错
        Example<? extends C> testCA = new Example<A>();
        Example<? extends C> testCB = new Example<B>();
    }
    {
        Example<? super A> testAA = new Example<A>();
        Example<? super A> testAB = new Example<B>();
        Example<? super A> testAC = new Example<C>();
        Example<? super B> testBA = new Example<A>();//报错
        Example<? super B> testBC = new Example<C>();
        Example<? super C> testCA = new Example<A>();//报错
        Example<? super C> testCB = new Example<B>();//报错
    }

}

上面这段代码演示了PECS的基本特性。使用? extends T之后,类型参数变量都被限定为T的子类(包含T)了;而使用? super T的话,类型参数变量都会被限定为T的父类(含T),所以才有上面代码的表现。

Producer意指生产者,生产者用extends关键字来限制类型参数变量的范围,这样类型参数变量的上限是指定的,也即约束是严格的;Consumer意指消费者,消费者用super关键字来指定类型参数变量的下限,这样类型参数变量的上限是约束宽松的。

为什么这么设计?这是因为我们生产软件的时候,相对更容易知道软件的生产者具体是谁;但是不太容易明确到底有哪些具体的消费者使用这个软件。因此在Sun设计JAVA的时候采用了这种朴素的思想,那就是针对生产者Producer具体化(约束严格),针对消费者Consumer抽象化(约束宽松)。而生产者只产出不进,消费者只进不产出,所以为方便读者记忆理解,我总结为 “ 宽进严出 ” 。

熟悉OOP之后我们又都知道:JAVA是单继承,所有继承的类构成一棵树。子类可以强制转换为父类,父类强制转换为子类(向下转型)会有风险。具体的可以抽象化,抽象的不一定能具体化,这就是自然规律的映射。

List<B> aList = new ArrayList<>();
aList.add(new B());

//这种向下转型(强制转换),编译时不报错,运行时报ClassCastException的错
A a = (A)aList .get(0);

如上面代码所示,在JAVA中进行类型转换,向下转型存在着风险,而且编译期间不容易发现,只有在运行期间才会抛出异常,十分隐蔽,所以要尽量避免使用向下转型。接着往下看代码:

/* JAVA */

List<? extends B> extendsList = new ArrayList<>();

extendsList.add(new A());//报错
extendsList.add(new B());//报错
extendsList.add(new C());//报错

B b = extendsList.get(0);//可以

看来作为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行可以安全地被通过编译。

同理,我们再看看下面的代码:

/* JAVA */

List<? super B > superList = new ArrayList<>();

superList .add(new A());//可以
superList .add(new B());//可以
superList .add(new C());//报错

B b = superList.get(0);//报错

可以发现,作为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有明显的区别:

//泛型方法,在TypeScript里也可叫泛型函数
function genericMethod<T>(arg: T): T {
    return arg;
}

//泛型类
class GenericClass<T> {
    property: T;
    //add右侧第一个冒号右边部分,称为【泛型函数类型】。【函数类型】为TypeScript的基础概念,
    //读者可自行查阅文档。
    add: (x: T, y: T) => T;
    //下面这样写与上一行也是等效的
    //add: { (x: T, y: T): T };
}

//泛型接口
interface GenericInterface<T> {
    (arg: T): T;//这里是【泛型函数类型】
}

2).类型推导

TypeScript编译器会根据传入的参数自动地帮助我们确定T的类型:

//明确指定类型T
let a= genericMethod<string>("coffe1891");  // a的类型是 'string'

//不指定类型T,由编译器推导类型
let b= genericMethod("Bob Ma");  // b的类型是 'string'

类型推论帮助我们保持代码精简和高可读性。如果编译器不能够自动地推断出类型的话,只能像上面第2行那样明确的传入T的类型,在一些复杂的情况下是必须这么做的。

3).泛型约束

另外,JAVA的接口里只能放方法(和static/final关键字修饰的属性),而TypeScript的接口里能额外地放各种属性。比如现在有这样一个需求:上面的genericMethod,我想传入一个数组,然后打印出数组的长度,如果这么写,编译器会报错。代码如下:

function genericMethod<T>(arg: T): T {
    console.log(arg.length);//报错:类型“T”上不存在属性“length”
    return arg;
}

为了解决这个问题,我们可以使用接口,在接口里约定必须具备length属性:

interface MyInterface{
    length:number;
}

function genericMethod<T extends MyInterface{>(arg: T): T {
    console.log(arg.length);
    return arg;
}

现在这个泛型函数的参数arg必须包含属性length,因此这个函数它不再适用于任意类型,正所谓有得必有失。

4).约束嵌套

约束嵌套意思就是说泛型extends了另外一个泛型、或者类。这个和JAVA里的PECS有点区别,笔者发现TypeScript还没有引入JAVA的PECS那种深层次的约束能力,参见TypeScript官方Github页的issues#4704

在TypeScript里,我们可以让一个类型参数被另外一个类型参数约束。比如,我要从一个Map对象里面获取某一个Key的值,并且要确保这个Key一定在Map里,在使用泛型之前我们可能会通过if来判断:

function method(arg:Map,key:string){
//todo
}

使用泛型约束嵌套之后,在代码运行前编译器就能帮我们执行好这个约束,十分方便:

//todo

5).用TypeScript+Vue实现泛型组件

什么是泛型组件(Generic Components)?当组件支持传递泛型这种参数的时候,这个组件也具备了泛型的特征,而成为了泛型组件。

前端常用的框架React和Vue都有组件的概念,本篇以Vue为例,React+Typescript实现泛型组件可以看这篇网文。

//todo

参考文献

最后更新于