为什么Java 8中的新java.util.Arrays方法没有针对所有原始类型进行重载?

58
我正在审查Java 8的API更改,发现java.util.Arrays中的新方法没有为所有基元类型进行重载。我注意到的方法有: 目前,这些新方法仅处理intlongdouble基元类型。 intlongdouble可能是最广泛使用的基元类型,因此如果他们必须限制API,选择这三个是有道理的,但是他们为什么要限制API呢?

9
这跟“为什么java.util.function中没有{Boolean,Byte,Char,Float,Short}UnaryOperator接口”这个问题不一样吗?如果想要额外的重载工作而不需要对操作符输出进行拆箱和空检查,就需要这些接口。 - Mike Samuel
在我看来,这是一个微妙的区别。决定是否支持原语,与支持一些原语但不支持所有原语之间的区别,而以前则完全支持原语。 - Matthew M
1个回答

86

为了回答这些问题,而不仅仅是这个特定的场景,我认为我们都想知道...

为什么Java 8中存在界面污染

例如,在像C#这样的语言中,有一组预定义的函数类型,接受任意数量的参数和可选的返回类型(FuncAction每个类型最多可达16个不同类型的参数T1T2T3,...,T16),但在JDK 8中,我们拥有一组不同的功能接口,具有不同名称和不同的方法名称,并且其抽象方法表示众所周知的function arities的子集(即零元,一元,二元,三元等)。然后我们有处理原始类型的爆炸案例,甚至还有其他导致更多功能接口爆炸的情况。

类型擦除问题

