Java泛型

661 阅读14分钟

泛型程序设计

在前面我们学习了最重要的类和对象,了解了面向对象编程的思想,注意,非常重要,面向对象是必须要深入理解和掌握的内容,不能草草结束。在本章节,我们还会继续深入了解,从泛型开始,再到数据结构,最后再开始我们的集合类学习,循序渐进。

泛型

为了统计学生成绩,要求设计一个Score对象,包括课程名称、课程号、课程成绩,但是成绩分为两种,一种是以优秀、良好、合格 来作为结果,还有一种就是 60.0、75.5、92.5 这样的数字分数,可能高等数学这门课是以数字成绩进行结算,而计算机网络实验这门课是以等级进行结算,这两种分数类型都有可能出现,那么现在该如何去设计这样的一个Score类呢?

现在的问题就是,成绩可能是String类型,也可能是Integer类型,如何才能很好的去存可能出现的两种类型呢?

public class Score {
    String name;
    String id;
    Object value;  //因为Object是所有类型的父类,因此既可以存放Integer也能存放String

  	public Score(String name, String id, Object value) {
        this.name = name;
        this.id = id;
        this.score = value;
    }
}

以上的方法虽然很好地解决了多种类型存储问题,但是Object类型在编译阶段并不具有良好的类型判断能力,很容易出现以下的情况:

public static void main(String[] args) {

    Score score = new Score("数据结构与算法基础", "EP074512", "优秀");  //是String类型的

    ...

    Integer number = (Integer) score.score;  //获取成绩需要进行强制类型转换,虽然并不是一开始的类型,但是编译不会报错
}

使用Object类型作为引用,对于使用者来说,由于是Object类型,所以说并不能直接判断存储的类型到底是String还是Integer,取值只能进行强制类型转换,显然无法在编译期确定类型是否安全,项目中代码量非常之大,进行类型比较又会导致额外的开销和增加代码量,如果不经比较就很容易出现类型转换异常,代码的健壮性有所欠缺

所以说这种解决办法虽然可行,但并不是最好的方案。

为了解决以上问题,JDK 5新增了泛型,它能够在编译阶段就检查类型安全,大大提升开发效率。

泛型类

泛型其实就一个待定类型,我们可以使用一个特殊的名字表示泛型,泛型在定义时并不明确是什么类型,而是需要到使用时才会确定对应的泛型类型。

我们可以将一个类定义为一个泛型类:

public class Score<T> {   //泛型类需要使用<>,我们需要在里面添加1 - N个类型变量
    String name;
    String id;
    T value;   //T会根据使用时提供的类型自动变成对应类型

    public Score(String name, String id, T value) {   //这里T可以是任何类型,但是一旦确定,那么就不能修改了
        this.name = name;
        this.id = id;
        this.value = value;
    }
}

我们来看看这是如何使用的:

public static void main(String[] args) {
    Score<String> score = new Score<String>("计算机网络", "EP074512", "优秀");
  	//因为现在有了类型变量,在使用时同样需要跟上<>并在其中填写明确要使用的类型
  	//这样我们就可以根据不同的类型进行选择了
    String value = score.value;   //一旦类型明确,那么泛型就变成对应的类型了
    System.out.println(value);
}

泛型将数据类型的确定控制在了编译阶段,在编写代码的时候就能明确泛型的类型,如果类型不符合,将无法通过编译!因为是具体使用对象时才会明确具体类型,所以说静态方法中是不能用的:

只不过这里需要注意一下,我们在方法中使用待确定类型的变量时,因为此时并不明确具体是什么类型,那么默认会认为这个变量是一个Object类型的变量,因为无论具体类型是什么,一定是Object类的子类:

我们可以对其进行强制类型转换,但是实际上没多大必要:

public void test(T t){
    String str = (String) t;   //都明确要用String了,那这里定义泛型不是多此一举吗
}

因为泛型本身就是对某些待定类型的简单处理,如果都明确要使用什么类型了,那大可不必使用泛型。还有,不能通过这个不确定的类型变量就去直接创建对象和对应的数组:

注意,具体类型不同的泛型类变量,不能使用不同的变量进行接收:

如果要让某个变量支持引用确定了任意类型的泛型,那么可以使用?通配符:

public static void main(String[] args) {
    Test<?> test = new Test<Integer>();
    test = new Test<String>();
  	Object o = test.value;    //但是注意,如果使用通配符,那么由于类型不确定,所以说具体类型同样会变成Object
}

当然,泛型变量不止可以只有一个,如果需要使用多个的话,我们也可以定义多个:

public class Test<A, B, C> {   //多个类型变量使用逗号隔开
    public A a;
    public B b;
    public C c;
}

那么在使用时,就需要将这三种类型都进行明确指定:

public static void main(String[] args) {
    Test<String, Integer, Character> test = new Test<>();  //使用钻石运算符可以省略其中的类型
    test.a = "lbwnb";
    test.b = 10;
    test.c = '淦';
}

是不是感觉好像还是挺简单的?只要是在类中,都可以使用类型变量:

public class Test<T>{
    
    private T value;

    public void setValue(T value) {
        this.value = value;
    }

    public T getValue() {
        return value;
    }
}

只不过,泛型只能确定为一个引用类型,基本类型是不支持的:

public class Test<T>{
    public T value;
}

如果要存放基本数据类型的值,我们只能使用对应的包装类:

public static void main(String[] args) {
    Test<Integer> test = new Test<>();
}

当然,如果是基本类型的数组,因为数组本身是引用类型,所以说是可以的:

public static void main(String[] args) {
    Test<int[]> test = new Test<>();
}

通过使用泛型,我们就可以将某些不明确的类型在具体使用时再明确。

泛型与多态

不只是类,包括接口、抽象类,都是可以支持泛型的:

public interface Study<T> {
    T test();
}

当子类实现此接口时,我们可以选择在实现类明确泛型类型,或是继续使用此泛型让具体创建的对象来确定类型:

public class Main {
    public static void main(String[] args) {
        A a = new A();
        Integer i = a.test();
    }

    static class A implements Study<Integer> {   
      	//在实现接口或是继承父类时,如果子类是一个普通类,那么可以直接明确对应类型
        @Override
        public Integer test() {
            return null;
        }
    }
}

或者是继续摆烂,依然使用泛型:

public class Main {
    public static void main(String[] args) {
        A<String> a = new A<>();
        String i = a.test();
    }

    static class A<T> implements Study<T> {   
      	//让子类继续为一个泛型类,那么可以不用明确
        @Override
        public T test() {
            return null;
        }
    }
}

继承也是同样的:

static class A<T> {
    
}

static class B extends A<String> {

}

泛型方法

当然,类型变量并不是只能在泛型类中才可以使用,我们也可以定义泛型方法。

当某个方法(无论是是静态方法还是成员方法)需要接受的参数类型并不确定时,我们也可以使用泛型来表示:

public class Main {
    public static void main(String[] args) {
        String str = test("Hello World!");
    }

    private static <T> T test(T t){   //在返回值类型前添加<>并填写泛型变量表示这个是一个泛型方法
        return t;
    }
}

泛型方法会在使用时自动确定泛型类型,比如上我们定义的是类型T作为参数,同样的类型T作为返回值,实际传入的参数是一个字符串类型的值,那么T就会自动变成String类型,因此返回值也是String类型。

public static void main(String[] args) {
    String[] strings = new String[1];
    Main main = new Main();
    main.add(strings, "Hello");
    System.out.println(Arrays.toString(strings));
}

private <T> void add(T[] arr, T t){
    arr[0] = t;
}

实际上泛型方法在很多工具类中也有,比如说Arrays的排序方法:

Integer[] arr = {1, 4, 5, 2, 6, 3, 0, 7, 9, 8};
Arrays.sort(arr, new Comparator<Integer>() {   
  	//通过创建泛型接口的匿名内部类,来自定义排序规则,因为匿名内部类就是接口的实现类,所以说这里就明确了类型
    @Override
    public int compare(Integer o1, Integer o2) {   //这个方法会在执行排序时被调用(别人来调用我们的实现)
        return 0;
    }
});

比如现在我们想要让数据从大到小排列,我们就可以自定义:

public static void main(String[] args) {
    Integer[] arr = {1, 4, 5, 2, 6, 3, 0, 7, 9, 8};
    Arrays.sort(arr, new Comparator<Integer>() {
        @Override
        public int compare(Integer o1, Integer o2) {   //两个需要比较的数会在这里给出
            return o2 - o1;    
          	//compare方法要求返回一个int来表示两个数的大小关系,大于0表示大于,小于0表示小于
          	//这里直接o2-o1就行,如果o2比o1大,那么肯定应该排在前面,所以说返回正数表示大于
        }
    });
    System.out.println(Arrays.toString(arr));
}

因为我们前面学习了Lambda表达式,像这种只有一个方法需要实现的接口,直接安排了:

public static void main(String[] args) {
    Integer[] arr = {1, 4, 5, 2, 6, 3, 0, 7, 9, 8};
    Arrays.sort(arr, (o1, o2) -> o2 - o1);   //瞬间变一行,效果跟上面是一样的
    System.out.println(Arrays.toString(arr));
}

包括数组复制方法:

public static void main(String[] args) {
    String[] arr = {"AAA", "BBB", "CCC"};
    String[] newArr = Arrays.copyOf(arr, 3);   //这里传入的类型是什么,返回的类型就是什么,也是用到了泛型
    System.out.println(Arrays.toString(newArr));
}

因此,泛型实际上在很多情况下都能够极大地方便我们对于程序的代码设计。

泛型的界限

现在有一个新的需求,现在没有String类型的成绩了,但是成绩依然可能是整数,也可能是小数,这时我们不希望用户将泛型指定为除数字类型外的其他类型,我们就需要使用到泛型的上界定义:

public class Score<T extends Number> {   //设定类型参数上界,必须是Number或是Number的子类
    private final String name;
    private final String id;
    private final T value;

    public Score(String name, String id, T value) {
        this.name = name;
        this.id = id;
        this.value = value;
    }

    public T getValue() {
        return value;
    }
}

只需要在泛型变量的后面添加extends关键字即可指定上界,使用时,具体类型只能是我们指定的上界类型或是上界类型的子类,不得是其他类型。否则一律报错:

实际上就像这样:

同样的,当我们在使用变量时,泛型通配符也支持泛型的界限:

public static void main(String[] args) {
    Score<? extends Integer> score = new Score<>("数据结构与算法", "EP074512", 60);
}

那么既然泛型有上界,那么有没有下界呢?肯定的啊:

只不过下界仅适用于通配符,对于类型变量来说是不支持的。下界限定就像这样:

那么限定了上界后,我们再来使用这个对象的泛型成员,会变成什么类型呢?

public static void main(String[] args) {
    Score<? extends Number> score = new Score<>("数据结构与算法基础", "EP074512", 10);
    Number o = score.getValue();   //可以看到,此时虽然使用的是通配符,但是不再是Object类型,而是对应的上界
}

但是我们限定下界的话,因为还是有可能是Object,所以说依然是跟之前一样:

public static void main(String[] args) {
    Score<? super Number> score = new Score<>("数据结构与算法基础", "EP074512", 10);
    Object o = score.getValue();
}

通过给设定泛型上限,我们就可以更加灵活地控制泛型的具体类型范围。

类型擦除

前面我们已经了解如何使用泛型,那么泛型到底是如何实现的呢,程序编译之后的样子是什么样的?

public abstract class A <T>{
    abstract T test(T t);
}

实际上在Java中并不是真的有泛型类型(为了兼容之前的Java版本)因为所有的对象都是属于一个普通的类型,一个泛型类型编译之后,实际上会直接使用默认的类型:

public abstract class A {
    abstract Object test(Object t);  //默认就是Object
}

当然,如果我们给类型变量设定了上界,那么会从默认类型变成上界定义的类型:

public abstract class A <T extends Number>{   //设定上界为Number
    abstract T test(T t);
}

那么编译之后:

public abstract class A {
    abstract Number test(Number t);  //上界Number,因为现在只可能出现Number的子类
}

因此,泛型其实仅仅是在编译阶段进行类型检查,当程序在运行时,并不会真的去检查对应类型,所以说哪怕是我们不去指定类型也可以直接使用:

public static void main(String[] args) {
    Test test = new Test();    //对于泛型类Test,不指定具体类型也是可以的,默认就是原始类型
}

只不过此时编译器会给出警告:

同样的,由于类型擦除,实际上我们在使用时,编译后的代码是进行了强制类型转换的:

public static void main(String[] args) {
    A<String> a = new B();
    String  i = a.test("10");     //因为类型A只有返回值为原始类型Object的方法
}

实际上编译之后:

public static void main(String[] args) {
    A a = new B();
    String i = (String) a.test("10");   //依靠强制类型转换完成的
}

不过,我们思考一个问题,既然继承泛型类之后可以明确具体类型,那么为什么@Override不会出现错误呢?我们前面说了,重写的条件是需要和父类的返回值类型和形参一致,而泛型默认的原始类型是Object类型,子类明确后变为其他类型,这显然不满足重写的条件,但是为什么依然能编译通过呢?

public class B extends A<String>{
    @Override
    String test(String s) {
        return null;
    }
}

我们来看看编译之后长啥样:

// Compiled from "B.java"
public class com.test.entity.B extends com.test.entity.A<java.lang.String> {
  public com.test.entity.B();
  java.lang.String test(java.lang.String);
  java.lang.Object test(java.lang.Object);   //桥接方法,这才是真正重写的方法,但是使用时会调用上面的方法
}

通过反编译进行观察,实际上是编译器帮助我们生成了一个桥接方法用于支持重写:

public class B extends A {
    
    public Object test(Object obj) {   //这才是重写的桥接方法
        return this.test((Integer) obj);   //桥接方法调用我们自己写的方法
    }
    
    public String test(String str) {   //我们自己写的方法
        return null;
    }
}

类型擦除机制其实就是为了方便使用后面集合类(不然每次都要强制类型转换)同时为了向下兼容采取的方案。因此,泛型的使用会有一些限制:

首先,在进行类型判断时,不允许使用泛型,只能使用原始类型:

只能判断是不是原始类型,里面的具体类型是不支持的:

Test<String> test = new Test<>();
System.out.println(test instanceof Test);   //在进行类型判断时,不允许使用泛型,只能使用原始类型

还有,泛型类型是不支持创建参数化类型数组的:

要用只能用原始类型:

public static void main(String[] args) {
    Test[] test = new Test[10];   //同样是因为类型擦除导致的,运行时可不会去检查具体类型是什么
}

只不过只是把它当做泛型类型的数组还是可以用的:

函数式接口

学习了泛型,我们来介绍一下再JDK 1.8中新增的函数式接口。

函数式接口就是JDK1.8专门为我们提供好的用于Lambda表达式的接口,这些接口都可以直接使用Lambda表达式,非常方便,这里我们主要介绍一下四个主要的函数式接口:

Supplier供给型函数式接口: 这个接口是专门用于供给使用的,其中只有一个get方法用于获取需要的对象。

@FunctionalInterface   //函数式接口都会打上这样一个注解
public interface Supplier<T> {
    T get();   //实现此方法,实现供给功能
}

比如我们要实现一个专门供给Student对象Supplier,就可以使用:

public class Student {
    public void hello(){
        System.out.println("我是学生!");
    }
}
//专门供给Student对象的Supplier
private static final Supplier<Student> STUDENT_SUPPLIER = Student::new;
public static void main(String[] args) {
    Student student = STUDENT_SUPPLIER.get();
    student.hello();
}

