泛型程序设计

为了统计学生成绩,需要设计一个Score对象,包括课程名称、课程号、课程成绩,但是成绩分为两种,一种是以优秀、良好、及格、不及格来作为结果,另一种是60.0、97.5这样的数字分数,不同的课程可能是用不同的累心,那应该如何设计这样的Score类。
如果我们使用以下方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
package org.ep;

public class Score {
String name;
String id;
Object value; //Object是所有类的父类,所以既能存放String又能存放Integer

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

以上的方法虽然很好地解决了多种类型存储问题,但是Object类型在编译阶段并不具有良好的类型判断能力,对于使用者来说不是很容易判断存储的类型到底是String还是Integer,所以只能在取值的时候进行强制类型转换,又无法保证在编译器的安全性。所以说这种方案不是最好的。

为了解决以上问题,可以使用泛型,能在编译阶段就检查类型安全,提升开发效率。

泛型类

泛型其实就是一个待定类型,我们可以使用一个特殊的名字表示泛型,在使用时不需要定义是什么类型。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
package org.ep;

public class Score<T> { //泛型类需要使用<>,我们需要在里面添加1~N个类型变量
String name;
String id;
T value;

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

使用时需要明确需要使用的类型:

1
2
3
4
5
6
7
8
9
package org.ep;

public class Main {
public static void main(String[] args) {
Score<String> score = new Score<String>("csapp", "341", "优秀");
String value = score.value;
System.out.println(value);
}
}
1
优秀

泛型将数据类型的确定控制在了编译阶段,在编写代码的时候就能明确泛型的类型,如果类型不符合则不能通过编译。

因为是具体使用对象是才会明确具体类型,所以静态方法中是不能用的。而且不能通过不确定的类型变量直接创建对象和对应的数组。

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

1
2
3
4
5
6
7
8
9
package org.ep;

public class Main {
public static void main(String[] args) {
Test<?> test = new Test<Integer>();
test = new Test<String>();
Object o = test.value;
}
}

这样,我们就创建了一个Test泛型类的test对象,这个对象可以分配给不同类型的Test对象,比如Test<Integer>Test<String>,因为通配符 ? 表示不确定的泛型类型。但是注意,如果使用通配符,那么由于类型不确定,所以说具体类型同样会变成Object

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

1
2
3
4
5
6
7
package org.ep;

public class Test<A, B, C> {
A a;
B b;
C c;
}

这样在使用时就需要将三种类型都明确指定:

1
2
3
4
5
6
7
8
9
10
package org.ep;

public class Main {
public static void main(String[] args) {
Test<String, Integer, Character> test = new Test<>();
test.a = "EP";
test.b = 13;
test.c = 't';
}
}

只要在类中,都可以使用类型变量:

1
2
3
4
5
6
7
8
9
10
11
12
13
package org.ep;

public class Test<T> {
private T value;

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

public T getValue() {
return value;
}
}

只不过,泛型只能确定为一个引用类型,基本类型(int、double等)是不支持的。如果要存放基本数据类型的值,我们只能使用对应的包装类:

1
2
3
4
5
6
7
package org.ep;

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

但是因为基本类型的数组时引用类型,所以可以直接使用:

1
2
3
4
5
6
7
package org.ep;

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

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

泛型与多态

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

1
2
3
4
5
package org.ep;

public interface Study<T> {
T test(); //定义了一个返回值为T的抽象方法
}

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package org.ep;

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

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

继续使用泛型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package org.ep;

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

public static class A<T> implements Study<T> {
public T test() {
return null;
}
}
}

继承也是相同的:

1
2
3
4
5
6
7
static class A<T> {

}

static class B extends A<String> {

}

泛型方法

类型变量不只有在泛型类中才能使用,也可以定义泛型方法

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

1
2
3
4
5
6
7
8
9
10
11
package org.ep;

public class Main {
public static void main(String[] args) {
System.out.println(test("enen"));
}

private static <T> T test(T t) {
return t;
}
}
1
enen

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package org.ep;

import java.util.Arrays;

public class Main {
public static void main(String[] args) {
String[] strings = new String[1];
Main main = new Main();
main.add(strings, "hello"); //自动识别T类型为字符串类型
System.out.println(Arrays.toString(strings));
}

private static <T> void add(T[] arr, T t) { //将一个类型变量加到同类型变量的数组的头
arr[0] = t;
}
}

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

1
2
3
4
5
6
7
8
9
10
11
12
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));
}

