泛型之类型擦除(type earasure)

一、类型擦除(官方介绍,看过的可以直接跳到二)

官方原文:https://docs.oracle.com/javase/tutorial/java/generics/erasure.html

1、通用类型的擦除

在类型擦除过程中,如果类型参数是有界的,Java编译器将擦除所有类型参数,并将每个类型参数替换为其第一个绑定;如果类型参数为无界的,则替换为Object。

考虑以下泛型类,该类表示单链列表中的节点:

public class Node<T> {

    private T data;
    private Node<T> next;

    public Node(T data, Node<T> next) {
        this.data = data;
        this.next = next;
    }

    public T getData() { return data; }
    // ...
}

由于类型参数T是无界的,Java编译器将其替换为Object:

public class Node {

    private Object data;
    private Node next;

    public Node(Object data, Node next) {
        this.data = data;
        this.next = next;
    }

    public Object getData() { return data; }
    // ...
}

在以下示例中,泛型Node类使用有界类型参数:

public class Node<T extends Comparable<T>> {

    private T data;
    private Node<T> next;

    public Node(T data, Node<T> next) {
        this.data = data;
        this.next = next;
    }

    public T getData() { return data; }
    // ...
}

Java编译器将有界类型参数T替换为第一个有界类Comparable:

public class Node {

    private Comparable data;
    private Node next;

    public Node(Comparable data, Node next) {
        this.data = data;
        this.next = next;
    }

    public Comparable getData() { return data; }
    // ...
}

2、通用方法的擦除

Java 编译器还会擦除泛型方法参数中的类型参数。请考虑以下通用方法:

// Counts the number of occurrences of elem in anArray.
//
public static <T> int count(T[] anArray, T elem) {
    int cnt = 0;
    for (T e : anArray)
        if (e.equals(elem))
            ++cnt;
        return cnt;
}

因为 T 是无界的,所以 Java 编译器将其替换为 Object:

public static int count(Object[] anArray, Object elem) {
    int cnt = 0;
    for (Object e : anArray)
        if (e.equals(elem))
            ++cnt;
        return cnt;
}

假设定义了以下类:

class Shape { /* ... */ }
class Circle extends Shape { /* ... */ }
class Rectangle extends Shape { /* ... */ }

您可以编写一个泛型方法来绘制不同的形状:

public static <T extends Shape> void draw(T shape) { /* ... */ }

Java 编译器将 T 替换为 Shape:

public static void draw(Shape shape) { /* ... */ }

3、类型擦除与桥接方法的影响(Effects of Type Erasure and Bridge Methods)

类型擦除有时会导致一些意料之外的情况,以下示例展示了这种情况如何发生。例如编译器可能需要创建一个合成方法(称为桥接方法),作为类型擦除过程的一部分。
考虑以下两个类:

public class Node<T> {

    public T data;

    public Node(T data) { this.data = data; }

    public void setData(T data) {
        System.out.println("Node.setData");
        this.data = data;
    }
}

public class MyNode extends Node<Integer> {
    public MyNode(Integer data) { super(data); }

    public void setData(Integer data) {
        System.out.println("MyNode.setData");
        super.setData(data);
    }
}

再考虑以下代码:

MyNode mn = new MyNode(5);
Node n = mn;            // A raw type - compiler throws an unchecked warning
n.setData("Hello");     // Causes a ClassCastException to be thrown.
Integer x = mn.data;    

类型擦除后,这段代码变为:

MyNode mn = new MyNode(5);
Node n = mn;            // A raw type - compiler throws an unchecked warning
                        // Note: This statement could instead be the following:
                        //     Node n = (Node)mn;
                        // However, the compiler doesn't generate a cast because
                        // it isn't required.
n.setData("Hello");     // Causes a ClassCastException to be thrown.
Integer x = (Integer)mn.data; 

下文解释了为什么在 n.setData(“Hello”); 这一行会抛出 ClassCastException。

4、桥接方法(Bridge Methods)

当编译一个【继承了参数化类】或【实现了参数化接口】的【类或接口】时,编译器可能需要创建一个合成方法,即桥接方法,作为类型擦除过程的一部分。通常你无需担心桥接方法,但如果它出现在堆栈跟踪中,你可能会感到困惑。
类型擦除后,Node 和 MyNode 类变为:

public class Node {

    public Object data;

