V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
amiwrong123
V2EX  ›  程序员

Java 泛型擦除与补偿的迷惑

  •  
  •   amiwrong123 · 2019-09-11 00:09:13 +08:00 · 4373 次点击
    这是一个创建于 1894 天前的主题,其中的信息可能已经有所发展或是发生改变。

    例子均来自 java 编程思想:

    //: generics/ArrayMaker.java
    import java.lang.reflect.*;
    import java.util.*;
    
    public class ArrayMaker<T> {
        private Class<T> kind;
        public ArrayMaker(Class<T> kind) { this.kind = kind; }
        @SuppressWarnings("unchecked")
        T[] create(int size) {
            return (T[])Array.newInstance(kind, size);
        }
        public static void main(String[] args) {
            ArrayMaker<String> stringMaker =
                    new ArrayMaker<String>(String.class);
            String[] stringArray = stringMaker.create(9);
            System.out.println(Arrays.toString(stringArray));
        }
    } /* Output:
    [null, null, null, null, null, null, null, null, null]
    *///:~
    

    作者刚在这个例子下说:即使 kind 被存储成了 Class<t>,擦除也意味着它实际上将存储为 Class,没有任何参数。Array.newInstance 传递过去的参数实际并未拥有 kind 所蕴含的类型信息。 我觉得作者说的有道理,确实 Class<t>的 T 作为类型参数不会被实际存储,但看完我有点担心对 Class 对象的使用了,因为 Class 对象并没有存储实际类型。我甚至开始怀疑以前 Class 对象的用法,它都没有存储实际类型,那它到底是怎么 newInstance 的呢?怎么还能产生正确的结果呢?</t></t>

    //: generics/ClassTypeCapture.java
    
    class Building {}
    class House extends Building {}
    
    public class ClassTypeCapture<T> {
        Class<T> kind;
        public ClassTypeCapture(Class<T> kind) {
            this.kind = kind;
        }
        public boolean f(Object arg) {
            return kind.isInstance(arg);
        }   
        public static void main(String[] args) {
            ClassTypeCapture<Building> ctt1 =
                new ClassTypeCapture<Building>(Building.class);
            System.out.println(ctt1.f(new Building()));
            System.out.println(ctt1.f(new House()));
            ClassTypeCapture<House> ctt2 =
                new ClassTypeCapture<House>(House.class);
            System.out.println(ctt2.f(new Building()));
            System.out.println(ctt2.f(new House()));
        }
    } /* Output:
    true
    true
    false
    true
    *///:~
    

    这里作者说:如果引入类型标签,就可以转而使用动态的 isInstance 了。总之他意思是用 Class 对象可以对擦除进行补偿。

    但这个例子中居然可以使用 Class 对象的 isInstance 方法,而且可以正确的返回值。那他前面强调的“即使 kind 被存储成了 Class<t>,擦除也意味着它实际上将存储为 Class”的这个担心点也不复存在了吗?</t>

    总之,问题就是:1 是否 Class 对象不会存储实际类型呢? 2 如果 Class 对象没有存储实际类型,那它到底是怎么正确工作的呢?

    第 1 条附言  ·  2019-09-11 16:15:37 +08:00
    感谢各位大佬的回答,现在基本理解了。这里简单说一下吧:
    ArrayMaker 的字节码或者说在运行期里面,程序只知道 kind 是一个 Class 而已,而没有具体得类型,这是因为擦除。

    而 ArrayMaker<String> stringMaker = new ArrayMaker<String>(String.class);这句的右边显示指定了类型为<String>,那么 create 方法的返回值那里编译器会隐式地加上一句强制类型转换(String[])。相对的,如果是 ArrayMaker<String> stringMaker = new ArrayMaker(String.class),那么 create 方法的返回值就是肯定 Object[],而不是 String[]了。
    第 2 条附言  ·  2019-09-12 10:38:12 +08:00

    我发现第一个例子还有个知识点,各位大佬帮忙看看我理解得对吗?(泛型数组) T[] create(int size)这里返回的是一个泛型数组,但一般的返回泛型数组的函数实际都是返回Object[](并且编译器不会帮忙添加类型转换),因为“任何方式可以推翻底层的数组类型”,但这里有点特殊,因为这里用了反射包里的Array类。 通过下面试验:

            Object[] a = new Object[5];
            //Integer[] b = a;报错,此句证明了Object[]转Integer[]必须强制转换
    

    第一个例子的java汇编如下:

      public static void main(java.lang.String[]);
        descriptor: ([Ljava/lang/String;)V
        flags: (0x0009) ACC_PUBLIC, ACC_STATIC
        Code:
          stack=3, locals=3, args_size=1
             0: new           #5                  // class ArrayMaker
             3: dup
             4: ldc           #6                  // class java/lang/String
             6: invokespecial #7                  // Method "<init>":(Ljava/lang/Class;)V
             9: astore_1
            10: aload_1
            11: bipush        9
            13: invokevirtual #8                  // Method create:(I)[Ljava/lang/Object;
            16: checkcast     #9                  // class "[Ljava/lang/String;"
            19: astore_2
    

    从// Method create:(I)[Ljava/lang/Object;可以看出,create方法返回的还是Object[]。从 // class "[Ljava/lang/String;"可以看出,这里编译器帮我添加了一句类型转换(因为前面证明了必须添加类型转换,但我的源码里并没有添加,所以只能是编译器添加的)。

    所以我想问:使用了Array.newInstance的返回泛型数组的方法,就和一般的返回泛型数组的方法不一样了吗?前者由于生成了真正对象的数组,所以编译器才会帮我添加类型转换的吗?

    34 条回复    2019-09-12 18:42:44 +08:00
    zgqq
        1
    zgqq  
       2019-09-11 02:07:12 +08:00
    Class 包含了实际类型信息, 要知道调用方想要的类型只能通过 Class,Java 的泛型只是用来做类型检查,尽量在编译期提示错误,想一下如果没有泛型到处都是 Object 对象,很容易就 ClassCastException 了
    amiwrong123
        2
    amiwrong123  
    OP
       2019-09-11 09:32:46 +08:00
    @zgqq
    Class<T>类作为一个泛型类,它是怎么保存实际类型信息的呢?它就是要特殊一点是吗
    napsterwu
        3
    napsterwu  
       2019-09-11 09:39:01 +08:00
    任何一个对象都有 getClass 方法,调一下就知道了
    bkmi
        4
    bkmi  
       2019-09-11 09:47:33 +08:00 via Android
    @amiwrong123 XXX.class ≠ Class<XXX>
    一个是子类,不接收泛型参数,一个是基类,接收泛型参数
    guyeu
        5
    guyeu  
       2019-09-11 10:16:20 +08:00
    Class 并不依靠泛型提供类型信息,它本身就是类型信息;
    泛型擦除会擦除所有的动态泛型信息,但是会保留泛型类型声明里的类型信息,到运行时就是参数化类型了。
    amiwrong123
        6
    amiwrong123  
    OP
       2019-09-11 10:21:47 +08:00
    @bkmi
    等一下,我有点蒙,左边右边不都是 Class 对象吗,怎么还有子类和基类
    amiwrong123
        7
    amiwrong123  
    OP
       2019-09-11 10:23:52 +08:00
    @guyeu
    泛型本来就会擦除,那肯定是也不能依靠泛型提供类型信息了。

    但“它本身就是类型信息”,这句我可能还没怎么理解==(虽然我知道 Class 对象的大概用法)
    guyeu
        8
    guyeu  
       2019-09-11 10:28:35 +08:00
    @amiwrong123 #7 Java 中每个对象都会持有一个 Class 类的引用,这个 Class 类就是该对象的类型。Class 类本身就是用来描述类型的,当然不需要任何额外的信息
    Aresxue
        9
    Aresxue  
       2019-09-11 10:32:14 +08:00
    擦除是擦除到上界(比如你这里 House 的上界就是 Building ),不是直接啥都擦没了
    icris
        10
    icris  
       2019-09-11 10:39:00 +08:00
    类型是类型,对象是对象,kind 是对象,给 kind 标成 Object 它一样保存类型信息。
    简单的解决方案:Java 一开始没有范型。
    bkmi
        11
    bkmi  
       2019-09-11 10:47:42 +08:00 via Android
    @amiwrong123 是我说错了,没有子类基类的关系,
    XXX.class 是 Class<XXX> 的实例,
    具体的类型信息存储在内部一堆成员变量上(包括 Nativie 部分),
    这里的泛型只是用于编码和编译期间的检查,不会存储任何信息
    Raymon111111
        12
    Raymon111111  
       2019-09-11 10:48:25 +08:00
    这个如果你深究 jvm 是怎么存 object 和 class 就很容易懂.

    每一个 object 都有一个引用(指针)指向了平常说的方法区(perm 区 /metaspace)中一个类信息对象, 存着这个 object 对应类的元信息, 通过这个指针就可以拿到这个 object 本身对应类的信息, 反射什么的也是从这里拿东西的
    amiwrong123
        13
    amiwrong123  
    OP
       2019-09-11 11:00:03 +08:00
    @icris
    所以 private Class<T> kind;这句里面的 T 并不会让泛型代码存储到实际类型,这是因为擦除。

    但 kind 指向的那个东西就是实际类型呗。。总感觉我是不是该好好研究一下 Class 才行啊。。
    oneisall8955
        14
    oneisall8955  
       2019-09-11 11:03:19 +08:00 via Android
    1 楼正解,泛型是为了编码时候,编译就能发现可能存在的问题从而做出最严格安全检测而已,实际上存的都是 Object 类型。编译器总是按照最安全的地方去检测。如果调用反射,就会破除这种限制,从而出现类型转换异常可能性。昨天写了一个小小的 Demo,不是很对题,但可以看出一些问题 https://github.com/oneisall8955/learn/blob/master/src/main/java/com/oneisall/learn/java/advanced/genericity/ErasureTest.java
    kifile
        15
    kifile  
       2019-09-11 11:04:18 +08:00
    String.class != Integer.class,你传入的时候就是一个对象,本身就具备属性信息
    amiwrong123
        16
    amiwrong123  
    OP
       2019-09-11 11:05:52 +08:00
    @Raymon111111
    确实想研究一下了,Class 到底是怎么存储实际类型的。

    还有就是,既然泛型会进行擦除,Class 的源码设计成了 Class<T>这样的泛型到底有什么用。
    Raymon111111
        17
    Raymon111111  
       2019-09-11 11:09:37 +08:00
    @amiwrong123 java 里的泛型主要为了强类型编译期检查错误.

    你把所有的东西都声明成 Object 类型也是可以跑通的. 但是很明显容易出错, 可能会把一个 Apple 赋值给 Banana
    iffi
        18
    iffi  
       2019-09-11 11:16:08 +08:00
    泛型定义不会被擦除,反射时可以动态获取泛型参数信息
    momocraft
        19
    momocraft  
       2019-09-11 11:19:59 +08:00
    Class 不是对象

    你能用 jawa 访问到的只是 Class (包括.java .class) 的一个 handle / representation
    shily
        20
    shily  
       2019-09-11 12:01:06 +08:00   ❤️ 2
    @amiwrong123 我觉得你没有理解,类型和值的问题。
    Class<T> 是类型;而 Building.class 是值,是具体的实现;例如:
    void func(Class<? extends Building> clazz)
    有如下的限定 Class<? extends Building> 用来接受 Building 及其子类。
    在编译期间,func(Building.class) 和 func(House.class) 是合法的,他们的类型符合 Building 的任意子类,但 String.class 不行。


    类型擦除是指,编译完成后,方法变成了
    void func(Class clazz)
    因为丢失了类型信息,可以传入 String.class 了。

    你说的 『 Array.newInstance 传递过去的参数实际并未拥有 kind 所蕴含的类型信息』,是错误的。虽然类型被变量 clazz 的类型被擦除了,但是一个对象 String.class 的具体实现并不会丢失。值并没有改变呐大兄弟。

    进而
    String s = "ok Google";
    Object o = s;

    虽然说 o 对于 s 来说 『类型』被擦除了,但它依然是 String 类型,依然可以调用 String 相关的方法,依然可以转换回 String,依然可以反射到 String 类型。
    mxalbert1996
        21
    mxalbert1996  
       2019-09-11 12:12:14 +08:00
    不是很能理解楼主为什么会这么想,如果类型擦除能把 Class 对象本身代表的类型信息都擦掉的话那你说 Class 对象的 getName()方法返回什么?难道全部返回"java.lang.Object"么?
    amiwrong123
        22
    amiwrong123  
    OP
       2019-09-11 14:10:20 +08:00
    @shily
    感谢回答,感觉有点弄清楚了。其实就是,虽然引用 Class<T>的类型参数被擦除了,但其 Class 对象还是没有改变。

    专门去看了 Class 的源码,大概有这几个函数用了 T:
    1. public T newInstance()
    2. public TypeVariable<Class<T>>[] getTypeParameters()
    3. public native Class<? super T> getSuperclass()
    4. public Constructor<T> getConstructor(Class<?>... parameterTypes)
    5. public Constructor<T> getDeclaredConstructor(Class<?>... parameterTypes)

    以最常用的 newInstance 为例,以泛型的知识来理解,这里也应该是: 调用 newInstance 返回的是一个 Object,然后隐式地在这里加了一句强制类型转换呗。
    leafin
        23
    leafin  
       2019-09-11 14:43:51 +08:00
    其实这个问题在 1 楼就已经回答了。
    在运行时关于类型的信息都是保存在 Class 里面的,这个时候没有泛型的事。
    泛型是用来在编译期防止不恰当的访问的,编译完成后,泛型信息就被擦除了。
    希望你能明白编译期和运行时的区别。
    yumeko
        24
    yumeko  
       2019-09-11 16:57:07 +08:00
    Java 的类型检查包括静态的和动态的检查。
    静态检查由 Java 编译器进行。动态检查由 Java 虚拟机进行。
    动态检查可以防止错误的 type cast 造成程序产生 undefined behavior。
    当然动态检查的结果是,错误的 type cast 会让程序产生 Error 而退出执行。
    范型虽然会擦除类型,但是如果代码里没有 type cast,那么执行就是安全的。
    T 是一个类型参数,你不能用一个类型参数来产生类型实例。
    类型参数只是给编译器确定代码里一个范型类的具体类型是什么。
    所以范型类内部无法使用 T 来确定需要构造什么类型。
    但是 Java Byte Code 在具体的范型类实现上又会保存类型信息,并且可以通过反射 API 获取。
    例如说 static List<String> getList() {...} 的函数签名返回类型是 java.util.List<java.lang.Object>,
    但是范型信息会保存 java.util.List<java.lang.String>。
    这样可以使用反射来对某一个范型类型的特化类型生成一个具有特化参数实例的安全的范型实例。
    pursuer
        25
    pursuer  
       2019-09-11 17:42:17 +08:00
    附言的理解不对啊,Java 的泛型只是用于编译器检查的,对实际生成的代码是没有影响的
    amiwrong123
        26
    amiwrong123  
    OP
       2019-09-11 17:50:40 +08:00
    @pursuer
    有影响的,泛型除了检查外,在明确指定了泛型的具体类型后( ArrayMaker<String> stringMaker = new ArrayMaker<String>(String.class);),返回 T 类型的方法会隐式地加一句强制类型转换,这一点我刚才通过 javap 看 java 汇编看出来了的。
    pursuer
        27
    pursuer  
       2019-09-11 17:53:41 +08:00
    @pursuer 不过好像这么理解也没错,kind 是一个 Class,运行期要用 isAssignabelFrom getName 之类的方法才能判断类型 kind 代表的类型
    amiwrong123
        28
    amiwrong123  
    OP
       2019-09-11 17:54:42 +08:00
    @pursuer
    准确的说,泛型代码的字节码没有变化。但是在主函数的调用 create 方法的地方,会隐式地加一句强制类型转换。
    pursuer
        29
    pursuer  
       2019-09-11 17:57:55 +08:00
    @amiwrong123 这样,我之前一直以为泛型对代码生成是没有影响的,感觉一般泛型 xxx<xxx>最后都会变成 xxx<Object>
    amiwrong123
        30
    amiwrong123  
    OP
       2019-09-12 11:15:49 +08:00
    @zgqq
    @napsterwu
    @bkmi
    @guyeu
    @Raymon111111
    @shily
    @leafin
    各位大佬,能否帮忙看一下附言 2 关于泛型数组的我的理解对吗?
    guyeu
        31
    guyeu  
       2019-09-12 16:30:22 +08:00
    声明的一个泛型类型是有上界和下界的,泛型擦除会把它的类型擦除到它的上界,默认情况下就是 Object 了。
    你可以把泛型理解为编码期间用于提示类型的一种语法糖(当然这有点片面),返回一个泛型数组的方法比如你这个 ArrayMaker.create,它的类型在你 new 出这个 ArrayMaker 实例的时候就给它指定了。

    另外,我使用的 jdk11,不可以进行类似的强转:
    ```java
    Integer[] array = (Integer[]) new Object[] {};
    ```
    这是因为`new Object[] {}`的类型是`Object[]`,而 java 是不支持向下的强制类型转换的。
    Array.newInstance 是一个 native 的方法,它返回的就是一个指定类型的数组,而你的字节码里的强制类型转换是你的代码里原本就存在的,并不是编译器帮你加的。
    amiwrong123
        32
    amiwrong123  
    OP
       2019-09-12 17:21:51 +08:00
    @guyeu
    我才发现,如果是 java12,所以这样 Integer[] b = (Integer[]) new Object[] {};居然是能通过编译的。然后试了 java8,也是不可以的。


    “而你的字节码里的强制类型转换是你的代码里原本就存在的,并不是编译器帮你加的。”(代码里也没有写(String[])的啊),难道你意思就是,因为 String[] stringArray = stringMaker.create(9);左边是 String[],所以就有了强制转换呗。但是,我觉得强制转换是需要自己写出来的啊,像这样 String[] stringArray = (String[])stringMaker.create(9);。毕竟 create 方法返回的是 Object[],从 // Method create:(I)[Ljava/lang/Object;也能看出来。

    而且根据 java8 的测试,就算写了也是编译通不过。那这里 String[] stringArray = stringMaker.create(9);到底是怎么执行成功的呢。

    有点笨,见谅哈==
    guyeu
        33
    guyeu  
       2019-09-12 18:39:23 +08:00
    @amiwrong123 #32
    你的这个实例的泛型参数是 String,然后`return (T[])Array.newInstance(kind, size);`这句话进行了显式的强制类型转换,所以我说代码里本来就有强制类型转换。

    `Array.newInstance`和`new Object[] {}`是不一样的,Array.newInstance 调用 native 方法生成了一个指定类型的对象数组,即使方法签名的返回值是 Object,那只是一个符号,实际的对象的类型是指定类型的数组,而`new Object[] {}`创建了一个 Object 类型的数组。

    而像你那样的强制类型转换编译是可以通过的,因为编译器并不负责保证你的显式强制类型转换的类型安全,但是在运行时会抛 ClassCastException。
    guyeu
        34
    guyeu  
       2019-09-12 18:42:44 +08:00
    @pursuer #29
    @amiwrong123 #32
    说泛型对字节码没有影响是片面的,泛型的上界会影响泛型变量的类型,类 /接口声明时的泛型参数会被记录,可以在运行时通过反射 API 获取。
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   2695 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 26ms · UTC 10:58 · PVG 18:58 · LAX 02:58 · JFK 05:58
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.