Arrays.sort() 方法可以接受一个比较器(Comparator)作为参数,这是因为 Java 提供了方法重载(Overloading)的机制,允许同一个方法接受不同类型或数量的参数。在 Arrays 类中,有多个重载的 sort 方法,其中一些接受不同类型的比较器。

Arrays.sort(T[] a, Comparator<? super T> c) 方法接受一个泛型数组 a 和一个比较器 c,用于对数组进行排序。这个方法的定义允许通过传递一个自定义的比较器来指定排序规则。

比较器是一种实现了 Comparator 接口的类,该接口定义了一个 compare 方法,用于比较两个对象的顺序。你可以通过实现 compare 方法来自定义排序规则,然后将这个比较器对象传递给 Arrays.sort() 方法。

根据前面学习的Lambda表达式,这种只有一个方法需要实现的接口,就可以使用Lambda表达式实现Comparator接口:

1
2
3
4
5
6
7
8
9
10
11
package org.ep;

import java.util.Arrays;

public class Main {
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));
}
}
1
[9, 8, 7, 6, 5, 4, 3, 2, 1, 0]

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

泛型的界限

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package org.ep;

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 getScore() {
return value;
}
}

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

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

1
2
3
4
5
6
7
8
9
package org.ep;

import java.util.Arrays;

public class Main {
public static void main(String[] args) {
Score<? extends Integer> score = new Score<>("E", "13", 60);
}
}

同样的,泛型也有下界:
下界1

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package org.ep;

public class Score<T> {
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 getScore() {
return value;
}
}
1
2
3
4
5
6
7
8
9
10
package org.ep;

import java.util.Arrays;

public class Main {
public static void main(String[] args) {
Score<? extends Number> score = new Score<>("E", "13", 60);
Number n = score.getScore();
}
}

也就是说此时虽然使用了通配符,但是因为限定了上界,所以泛型变量已经不再是Object类型,而是变成了上界Number类型。但是如果限定下界,就不会改变泛型变量的类型,保持Object不变。

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

类型擦除

