Java编程的逻辑 (35) - 泛型 (上) - 基本概念和原理

之前章节中我们多次提到过泛型这个概念,从本节开始,我们就来详细讨论Java中的泛型,虽然泛型的基本思维和概念是比较简单的,但它有一些非常令人费解的语法、细节、以及局限性,内容比较多。

所以我们分为三节,逐步来讨论,本节我们主要来介绍泛型的基本概念和原理,下节我们重点讨论令人费解的通配符,最后一节,我们讨论一些细节和泛型的局限性。

后续章节我们会介绍各种容器类,容器类可以说是日常程序开发中天天用到的,没有容器类,难以想象能开发什么真正有用的程序。而容器类是基于泛型的,不理解泛型,我们就难以深刻理解容器类。那,泛型到底是什么呢?

什么是泛型?

之前我们一直强调数据类型的概念,Java有8种基本类型,可以定义类,类相当于自定义数据类型,类之间还可以有组合和继承。不过,在第19节,我们介绍了接口,其中提到,其实,很多时候,我们关心的不是类型,而是能力,针对接口和能力编程,不仅可以复用代码,还可以降低耦合,提高灵活性。

泛型将接口的概念进一步延伸,"泛型"字面意思就是广泛的类型,类、接口和方法代码可以应用于非常广泛的类型,代码与它们能够操作的数据类型不再绑定在一起,同一套代码,可以用于多种数据类型,这样,不仅可以复用代码,降低耦合,同时,还可以提高代码的可读性和安全性。

这么说可能比较抽象,接下来,我们通过一些例子逐步来说明。在Java中,类、接口、方法都可以是泛型的,我们先来看泛型类。

一个简单泛型类

我们通过一个简单的例子来说明泛型类的基本概念、实现原理和好处。

基本概念

我们直接来看代码:

public class Pair<T> {

    T first;
    T second;

    public Pair(T first, T second){
        this.first = first;
        this.second = second;
    }

    public T getFirst() {
        return first;
    }

    public T getSecond() {
        return second;
    }
}

Pair就是一个泛型类,与普通类的区别,体现在:

  1. 类名后面多了一个<T>
  2. first和second的类型都是T

T是什么呢?T表示类型参数,泛型就是类型参数化,处理的数据类型不是固定的,而是可以作为参数传入。

怎么用这个泛型类,并传递类型参数呢?看代码:

Pair<Integer> minmax = new Pair<Integer>(1,100);
Integer min = minmax.getFirst();
Integer max = minmax.getSecond();

Pair<Integer>,这里Integer就是传递的实际类型参数。

Pair类的代码和它处理的数据类型不是绑定的,具体类型可以变化。上面是Integer,也可以是String,比如:

Pair<String> kv = new Pair<String>("name","老马");

类型参数可以有多个,Pair类中的first和second可以是不同的类型,多个类型之间以逗号分隔,来看改进后的Pair类定义:

public class Pair<U, V> {

    U first;
    V second;

    public Pair(U first, V second){
        this.first = first;
        this.second = second;
    }

    public U getFirst() {
        return first;
    }

    public V getSecond() {
        return second;
    }
}

可以这样使用:

Pair<String,Integer> pair = new Pair<String,Integer>("老马",100);

<String,Integer>既出现在了声明变量时,也出现在了new后面,比较啰嗦,Java支持省略后面的类型参数,可以这样:

Pair<String,Integer> pair = new Pair<>("老马",100);

基本原理

泛型类型参数到底是什么呢?为什么一定要定义类型参数呢?定义普通类,直接使用Object不就行了吗?比如,Pair类可以写为:

public class Pair {

    Object first;
    Object second;

    public Pair(Object first, Object second){
        this.first = first;
        this.second = second;
    }

    public Object getFirst() {
        return first;
    }

    public Object getSecond() {
        return second;
    }
}    

使用Pair的代码可以为:

Pair minmax = new Pair(1,100);
Integer min = (Integer)minmax.getFirst();
Integer max = (Integer)minmax.getSecond();

Pair kv = new Pair("name","老马");
String key = (String)kv.getFirst();
String value = (String)kv.getSecond();

这样是可以的。实际上,Java泛型的内部原理就是这样的。

我们知道,Java有Java编译器和Java虚拟机,编译器将Java源代码转换为.class文件,虚拟机加载并运行.class文件。对于泛型类,Java编译器会将泛型代码转换为普通的非泛型代码,就像上面的普通Pair类代码及其使用代码一样,将类型参数T擦除,替换为Object,插入必要的强制类型转换。Java虚拟机实际执行的时候,它是不知道泛型这回事的,它只知道普通的类及代码。