    public Node(Object data) { this.data = data; }

    public void setData(Object data) {
        System.out.println("Node.setData");
        this.data = data;
    }
}

public class MyNode extends Node {

    public MyNode(Integer data) { super(data); }

    public void setData(Integer data) {
        System.out.println("MyNode.setData");
        super.setData(data);
    }
}

类型擦除后,方法签名不匹配;Node.setData(T) 方法变成了 Node.setData(Object)。因此,MyNode.setData(Integer) 方法并没有覆盖 Node.setData(Object) 方法。
为了解决这个问题,并在类型擦除后保留泛型类型的多态性,Java 编译器生成一个桥接方法来确保子类型关系按预期工作。
对于 MyNode 类,编译器为 setData 方法生成以下桥接方法:

class MyNode extends Node {

    // Bridge method generated by the compiler
    //
    public void setData(Object data) {
        setData((Integer) data);
    }

    public void setData(Integer data) {
        System.out.println("MyNode.setData");
        super.setData(data);
    }

    // ...
}

桥接方法 MyNode.setData(Object) 代理到原始的 MyNode.setData(Integer) 方法。结果是,n.setData("Hello"); 调用了 MyNode.setData(Object) 方法,由于 "Hello" 无法转换为 Integer 类型,因此抛出了 ClassCastException 异常。

字节码如下,桥接方法使用了synthetic (合成)关键字

public synthetic bridge setData(Ljava/lang/Object;)V
L0
LINENUMBER 3 L0
ALOAD 0
ALOAD 1
CHECKCAST java/lang/Integer
INVOKEVIRTUAL generics/bridgeMrthod/MyNode.setData (Ljava/lang/Integer;)V
RETURN
L1
LOCALVARIABLE this Lgenerics/bridgeMrthod/MyNode; L0 L1 0
MAXSTACK = 2
MAXLOCALS = 2

思考一下:synthetic合成关键字的其他使用场景

5、非可实例化类型

类型擦除Type Erasure) 讨论了编译器移除与类型参数和类型实参相关的信息的过程。类型擦除对那些变长参数(也称为 varargs)方法产生影响,特别是当这些方法的变长正式参数具有非可实例化类型时。有关变长参数方法的更多信息,请参阅 任意数量的参数Arbitrary Number of Arguments)部分,该部分位于 向方法或构造函数传递信息Passing Information to a Method or a Constructor )教程中。

本页面涵盖了以下主题:

非可实例化类型

可实例化类型是指在运行时可以完全获取其类型信息的类型,这包括基本类型、非泛型类型、原始类型,以及无界通配符的调用。非可实例化类型则是指那些在编译时由类型擦除过程移除了相关信息的泛型调用,具体来说,就是那些没有被定义为无界通配符的泛型调用。非可实例化类型在运行时并不保留所有类型信息。例如,List<String>List<Number> 这样的类型,在运行时 JVM 无法区分它们。如《泛型的限制(Restrictions on Generics)》章节所述,存在一些情况,非可实例化类型不能被使用,比如在 instanceof 表达式中,或者作为数组的元素

堆污染

当一个参数化类型的变量引用的对象并不是该参数化类型的实例时,就发生了堆污染。这种情况通常发生在程序执行了某些操作,导致在编译时产生了未检查警告。未检查警告会在编译时(在编译时类型检查规则的范围内)或运行时出现,当涉及到参数化类型的运算(如类型转换或方法调用)的正确性无法被验证时。例如,混合使用原始类型和参数化类型,或者进行未检查的类型转换,都可能导致堆污染。
在正常情况下,如果所有代码同时编译,编译器会发出未检查警告来提示你注意可能发生的堆污染。如果你分别编译代码的不同部分,很难检测到堆污染的潜在风险。如果你确保代码在没有警告的情况下编译,那么就不会发生堆污染。

含非可实例化正式参数的变长参数方法的潜在脆弱性

包含变长参数的泛型方法可能会引起堆污染。
考虑下面的 ArrayBuilder 类:

public class ArrayBuilder {

  public static <T> void addToList (List<T> listArg, T... elements) {
    for (T x : elements) {
      listArg.add(x);
    }
  }

  public static void faultyMethod(List<String>... l) {
    Object[] objectArray = l;     // Valid
    objectArray[0] = Arrays.asList(42);
    String s = l[0].get(0);       // ClassCastException thrown here
  }

}

