Java基础拾遗 ● 泛型篇

2021/5/1 22:27:26

本文主要是介绍Java基础拾遗 ● 泛型篇,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

前言

Java5开始,泛型( generic)已经成了Java编程语言的一部分。

在没有泛型之前,从集合中读取到的每一个对象都必须进行转换。如果有人不小心插入了类型错误的对象,在运行时的转换处理就会出错。

有了泛型之后,你可以告诉编译器每个集合中接受哪些对象类型。编译器自动为你的插入进行转换,并在编译时告知是否插入了类型错误的对象。

这样可以使程序更加安全,也更加清楚。但是对于大部分新手而言,如何理解并利用好泛型的这些优势,还是有一定的难度。

本文就是告诉大家如何最大限度的理解、使用泛型,并且整个过程尽可能简单化。

 

1、泛型是什么?

声明中具有一个或多个类型参数的类或者接口,被称作泛型类或泛型接口。
泛型的定义格式为:类或接口的名称+尖括号<泛型类型对应的实际类型参数>

例如:List接口就是一个泛型接口,接口全称:List<E>,它只有一个类型参数E 。

如我们实际开发中常用的:字符串列表List<String>,它是一个参数化的类型,表示元素类型为String的集合,String是与泛型的类型参数E对应的实际类型参数。

 

2、原生态类型

正如文章开篇所说泛型是从Java5才出现的,那像接口List这样之前没有用到泛型的类,能够正常工作吗?又该怎么称呼呢?
答案就是:之前代码可以正常工作,被称作:原生态类型,即不带任何实际类型参数的泛型名称,例如:与List<String>相对应的原生态类型是List。
原生态类型就像从类型声明中删除了泛型参数信息一样,它的作用主要是为了与泛型出现之前的代码兼容。

知道了原生态类型的作用说明,大家应该明白和注意到:请不要使用原生态类型!
例如下面的代码,在Java增加泛型之前,这样的定义是完全没有问题的,即使到了Java9开始,它依然是合法的,但是已经没有任何参考价值了。

List nameList = new ArrayList();
nameList.add("listen");
nameList.add(1024);

nameList集合的本意是存储String类型的姓名,但如果不小心将一个其他类型如Integer放进集合中,此时尽管编译器会警告信息,但这段代码照样可以编译和运行。
直到从nameList中取出数据的时候,由于缺乏泛型信息,你不得不进行强制转换,此时错误才浮现出来,你会得到一个异常:ClassCastException

for (Object name : nameList) {
    // 此处出现异常:ClassCastException: java.lang.Integer cannot be cast to java.lang.String
    String nameStr = (String) name;
    System.out.println(nameStr);
}

良好的编程习惯,其中很重要的一条就是:出错之后应该尽快发现,最好是编译时就发现。上面的这个例子,直到运行时才发现错误,犹如一颗代码里的定时炸弹,等爆炸时黄花菜都凉了。
多说一句:虽然说开发可以通过人为约定、加上注释去说明这个List只能放入String类型,但是依靠人为约定的代码是极其脆弱,不健全的!

总结:原生态类型只是为了与引入泛型之前的遗留代码进行兼容而提供的。所以不应该使用原生态类型,因为这样就失掉了泛型在安全性和描述性方面的所有优势。

 

3、无限制的通配符类型

如果你要使用泛型,但不确定或不关心实际的参数类型,就可以使用一个问号代替,如:List<?>
例子:假如你需要定义操作一个集合,但是你并不关心,也不需要知道集合中元素的类型,那此时无限制通配符的作用就体现出来了。

 /**
 * 计算两个集合中包含相同元素的数量
 * 注:本段代码只是为了演示无限制通配符的作用,不代表实现是最优解
 *
 * @param list1
 * @param list2
 * @return 相同元素数量
 * @author Turbolisten
 */
public static int findSameCount(List<?> list1, List<?> list2) {
    int count = 0;
    for (Object obj : list1) {
        if (list2.contains(obj)) {
            count++;
        }
    }
    return count;
}