再强调一下,Java泛型是通过擦除实现的,类定义中的类型参数如T会被替换为Object,在程序运行过程中,不知道泛型的实际类型参数,比如Pair<Integer>,运行中只知道Pair,而不知道Integer,认识到这一点是非常重要的,它有助于我们理解Java泛型的很多限制。

Java为什么要这么设计呢?泛型是Java 1.5以后才支持的,这么设计是为了兼容性而不得已的一个选择。

泛型的好处

既然只使用普通类和Object就是可以的,而且泛型最后也转换为了普通类,那为什么还要用泛型呢?或者说,泛型到底有什么好处呢?

主要有两个好处:

  • 更好的安全性
  • 更好的可读性

语言和程序设计的一个重要目标是将bug尽量消灭在摇篮里,能消灭在写代码的时候,就不要等到代码写完,程序运行的时候。

只使用Object,代码写错的时候,开发环境和编译器不能帮我们发现问题,看代码:

Pair pair = new Pair("老马",1);
Integer id = (Integer)pair.getFirst();
String name = (String)pair.getSecond();

看出问题了吗?写代码时,不小心,类型弄错了,不过,代码编译时是没有任何问题的,但,运行时,程序抛出了类型转换异常ClassCastException。

如果使用泛型,则不可能犯这个错误,如果这么写代码:

Pair<String,Integer> pair = new Pair<>("老马",1);
Integer id = pair.getFirst();
String name = pair.getSecond();

开发环境如Eclipse会提示你类型错误,即使没有好的开发环境,编译时,Java编译器也会提示你。这称之为类型安全,也就是说,通过使用泛型,开发环境和编译器能确保你不会用错类型,为你的程序多设置一道安全防护网。

使用泛型,还可以省去繁琐的强制类型转换,再加上明确的类型信息,代码可读性也会更好。

容器类

泛型类最常见的用途是作为容器类,所谓容器类,简单的说,就是容纳并管理多项数据的类。数组就是用来管理多项数据的,但数组有很多限制,比如说,长度固定,插入、删除操作效率比较低。计算机技术有一门课程叫数据结构,专门讨论管理数据的各种方式。

这些数据结构在Java中的实现主要就是Java中的各种容器类,甚至,Java泛型的引入主要也是为了更好的支持Java容器。后续章节我们会详细讨论主要的Java容器,本节我们先自己实现一个非常简单的Java容器,来解释泛型的一些概念。

我们来实现一个简单的动态数组容器,所谓动态数组,就是长度可变的数组,底层数组的长度当然是不可变的,但我们提供一个类,对这个类的使用者而言,好像就是一个长度可变的数组,Java容器中有一个对应的类ArrayList,本节我们来实现一个简化版。

来看代码:

public class DynamicArray<E> {
    private static final int DEFAULT_CAPACITY = 10;

    private int size;
    private Object[] elementData;

    public DynamicArray() {
        this.elementData = new Object[DEFAULT_CAPACITY];
    }

    private void ensureCapacity(int minCapacity) {
        int oldCapacity = elementData.length;
        if(oldCapacity>=minCapacity){
            return;
        }
        int newCapacity = oldCapacity * 2;
        if (newCapacity < minCapacity)
            newCapacity = minCapacity;
        elementData = Arrays.copyOf(elementData, newCapacity);
    }

    public void add(E e) {
        ensureCapacity(size + 1);
        elementData[size++] = e;
    }

    public E get(int index) {
        return (E)elementData[index];
    }

    public int size() {
        return size;
    }

    public E set(int index, E element) {
        E oldValue = get(index);
        elementData[index] = element;
        return oldValue;
    }

}    

DynamicArray就是一个动态数组,内部代码与我们之前分析过的StringBuilder类似,通过ensureCapacity方法来根据需要扩展数组。作为一个容器类,它容纳的数据类型是作为参数传递过来的,比如说,存放Double类型:

DynamicArray<Double> arr = new DynamicArray<Double>();
Random rnd = new Random();
int size = 1+rnd.nextInt(100);
for(int i=0; i<size; i++){
    arr.add(Math.random());
}

Double d = arr.get(rnd.nextInt(size));

这就是一个简单的容器类,适用于各种数据类型,且类型安全。本节后面和后面两节还会以DynamicArray为例进行扩展,以解释泛型概念。

具体的类型还可以是一个泛型类,比如,可以这样写:

DynamicArray<Pair<Integer,String>> arr = new DynamicArray<>()

arr表示一个动态数组,每个元素是Pair<Integer,String>类型。

泛型方法