下面的示例 HeapPollutionExample 使用了 ArrayBuilder 类:

public class HeapPollutionExample {

  public static void main(String[] args) {

    List<String> stringListA = new ArrayList<String>();
    List<String> stringListB = new ArrayList<String>();

    ArrayBuilder.addToList(stringListA, "Seven", "Eight", "Nine");
    ArrayBuilder.addToList(stringListB, "Ten", "Eleven", "Twelve");
    List<List<String>> listOfStringLists =
      new ArrayList<List<String>>();
    ArrayBuilder.addToList(listOfStringLists,
      stringListA, stringListB);

    ArrayBuilder.faultyMethod(Arrays.asList("Hello!"), Arrays.asList("World!"));
  }
}

编译时,ArrayBuilder.addToList 方法定义会产生以下警告

warning: [varargs] Possible heap pollution from parameterized vararg type T

当编译器遇到变长参数方法时,它会将变长形式参数转换为数组。但是,Java 编程语言不允许创建参数化类型的数组。在 ArrayBuilder.addToList 方法中,编译器将变长形式参数 T… elements 转换为 T[] elements 形式的数组。然而,由于类型擦除,编译器实际上将其转换为 Object[] elements。因此,存在堆污染的风险。

下面的语句将变长形式参数 l 赋值给了 Object 数组 objectArray

Object[] objectArray = l;

这可能引入堆污染。可以将不符合变长形式参数 l 参数化类型的值赋给变量 objectArray,进而赋给 l。但是,编译器在此语句中不会生成未检查警告。编译器在将变长形式参数 List<String>... l 转换成 List[] l 时已经生成了警告。这个语句是有效的;变量 l 的类型是 List[],它是 Object[] 的子类型。

因此,如果你将任何类型的 List 对象赋值给 objectArray 数组的任意数组元素,如下面的语句所示,编译器不会发出警告或错误:

objectArray[0] = Arrays.asList(42);

这条语句将一个包含一个 Integer 类型对象的 List 对象赋值给 objectArray 数组的第一个元素。

假设你使用下面的语句调用 ArrayBuilder.faultyMethod

ArrayBuilder.faultyMethod(Arrays.asList("Hello!"), Arrays.asList("World!"));

在运行时,JVM 在下面的语句处抛出 ClassCastException

// ClassCastException thrown here
String s = l[0].get(0);

存储在变量 l 的第一个数组元素中的对象具有 List<Integer> 类型,但这条语句期望的是 List<String> 类型的对象。

防止来自含非可实例化正式参数的变长参数方法的警告

如果你声明了一个具有参数化类型参数的变长参数方法,并且确保方法体不会因为不当处理变长形式参数而抛出 ClassCastException 或其他类似的异常,你可以通过在静态和非构造方法声明中添加以下注解来避免编译器为此类变长参数方法生成警告:

@SafeVarargs // 作用于方法和调用他的地方

@SafeVarargs 注解是方法契约(method’s contract)的一部分;这个注解声明方法的实现不会不当处理变长形式参数。

虽然不太理想,你也可以通过在方法声明中添加以下内容来抑制这类警告:

@SuppressWarnings({"unchecked", "varargs"}) // 只作用于内部,调用的地方还是会提示

但是,这种方法不会抑制从方法调用位置产生的警告。如果你对 @SuppressWarnings 语法不熟悉,可以查阅《注解(Annotations)》的相关资料。

二、类型擦除导致的问题

由于类型擦除的存在,会出现下列问题

  • 无法在泛型类中获取实际泛型类型
  • 无法在泛型接口中获取实际泛型类型
  • 无法在泛型方法中获取实际泛型类型

1、在泛型类中获取实际泛型类型

由于类型擦除,在编译后运行的代码中,对象是无法知道当前的类型是什么的,如上面举例的单链表Node

public class Node<T> {

    public T data;

    public Node(T data) { this.data = data; }

    public T getData() {
        return data;
    }

    public void setData(T data) {
        this.data = data;
    }

}

在编译完成之后,泛型信息真的就不见了吗?我们在下面创建一个Node<Integer>类型的节点

public class ErasureTest {

    public static void main(String[] args) {
        Node<Integer> node = new Node<>(5);
        System.out.println(node.data);
    }
}

