用var替换Object:可能出什么问题?

我最近遇到了一种情况,在Java 10程序中用var替换Object会在运行时引发异常。 我对实现这种效果的几种不同方法感兴趣,并向社区提出了这个问题:



事实证明,您可以通过不同的方式实现效果。 尽管它们都有些复杂,但以这样的任务为例来回顾语言的各种细微之处是很有趣的。 让我们看看找到了什么方法。


会员


在受访者中,有很多名人,但不是很多。 这是Sergey bsideup Egorov,Pivotal员工,演讲者,Testcontainers的创建者之一。 这是Victor Polishchuk ,以关于血腥企业的报道而闻名。 还注意到Google的Nikita Artyushov德米特里•米哈伊洛夫Dmitry Mikhailov)麦克西莫(Maccimo) 。 但是我对Wouter Coekaerts的到来感到特别高兴。 去年 ,他以他的文章而闻名,在那里他浏览了Java类型系统,并讲述了它多么无希望地被打破了。 jbaruch的一些文章甚至在Java Puzzlers的第四版中使用过。


任务和解决方案


因此,我们任务的本质是:存在一个Java程序,其中声明了一个变量,形式为Object x = ... (诚​​实的标准java.lang.Object ,没有类型替代)。 该程序将编译,运行并打印类似“确定”的内容。 我们用var替换Object ,要求自动类型推断,此后程序继续编译,但在启动时崩溃,并出现异常。


解决方案可以大致分为两组。 首先,用var替换后,变量变为原始变量(也就是说,它最初是自动装箱的)。 第二种类型仍然是object,但是比Object更具体。 在这里,您可以突出显示一个使用泛型的有趣子组。


装箱


如何区分对象和基元? 有很多不同的方法。 最简单的方法是检查身份。 该解决方案由Nikita提出:


 Object x = 1000; if (x == new Integer(1000)) throw new Error(); System.out.println("Ok"); 

x是一个对象时,通过引用新对象new Integer(1000)当然不能相等。 并且如果它是原始的,则根据语言的规则, new Integer(1000)立即展开为原始,并将数字作为原始进行比较。


另一种方法是重载方法。 您可以编写自己的代码,但Sergey提出了一个更优雅的选择:使用标准库。 List.remove方法是List.remove ,它是重载的,如果传递了原语,则可以按索引删除元素,如果传递对象,则可以按值删除元素 。 如果使用List<Integer>这会反复导致实际程序中的错误。 对于我们的任务,解决方案可能如下所示:


 Object x = 1000; List<?> list = new ArrayList<>(); list.remove(x); System.out.println("Ok"); 

现在,我们尝试从空列表中删除不存在的元素1000,这只是一个无用的操作。 但是,如果将Object替换为var ,我们将调用另一个方法来删除索引为1000的元素。这已经导致IndexOutOfBoundsException


第三种方法是类型转换运算符。 我们可以成功地将另一个基元转换为基元类型,但是只有在要转换为相同类型的包装器上才会转换对象(然后才会进行装箱)。 实际上,我们需要相反的效果:在原始情况下而不是在对象情况下会发生异常,但是使用try-catch可以轻松实现,Viktor曾使用过:


 Object x = 40; try { throw new Error("Oops :" + (char)x); } catch (ClassCastException e) { System.out.println("Ok"); } 

在这里, ClassCastException是预期的行为,然后程序正常退出。 但是在使用var该异常消失了,我们抛出了其他东西。 我想知道这是否受到血腥企业的真实代码的启发?


Wouter提出了另一个类型转换选项。 您可以使用运算符的奇怪逻辑?: 。 没错,它的代码只会给出不同的结果,因此您必须以某种方式对其进行修改,以免出现异常。 因此,在我看来,非常优雅:


 Object x = 1.0; System.out.println(String.valueOf(false ? x : 100000000000L).substring(12) + "Ok"); 

此方法之间的区别是我们不直接使用x的值,而是类型x影响表达式false ? x : 100000000000L的类型false ? x : 100000000000L false ? x : 100000000000L 。 如果x是一个Object ,则整个表达式的类型为Object ,然后我们只有装箱, String.valueOf()String.valueOf()字符串100000000000 ,其中substring(12)是一个空字符串。 如果使用var ,则类型x变为double ,因此类型为false ? x : 100000000000L false ? x : 100000000000L也是double ,即100000000000L将变成1.0E11 ,该字符少于12个字符,因此调用substring会导致StringIndexOutOfBoundsException


