但行好事  莫问前程

泛型系列(四):泛型方法

在定义类,接口时可以使用类型形参,在该类的方法定义和Field定义,接口的方法定义中,这些类型形参可被当成普通类型使用。在另外一些情况下,我们定义类,接口时没有使用类型形参,但定义方法时想自己定义类型形参,这也是可以的,Java5提供了对泛型方法的支持。

1.1 定义泛型方法

假设需要实现这样一个方法,该方法负责将一个Object数组的所有元素添加到一个Collection集合中。考虑采用如下代码来实现该方法。

static void fromArrayToCollection(Object[] a,Collection<Object> c)
    {
        for(Object o:a){
            c.add(o);
        }
    }

上面定义的方法没有任何问题,关键在于方法中的c形参,它的数据类型是Collection<Object>,正如前面所介绍的,Collection<String>不是Collection<Object>的子类型,所以这个方法的功能非常有限,它只能将Object数组的元素复制到Object(Object的子类不行) Collection集合中,下面的代码将引起编译错误。

String[] strArr = { "a", "b" };
        List<String> strList=new ArrayList<String>();
        //Collection<String>对象不能当成Collection<Object>使用,下面的代码出现编译错误
        fromArrayToCollection(strArr, strList);

可见上面方法的参数类型不可以使用Collection<String>,那使用通配符Collection<?>是否可行呢?显然也不行,我们不能把对象放进一个未知类型的集合中。

为了解决这个问题,可以使用Java5提供的泛型方法(Generic Method)。所谓泛型方法,就是在声明方法时定义一个或多个类型形参。泛型方法格式如下:

修饰符 <T, S> 返回值类型  方法名(形参列表)
{
   //方法体...
}

把上面方法的格式和普通方法的格式进行对比,不难发现泛型方法的方法签名比普通方法的方法签名多了类型形参声明,类型形参声明以尖括号括起来,多个类型形参之间以逗号(,)隔开,所有的类型形参声明放在方法修饰符和方法返回值类型之间。
采用支持泛型的方法,就可以将上面的fromArrayToCollection方法改为如下形式:

static <T> void fromArrayToCollection(T[] a, Collection<T> c) {
        for (T o : a) {
            c.add(o);
        }
    }

下面程序示范了完整的用法:

public class GenericMethodTest
{
    // 声明一个泛型方法,该泛型方法中带一个T类型形参,
    static <T> void fromArrayToCollection(T[] a, Collection<T> c)
    {
        for (T o : a)
        {
            c.add(o);
        }
    }
    public static void main(String[] args) 
    {
        Object[] oa = new Object[100];
        Collection<Object> co = new ArrayList<>();
        // 下面代码中T代表Object类型
        <Object> fromArrayToCollection(oa, co);
        String[] sa = new String[100];
        Collection<String> cs = new ArrayList<>();
        // 下面代码中T代表String类型
        fromArrayToCollection(sa, cs);
        // 下面代码中T代表Object类型
        fromArrayToCollection(sa, co);
        Integer[] ia = new Integer[100];
        Float[] fa = new Float[100];
        Number[] na = new Number[100];
        Collection<Number> cn = new ArrayList<>(); 
        // 下面代码中T代表Number类型
        fromArrayToCollection(ia, cn);
        // 下面代码中T代表Number类型
        fromArrayToCollection(fa, cn); 
        // 下面代码中T代表Number类型
        fromArrayToCollection(na, cn);
        // 下面代码中T代表Object类型
        fromArrayToCollection(na, co);
        // 下面代码中T代表String类型,但na是一个Number数组,
        // 因为Number既不是String类型,
        // 也不是它的子类,所以出现编译错误
        //fromArrayToCollection(na, cs);
    }
}

上面程序定义了一个泛型方法,该泛型方法中定义了一个T类型形参,这个T类型形参就可以在该方法内当成普通类型使用。与接口,类声明中定义的类型形参不同的是,方法声明中定义的形参只能在该方法里使用,而接口,类声明中定义的类型形参则可以在整个接口,类中使用。

与类,接口中使用泛型参数不同的是,方法中的泛型参数无须显式传入实际类型参数,如上面程序所示,当程序调用fromArrayToCollection()方法时,无须在调用该方法前传入String,Object等类型,但系统依然可以知道类型形参的数据类型,因为编译器根据实参推断类型形参的值,它通常推断出最直接的类型参数。例如,下面调用代码:

fromArrayToCollection(sa,cs);

上面代码cs是一个Collection<String>类型,与方法定义时的fromArrayToCollection(T[] a,Collection c)进行比较,只比较泛型参数,不难发现该T类型形参代表的实际类型是String类型。
对于如下调用代码:

fromArrayToCollection(ia,cn);

上面的cnCollection<Number>类型,与此方法的方法签名进行比较,只比较泛型参数,不难发现该T类型形参代表了Number类型。
为了让编译器能准确的推断出泛型方法中类型形参的类型,不要制造迷惑,系统一旦迷惑了,就是你错了,看如下程序。

public class ErrorTest
{
    // 声明一个泛型方法,该泛型方法中带一个T类型形参
    static <T> void test(Collection<T> from, Collection<T> to)
    {
        for (T ele : from)
        {
            to.add(ele);
        }
    }
    public static void main(String[] args) 
    {
        List<Object> as = new ArrayList<>();
        List<String> ao = new ArrayList<>();
        // 下面代码将产生编译错误
        test(as , ao);
    }
}

上面程序中定义了test()方法,该方法用于将前一个集合里面的元素复制到下一个集合中,该方法中的两个形参from,to的类型都是Collection<T>,这要求调用该方法时的两个集合实参中的泛型类型相同,否则编译器无法准确的推断出泛型方法中类型形参的类型。上面程序中调用test方法传入了两个实际参数,其中as的数据类型是List<String>,而os的数据类型是List<Object>,与泛型方法签名进行对比:test(Collection<T> a,Collection<T> c),编译器无法准确识别T所代表的实际类型。为了避免这种错误,可以将该方法改为如下形式:

public class RightTest
{
    // 声明一个泛型方法,该泛型方法中带一个T形参
    static <T> void test(Collection<? extends T> from , Collection<T> to)
    {
        for (T ele : from)
        {
            to.add(ele);
        }
    }
    public static void main(String[] args) 
    {
        List<Object> ao = new ArrayList<>();
        List<String> as = new ArrayList<>();
        // 下面代码完全正常
        test(as , ao);
    }
}

上面的代码改变了test方法签名,将该方法的前一个形参类型改为Collection<? extends T>,这种采用类型通配符的表示方式,只有test方法的前一个Collection集合里的元素类型是后一个Collection集合里面元素类型的子类即可。
那么这么产生了一个问题:到底何时使用泛型方法?合适使用类型通配符呢?下面说说泛型方法和类型通配符的区别。

1.2 泛型方法和类型通配符的区别

大多数时候都可以使用泛型方法来代替类型通配符。例如,对于JavaCollection接口中的两个方法定义:

public interface Collection<E>{
     boolean containsAll(Collection<> c);
     boolean addAll(Collection<? extends E> c);
}

上面集合中两个方法的形参都采用了类型通配符的形式,也可以采用泛型方法的形式,如下所示:

public interface Collection<E>{
     boolean <T> containsAll(Collection<T> c);
     boolean <T extends E> addAll(Collection<T> c);
}

上面使用了<T extends E>泛型形式,这时定义类型形参时设定上限(其中ECollection接口里面定义的类型形参,在该接口里E可以当成普通类型使用)。

上面两个方法中类型形参T只使用一次,类型形参T产生的唯一效果是可以在不同的调用点传入不同的类型形参。对于这种情况,应该使用通配符:通配符就是被设计用来支持灵活的子类化的

泛型方法允许类型形参被用来表示方法的一个或者多个参数之间的类型依赖关系,或者方法返回值与参数之间的类型依赖关系。如果没有这样的依赖关系,就不应该使用泛型方法。

提示:
如果某个方法中一个形参(a)的类型或者返回值的类型依赖于另一个形参(b)的类型,则形参(b)的类型声明不应该使用通配符,因为形参(a)或返回值的类型依赖于该形参(b)的类型,如果形参(b)的类型无法确定,程序就无法定义形参(a)的类型,在这种情况下,只能考虑使用在方法签名中声明类型形参,也就是泛型方法。

如果有需要,我们可以同时使用泛型方法和通配符,如JavaCollections.copy()方法。

public class Collections
{
  public static <T> void copy(List<? super T> dest, List<? extends T> src) {...}
...
}

上面copy方法中的destsrc存在明显的依赖关系,从源List中复制出来的元素,必须可以保存到目标List中,所以源List集合元素的类型只能是目标集合元素的类型的子类型或者它本身。但是JDK定义src形参类型时使用的是类型通配符,而不是泛型方法。这是因为:该方法无须向src集合中添加元素,也无须修改src集合里的元素,所以可以使用类型通配符,无须使用泛型方法。
当然,也可以将上面的方法签名改为使用泛型方法,不使用类型通配符,如下所示:

class Collections
{
  public static <T,S extends T> void copy(List<T> dest, List<S> src) {...}
...
}