然后运行后使用jclasslib插件查看字节码(IDEA菜单中View-Show ByteCode With Jclasslib),在main方法的LocalVariableTypeTable 区中可以看到node对象和签名

image-20240715234720366

点击签名cp_info #25,可以看到里面记载了node对象的实际类型。但这是调用处(ErasureTest)才知道的,在Node内部还是不知道当前的类型是什么。

所以如果我想在Node内部知道泛型的实际类型,我们应该怎么做?

方法一:传入实际类型

因为外部知道内部不知道,所以我们可以对Node类的构造函数进行改造,增加一个Clazz参数

public class Node<T> {

    public T data;
    
    public Class<T> clazz;

    public Node(T data, Class<T> clazz) { 
    	this.data = data; 
        this.clazz = clazz;
    }
	
    /*...*/

}

然后从外部传入,这个时候Node内部就知道当前类型是什么了。但是这样子需要修改代码,违背了开闭原则,并不推荐。

public class ErasureTest {

    public static void main(String[] args) {
        Node<Integer> node = new Node<>(5, Integer.class);
        // ...
    }
}
方法二:使用继承类

假如我们定义了一个DNode如下,他继承了Node<Integer>

public class DNode extends Node<Integer> {

    public DNode pre;

    public DNode next;
    
    public DNode(Integer data) {
        super(data);
    }
    
}

为了方便测试,我们在Node类内部添加一个打印泛型类型信息的方法,DNode继承之后也可以调用

// Node类内部方法
public void printGenericInfo() {
    ParameterizedType genericSuperclass = (ParameterizedType) this.getClass().getGenericSuperclass();
    Type[] typeArguments = genericSuperclass.getActualTypeArguments();
    for (Type typeArgument : typeArguments) {
        System.out.println(typeArgument.getTypeName());
    }
}

如果我们直接实例化Node再调用的话

public class ErasureTest {

    public static void main(String[] args) {
        Node<Integer> node = new Node<>(10);
        // 或Node node = new Node(10);
        node.printGenericInfo();
    }
}

// 输出:java.lang.ClassCastException:java.lang.Class cannot be cast to java.lang.reflect.ParameterizedType

会报错,提示无法转换,我们来看下this.getClass().getGenericSuperclass()getGenericSuperclass方法的说明

返回值 Type 表示这个实体(类、接口、基元类型或 void)的直接超类(superclass)

  • 如果超类是参数化类型(parameterized type),则返回的 Type 对象必须准确反映源代码中使用的实际类型参数(actual type parameters)
  • 如果之前未创建表示超类的参数化类型,则会创建该类型
  • 如果这个类表示Object类、接口、基本类型(int、long…)或 void,则返回 null。
  • 如果当前的对象代表的是一个数组类,那么返回的将是代表 Object 类的 Class 对象(换句话说:数组类的 Class 对象不是 Object.class,而是 " [I "(代表int[])之类的,但数组类的超类是 Object 类,因此 getSuperclass() 方法会返回 Object.class)

如何理解【如果之前未创建表示超类的参数化类型,则会创建该类型】

这句话涉及到Java泛型中的类型擦除(type erasure)以及参数化类型(parameterized type)的概念。

在Java中,当你定义一个泛型类或者使用一个泛型类的实例时,实际上发生的事情比表面上看到的要复杂一点。

让我们通过一个具体的例子来理解这句话的意思:

假设你有一个泛型类 MyList<T>,并且你想创建一个 MyList 的实例,其中 TString 类型。在Java中,你可以这样创建:

MyList<String> myList = new MyList<>();

这里,MyList<String> 就是一个参数化类型,它表示 MyList 类的一个具体实例,其中类型参数 T 被替换为了 String

然而,在Java的字节码层面,所有泛型信息都会被擦除,也就是说,编译后的字节码中,MyList<String> 实际上会被视为 MyList,即没有类型参数的原始类型(raw type)。这是因为Java的虚拟机并不理解泛型,泛型信息仅在编译期存在,用于类型检查和编译时的错误检测。

但是,为了支持泛型的运行时行为,Java引入了类型参数化(parameterization)的概念。当创建一个泛型类的实例时,如果该实例的类型参数之前没有被创建过,Java会创建一个参数化类型来代表这个带有类型参数的超类实例。这实际上意味着,虽然字节码中只看到了 MyList,但在运行时,JVM会记住 MyList 实例实际上是 MyList<String>