除了泛型类,方法也可以是泛型的,而且,一个方法是不是泛型的,与它所在的类是不是泛型没有什么关系。

我们看个例子:

public static <T> int indexOf(T[] arr, T elm){
    for(int i=0; i<arr.length; i++){
        if(arr[i].equals(elm)){
            return i;
        }
    }
    return -1;
}

这个方法就是一个泛型方法,类型参数为T,放在返回值前面,它可以这么调用:

indexOf(new Integer[]{1,3,5}, 10)

也可以这么调用:

indexOf(new String[]{"hello","老马","编程"}, "老马")

indexOf表示一个算法,在给定数组中寻找某一个元素,这个算法的基本过程与具体数据类型没有什么关系,通过泛型,它就可以方便的应用于各种数据类型,且编译器保证类型安全。

与泛型类一样,类型参数可以有多个,多个以逗号分隔,比如:

public static <U,V> Pair<U,V> makePair(U first, V second){
    Pair<U,V> pair = new Pair<>(first, second);
    return pair;
}

与泛型类不同,调用方法时一般并不需要特意指定类型参数的实际类型是什么,比如调用makePair:

makePair(1,"老马");

并不需要告诉编译器U的类型是Integer,V的类型是String,Java编译器可以自动推断出来。

泛型接口

接口也可以是泛型的,我们之前介绍过的Comparable和Comparator接口都是泛型的,它们的代码如下:

public interface Comparable<T> {
    public int compareTo(T o);
}
public interface Comparator<T> {
    int compare(T o1, T o2);
    boolean equals(Object obj);
}

与前面一样,T是类型参数。实现接口时,应该指定具体的类型,比如,对Integer类,实现代码是:

public final class Integer extends Number implements Comparable<Integer>{
    public int compareTo(Integer anotherInteger) {
        return compare(this.value, anotherInteger.value);
    }
    //...
}

通过implements Comparable<Integer>,Integer实现了Comparable接口,指定了实际类型参数为Integer,表示Integer只能与Integer对象进行比较。

再看Comparator的一个例子,String类内部一个Comparator的接口实现为:

private static class CaseInsensitiveComparator
        implements Comparator<String> {
    public int compare(String s1, String s2) {
        //....
    }
}

这里,指定了实际类型参数为String。

类型参数的限定

在之前的介绍中,无论是泛型类、泛型方法还是泛型接口,关于类型参数,我们都知之甚少,只能把它当做Object,但Java支持限定这个参数的一个上界,也就是说,参数必须为给定的上界类型或其子类型,这个限定是通过extends这个关键字来表示的。

这个上界可以是某个具体的类,或者某个具体的接口,也可以是其他的类型参数,我们逐个来看下其应用。

上界为某个具体类

比如说,上面的Pair类,可以定义一个子类NumberPair,限定两个类型参数必须为Number,代码如下:

public class NumberPair<U extends Number, V extends Number> extends Pair<U, V> {

    public NumberPair(U first, V second) {
        super(first, second);
    }
}    

限定类型后,就可以使用该类型的方法了,比如说,对于NumberPair类,first和second变量就可以当做Number进行处理了,比如可以定义一个求和方法,如下所示:

public double sum(){
    return getFirst().doubleValue()
            +getSecond().doubleValue();
}

可以这么用:

NumberPair<Integer, Double> pair = new NumberPair<>(10, 12.34);
double sum = pair.sum();

限定类型后,如果类型使用错误,编译器会提示。

指定边界后,类型擦除时就不会转换为Object了,而是会转换为它的边界类型,这也是容易理解的。

上界为某个接口

在泛型方法中,一种常见的场景是限定类型必须实现Comparable接口,我们来看代码:

public static <T extends Comparable> T max(T[] arr){
    T max = arr[0];
    for(int i=1; i<arr.length; i++){
        if(arr[i].compareTo(max)>0){
            max = arr[i];
        }
    }
    return max;
}

max方法计算一个泛型数组中的最大值,计算最大值需要进行元素之间的比较,要求元素实现Comparable接口,所以给类型参数设置了一个上边界Comparable,T必须实现Comparable接口。

不过,直接这么写代码,Java中会给一个警告信息,因为Comparable是一个泛型接口,它也需要一个类型参数,所以完整的方法声明应该是:

public static <T extends Comparable<T>> T max(T[] arr){

//...

}

<T extends Comparable<T>>是一种令人费解的语法形式,这种形式称之为递归类型限制,可以这么解读,T表示一种数据类型,必须实现Comparable接口,且必须可以与相同类型的元素进行比较。

上界为其他类型参数