这个方法签名可以代替签名的方法签名。但是注意上面的类型形参S,它仅使用了一次,没有其他参数的类型,方法返回值的类型依赖于它,那类型形参S就没有存在的必要,即可以用通配符来代替S

使用通配符比使用泛型方法(在方法签名中显示声明类型形参)更加清晰和准确,因此Java设计该方法时采用了通配符,而不是泛型方法。

类型通配符与泛型方法(在方法签名中显示声明类型形参)还有一个显著的区别:类型通配符既可以在方法签名中定义形参的类型,也可以用于定义变量的类型;但泛型方法中的类型形参必须在对应方法中显式声明。

1.3 Java7的”菱形”语法与泛型构造器

正如泛型方法允许在方法签名中声明类型形参一样,Java也允许在构造器签名中声明类型形参,这样就产生了所谓的泛型构造器。

一旦定义了泛型构造器,接下来在调用构造器时,就不仅可以让java根据数据参数的类型来推断类型形参的类型,而且程序员也可以显示地为构造器中的类型形参指定实际的类型。如下代码所示:

class Foo
{
    public <T> Foo(T t)
    {
        System.out.println(t);
    }
}
public class GenericConstructor
{
    public static void main(String[] args) 
    {
        // 泛型构造器中的T参数为String。
        new Foo("org.light4j");
        // 泛型构造器中的T参数为Integer。
        new Foo(200);
        // 显式指定泛型构造器中的T参数为String,
        // 传给Foo构造器的实参也是String对象,完全正确。
        new <String> Foo("com.light4j");
        // 显式指定泛型构造器中的T参数为String,
        // 传给Foo构造器的实参也是Double对象,下面代码出错
        new <String> Foo(12.3);
    }
}

上面程序中号代码不仅显示指定了泛型构造器中的类型形参T的类型应该是String,而且程序传给该构造器的参数值也是String类型,因此程序完全正常。但是号代码处,程序显示指定了泛型构造器中的类型形参T的类型应该是String,但是实际传给该构造器的参数值是Double类型,因此这行代码将会出现错误。

Java7新增的”菱形”语法,它允许调用构造器时在构造器后使用一对尖括号来代表泛型信息。但如果程序显示指定了泛型构造器中声明的类型形参的实际类型,则不可以使用”菱形”语法,如下程序所示:

class MyClass<E>
{
    public <T> MyClass(T t) 
    {
        System.out.println("t参数的值为:" + t);
    }
}
public class GenericDiamondTest
{
    public static void main(String[] args) 
    {
        // MyClass类声明中的E形参是String类型。
        // 泛型构造器中声明的T形参是Integer类型
        MyClass<String> mc1 = new MyClass<>(5);
        // 显式指定泛型构造器中声明的T形参是Integer类型,
        MyClass<String> mc2 = new <Integer> MyClass<String>(5);
        // MyClass类声明中的E形参是String类型。
        // 如果显式指定泛型构造器中声明的T形参是Integer类型
        // 此时就不能使用"菱形"语法,下面代码是错的。
        //MyClass<String> mc3 = new <Integer> MyClass<>(5);
    }
}

上面程序中最后一行代码即指定了泛型构造器中的类型形参是Integer类型,又想使用”菱形”语法,所以这行代码无法通过编译。

1.4 设定通配符下限

假设自己实现一个工具方法:实现将src集合里面的元素复制到dest集合里面的功能,因为dest集合可以保存src集合里面的所有元素,所以dest集合元素的类型应该是src集合元素类型的父类。为了表示两个参数之间的类型依赖,考虑同时使用通配符和,泛型参数来实现该方法。代码如下:

public static <T> void copy(Collection<T> dest, Collection<? extends T> src) {
        for (T ele : src) {
            dest.add(ele);
        }
    }

上面方法实现了前面的功能。现在假设该方法需要一个返回值,返回最后一个被复制的元素,则可以把上面方法改为如下形式:

public static <T> T copy(Collection<T> dest, Collection<? extends T> src) {
        T last=null;
        for (T ele : src) {
            last=ele;
            dest.add(ele);
        }
        return last;
    }

表面上看起来,上面方法实现了这个功能,实际上有一个问题:当遍历src集合的元素时,src元素的类型是不确定的(但可以肯定它是T的子类),程序只能用T来笼统的表示各种src集合的元素类型,例如如下代码:

List<Number> ln=new ArrayList<>();
        List<Integer> li=new ArrayList<>();
        //下面的代码将引起编译错误
        Integer lat=copy(ln, li);

上面代码中ln的类型是List<Number>,与copy方法签名的形参类型进行对比即得到T的实际类型是Number,而不是Integer类型,即copy方法的返回值也是Number类型,而不是Integer类型,但实际上最后一个复制元素的元素类型一定是Integer。也就是说,程序在复制集合元素的过程中,丢失了src集合元素的类型。

