CodeGym/Java 博客/China/Java 泛型:如何在实践中使用尖括号
作者
Lihu Zhai
Senior Software Architect at Shinetech Software Inc.

Java 泛型:如何在实践中使用尖括号

已在 China 群组中发布
个会员

简介

从 JSE 5.0 开始,泛型被添加到了 Java 语言库中。

java 中的泛型是什么?

泛型是 Java 实现泛型编程的特殊机制 — 一种描述数据和算法的方法,支持在不改变算法描述的情况下使用不同的数据类型。Oracle 网站有专门针对泛型的单独教程:课程。要理解泛型,首先需要弄清楚为什么需要泛型,泛型有何用途。 教程的“为什么使用泛型?”一节有两个目的:一是在编译时进行更强的类型检查,二是消除对显式强制转换的需要。 让我们在心爱的 Tutorialspoint 在线 Java 编译器中准备进行一些测试。 假设你有以下代码:
import java.util.*;
public class HelloWorld {
	public static void main(String []args) {
		List list = new ArrayList();
		list.add("Hello");
		String text = list.get(0) + ", world!";
		System.out.print(text);
	}
}
这段代码将运行得非常好。但是如果老板过来对我们说,“你好,世界!”这个短语都烂大街了,你只能回复“你好”?我们将删除串联“,世界!”的代码这似乎是无害的,对不对?但我们在编译时实际会得到错误:

error: incompatible types: Object cannot be converted to String
问题是在我们的 List 中存储 Object。StringObject 的后代(因为所有 Java 类都隐式继承 Object),这意味着我们需要显式强制转换,但我们并没有添加。在串联操作期间,将使用对象调用静态 String.valueOf(obj) 方法。最终,它将调用 Object 类的 toString 方法。 换句话说,我们的 List 包含一个 Object。这意味着当我们需要一个特定的类型(不是 Object)时,我们必须自行进行类型转换:
import java.util.*;
public class HelloWorld {
	public static void main(String []args) {
		List list = new ArrayList();
		list.add("Hello!");
		list.add(123);
		for (Object str : list) {
		    System.out.println("-" + (String)str);
		}
	}
}
但是,在这种情况下,因为 List 接受对象,所以它不仅可以存储 String,还可以存储 Integer。但最糟糕的是,编译器在这里没有发现任何错误。现在我们将在运行时得到一个错误(称为“运行时错误”)。 错误将是:

java.lang.ClassCastException: java.lang.Integer cannot be cast to java.lang.String
你必须承认这不是很好。 发生此错误都是因为编译器没有能够始终正确猜测程序员意图的人工智能。Java SE 5 引入了泛型,让我们告诉编译器我们的意图 — 我们将使用哪些类型。 我们通过告诉编译器想要什么来修复代码:
import java.util.*;
public class HelloWorld {
	public static void main(String []args) {
		List<String> list = new ArrayList<>();
		list.add("Hello!");
		list.add(123);
		for (Object str : list) {
		    System.out.println("-" + str);
		}
	}
}
如你所见,我们不再需要转换成 String。此外,类型参数周围有尖括号。现在编译器不会让我们编译这个类,除非我们删除列表中增加 123 的那一行,因为这是一个 Integer。 编译器会告诉我们这个问题。很多人把泛型称为“语法糖”。他们是对的,因为在泛型被编译之后,确实变成了相同的类型转换。让我们看看编译后类的字节码:一个使用显式强制类型转换,一个使用泛型: Java 泛型:如何在实践中使用尖括号 - 1编译后,所有的泛型都被擦除。该操作称为“类型擦除”。 类型擦除和泛型的设计目的是向后兼容旧版本的 JDK,同时允许编译器在新版 Java 中帮助进行类型定义。

原始类型

