为什么在Java 8中,String.chars()返回的是一个int流?

238
在Java 8中,有一个名为String.chars()的新方法,它返回一个表示字符编码的int流(IntStream)。我猜很多人会期望这里返回一个char流。那么设计API这种方式的动机是什么呢?

4
@RohitJain 我并没有特指任何流。如果 CharStream 不存在,添加它会有什么问题? - Adam Dyga
5
设计师明确选择避免类和方法的爆炸式增长,通过将原始流限制为3种类型来实现,因为其他类型(char、short、float)可以由它们的较大等效类型(int、double)表示,而没有任何显著的性能损失。 - JB Nizet
10
在我看来,鉴于所有流重载以及所有函数接口,似乎我们已经拥有了接口的大量扩展。参见链接 - Holger
5
是的,即使只有三种基本流专业化,已经存在了一场爆炸。如果所有八种基本类型都有流专业化,那会是什么情况呢?灾难性的吗? :-) - Stuart Marks
5
虽然有点离题,但我建议人们更喜欢使用String.codePoints()而不是.chars()——后者处理Unicode字符的方式不如人意(它会分割代理对)。除非您确定字符串绝不包含高代码点字符,否则应避免使用.chars() - dimo414
显示剩余4条评论
2个回答

259

正如其他人已经提到的,这个设计决定是为了防止方法和类的爆炸。

尽管如此,我个人认为这是一个非常糟糕的决定,考虑到他们不想创建一个CharStream,这是合理的,而不是使用chars()方法,我会考虑以下几种方法:

  • Stream<Character> chars(),它提供了一个装箱字符流,这将有一些轻微的性能损失。
  • IntStream unboxedChars(),可用于性能代码。

然而,与其关注当前是以何种方式完成的,我认为这个答案应该集中在展示如何使用我们在Java 8中获得的API来完成它。

在Java 7中,我会这样做:

for (int i = 0; i < hello.length(); i++) {
    System.out.println(hello.charAt(i));
}

我认为在Java 8中实现它的一个合理方法如下:

hello.chars()
        .mapToObj(i -> (char)i)
        .forEach(System.out::println);

这里我获取了一个 IntStream 并通过 lambda 表达式 i -> (char)i 将其映射为对象,这将自动将其装箱为一个 Stream<Character>,然后我们可以按照需求进行操作,而且仍然可以使用方法引用。

请注意,如果你忘记使用 mapToObj 而使用了 map,虽然不会报错,但你最终得到的仍然是一个 IntStream,你可能会惊奇地发现它打印的是整数值而不是代表字符的字符串。

Java 8 的其他不太好看的替代方案:

如果你仍然停留在一个 IntStream 中并希望最终将它们打印出来,那么你就不能再使用方法引用进行打印了:

hello.chars()
        .forEach(i -> System.out.println((char)i));

此外,使用对自己方法的方法引用将不再起作用!请考虑以下情况:

private void print(char c) {
    System.out.println(c);
}

然后

hello.chars()
        .forEach(this::print);

这将导致编译错误,因为可能存在信息丢失的转换。

结论:

API 是以这种方式设计的,因为不想添加 CharStream,我个人认为该方法应该返回一个 Stream<Character>,目前的解决方法是在 IntStream 上使用 mapToObj(i -> (char)i),以便能够正确地处理它们。


8
我的结论是:这部分API设计存在缺陷。但感谢您提供详尽的答案。 - Adam Dyga
33
+1,但我的建议是使用codePoints()而不是chars(),你会发现许多库函数已经接受了一个代表代码点的int,除了char之外。例如:java.lang.Character的所有方法以及StringBuilder.appendCodePoint等。此支持从jdk1.5开始存在。 - Holger
7
代码点的概念很好。使用它们可以处理代理对表示的增补字符,这些增补字符在字符串或字符数组中以代理对形式表示。我敢打赌大多数char处理代码都无法正确处理代理对。 - Stuart Marks
2
@skiwi,定义 void print(int ch) { System.out.println((char)ch); } 然后你就可以使用方法引用了。 - Stuart Marks
3
为什么Stream<Character>被拒绝,请查看我的答案。 - Stuart Marks
显示剩余2条评论