Consumer消费型函数式接口: 这个接口专门用于消费某个对象的。

@FunctionalInterface
public interface Consumer<T> {
    void accept(T t);    //这个方法就是用于消费的,没有返回值

    default Consumer<T> andThen(Consumer<? super T> after) {   //这个方法便于我们连续使用此消费接口
        Objects.requireNonNull(after);
        return (T t) -> { accept(t); after.accept(t); };
    }
}

使用起来也是很简单的:

//专门消费Student对象的Consumer
private static final Consumer<Student> STUDENT_CONSUMER = student -> System.out.println(student+" 真好吃!");
public static void main(String[] args) {
    Student student = new Student();
    STUDENT_CONSUMER.accept(student);
}

当然,我们也可以使用andThen方法继续调用:

public static void main(String[] args) {
    Student student = new Student();
    STUDENT_CONSUMER   //我们可以提前将消费之后的操作以同样的方式预定好
            .andThen(stu -> System.out.println("我是吃完之后的操作!")) 
            .andThen(stu -> System.out.println("好了好了,吃饱了!"))
            .accept(student);   //预定好之后,再执行
}

这样,就可以在消费之后进行一些其他的处理了,使用很简洁的代码就可以实现:

Function函数型函数式接口: 这个接口消费一个对象,然后会向外供给一个对象(前两个的融合体)

@FunctionalInterface
public interface Function<T, R> {
    R apply(T t);   //这里一共有两个类型参数,其中一个是接受的参数类型,还有一个是返回的结果类型

    default <V> Function<V, R> compose(Function<? super V, ? extends T> before) {
        Objects.requireNonNull(before);
        return (V v) -> apply(before.apply(v));
    }

    default <V> Function<T, V> andThen(Function<? super R, ? extends V> after) {
        Objects.requireNonNull(after);
        return (T t) -> after.apply(apply(t));
    }

    static <T> Function<T, T> identity() {
        return t -> t;
    }
}

这个接口方法有点多,我们一个一个来看,首先还是最基本的apply方法,这个是我们需要实现的:

//这里实现了一个简单的功能,将传入的int参数转换为字符串的形式
private static final Function<Integer, String> INTEGER_STRING_FUNCTION = Object::toString;
public static void main(String[] args) {
    String str = INTEGER_STRING_FUNCTION.apply(10);
    System.out.println(str);
}

我们可以使用compose将指定函数式的结果作为当前函数式的实参:

public static void main(String[] args) {
    String str = INTEGER_STRING_FUNCTION
            .compose((String s) -> s.length())   //将此函数式的返回值作为当前实现的实参
            .apply("lbwnb");   //传入上面函数式需要的参数
    System.out.println(str);
}

相反的,andThen可以将当前实现的返回值进行进一步的处理,得到其他类型的值:

public static void main(String[] args) {
    Boolean str = INTEGER_STRING_FUNCTION
            .andThen(String::isEmpty)   //在执行完后,返回值作为参数执行andThen内的函数式,最后得到的结果就是最终的结果了
            .apply(10);
    System.out.println(str);
}

比较有趣的是,Function中还提供了一个将传入参数原样返回的实现:

public static void main(String[] args) {
    Function<String, String> function = Function.identity();   //原样返回
    System.out.println(function.apply("不会吧不会吧,不会有人听到现在还是懵逼的吧"));
}

Predicate断言型函数式接口: 接收一个参数,然后进行自定义判断并返回一个boolean结果。

@FunctionalInterface
public interface Predicate<T> {
    boolean test(T t);    //这个方法就是我们要实现的

    default Predicate<T> and(Predicate<? super T> other) {
        Objects.requireNonNull(other);
        return (t) -> test(t) && other.test(t);
    }

    default Predicate<T> negate() {
        return (t) -> !test(t);
    }

    default Predicate<T> or(Predicate<? super T> other) {
        Objects.requireNonNull(other);
        return (t) -> test(t) || other.test(t);
    }