上面的限定都是指定了一个明确的类或接口,Java支持一个类型参数以另一个类型参数作为上界。为什么需要这个呢?

我们看个例子,给上面的DynamicArray类增加一个实例方法addAll,这个方法将参数容器中的所有元素都添加到当前容器里来,直觉上,代码可以这么写:

public void addAll(DynamicArray<E> c) {
    for(int i=0; i<c.size; i++){
        add(c.get(i));
    }
}

但这么写有一些局限性,我们看使用它的代码:

DynamicArray<Number> numbers = new DynamicArray<>();
DynamicArray<Integer> ints = new DynamicArray<>();
ints.add(100);
ints.add(34);
numbers.addAll(ints);

numbers是一个Number类型的容器,ints是一个Integer类型的容器,我们希望将ints添加到numbers中,因为Integer是Number的子类,应该说,这是一个合理的需求和操作。

但,Java会在number.addAll(ints)这行代码上提示编译错误,提示,addAll需要的参数类型为DynamicArray<Number>,而传递过来的参数类型为DynamicArray<Integer>,不适用,Integer是Number的子类,怎么会不适用呢?

事实就是这样,确实不适用,而且是很有道理的,假设适用,我们看下会发生什么。

DynamicArray<Integer> ints = new DynamicArray<>();
//假设下面这行是合法的
DynamicArray<Number> numbers = ints;

numbers.add(new Double(12.34));

那最后一行就是合法的,这时,DynamicArray<Integer>中就会出现Double类型的值,而这,显然就破坏了Java泛型关于类型安全的保证。

我们强调一下,虽然Integer是Number的子类,但DynamicArray<Integer>并不是DynamicArray<Number>的子类,DynamicArray<Integer>的对象也不能赋值给DynamicArray<Number>的变量,这一点初看上去是违反直觉的,但这是事实,必须要理解这一点。

不过,我们的需求是合理的啊,将Integer添加到Number容器中,这没有问题啊。这个问题,可以通过类型限定,这样来解决:

public <T extends E> void addAll(DynamicArray<T> c) {
    for(int i=0; i<c.size; i++){
        add(c.get(i));
    }
}

E是DynamicArray的类型参数,T是addAll的类型参数,T的上界限定为E,这样,下面的代码就没有问题了:

DynamicArray<Number> numbers = new DynamicArray<>();
DynamicArray<Integer> ints = new DynamicArray<>();
ints.add(100);
ints.add(34);
numbers.addAll(ints);

对于这个例子,这个写法有点啰嗦,下节我们会看到一种简化的方式。

小结

泛型是计算机程序中一种重要的思维方式,它将数据结构和算法与数据类型相分离,使得同一套数据结构和算法,能够应用于各种数据类型,而且还可以保证类型安全,提高可读性。在Java中,泛型广泛应用于各种容器类中,理解泛型是深刻理解容器的基础。

本节介绍了泛型的基本概念,包括泛型类、泛型方法和泛型接口,关于类型参数,我们介绍了多种上界限定,限定为某具体类、某具体接口、或其他类型参数。泛型类最常见的用途是容器类,我们实现了一个简单的容器类DynamicArray,以解释泛型概念。

在Java中,泛型是通过类型擦除来实现的,它是Java编译器的概念,Java虚拟机运行时对泛型基本一无所知,理解这一点是很重要的,它有助于我们理解Java泛型的很多局限性。

关于泛型,Java中有一个通配符的概念,语法非常令人费解,而且容易混淆,下一节,我们力图对它进行清晰的剖析。

原文地址:https://www.cnblogs.com/ivy-xu/p/12388945.html

时间: 2024-08-02 06:12:14

Java编程的逻辑 (35) - 泛型 (上) - 基本概念和原理的相关文章

Java中的泛型 (上) - 基本概念和原理