看到上面的代码,可能有的同学会发出疑问:看起来上面的例子使用原生态类型List也行啊,无限制通配符与原生态类型的区别作用在哪呢?这个问号真正起到作用了吗?
答案是:依然是前面提到的,原生态类型是不安全的,在上面的方法中如果你使用了原生态类型List,你在方法中将可以把任意元素添加到list1或list2中,必然会导致其他使用此集合的地方产生异常。
无限制通配符的问号虽然没有限制元素的类型,但是它起到了泛型的标识作用,编译器会提示明显的错误,阻止你将任何元素(除了null)放入list1、list2中去,因为在无限制通配符的情况下,编译器根本无法猜测你会得到哪种类型的对象。

 

4、有限制的通配符类型

想象这样一个场景,不想重复搬运代码的你写了一个方法去操作一个定义了泛型参数类型的 List<E> 集合,这个方法造福了群众,你感到无比开心。

随着项目的迭代,某一天这个参数类型衍生了很多子类型,为此你不得不又编写了一大堆相同的方法,只不过将泛型参数类型改成了子类型,

即使你知道这些方法操作的都是基于这个父类或接口提供的公共参数或方法,为此你感到十分厌倦烦恼。

那么这个时候,有限制的通配符类型就派上用场了。

例:一个水果类 Fruit ,只有两个属性名称-name,价格-price,后来又有了它的子类:Apple和Banana, 你需要一个方法遍历出集合中最低价格的水果

public class Fruit {
    private String name;
    private Double price;
}

public class Apple extends Fruit {

    public Apple(String name, Double price) {
        super(name, price);
    }
}

public class Banana extends Fruit {

    public Banana(String name, Double price) {
        super(name, price);
    }
}

public static void main(String[] args) {
    List<Apple> appleList = new ArrayList<>();
    appleList.add(new Apple("红富士苹果", 1.1));
    appleList.add(new Apple("乔纳金苹果", 2.2));

    List<Banana> bananaList = new ArrayList<>();
    bananaList.add(new Banana("北蕉", 1.3));
    bananaList.add(new Banana("仙人蕉", 2.4));

    System.out.println(findMinPrice(appleList).getName());
    System.out.println(findMinPrice(bananaList).getName());
}

/**
 * 查询最小价格的水果
 * 注:示例代码
 *
 * @param fruitList
 * @return 返回最小价格的水果, 集合为空则返回null
 * @author Turbolisten
 */
public static Fruit findMinPrice(List<? extends Fruit> fruitList) {
    if (null == fruitList || fruitList.size() == 0) {
        return null;
    }
    return fruitList.stream().min(Comparator.comparingDouble(Fruit::getPrice)).get();
}

List<? extends Fruit> 就是有限制通配符的一个使用示例,它约束了这个方法的参数只能是 Fruit 或它的子类型,但没有限制具体是哪种子类型。这样就给这个方法带来了很多的灵活,无论以后 Fruit 会衍生出多少子类,这个方法将始终正确的工作。

List<? extends Fruit>这样的形式也被成为:上界通配符(泛型协变),约束了参数类型只能是基类 Fruit 或其子孙类。


与之对应的还有一种形式:List<? super Apple> 称为:下界通配符(泛型逆变)约束了参数类型只能是子类 Apple 或其父类及祖类。

 

5、泛型方法

正如类可以从泛型中受益一般,方法也一样。静态工具方法尤其适合于泛型化,例如java.util类库中的Collections类,其中绝大部分都是泛型方法。
如 sort:

public static <T> void sort(List<T> list, Comparator<? super T> c) {
    list.sort(c);
}

方法中声明了一个类型参数T,表示参数list集合、比较器Comparator,以及返回值,三个都是相同的参数类型。
声明的一个或多个(逗号分隔)类型参数放在方法的修饰符与返回值之间。

我们可以自己编写一个简单的泛型方法:如copy对象属性

/**
 * 复制对象
 *
 * @param source 源 要复制的对象
 * @param target 目标 复制到此对象
 * @return 返回类型的实例,如果source或class空则返回null
 */
public static <T> T copy(T source, Class<T> target) {
    if (source == null || target == null) {
        return null;
    }
    T newInstance = target.newInstance();
    BeanUtils.copyProperties(source, newInstance);
    return newInstance;
}

该方法限制了两个参数以及返回值都为相同的类型参数。

 