换句话说,这句话【如果之前未创建表示超类的参数化类型,则会创建该类型】意味着当第一次创建一个带有特定类型参数的泛型类实例时,Java会记住这个具体的参数化类型,以便在后续的类型检查和运行时操作中使用。例如,当你调用 myList.add("Hello") 时,JVM会知道 add 方法应该接收一个 String 类型的参数,而不是任何其他类型,即使在字节码中没有显式地记录这一点。
这种机制允许Java在运行时维护泛型的安全性和语义,同时又不会增加虚拟机的复杂度。

所以直接实例化Node,所有的类的父类都是Object,所以会返回Object类导致报错。我们现在修改为下面代码

public class ErasureTest {

    public static void main(String[] args) {
        DNode dNode = new DNode(10);
        dNode.printGenericInfo();
    }
}

// 输出结果:java.lang.Integer

这样子DNode内部就可以获取到实际类型了。思考一下,这样子还能获取到吗

public class ErasureTest {

    public static void main(String[] args) {
        DNode dNode = new DNode(10);
        Node node = dNode;// 向上转型会导致自身类型变成父类类型吗?可以试试看
        node.printGenericInfo();
    }
}
方法三:使用匿名内部类
public class ErasureTest {

    public static void main(String[] args) {
        Node<Integer> node = new Node<Integer>(10){}; // {}定义了一个匿名内部类,
        											// 由于Node没有需要重写的方法,所以不需要内容
        node.printGenericInfo();
    }
}
方法四:使用对象的getClass()方法

如果data对象不为空,那我们直接调用data对象的getClass()方法可以得到当前对象的实际类型

public void printGenericInfo() {
    System.out.println(data.getClass());
}
方法五:使用JAVAssist插件,具体查阅其他相关资料

2、 在泛型接口中获取实际泛型类型

定义一个接口

public interface TestFunction<T, U> {

    U test(T obj);

    // 方便测试需要
    default void printGenericInfo() {
        Type[] genericInterfaces = this.getClass().getGenericInterfaces(); // 注意这里跟泛型类的获取方式不一样
        for (Type inf : genericInterfaces) {
            ParameterizedType parameterizedType = (ParameterizedType) inf;
            Type[] typeArguments = parameterizedType.getActualTypeArguments();
            for (Type typeArgument : typeArguments) {
                System.out.println(typeArgument.getTypeName());
            }
        }
    }
}

当然,你可以通过传入Class参数获取实际类型,但是正如上面说的,我并不推荐。

我们通过实现类、匿名内部类和lambda表达式的方式看接口内部是否能够获取到当前的实际类型

方法一:实现类
// 实现类
public class TestConvertFunc implements TestFunction<Integer, String> {

    @Override
    public String test(Integer obj) {
        return String.valueOf(obj);
    }

}

测试之后,实现类是可以成功获取实际泛型类型的

public class ErasureTest {

    public static void main(String[] args) {
        System.out.println("实现类");
        TestConvertFunc func = new TestConvertFunc();
        func.printGenericInfo();
    }
    
}

// 输出结果
实现类
java.lang.Integer
java.lang.String
方法二:匿名内部类和lambda表达式
public class ErasureTest {

    public static void main(String[] args) {
        System.out.println("匿名内部类");
        TestFunction<Integer, String> innerFunc = new TestFunction<Integer, String>() {
            @Override
            public String test(Integer obj) {
                return String.valueOf(obj);
            }
        };
        innerFunc.printGenericInfo();
        
        System.out.println("lambda表达式");
        TestFunction<Integer, String> lambdaFunc = obj -> String.valueOf(obj);
        lambdaFunc.printGenericInfo();
    }
    
}

// 输出结果
匿名内部类
java.lang.Integer
java.lang.String
lambda表达式
Exception in thread "main" java.lang.ClassCastException: java.lang.Class cannot be cast to java.lang.reflect.ParameterizedType
	at generics.test.TestFunction.printGenericInfo(TestFunction.java:16)
	at generics.test.ErasureTest.main(ErasureTest.java:24)

可以看到匿名内部类可以成功获取实际类型的,但是lambda表达式却不可以,这是为什么?我们分开来看下

在编译匿名内部类的时候,可以发现在编译后的文件夹里生成了两个文件

image-20240716160707316