类型擦除是一种编译期优化,旨在确保泛型代码与旧版非泛型代码兼容,并且在运行时减少了泛型信息的存储开销。泛型的类型擦除主要包括以下几个方面:

  1. 类型参数擦除:在编译时,泛型类型的类型参数会被擦除,例如,List<Integer>List<String> 在运行时都被视为 List
  2. 泛型方法擦除:泛型方法的类型参数也会被擦除,方法的参数和返回类型会被转换为适当的泛型类型。
  3. 泛型类擦除:泛型类的类型参数也会被擦除,类的字段和方法会根据泛型类型参数的擦除后的类型进行调整。
  4. 桥方法生成:当泛型类或接口涉及到继承或实现时,编译器会生成桥方法,以确保类型安全性。
  5. 前面已经了解了如何使用泛型,那么泛型到底是怎么实现的:

    1
    2
    3
    4
    5
    package org.ep;

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

    泛型类型并不是一个真正存在的类型,因为所有的对象都是属于一个普通的类型,一个泛型类型变异之后,实际上会直接使用默认的类型Object

    1
    2
    3
    4
    5
    package org.ep;

    public abstract class A {
    abstract Object test (Object t);
    }

    如果我们限定了上界,则默认类型就会变成我们限定的上界的类型:

    1
    2
    3
    4
    5
    package org.ep;

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

    那么编译之后:

    1
    2
    3
    4
    5
    package org.ep;

    public abstract class A {
    abstract Number test (Number t);
    }

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

    1
    2
    3
    4
    5
    6
    7
    package org.ep;

    public class Main {
    public static void main(String[] args) {
    Test test = new Test();
    }
    }

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

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

    1
    2
    3
    4
    5
    6
    7
    8
    package org.ep;

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

    实际上编译之后:

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

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

    1
    2
    3
    4
    5
    6
    7
    8
    package org.ep;

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

    实际上编译之后:

    1
    2
    3
    4
    5
    6
    // 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); //桥接方法,这才是真正重写的方法,但是使用时会调用上面的方法
    }

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

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    public class B extends A {

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

    public String test(String str) { //我们自己写的方法
    return null;
    }
    }

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

    首先,在进行类型判断时instanceof Test<String,不允许使用泛型,只能使用原始类型,其次,泛型类型是不支持创建参数化类型数组的:Test<String>[] test = new Test<String>[10],因为运行时不会检查具体类型是什么。

    函数式接口

    函数式接口就是专门用于Lambda表达式的接口,这些接口都可以直接使用Lambda表达式,非常方便。

    Supplier供给型函数式接口

    这个接口时专门用于供给使用的,其中只有一个get方法用于获取需要的对象。

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

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

    1
    2
    3
    4
    5
    6
    7
    8
    //Student.java
    package org.ep;

    public class Student {
    public void hello() {
    System.out.println("hi");
    }
    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    package org.ep;

    import java.util.function.Supplier;

    public class Main {
    private static final Supplier<Student> STUDENT_SUPPLIER = Student::new;
    public static void main(String[] args) {
    Student student = STUDENT_SUPPLIER.get();
    student.hello();
    }
    }

    在这段代码中,创建了一个类型为Supplier<Student>类型的常量STUDENT_SUPPLIER,这个常量通过方法引用Student::new来表示一个能提供Student类对象的Supplier。然后在main方法中,使用STUDENT_SUPPLIER.get()调用了这个Supplier获取了一个Student对象,然后调用了hello()方法来执行Student对象的hello方法。

    通常情况下,对象的创建是一个开销较大的操作,如果你在某些情况下不需要立即创建对象,可以使用延迟创建的方式,只有在需要时才实际创建对象。使用这种方法就可以实现延迟创建对象,可以提高程序的性能和效率,因为不会在不需要的时候创建对象,从而节省了资源。

    Consumer消费型函数式接口

    专门用于消费某个对象:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    @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); };
    }
    }

    Consumer代表了接受一个输入参数并且不返回任何结果的操作。它的作用是执行一些操作或者消费输入的数据,通常用于需要对数据进行处理、输出、或者其他副作用操作的情况。Consumer 接口定义了一个名为 accept 的抽象方法,该方法接受一个参数并执行操作。这个接口可以用于各种场景,比如集合操作中对每个元素执行相同的操作,文件处理中对每行文本执行操作等等。

    1
    2
    3
    4
    5
    6
    //专门消费Student对象的Consumer
    private static final Consumer<Student> STUDENT_CONSUMER = student -> System.out.println(student+" eating");
    public static void main(String[] args) {
    Student student = new Student();
    STUDENT_CONSUMER.accept(student);
    }

    也可以使用andThen方法继续调用:

    1
    2
    3
    4
    5
    6
    7
    public static void main(String[] args) {
    Student student = new Student();
    STUDENT_CONSUMER //我们可以提前将消费之后的操作以同样的方式预定好
    .andThen(stu -> System.out.println("after eating"))
    .andThen(stu -> System.out.println("full"))
    .accept(student); //预定好之后,再执行
    }

    Function函数型函数式接口

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

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    @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方法:

    1
    2
    3
    4
    5
    6
    //这里实现了一个简单的功能,将传入的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将指定函数式的结果作为当前函数式的实参:

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

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

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

    Predicate断言型函数式接口

    接收一个参数,然后进行自定义判断并返回一个boolean结果。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    @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);
    }
    }

    编写一个简单的例子:

    1
    2
    3
    public class Student {
    public int score;
    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    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("不及格");
    }
    }

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

    1
    2
    3
    4
    5
    6
    7
    8
    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("优秀");
    }

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

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

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

    判空包装

    Optional类可以很有效的处理空指针问题。

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

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

    但是如果传入一个null,就会出现错误。所以我们在使用之前进行判空操作:

    1
    2
    3
    4
    5
    6
    private static void test(String str){
    if(str == null) return; //这样就可以防止null导致的异常了
    if(!str.isEmpty()) {
    System.out.println("length is:"+str.length());
    }
    }

    以上的这种情况,有了Optional类,就可以更加简便地处理这种问题:

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

    并且包装之后,再获取时就可以再简便一点:

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

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

    1
    2
    3
    4
    private static void test(String str){
    String s = Optional.ofNullable(str).orElse("Optional");
    System.out.println(s);
    }

    我们还可以将包装的类型直接转换为另一种类型:

    1
    2
    3
    4
    5
    6
    7
    private static void test(String str){
    Integer i = Optional
    .ofNullable(str)
    .map(String::length) //使用map来进行映射,将当前类型转换为其他类型,或者是进行处理
    .orElse(-1);
    System.out.println(i);
    }
    文章作者: Ep13
    文章链接: http://example.com/2023/10/07/JavaSE%E7%AC%94%E8%AE%B0%EF%BC%88%E4%B8%89%EF%BC%89%EF%BC%9A%E6%B3%9B%E5%9E%8B/
    版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 EpisodeXIII