但行好事  莫问前程

泛型系列(三):类型通配符

当使用一个泛型时(包括声明变量和创建对象两种情况),都应该为这个泛型类传入一个类型实参。如果没有传入类型实际参数,编译器会提出泛型警告。假设现在需要定义一个方法,该方法里面有一个集合形参,集合形参的元素类型是不确定的,那么应该如何定义呢?考虑下面的代码:

public void test(List c)
    {
        for(int i=0;i<c.size();i++)
        {
            System.out.println(c.get(i));
        }
    }

上面的代码当然没有问题,这是一段最普通的遍历List集合的代码。问题是上面程序中List是一个有泛型声明的接口,此处使用List接口时没有传入实际类型参数,这将引起泛型警告。为此,考虑为List接口传入实际的类型参数,因为List集合里面的元素是不确定的,将上面的代码改为如下形式:

public void test(List<Object> c)
    {
        for(int i=0;i<c.size();i++)
        {
            System.out.println(c.get(i));
        }
    }

表面看来,上面方法声明没有问题,这个方法声明的确没有任何问题。问题是调用该方法传入的实际参数值时可能不是我们所期望的,例如,下面代码试图调用该方法。

//创建一个List<String>对象
List<String> strList = new ArrayList<String>();
//将strList作为参数来调用签名的test方法
test(strList);  //①

编译上面的程序,将在处发生如下编译错误:

无法将Test中的test(java.util.List<java.lang.Object>)应用于(java.util.List<java.lang.String>)

上面程序出现了编译错误,这说明List<String>对象不能被当成List<Object>对象使用,也就是说,List<String>类并不是List<Object>类的子类。

注意:
如果FooBar类一个子类型(子类型或者子接口),而G是具有泛型声明的类或接口,G<Foo>并不是G<Bar>的子类型!这一点非常值得注意,因为它与我们的习惯看法不一样。

与数组进行对比,先看下数组是如何进行工作的。在数组中,程序可以直接把一个Integer[]数组赋值给一个Number[]变量。如果试图把一个Double对象保存到该Number[]数组中,编译可以通过,但在运行时抛出ArrayStoreException异常。例如下面的程序:

public class ArrayErr
{
    public static void main(String[] args) 
    {
        // 定义一个Integer数组
        Integer[] ia = new Integer[5];
        // 可以把一个Integer[]数组赋给Number[]变量
        Number[] na = ia;
        // 下面代码编译正常,但运行时会引发ArrayStoreException异常
        // 因为0.5并不是Integer
        na[0] = 0.5;   //①

    }
}

上面的程序会在处引发ArrayStoreException运行时异常,这就是一种潜在的风险。

java的早期设计中,允许Integer[]数组赋值给Number[]变量存在缺陷,因此java在泛型设计时进行了改进,它不再允许把List<Integer>对象赋值给List<Number>变量。例如,下面的代码将导致编译错误(程序使用上面的代码)。

List<Integer> iList = new ArrayList<>();
  // 下面代码导致编译错误
List<Number> nList = iList;

Java泛型的设计原则是,只要代码在编译时没有出现警告,就不会遇到运行时ClassCastException异常。

注意:
数组和泛型有所不同,假设FooBar的一个子类型(子类或者子接口),那么Foo[]依然是Bar[]的字类型;但是G<Foo>不是G<Bar>的子类型。

1.1 使用类型通配符

为了表示各种泛型List的父类,我们需要使用泛型通配符,类型通配符是以一个问号(?),将一个问号作为类型实参传给List集合,写作:List<?>(意思是未知类型元素的List)。这个问号(?)被称为通配符,它的元素类型可以匹配任何类型。可以将上面方法改写成如下形式:

public void test(List<?> c)
    {
        for(int i=0;i<c.size();i++)
        {
            System.out.println(c.get(i));
        }
    }

现在使用任何类型的List来调用它,程序依然可以访问集合c中的元素,其类型是Object,这永远是安全的,因为不管List的真实类型是什么,它包含的都是Object

注意:
上面程序中使用的List<?>,其实这种写法可以适应于任何支持泛型声明的接口和类,比如写成Set<?>,Collection<?>,Map<?,?>等。

但这种带通配符的List仅表示它是各种泛型的父类,并不能把元素加入到其中,例如,如下代码将会引起编译错误。

List<?> c = new ArrayList<String>();
//下面程序引起编译错误
c.add(new Object());

因为我们不知道上面程序中c集合里元素的类型,所以不能向其中添加对象。根据前面的List<E>接口定义的代码可以发现:add方法有类型参数E作为集合的元素类型,所以传给add的参数必须是E类的对象或者子类的对象。但因为在该例中不知道E是什么类型,所以程序无法将任何对象放入该集合。唯一例外的是null,它是所有引用类型的实例。

另一方面,程序可以调用get()方法来返回List<?>集合指定索引处的元素,其返回值是一个未知类型,但可以肯定的是,它总是一个Object。因此,把get()的返回值赋值给一个Object的类型的变量,或者放在任何希望是Object类型的地方都可以。

1.2 设定类型通配符的上限

当直接使用List<?>这种形式时,即表明这个List集合可以是任何泛型的父类。但是还有一种特殊的情形,我们不想使这个List<?>是任何泛型的父类,只能表示它是某一类泛型的父类。下面考虑一个简单的绘图程序,先定义三个形状类:
Shape.java:

//定义一个抽象类Shape
public abstract class Shape
{ 
    public abstract void draw(Canvas c); 
}

Circle.java:

// 定义Shape的子类Circle
public class Circle extends Shape
{
    // 实现画图方法,以打印字符串来模拟画图方法实现
    public void draw(Canvas c)
    {
        System.out.println("在画布" + c +"上画一个圆");
    }
}

Rectangle.java:

// 定义Shape的子类Rectangle
public class Rectangle extends Shape 
{
    // 实现画图方法,以打印字符串来模拟画图方法实现
    public void draw(Canvas c) 
    { 
        System.out.println("把一个矩形画在画布" + c + "上");
    } 
}

上面定义了三个形状类,其中Shape是一个抽象父类,该抽象父类有两个子类:CircleRectangle,接下来定义一个Canvas类,该画布类可以画数量不等的形状(Shape子类的对象),我们该如何定义着个Canvas类呢?考虑如下的Canvas实现类。


public class Canvas
{
    // 同时在画布上绘制多个形状
    public void drawAll(List<Shape> shapes)
    {
        for (Shape s : shapes)
        {
            s.draw(this);
        }
    }
}

注意上面的drawAll()方法的形参类型是Lis<Shape>,List<Circle>并不是List<Shape>的子类型,因此,下面代码将引起编译错误。


List<Circle> circleList = new ArrayList<>();
Canvas c = new Canvas();
//不能把List<Circle>当成List<Shape>使用,所以下面代码引起编译错误
c.drawAll(circleList );

关键在于List<Circle>并不是List<Shape>的子类型,所以不能把List<Circle>对象当成List<Shape>使用。为了表示List<Circle>的父类,可以考虑使用List<?>,把Canvas改为如下形式(程序代码同上):

public class Canvas
{
    // 同时在画布上绘制多个形状
    public void drawAll(List<?> shapes)
    {
        for (Object obj : shapes)
        {
            Shape s = (Shape)obj;
            s.draw(this);
        }
    }
}

上面程序使用了通配符来表示所有的类型。上面的drawAll()方法可以接受List<Circle>对象作为参数,问题是上面的方法实现体显得极臃肿而繁琐;使用了泛型还需要进行强制类型转换。

实际上,我们需要一种泛型表示方法,它可以表示所有Shape泛型List的父类。为了满足这种需求,java泛型提供了被限制的泛型通配符。被限制的泛型通配符表示如下:

//它表示所有Shape泛型List的父类
List<? extends Shape>

有了这种被限制的泛型通配符,我们就可以把上面的Canvas程序改为如下形式(程序代码同上):

public class Canvas
{
    // 同时在画布上绘制多个形状,使用被限制的泛型通配符
    public void drawAll(List<? extends Shape> shapes)
    {
        for (Shape s : shapes)
        {
            s.draw(this);
        }
    }
}

Canvas改为如上形式,就可以把List<Circle>对象当成List<? extends Shape>使用。即List<? extends Shape>可以表示List<Circle>,List<Rectangle>的父类,只要List尖括号里的类型是Shape的子类型即可。

List<? extends Shape>是受限制通配符的例子,此处的问号(?)代表一个未知的类型,就像前面看到的通配符一样。但是此处的这个未知类型一定是Shape的子类型(也可以是Shape本身),因此我们把Shape称为这个通配符的上限(upper bound)。
因为我们不知道这个受限制的通配符的具体类型,所以不能把Shape对象或其子类的对象加入这个泛型集合中。例如,下面代码就是错误的。

public void addRectangle(List<? extends Shape> shapes){
    //下面代码引发编译错误
    shapes.add(0, new Rectangle());
}

与使用普通通配符相似的是,shapes.add()的第二个参数类型是? extends Shape,它表示Shape未知的子类,我们无法准确知道这个类型是什么,所以无法将任何对象添加到这种集合中。

1.3 设定通配符的上限

Java泛型不仅允许在使用通配符形参时设定上限,而且可以在定义类型形参时设定上限,用于表示传给该类型形参的实际类型要么是该上限类型,要么是该上限类型的子类。下面演示这种用法:

public class Apple<T extends Number>
{
    T col;
    public static void main(String[] args)
    {
        Apple<Integer> ai = new Apple<>();
        Apple<Double> ad = new Apple<>();
        // 下面代码将引起编译异常,下面代码试图把String类型传给T形参
        // 但String不是Number的子类型,所以引发编译错误
        Apple<String> as = new Apple<>();       //①
    }
}

上面程序定义了一个Apple泛型类,该Apple类的类型形参的上限是Number类,这表明使用Apple类时为T形参传入的实际类型参数只能是Number或者Number的子类。上面程序在处将引起编译错误:类型形参T的上限是Number类型,而此处传入的实际类型是String类型,既不是Number类型,也不是Number类型的子类,所以将会导致编译错误。在一种更加极端的情况下,程序需要为类型形参设定多个上限(至多有一个父类上限,可以有多个接口上限),表明该类型形参必须是其父类的子类(是父类本身也行),并且实现多个上限接口,如下代码所示:

//表明T类型必须是Number类或其子类,并必须实现java.io.Serialiable接口
public class Apple<T extends Number & java.io.Serialiable>
{
 ...
}

与类同时继承父类,实现接口类似的是,为类型形参指定多个上限时,所有的接口上限必须位于类上限之后。也就是说,如果需要为类型形参指定类上限,类上限必须位于第一位。

附:源代码示例

github地址:点击查看
码云地址:点击查看

打赏
欢迎关注人生设计师的微信公众账号
公众号ID:longjiazuoA

未经允许不得转载:人生设计师 » 泛型系列(三):类型通配符

分享到:更多 ()

人生设计师-接受不同的声音

联系我关于我