其中ErasureTest$1.class就是匿名内部类了(下图),可以看到他是单独一个类,跟实现类是一样的,会把实际泛型类型保存下来

package generics.test;

final class ErasureTest$1 implements TestFunction<Integer, String> {
    ErasureTest$1() {
    }

    public String test(Integer obj) {
        return String.valueOf(obj);
    }
}

现在来看lambda表达式,在编译后的文件夹中只有一个文件(下图),也就是说lambda表达式在编译期间不会生成新的内部类,而是直接使用原有类,所以在经历类型擦除后会失去所有类型信息

image-20240716160936010

方法三:使用对象的getClass()方法

如果接口中存在泛型对象,且对象不为空,那我们直接调用对象的getClass()方法可以得到当前对象的实际类型

obj.getClass());

3、在泛型方法中获取实际类型

泛型方法分为在泛型类/接口中不在泛型类/接口中

1)、在泛型类/接口中的泛型方法中获取实际类型

还是以链表的节点为示例,我们添加了两个方法用于连接下一个节点,分别是静态方法和非静态方法

public class Node<T> {

    public T data;
    
    public Node<T> next;

    public Node(T data) {
        this.data = data;
    }
    
    // 静态方法
    public static <U> void link(Node<U> node, Node<U> another) {
        node.next = another;
    }
    
    // 非静态方法
    public void link(Node<T> another) {
        this.next = another;
    }

}

对于静态方法,此时他的实际泛型类型其实已经跟类没有关系了,他是独立的,我将合并在下面解释。

对于非静态方法,由于泛型的实际类型在父类上,所以跟在泛型类/接口上获取实际泛型类型是一样的。

除此之外,还可以使用成员变量存储在对象中

public class MyGenericClass<T> {
    private Class<T> type;

    public MyGenericClass(Class<T> type) {
        this.type = type;
    }

    public void doSomethingWithT() {
        System.out.println(type.getName());
    }
}
2)、在非泛型类/接口中的泛型方法中获取实际类型

静态泛型方法非静态泛型方法的获取方式一样

假设存在下面泛型方法,他可以对列表中一些数据进行处理,我们如何获取列表中对象的实际类型呢?

public interface GenericUtil {

    static <T extends Number & Comparable<T>, U extends T> void handleList(List<U> list) {
        for (int i = 0; i < list.size(); i++) {
            U obj = list.get(i);
            // 分类型操作
        }
    }
}
方法一:使用对象的getClass()方法
U obj = list.get(i);
if (obj.getClass() == Integer.class) {
    // ...
} else {
    // ...
}

这样子可以获取到实际类型,因为我们是从对象获取的,而不是从方法声明获取的,但这仅限于列表不为空。

但是这样子每增加一种类型都要修改这个方法,不符合开闭原则

方法二:传入类型参数
public interface GenericUtil {

    static <T extends Number & Comparable<T>, U extends T> 
    void handleList(List<U> list, Class<?> clazz) {
        for (int i = 0; i < list.size(); i++) {
            U obj = list.get(i);
            if (clazz == Integer.class) {
                // ...
            }
        }
    }
}
方法三:使用泛型函数式接口

使用泛型函数式接口之后,我们就无需关心内部的实际类型,只需要在外部处理好对应逻辑就可以了

public interface Handler<T> {
    T handle(T t);
}

public interface GenericUtil {

    static <T extends Number & Comparable<T>, U extends T>
    void handleList(List<U> list, Handler<U> handler) {
        for (int i = 0; i < list.size(); i++) {
            U obj = list.get(i);
            U updObj = handler.handle(obj);
            list.set(i, updObj);
        }
    }

}

调用的时候就可以通过匿名内部类或lambda表达式的形式,而且每增加一种类型只修改Handler的handle方法就可以了

public class ErasureTest {

    public static void main(String[] args) {
        List<Integer> list = new ArrayList<>();
        list.add(1); list.add(2); list.add(3);
        GenericUtil.handleList(list, obj -> obj + 1);
        for (Integer i : list) {
            System.out.println(i);
        }
        // 增加新类型
        List<Double> doubles = new ArrayList<>();
        doubles.add(1.2); doubles.add(2.3); doubles.add(3.4);
        GenericUtil.handleList(doubles, obj -> obj + 1);
        for (Double i : doubles) {
            System.out.println(i);
        }
    }
}
  • 15
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值