泛型始终分为两类:参数化类型和原始类型。 原始类型是省略了尖括号中的“类型说明”的类型: Java 泛型:如何在实践中使用尖括号 - 2另一方面,参数化类型包括一个“说明”: Java 泛型:如何在实践中使用尖括号 - 3如您所见,我们使用了一个不常用的构造,在截图中用一个箭头标出。这是 Java SE 7 中添加的特殊语法。该语法称为“菱形”。为什么?尖括号形成一个菱形:<>。 你还应该知道,菱形语法与“类型推断”的概念相关联。毕竟,编译器在右边看到 <>,在左边看到赋值运算符,在此找到被赋值变量的类型。 根据在这部分中找到的内容,它会理解右边值的类型。事实上,如果泛型类型在左边给出,而不是在右边,编译器可以推断出类型:
import java.util.*;
public class HelloWorld {
	public static void main(String []args) {
		List<String> list = new ArrayList();
		list.add("Hello, World");
		String data = list.get(0);
		System.out.println(data);
	}
}
但这段代码混合了有泛型的新风格和没有泛型的旧风格。这种混合高度不可取。当编译上面的代码时,我们得到以下消息:

Note: HelloWorld.java uses unchecked or unsafe operations
事实上,甚至你需要在这里添加一个菱形的原因似乎不可理喻。但看看下面的例子:
import java.util.*;
public class HelloWorld {
	public static void main(String []args) {
		List<String> list = Arrays.asList("Hello", "World");
		List<Integer> data = new ArrayList(list);
		Integer intNumber = data.get(0);
		System.out.println(data);
	}
}
你可能还记得 ArrayList 还有一个构造函数,它接受一个集合作为参数。这里隐藏着邪恶的意图。没有菱形语法,编译器不知道自己被欺骗了。使用菱形语法,就知道了。 所以,规则#1是:始终对参数化类型使用菱形语法。否则,我们可能会错过使用原始类型的地方。 为了消除发生“使用未检查的或不安全的操作”警告,可以在方法或类中使用 @SuppressWarnings("unchecked") 注释。 但是想想你为什么决定使用它。记住第一条规则。也许你需要添加一个类型参数。

Java 泛型方法

泛型允许你创建参数类型和返回类型被参数化的方法。Oracle 教程中有一个单独的章节专门介绍这种功能:“泛型方法”。 记住本教程中讲的语法很重要:
  • 它包括尖括号内的类型参数列表;
  • 类型参数列表位于方法的返回类型之前。
让我们看一个例子:
import java.util.*;
public class HelloWorld {

    public static class Util {
        public static <T> T getValue(Object obj, Class<T> clazz) {
            return (T) obj;
        }
        public static <T> T getValue(Object obj) {
            return (T) obj;
        }
    }

    public static void main(String []args) {
		List list = Arrays.asList("Author", "Book");
		for (Object element : list) {
		    String data = Util.getValue(element, String.class);
		    System.out.println(data);
		    System.out.println(Util.<String>getValue(element));
		}
    }
}
如果你查看 Util 类,您会发现它有两个泛型方法。由于类型推断的可能性,我们既可以直接向编译器指明类型,也可以自己指定类型。示例中提供了两种选项。 顺便说一下,如果你仔细想想,这个语法很有意义。当声明泛型方法时,我们在方法“之前”指定类型参数,因为如果在方法之后声明类型参数,JVM 将不能确定使用哪个类型。相应地,我们首先声明我们将使用 T 类型参数,然后我们说我们将返回这个类型。 自然,Util.<Integer>getValue(element, String.class) 将失败,并出现错误:incompatible types:Class<String> 无法转换为 Class<Integer>。 使用泛型方法时,你应该始终记住类型擦除。让我们看一个例子:
import java.util.*;
public class HelloWorld {

    public static class Util {
        public static <T> T getValue(Object obj) {
            return (T) obj;
        }
    }

    public static void main(String []args) {
		List list = Arrays.asList(2, 3);
		for (Object element : list) {
		    System.out.println(Util.<Integer>getValue(element) + 1);
		}
    }
}
这段代码运行正常。但前提是编译器知道被调用方法的返回类型是 Integer。 用下面一行代码替换控制台输出语句:
System.out.println(Util.getValue(element) + 1);
我们得到一个错误:

bad operand types for binary operator '+', first type: Object, second type: int.
换句话说,类型擦除已经发生。编译器发现没有人指定该类型,因此该类型被指示为 Object,并且该方法失败并出现错误。