对于上面的copy方法,可以这样理解两个集合参数之间的依赖关系:不管src集合元素的类型是什么,只要dest集合元素的类型与前者相同或是前者的父类即可。为了表达这种约束关系,Java允许设定通配符的下限:<<? super Type>,这个通配符表示它必须是Type本身,或者是Type的父类/下面程序采取设定通配符下限的方式改写了前面的copy方法。

public class MyUtils
{
    // 下面dest集合元素类型必须与src集合元素类型相同,或是其父类
    public static <T> T copy(Collection<? super T> dest 
        , Collection<T> src)
    {
        T last = null;
        for (T ele  : src)
        {
            last = ele;
            dest.add(ele);
        }
        return last;
    }
    public static void main(String[] args) 
    {
        List<Number> ln = new ArrayList<>();
        List<Integer> li = new ArrayList<>();
        li.add(5);
        // 此处可准确的知道最后一个被复制的元素是Integer类型
        // 与src集合元素的类型相同
        Integer last = copy(ln , li);    // ①
        System.out.println(ln);
    }
}

使用这种语句,就可以保证程序的①处调用后推断出最后一个被复制的元素类型是Integer,而不是笼统的Number类型。

实际上,Java集合框架中的TreeSet<E>有一个构造器也用到了这种设定通配符下限的语法,如下所示:

//下面的E是定义TreeSet类时的类型形参
TreeSet(Comparator<? super E>)

TreeSet会对集合中的元素按照自然顺序或定制顺序进行排序。如果需要TreeSet对集合中的元素进行定制排序,则要求TreeSet对象有一个与之关联的Comparator对象。上面构造器中的参数c就是进行定制排序的Comparator对象。
Comparator接口也是一个带泛型声明的接口:

public interface Comparator<T>
{
  int compare(T fst,T snd);
}

通过这种带下限的通配符的语法,可以在创建TreeSet对象时灵活地选择合适的Comparator。假如续约创建一个TreeSet<String>集合,并传入一个可以比较String大小的Comparator,这个Comparator既可以是Comparator<String>,也可以是Comparator<Object>,只要尖括号里传入的类型是String的父类型或者它本身,如下程序所示:

public class TreeSetTest
{
    public static void main(String[] args) 
    {
        // Comparator的实际类型是TreeSet里实际类型的父类,满足要求
        TreeSet<String> ts1 = new TreeSet<>(
            new Comparator<Object>()
        {
            public int compare(Object fst, Object snd)
            {
                return hashCode() > snd.hashCode() ? 1
                    : hashCode() < snd.hashCode() ? -1 : 0;
            }
        });
        ts1.add("hello");
        ts1.add("wa");
        TreeSet<String> ts2 = new TreeSet<>(
            new Comparator<String>()
        {
            public int compare(String first, String second)
            {
                return first.length() > second.length() ? -1
                    : first.length() < second.length() ? 1 : 0;
            }
        });
        ts2.add("hello");
        ts2.add("wa");
        System.out.println(ts1);
        System.out.println(ts2);
    }
}

通过使用这种通配符下限的方式来定义TreeSet构造器的参数,就可以将所有可用的Comparator作为参数传入,从而增加了程序的灵活性。当然,不仅TreeSet有这种用法,TreeMap也有类似的用法,具体可以参考JavaApi文档。

1.5 泛型方法与方法重载

因为泛型即允许设置通配符的上限,也允许设定通配符的上限,从而允许在一个类里包含如下两个方法定义:

public class MyUtils
{
    public static <T> void copy(Collection<T> dest,Collection<? extends T> src)
    {...} //①

    public static <T> void copy(Collection<? super T> dest,Collection<T> src)
    {...} //②
}

上面的MyUtils类中包含两个copy方法,这两个方法的参数列表存在一定的区别,但这种区别不是很明确:这两个方法的两个参数都是Collection对象,前一个集合里面的集合元素类型是后一个集合里集合元素的父类。如果这个类仅仅包含这两个方法不会有任何错误,但是只要调用这个方法就会引起编译错误。例如,下面代码:

List<Number> ln=new ArrayList<>();
List<Integer> li=new ArrayList<>();
copy(ln,li);

上面程序中最后一行代码调用copy方法,但这个copy方法即可以匹配copy方法,此时T参数的类型是Number;又可以匹配copy方法,此时T参数的类型是Integer。编译器无法确定这行代码想调用哪个copy方法,所以这行代码将引起编译错误。

附:源代码示例

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

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

未经允许不得转载:人生设计师 » 泛型系列(四):泛型方法

分享到:更多 ()

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

联系我关于我