• 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中有一个通配符的概念,语法非常令人费解,而且容易混淆,下一节,我们力图对它进行清晰的剖析。

  • 相关阅读:
    jsp转向
    什么是 XDoclet?
    tomcat中的几点配置说明
    mysql5问题
    POJ 3734 Blocks
    POJ 2409 Let it Bead
    HDU 1171 Big Event in HDU
    POJ 3046 Ant Counting
    HDU 2082 找单词
    POJ 1286 Necklace of Beads
  • 原文地址:https://www.cnblogs.com/ivy-xu/p/12388945.html
Copyright © 2020-2023  润新知