泛型类

不仅方法可以参数化。类也可以参数化。Oracle教程的“泛型类型”一节专门讨论这一点。考虑下面的例子:
public static class SomeType<T> {
	public <E> void test(Collection<E> collection) {
		for (E element : collection) {
			System.out.println(element);
		}
	}
	public void test(List<Integer> collection) {
		for (Integer element : collection) {
			System.out.println(element);
		}
	}
}
这里一切都很简单。如果我们使用泛型类,类型参数在类名后指明。 现在让我们在 main 方法中创建该类的一个实例:
public static void main(String []args) {
	SomeType<String> st = new SomeType<>();
	List<String> list = Arrays.asList("test");
	st.test(list);
}
此代码会正常运行。编译器会发现一个数字 List 和一个 StringsCollection。但是如果我们去掉类型参数,如下面这样呢:
SomeType st = new SomeType();
List<String> list = Arrays.asList("test");
st.test(list);
我们得到一个错误:

java.lang.ClassCastException: java.lang.String cannot be cast to java.lang.Integer
在强调以下,这是类型擦除。由于该类不再使用类型参数,编译器决定,由于我们传递了一个 List,使用 List<Integer> 的方法是最合适的。我们失败了且出现错误。 因此,我们有规则 #2:如果你有一个泛型类,始终指定类型参数。

限制

我们可以限制泛型方法和类中指定的类型。例如,假设我们希望一个容器只接受 Number 作为类型参数。 Oracle 教程的有界类型参数一节中描述了这个功能。 让我们看一个例子:
import java.util.*;
public class HelloWorld {

    public static class NumberContainer<T extends Number> {
        private T number;

        public NumberContainer(T number) { this.number = number; }

        public void print() {
            System.out.println(number);
        }
    }

    public static void main(String []args) {
		NumberContainer number1 = new NumberContainer(2L);
		NumberContainer number2 = new NumberContainer(1);
		NumberContainer number3 = new NumberContainer("f");
    }
}
正如你所看到的,我们已经将类型参数限制为 Number 类/接口或其后代。请注意,你不仅可以指定类,还可以指定接口。例如:
public static class NumberContainer<T extends Number & Comparable> {
泛型还支持通配符。 它们分为三种类型:通配符的使用应该遵循 Get-Put 原则。它表示如下:
  • 当只从结构外获取值时,使用 extend 通配符。
  • 当你只将值放入结构中时,请使用 super 通配符。
  • 当你想从结构中获取通配符或将通配符放入结构中时,不要使用通配符。
这一原则也被称为生产者 extends 消费者超 super (PECS) 原则。下面是 Java 的 Collections.copy 方法的源代码的一个小例子: Java 泛型:如何在实践中使用尖括号 - 4这里有一个不起作用的小例子:
public static class TestClass {
	public static void print(List<? extends String> list) {
		list.add("Hello, World!");
		System.out.println(list.get(0));
	}
}

public static void main(String []args) {
	List<String> list = new ArrayList<>();
	TestClass.print(list);
}
但是如果你将 extends 替换成 super,一切正常。因为我们在显示列表内容之前用值填充了列表,所以它是一个消费者。因此,我们使用 super。

继承

泛型还有一个有趣的特性:继承。Oracle 教程中的“泛型、继承和子类型”描述了泛型的继承方式。重要的是记住并认识到以下几点。我们不能这样做:
List<CharSequence> list1 = new ArrayList<String>();
因为继承与泛型的工作方式不同: Java 泛型:如何在实践中使用尖括号 - 5这是另一个因失败而出现错误的好例子:
List<String> list1 = new ArrayList<>();
List<Object> list2 = list1;
这里一切都很简单。List<String>List<Object> 的后代,尽管 StringObject 的后代。 Java 泛型:如何在实践中使用尖括号 - 6

结论

所以我们已经刷新了关于泛型的记忆。如果你很少充分利用泛型的功能,部分细节就会变得模糊不清。我希望这篇简短的回顾有助于唤起你的记忆。
评论
  • 受欢迎
你必须先登录才能发表评论
此页面还没有任何评论