最后,我们利用了一个事实,即变量在创建后实际上可以更改。 并且在对象变量中,与原始变量不同,您可以放置null 。 将null放入变量很容易;有很多方法。 但是在这里,Wouter还使用了荒谬的Integer.getInteger方法采取了一种创造性的方法:


 Object x = 1; x = Integer.getInteger("moo"); System.out.println("Ok"); 

并非所有人都知道此方法读取一个称为moo的系统属性,如果存在,将尝试将其转换为数字,否则返回null 。 如果没有属性,我们将为对象安静地分配null ,但是在尝试将其分配给原始对象时会从NullPointerException掉落(在那里发生自动装箱)。 当然,它本来可以更容易。 粗糙版本x = null; 它不会爬网-它不会编译,但是编译器现在会吞下它:


 Object x = 1; x = (Integer)null; System.out.println("Ok"); 

对象类型


假设您不再可以使用基本体。 您还能想到什么?


好吧,首先,Dmitry提出了最简单的方法重载方法:


 public static void main(String[] args) { Object x = "Ok"; sayWhat(x); } static void sayWhat(Object x) { System.out.println(x); } static void sayWhat(String x) { throw new Error(); } 

Java中重载方法的链接在编译阶段静态发生。 在sayWhat(Object) sayWhat sayWhat(Object)方法,但是如果我们自动推断类型x ,则将sayWhat(String)String ,因此将链接更具体的sayWhat(String)方法。


在Java中进行歧义调用的另一种方法是使用变量参数(varargs)。 伍特再次想起了这一点:


 Object x = new Object[] {}; Arrays.asList(x).get(0); System.out.println("Ok"); 

当变量类型为Object ,编译器认为它是变量参数,并将该数组包装在一个元素的另一个数组中,因此get()成功实现。 如果使用varObject[]显示类型为Object[] ,并且不会进行其他包装。 这样,我们得到一个空列表,并且get()调用将失败。


Maccimo追求核心:他决定通过MethodHandle API调用println


 Object x = "Ok"; MethodHandles.Lookup lookup = MethodHandles.lookup(); MethodHandle mh = lookup.findVirtual( PrintStream.class, "println", MethodType.methodType(void.class, Object.class)); mh.invokeExact(System.out, x); 

invokeExact方法和java.lang.invoke中的其他几种方法都具有所谓的“多态签名”。 尽管invokeExact(Object... args)其声明为常用的vararg invokeExact(Object... args)方法,但在标准数组包装中不会发生这种情况。 而是在字节码中生成与实际传递的参数类型匹配的签名。 invokeExact方法invokeExact为方法句柄的超快速调用而设计的,因此它不执行任何标准参数转换,例如强制转换或装箱。 句柄方法类型应与呼叫签名完全匹配。 这是在运行时检查的,就像在var情况下,匹配被破坏一样,我们得到了WrongMethodTypeException


泛型


当然,类型的参数化可以给Java中的任何任务带来麻烦。 Dmitry带来了一个类似于我最初遇到的代码的解决方案。 德米特里(Dmitry)的决定很冗长,因此我将向您展示:


 public static void main(String[] args) { Object x = foo(new StringBuilder()); System.out.println(x); } static <T> T foo(T x) { return (T)"Ok"; } 

类型T输出为StringBuilder ,但在此代码中,不需要编译器在拨号对等点的字节码中插入类型检查。 对他而言,可以将StringBuilder分配给Object就足够了,这意味着一切都很好。 如果将结果分配给Object类型的变量,则没有人反对带有返回值StringBuilder的方法实际上返回了字符串的事实。 编译器诚实地警告您,您进行了未经检查的演员表转换,这意味着他在洗手。 但是,当用var替换xvar类型x也将显示为StringBuilder ,并且没有类型检查将不再可能,因为将其他内容分配给StringBuilder类型变量毫无用处。 结果,在更改为var程序会因ClassCastException安全地崩溃。


Wouter使用标准方法建议了此解决方案的一种变体:


 Object o = ((List<String>)(List)List.of(1)).get(0); System.out.println("Ok"); 

最后,Wouter的另一个选择:


 Object x = ""; TreeSet<?> set = Stream.of(x) .collect(toCollection(() -> new TreeSet<>((a, b) -> 0))); if (set.contains(1)) { System.out.println("Ok"); } 

在此,根据varObject的使用Object流类型显示为Stream<Object>Stream<String> 。 因此,将显示TreeSet类型和比较器lambda类型。 在var的情况下,字符串必须到达lambda,因此在生成lambda运行时表示时,将自动插入类型转换,这在尝试将单元转换为字符串时会产生ClassCastException


通常,结果非常无聊。 如果您可以提出根本不同的方法来破坏var ,请在注释中编写。

Source: https://habr.com/ru/post/zh-CN469111/


All Articles