    static <T> Predicate<T> isEqual(Object targetRef) {
        return (null == targetRef)
                ? Objects::isNull
                : object -> targetRef.equals(object);
    }
}

我们可以来编写一个简单的例子:

public class Student {
    public int score;
}
private static final Predicate<Student> STUDENT_PREDICATE = student -> student.score >= 60;
public static void main(String[] args) {
    Student student = new Student();
    student.score = 80;
    if(STUDENT_PREDICATE.test(student)) {  //test方法的返回值是一个boolean结果
        System.out.println("及格了,真不错,今晚奖励自己一次");
    } else {
        System.out.println("不是,Java都考不及格?隔壁初中生都在打ACM了");
    }
}

我们也可以使用组合条件判断:

public static void main(String[] args) {
    Student student = new Student();
    student.score = 80;
    boolean b = STUDENT_PREDICATE
            .and(stu -> stu.score > 90)   //需要同时满足这里的条件,才能返回true
            .test(student);
    if(!b) System.out.println("Java到现在都没考到90分?你的室友都拿国家奖学金了");
}

同样的,这个类型提供了一个对应的实现,用于判断两个对象是否相等:

public static void main(String[] args) {
    Predicate<String> predicate = Predicate.isEqual("Hello World");   //这里传入的对象会和之后的进行比较
    System.out.println(predicate.test("Hello World"));
}

通过使用这四个核心的函数式接口,我们就可以使得代码更加简洁,具体的使用场景会在后面讲解。

判空包装

Java8还新增了一个非常重要的判空包装类Optional,这个类可以很有效的处理空指针问题。

比如对于下面这样一个很简单的方法:

private static void test(String str){   //传入字符串,如果不是空串,那么就打印长度
    if(!str.isEmpty()) {
        System.out.println("字符串长度为:"+str.length());
    }
}

但是如果我们在传入参数时,丢个null进去,直接原地爆炸:

public static void main(String[] args) {
    test(null);
}

private static void test(String str){ 
    if(!str.isEmpty()) {   //此时传入的值为null,调用方法马上得到空指针异常
        System.out.println("字符串长度为:"+str.length());
    }
}

因此我们还需要在使用之前进行判空操作:

private static void test(String str){
    if(str == null) return;   //这样就可以防止null导致的异常了
    if(!str.isEmpty()) {
        System.out.println("字符串长度为:"+str.length());
    }
}

虽然这种方式很好,但是在Java8之后,有了Optional类,它可以更加优雅地处理这种问题,我们来看看如何使用:

private static void test(String str){
    Optional
            .ofNullable(str)   //将传入的对象包装进Optional中
            .ifPresent(s -> System.out.println("字符串长度为:"+s.length()));  
  					//如果不为空,则执行这里的Consumer实现
}

优雅,真是太优雅了,同样的功能,现在我们只需要两行就搞定了,而且代码相当简洁。如果你学习过JavaScript或是Kotlin等语言,它的语法就像是:

var str : String? = null
str?.upperCase()

并且,包装之后,我们再获取时可以优雅地处理为空的情况:

private static void test(String str){
    String s = Optional.ofNullable(str).get();   //get方法可以获取被包装的对象引用,但是如果为空的话,会抛出异常
    System.out.println(s);
}

我们可以对于这种有可能为空的情况进行处理,如果为空,那么就返回另一个备选方案:

private static void test(String str){
    String s = Optional.ofNullable(str).orElse("我是为null的情况备选方案");
    System.out.println(s);
}

是不是感觉很方便?我们还可以将包装的类型直接转换为另一种类型:

private static void test(String str){
    Integer i = Optional
            .ofNullable(str)
            .map(String::length)   //使用map来进行映射,将当前类型转换为其他类型,或者是进行处理
            .orElse(-1);
    System.out.println(i);
}

当然,Optional的方法比较多,这里就不一一介绍了。

开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 25 天,点击查看活动详情