本节我们主要来介绍泛型的基本概念和原理 后续章节我们会介绍各种容器类,容器类可以说是日常程序开发中天天用到的,没有容器类,难以想象能开发什么真正有用的程序.而容器类是基于泛型的,不理解泛型,我们就难以深刻理解容器类.那,泛型到底是什么呢? 什么是泛型? 一个简单泛型类 我们通过一个简单的例子来说明泛型类的基本概念.实现原理和好处. 基本概念 我们直接来看代码: public class Pair<T> { T first; T second; public Pair(T first, T se

Java编程的逻辑 (24) - 异常 (上)

之前我们介绍的基本类型.类.接口.枚举都是在表示和操作数据,操作的过程中可能有很多出错的情况,出错的原因可能是多方面的,有的是不可控的内部原因,比如内存不够了.磁盘满了,有的是不可控的外部原因,比如网络连接有问题,更多的可能是程序的编程错误,比如引用变量未初始化就直接调用实例方法. 这些非正常情况在Java中统一被认为是异常,Java使用异常机制来统一处理,由于内容较多,我们分为两节来介绍,本节介绍异常的初步概念,以及异常类本身,下节主要介绍异常的处理. 我们先来通过一些例子认识一下异常. 初始

Java编程的逻辑 (26) - 剖析包装类 (上)

包装类 Java有八种基本类型,每种基本类型都有一个对应的包装类. 包装类是什么呢?它是一个类,内部有一个实例变量,保存对应的基本类型的值,这个类一般还有一些静态方法.静态变量和实例方法,以方便对数据进行操作. Java中,基本类型和对应的包装类如下表所示: 基本类型 包装类 boolean Boolean byte Byte short Short int Integer long Long float Float double Double char Character 包装类也都很好记,除

Java编程的逻辑 (19) - 接口的本质

数据类型的局限 之前我们一直在说,程序主要就是数据以及对数据的操作,而为了方便操作数据,高级语言引入了数据类型的概念,Java定义了八种基本数据类型,而类相当于是自定义数据类型,通过类的组合和继承可以表示和操作各种事物或者说对象. 但,这种只是将对象看做属于某种数据类型,并按该类型进行操作,在一些情况下,并不能反映对象以及对对象操作的本质. 为什么这么说呢?很多时候,我们实际上关心的,并不是对象的类型,而是对象的能力,只要能提供这个能力,类型并不重要.我们来看一些生活中的例子. 要拍个照片,很多

Java编程的逻辑 (87) - 类加载机制

上节,我们探讨了动态代理,在前几节中,我们多次提到了类加载器ClassLoader,本节就来详细讨论Java中的类加载机制与ClassLoader. 类加载器ClassLoader就是加载其他类的类,它负责将字节码文件加载到内存,创建Class对象.与之前介绍的反射.注解.和动态代理一样,在大部分的应用编程中,我们不太需要自己实现ClassLoader. 不过,理解类加载的机制和过程,有助于我们更好的理解之前介绍的内容,更好的理解Java.在反射一节,我们介绍过Class的静态方法Class.f

Java编程的逻辑 (38) - 剖析ArrayList

从本节开始,我们探讨Java中的容器类,所谓容器,顾名思义就是容纳其他数据的,计算机课程中有一门课叫数据结构,可以粗略对应于Java中的容器类,我们不会介绍所有数据结构的内容,但会介绍Java中的主要实现,并分析其基本原理和主要实现代码. 前几节在介绍泛型的时候,我们自己实现了一个简单的动态数组容器类DynaArray,本节,我们介绍Java中真正的动态数组容器类ArrayList. 我们先来看它的基本用法. 基本用法 新建ArrayList ArrayList是一个泛型容器,新建ArrayLi

Java编程的逻辑 (91) - Lambda表达式

在之前的章节中,我们的讨论基本都是基于Java 7的,从本节开始,我们探讨Java 8的一些特性,主要内容包括: 传递行为代码 - Lambda表达式 函数式数据处理 - 流 组合式异步编程 - CompletableFuture 新的日期和时间API 本节,我们先讨论Lambda表达式,它是什么?有什么用呢? Lambda表达式是Java 8新引入的一种语法,是一种紧凑的传递代码的方式,它的名字来源于学术界的λ演算,具体我们就不探讨了. 理解Lambda表达式,我们先回顾一下接口.匿名内部类和

Java编程的逻辑 (29) - 剖析String

上节介绍了单个字符的封装类Character,本节介绍字符串类.字符串操作大概是计算机程序中最常见的操作了,Java中表示字符串的类是String,本节就来详细介绍String. 字符串的基本使用是比较简单直接的,我们来看下. 基本用法 可以通过常量定义String变量 String name = "老马说编程"; 也可以通过new创建String String name = new String("老马说编程"); String可以直接使用+和+=运算符,如: S

Java编程的逻辑 (79) - 方便的CompletionService

上节,我们提到,在异步任务程序中,一种常见的场景是,主线程提交多个异步任务,然后希望有任务完成就处理结果,并且按任务完成顺序逐个处理,对于这种场景,Java并发包提供了一个方便的方法,使用CompletionService,这是一个接口,它的实现类是ExecutorCompletionService,本节我们就来探讨它们. 基本用法 接口和类定义 与77节介绍的ExecutorService一样,CompletionService也可以提交异步任务,它的不同是,它可以按任务完成顺序获取结果,其具