6、泛型类型参数的命名

按照约定,通常由单个字母组成,以便可以在使用普通类或接口名称时能够容易地区分。

一般情况下常用的五种类型命名:
T 表示任意的类型,
E 表示集合的元素类型,
K 和 V 表示映射的键和值类型,
X 表示异常,
R 通常表示函数的返回类型

当然这些都不是绝对的,你就算用U、S、B来命名类型参数也没有问题,只是通常情况下采用大家约定的规则,便于大家理解,提高代码可读性。

 

7、优先使用泛型

一般来,将集合声明参数化,以及使用JDK所提供的泛型方法,这些都不太困难。但编写自己的泛型类会稍微困难一点,但是值得我们花些时间学习一下的。

 

例如:我们日常的后端API编写中,通常需要给前端返回一个统一的包装对象,每次请求中返回的包装对象中数据都有所不同。
在不使用泛型的情况下,你也许会这么写:

@Data
public class ResponseDTO {

    private Integer code;

    private String msg;

    private Object data;

    public static void main(String[] args) {
        ResponseDTO responseDTO = new ResponseDTO();
        responseDTO.setCode(1);
        responseDTO.setMsg("success");
        responseDTO.setData(new UserEntity());
        UserEntity userEntity = (UserEntity) responseDTO.getData();

        ResponseDTO responseDTO2 = new ResponseDTO();
        responseDTO2.setCode(1);
        responseDTO2.setMsg("success");
        responseDTO2.setData(new EmployeeEntity());
        EmployeeEntity employeeEntity = (EmployeeEntity) responseDTO2.getData();
    }
}

因为Object是所有类的父类,所以这样的设计也行得通。
但正如前面所言,这样的设计最大的弊端就是类型转换的不安全性,假如同为后端的小伙伴们需要调用你的方法,使用data时,要么你们已经约定好了类类型,要么自己去找setData的代码,看看到底set了啥玩意。


这种情况下的代码,很容易带来类型转换的异常,所以将这个类使用泛型简单改造一下,就很容易避免了以上情况的发生。

@Data
public class ResponseDTO<T> {

    private Integer code;

    private String msg;

    private T data;

    public static void main(String[] args) {
        ResponseDTO1<UserEntity> responseDTO = new ResponseDTO1();
        responseDTO.setCode(1);
        responseDTO.setMsg("success");
        responseDTO.setData(new UserEntity());
        UserEntity userEntity = responseDTO.getData();

        ResponseDTO1<EmployeeEntity> responseDTO2 = new ResponseDTO1();
        responseDTO2.setCode(1);
        responseDTO2.setMsg("success");
        responseDTO2.setData(new EmployeeEntity());
        EmployeeEntity employeeEntity =  responseDTO2.getData();
    }
}

因为携带了泛型参数类型信息,编译器能够清楚的知道参数类型,避免了强制转换的不安全性,无须担心会出现头疼的ClassCastException。

总而言之,Java5泛型的出现,带来的优势特性,提高了代码的健壮性、可读性,我们有必要去认真学习一下泛型的基础。受限于篇幅,本次只讲了泛型的一些基础概念以及日常使用姿势,泛型还有其他更深入的概念,如:泛型擦除、递归类型限制等,就留给各位1024的水军们自己搜索研究,悄悄的提高一下。

尾巴

 

本文参照及引用了《Effective Java》这本书中关于泛型的介绍,只是碍于原书中的描述和示例,着实有些晦涩难懂。我在这里尽量用简洁、易懂的文字和示例,希望能给大家带来些许帮助。

最后,这是我在1024公众号的第一篇文章,时间仓促,内心忐忑,里面如有错误、不严谨的地方,还请大家不吝赐教。如果大家有其他想看的内容,多多留言,我们可能下期见~

 

 

如果您对我们的文章有兴趣,欢迎关注我们的微信公众号,上面有最新最全的文章。

联系我们

1024创新实验室

1024Lab官方微信号(加我拉你入群!):

1024创新实验室 公众号

公众号二维码-12com.jpg

捐赠
开源不易,感谢捐赠
赞赏码.jpg



这篇关于Java基础拾遗 ● 泛型篇的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!


扫一扫关注最新编程教程