因此,在某种程度上,这两种语言都遭受了某种形式的接口污染(或在C#中称为委托污染)。唯一的区别是,在C#中它们都有相同的名称。不幸的是,在Java中,由于类型擦除Function<T1,T2>Function<T1,T2,T3>Function<T1,T2,T3,...Tn>之间没有区别,因此显然我们不能简单地以相同的方式命名它们,并且我们必须为所有可能的函数组合类型想出创意名称。有关此问题的更多参考,请参阅Brian Goetz的How we got the generics we have

请不要认为专家小组没有努力解决这个问题。用Brian Goetz的话说,他在lambda邮件列表中说:

[...] 以函数类型为例,让我们来看一下。在Devoxx提供的lambda草案中有函数类型。我坚持要删除它们,这使我不受欢迎。但是,我反对函数类型并不是因为我不喜欢函数类型——我喜欢函数类型——而是因为函数类型与Java类型系统的现有方面(即类型擦除)相冲突。被擦除的函数类型是两个世界中最糟糕的。所以我们从设计中删除了它。

但我不愿意说“Java永远不会有函数类型”(尽管我认识到Java可能永远不会有函数类型)。我认为,为了获得函数类型,我们必须首先处理擦除。这可能是可能的,也可能不可能。但在具有具体结构类型的世界中,函数类型开始变得更加合理 [...]

这种方法的优点是我们可以定义自己的接口类型,其方法接受任意数量的参数,并且我们可以根据需要使用它们来创建lambda表达式和方法引用。换句话说,我们有权力污染世界,增加更多新的函数接口。此外,我们甚至可以为JDK早期版本或我们自己API的早期版本中定义的SAM类型创建lambda表达式。因此,现在我们有权将RunnableCallable用作函数接口。
然而,由于它们都具有不同的名称和方法,这些接口变得更难记忆。
尽管如此,我仍然想知道为什么他们没有像Scala一样解决问题,定义像Function0Function1Function2、...、FunctionN这样的接口。也许,我唯一能提出反对意见的理由是,他们希望最大化在早期版本的API中为接口定义lambda表达式的可能性,正如前面提到的那样。

缺乏值类型问题

因此,很明显类型擦除是其中一个驱动力。但如果你是那些想知道为什么我们还需要所有这些具有类似名称和方法签名的附加功能接口,并且唯一的区别是使用原始类型的人之一,那么让我提醒您,在Java中,我们缺乏像C#语言中那样的值类型。这意味着我们在泛型类中使用的泛型类型只能是引用类型,而不是原始类型。

换句话说,我们不能这样做:

List<int> numbers = asList(1,2,3,4,5);

但我们确实可以做到这一点:
List<Integer> numbers = asList(1,2,3,4,5);

第二个例子需要将包装对象和原始类型之间反复转换,这会带来转换成本。对于处理基本值集合的操作来说,这可能会变得非常昂贵。因此,专家小组决定创建这些接口扩展以应对不同的情况。为了让事情“不那么糟糕”,他们决定只处理三种基本类型:int、long和double。
引用Brian Goetz在lambda邮件列表中的话:
更一般地说:拥有专门的原始流(例如IntStream)背后的哲学充满了令人讨厌的权衡。一方面,这是很多丑陋的代码重复、接口污染等问题。另一方面,任何盒装操作上的算术都很糟糕,而没有关于整数缩减的故事会很糟糕。所以我们处于一个艰难的境地,我们正在努力不要让它变得更糟。
不让情况变得更糟的第一招是:我们不会做所有8种原始类型。我们正在做int、long和double;其他所有类型都可以通过这些来模拟。可以说我们也可以摆脱int,但我们认为大多数Java开发者还没有准备好。是的,会有对Character的调用,答案是“将其放入int中”。(每个专业化对JRE占用空间约100K。)
第二个技巧是:我们使用原始流来公开在原始域中最好完成的任务(排序、缩减),但不尝试复制您在盒装域中可以执行的所有操作。例如,没有IntStream.into(),正如Aleksey指出的那样。(如果有的话,下一个问题将是“IntCollection在哪里?IntArrayList?IntConcurrentSkipListMap?)意图是许多流可能从参考流开始,最终成为原始流,但反之则不然。这没关系,它减少了所需的转换数量(例如,没有int -> T的map重载,也没有int -> T的Function专业化等)。[...]

我们可以看到这对专家组来说是一个艰难的决定。我认为很少有人会认为这是优美的,但大多数人可能会认为这是必要的。

如果您想进一步了解此主题,您可以阅读由John Rose、Brian Goetz和Guy Steele撰写的价值类型的状态

检查异常问题

还有第三个推动力可能会使情况更糟,那就是Java支持两种异常类型:已检查异常和未检查异常。编译器要求我们处理或明确声明已检查异常,但对于未检查异常则不需要任何声明。所以,这就创造了一个有趣的问题,因为大多数功能接口的方法签名都不声明抛出任何异常。因此,例如以下操作是不可能的:

Writer out = new StringWriter();
Consumer<String> printer = s -> out.write(s); //oops! compiler error

由于write操作抛出了已检查的异常(即IOException),但是Consumer方法的签名并没有声明它会抛出任何异常,因此无法实现。因此,解决这个问题的唯一方法就是创建更多的接口,有些声明异常,有些则不声明(或在语言层面上想出另一种机制来实现异常透明性)。为了让事情变得“不那么糟糕”,专家组决定在这种情况下不采取任何措施。

用Brian Goetz在lambda邮件列表中的话说:

"[...] 是的,您必须提供自己的异常SAM。但是,使用它们进行lambda转换将运行良好。
EG讨论了为此问题提供额外语言和库支持,最终认为这是一种不好的成本效益权衡。
基于库的解决方案会导致SAM类型增加2倍(异常与非异常),这与原始特化的现有组合爆炸交互不良。
可用的基于语言的解决方案在复杂度/价值权衡中失败。虽然我们将继续探索一些替代方案,但显然不适用于8,可能也不适用于9。
与此同时,您有工具可以完成您想要的事情。我知道您更喜欢我们为您提供最后一英里服务(其次,您的请求实际上是一个含蓄的请求,“为什么不放弃检查异常”),但我认为当前状态可以让您完成工作。[...]"

因此,由我们开发人员来制定更多的接口爆炸,以针对每种情况进行处理。

interface IOConsumer<T> {
   void accept(T t) throws IOException;
}

static<T> Consumer<T> exceptionWrappingBlock(IOConsumer<T> b) {
   return e -> {
    try { b.accept(e); }
    catch (Exception ex) { throw new RuntimeException(ex); }
   };
}

为了做:

Writer out = new StringWriter();
Consumer<String> printer = exceptionWrappingBlock(s -> out.write(s));

或许在未来,当我们获得Java中值类型的支持和具体化时,我们将能够摆脱(或至少不再需要使用)一些这些多个接口。

总之,我们可以看到专家组在几个设计问题上遇到了困难。需要、要求或约束保持向后兼容性使事情变得困难,然后我们还有其他重要条件,如缺乏值类型、类型擦除和已检查异常。如果Java具有第一个并且缺少其他两个,则JDK 8的设计可能会有所不同。因此,我们都必须理解这些是具有很多权衡的困难问题,EG必须在某个地方划线并做出决策。


10
几个月前我看到了Java 8的新特性,我想:“嗯,比全世界晚了八年,但似乎Java正在变得现代化:Lambda表达式、一等函数、类似于LINQ的API...”但是现在...这个回答表明为什么Java根本没有未来,必须完全重新设计。几乎所有Java 8“现代”特性都存在与Java糟糕设计决策相关的问题:受查异常、一切皆接口/类层级结构、没有值类型、类型擦除等等。说实话,Java对我来说就像一个笑话。 - Manu343726
4
@Manu343726,我认为你会觉得这个很有趣:Neal Gafter关于Java未来的讨论 - Edwin Dalorzo
6
我敢说8年后并不准确。要成立,Lambda表达式应该在8年前被发明,但它们其实是最老的技巧之一。我们可以说距阿隆佐·邱奇的λ演算约84年,或距麦卡锡等人开发出Lisp语言约56年,Lisp很可能是第一个使用Lambda表达式的语言 :-) - Edwin Dalorzo
1
感谢您的详细解释。作为一个从长期使用 .net 和脚本语言转过来的 Java 新手,我并不能完全理解为什么要做出这些选择,尽管我了解 Java 所面临的基本限制。现在我更愿意接受日常的痛苦了 ;) 但是,如果有像 C# 中的扩展方法那样的功能就好了,这样我就可以将其封装起来,以便重复使用。但是,由于类型擦除的原因,我可能无法实现这一点。他们真的必须解决这个问题。在那之前,我想我还是会坚持使用 foreach :D - Andrew
3
这样做过于复杂了。对于int[]long[]double[],已经有流操作了。因此,其他原始类型也应该有相应的操作。实际上,我觉得这非常有趣——Java最好笑了!哈哈。 - The Coordinator

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接