104

skiwi的回答已经涵盖了许多主要观点。我将提供更多背景信息。

任何API的设计都是一系列权衡。在Java中,其中一个棘手的问题是处理早期设计决策。

原始类型从Java 1.0开始就存在。它们使得Java成为了一个“不纯”的面向对象语言,因为原始类型并不是对象。添加原始类型是一种实用的决策,可以在牺牲面向对象纯度的前提下提高性能。

这是一个我们今天仍在使用的权衡,已经快20年了。Java 5中添加的自动装箱特性基本上消除了在源代码中添加装箱和拆箱方法调用的需要,但是开销仍然存在。在许多情况下,这并不明显。但是,如果您在内部循环中执行装箱或拆箱,您将看到它会施加重大的CPU和垃圾收集开销。

设计Streams API时,清楚地意识到我们必须支持原始类型。装箱/拆箱开销将抵消并行性带来的任何性能优势。然而,我们并不想支持所有的原始类型,因为那样会给API添加大量的混乱。 (你真的能看到ShortStream的用法吗?)"全部"或"无"都是设计中舒适的位置,但两者都不可接受。所以我们必须找到一个合理的"一些"的值。最终我们选择了intlongdouble的原始类型专业化。(个人认为应该把int省略掉,但这只是我的想法。)

对于CharSequence.chars(),我们考虑返回Stream<Character>(早期原型可能已实现此功能),但由于装箱开销而被拒绝。考虑到字符串具有char作为原始类型的值,强制进行装箱并不明智,因为调用方可能只会对该值进行一些处理,并将其迅速解压回字符串中。

我们还考虑了一个CharStream原始特化,但是与它所添加的大量内容相比,其使用似乎非常有限。因此将其添加在API中不值得。
这给调用者带来的惩罚是他们必须知道IntStream包含作为int表示的char值,并且必须在正确的位置进行转换。这是双重困惑的,因为存在重载API调用,例如PrintStream.print(char)和PrintStream.print(int),它们的行为差别很大。可能会产生混淆的另一个点是codePoints()调用还返回一个IntStream,但它包含的值非常不同。
因此,这归结为在几个选择中实现实用主义:

  1. 我们可以不提供原始特化,从而获得简单、优雅、一致的API,但这会带来高性能和GC开销;

  2. 我们可以提供完整的原始特化集,代价是使API变得混乱,并对JDK开发人员施加维护负担;或者

  3. 我们可以提供子集的原始特化,提供一个相当小范围的使用情况(字符处理)下给调用者带来较小负担的高性能API。

我们选择了最后一个。

2
不错的答案!然而它并没有回答为什么 chars() 不能有两种不同的方法,一种返回一个 Stream<Character>(性能较差),另一种是 IntStream,这也被考虑过吗?如果人们认为方便的价值超过了性能损失,他们很可能会将其映射到 Stream<Character> 上。 - skiwi
4
这里涉及到极简主义。如果已经有一个返回IntStream中char值的chars()方法,那么再添加另一个API调用在装箱形式下获取相同的值并没有太多意义。调用者可以轻松地将这些值装箱。当然,在这种(可能很少见)情况下,不需要这样做会更加方便,但代价是给API添加混乱的元素。 - Stuart Marks
5
由于重复的问题,我注意到了这个。我同意 chars() 返回 IntStream 并不是一个大问题,特别是考虑到这个方法很少被使用。然而,最好有一种内置的方法将 IntStream 转换回 String。可以通过 .reduce(StringBuilder::new, (sb, c) -> sb.append((char)c), StringBuilder::append).toString() 实现,但这样做实在太长了。 - Tagir Valeev
7
@TagirValeev 是的,这有点繁琐。使用代码点流(IntStream)并不太麻烦:collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append).toString()。我想它并没有变得更短,但使用代码点避免了(char)强制转换,并允许使用方法引用。此外,它可以正确处理代理项。 - Stuart Marks
2
很遗憾,像IntStream这样的原始流没有接受Collectorcollect()方法。正如之前的评论所提到的那样,它们只有一个三个参数的collect()方法。 - Stuart Marks
显示剩余4条评论

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