为什么在Java 8中,split有时会删除结果数组开头的空字符串?

116

在 Java 8 之前,当我们使用空字符串分割时,如下所示:

String[] tokens = "abc".split("");

分割机制会在标有|的位置进行分割。

|a|b|c|

因为空格 "" 存在于每个字符之前和之后,所以它会首先生成这个数组

["", "a", "b", "c", ""]

后续将删除末尾的空字符串(因为我们没有显式指定 limit 参数的负值),所以最终会返回

["", "a", "b", "c"]

在Java 8中,split机制似乎已经发生了变化。现在当我们使用

"abc".split("")

我们将获得["a", "b", "c"]数组,而不是["", "a", "b", "c"]

我的第一个猜想是可能现在像删除结尾的空字符串一样,也删除了前导空字符串。

但这个理论是错误的,因为

"abc".split("a")

返回结果为["", "bc"],因此前导空字符串未被移除。

有人能解释一下这是什么情况吗?Java 8中的split规则发生了什么变化?


3
在我的问题中描述的行为不是Java 8之前版本的错误。虽然这种行为并不特别有用,但它仍然是正确的(如我的问题所示),因此我们不能说它被“修复”了。我认为这更像是一种改进,这样我们就可以使用 split("") 而不是对于不使用正则表达式的人来说比较难懂的 split("(?!^)") 或者 split("(?<!^)"),还有其他几个正则表达式。 - Pshemo
1
在将 Fedora 升级到 Fedora 21 后遇到了同样的问题,因为 Fedora 21 预装了 JDK 1.8,导致我的 IRC 游戏应用程序无法使用。 - LiuYan 刘研
8
这个问题似乎是Java 8中唯一说明这个重大变化的文档。Oracle在他们的不兼容列表中没有提到它。 - Sean Van Gorder
5
这次JDK的改动让我花了两个小时来追踪问题。代码在我的电脑上(JDK8)运行良好,但在另一台机器上(JDK7)却神秘地失败了。Oracle 真的应该更新 String.split(String regex) 的文档,而不是在Pattern.split或String.split(String regex, int limit)中更新,因为这是目前最常见的用法。Java以其可移植性即所谓的WORA而闻名。这是一个重大的反向兼容性变化,并且文档说明不够详细。 - PoweredByRice
@Nhan 是的,我也遇到了找不到关于这个更改的任何信息的问题,因此提出了这个问题。无论如何,如果您正在寻找一种适用于所有版本的方法,而不是使用 split(""),请使用 split("(?!^)") - 它将尝试在文本开头之外的每个空字符串上进行拆分。顺便说一句,在 Java 8 中引入的正则表达式引擎中的另一个更改是 \R,它表示 \n\r\r\n(以及几个其他分隔符)。 - Pshemo
显示剩余4条评论
3个回答

88

String.split(调用Pattern.split)的行为在Java 7和Java 8之间发生了变化。

文档

比较Java 7Java 8Pattern.split的文档,我们可以看到添加了以下条款:

当输入序列开头有正宽度匹配时,结果数组的开头包括一个空的前导子字符串。然而,在开头处零宽度匹配从不产生这样的空前导子字符串。

Java 7相比,Java 8String.split也添加了相同的条款。

参考实现

让我们比较Java 7和Java 8中的参考实现Pattern.split代码。该代码是从grepcode检索的,版本为7u40-b43和8-b132。

Java 7

public String[] split(CharSequence input, int limit) {
    int index = 0;
    boolean matchLimited = limit > 0;
    ArrayList<String> matchList = new ArrayList<>();
    Matcher m = matcher(input);

    // Add segments before each match found
    while(m.find()) {
        if (!matchLimited || matchList.size() < limit - 1) {
            String match = input.subSequence(index, m.start()).toString();
            matchList.add(match);
            index = m.end();
        } else if (matchList.size() == limit - 1) { // last one
            String match = input.subSequence(index,
                                             input.length()).toString();
            matchList.add(match);
            index = m.end();
        }
    }

    // If no match was found, return this
    if (index == 0)
        return new String[] {input.toString()};

    // Add remaining segment
    if (!matchLimited || matchList.size() < limit)
        matchList.add(input.subSequence(index, input.length()).toString());

    // Construct result
    int resultSize = matchList.size();
    if (limit == 0)
        while (resultSize > 0 && matchList.get(resultSize-1).equals(""))
            resultSize--;
    String[] result = new String[resultSize];
    return matchList.subList(0, resultSize).toArray(result);
}

Java 8

public String[] split(CharSequence input, int limit) {
    int index = 0;
    boolean matchLimited = limit > 0;
    ArrayList<String> matchList = new ArrayList<>();
    Matcher m = matcher(input);

    // Add segments before each match found
    while(m.find()) {
        if (!matchLimited || matchList.size() < limit - 1) {
            if (index == 0 && index == m.start() && m.start() == m.end()) {
                // no empty leading substring included for zero-width match
                // at the beginning of the input char sequence.
                continue;
            }
            String match = input.subSequence(index, m.start()).toString();
            matchList.add(match);
            index = m.end();
        } else if (matchList.size() == limit - 1) { // last one
            String match = input.subSequence(index,
                                             input.length()).toString();
            matchList.add(match);
            index = m.end();
        }
    }

    // If no match was found, return this
    if (index == 0)
        return new String[] {input.toString()};

    // Add remaining segment
    if (!matchLimited || matchList.size() < limit)
        matchList.add(input.subSequence(index, input.length()).toString());

    // Construct result
    int resultSize = matchList.size();
    if (limit == 0)
        while (resultSize > 0 && matchList.get(resultSize-1).equals(""))
            resultSize--;
    String[] result = new String[resultSize];
    return matchList.subList(0, resultSize).toArray(result);
}

在Java 8中添加以下代码会排除输入字符串开头的零长度匹配项,这解释了上面的行为。
            if (index == 0 && index == m.start() && m.start() == m.end()) {
                // no empty leading substring included for zero-width match
                // at the beginning of the input char sequence.
                continue;
            }

保持兼容性

遵循Java 8及以上版本的行为

为了使split在各个版本中表现一致并与Java 8中的行为兼容:

  1. 如果您的正则表达式可以匹配零长度字符串,请在正则表达式的末尾添加(?!\A),并将原始正则表达式包装在非捕获组(?:...)中(如果需要)。
  2. 如果您的正则表达式不能匹配零长度字符串,则不需要做任何事情。
  3. 如果您不知道正则表达式是否可以匹配零长度字符串,请同时执行步骤1中的两个操作。

(?!\A)检查字符串不以字符串开头结束,这意味着匹配是一个空匹配在字符串开头。

遵循Java 7及以下版本的行为

除了将所有split实例替换为指向自己的自定义实现之外,没有通用解决方案可以使split向后兼容Java 7及以下版本。


有什么想法可以改变 split("") 代码,使其在不同的Java版本中保持一致吗? - Daniel
2
@Daniel:可以通过在正则表达式的末尾添加(?!^)并将原始正则表达式包装在非捕获组 (?:...) 中(如果必要),使其向前兼容(遵循Java 8的行为),但我想不出任何方法使其向后兼容(遵循Java 7及更早版本的旧行为)。 - nhahtdh
谢谢你的解释。你可以描述一下 "(?!^)" 吗?在什么情况下它会与 "" 不同?(我对正则表达式很糟糕!:-/)。 - Daniel
1
@Daniel:它的含义受Pattern.MULTILINE标志影响,而\A始终与字符串开头匹配,无论标志如何。 - nhahtdh

31
这已经在split(String regex, limit)的文档中说明。

当此字符串开头存在正宽度匹配时,结果数组开头将包含一个空的前导子字符串。但是,开头的零宽度匹配从不产生这样的空的前导子字符串。

"abc".split("")中,你得到了一个开头的零宽度匹配,因此不会在结果数组中包含前导空字符串。

但是在第二个片段中,当你在"a"上分割时,你得到了一个正宽度匹配(此处为1),因此预期的空前导子串被包含在内。

(已删除无关源代码)


3
这只是一个问题。可以发布JDK中的代码片段吗?还记得Google - Harry Potter - Oracle的版权问题吗?请注意。 - Paul Vargas
6
公平地说,我不确定,但我认为没问题,因为你可以下载JDK并解压src文件,其中包含所有源代码。所以从技术上讲,每个人都可以看到源代码。 - Alexis C.
12
@PaulVargas 中的“开放”确实代表了某种意义,它是“开源”的一部分。 - Marko Topolnik
2
@ZouZou:仅仅因为每个人都能看到它,并不意味着你可以重新发布它。 - user102008
2
@Paul Vargas,我不是律师,但在许多其他情况下,这种类型的帖子属于引用/公平使用情况。更多关于此主题的信息在这里:http://meta.stackexchange.com/questions/12527/do-i-have-to-worry-about-copyright-issues-for-code-posted-on-stack-overflow - Alex Pakka
显示剩余3条评论

14

Java 7到Java 8的split()文档有轻微变化。具体来说,添加了以下语句:

当此字符串开头存在正宽匹配时,则在结果数组的开头包含一个空的前导子字符串。但是,开头的零宽度匹配永远不会产生这样的空前导子字符串。

(强调是我的)

空字符串拆分会在开头生成零宽度匹配,因此不会按照上述规定在结果数组的开头包含空字符串。相比之下,您第二个示例使用"a"进行拆分,在字符串开头生成了宽度匹配,因此实际上在结果数组的开头包含了空字符串。


几秒钟的时间就能改变一切。 - Paul Vargas
2
@PaulVargas 实际上,这里的arshajii在ZouZou之前几秒钟就回答了我的问题,但不幸的是,ZouZou先在这里回答了我的问题。我想知道是否应该问这个问题,因为我已经知道了一个答案,但它似乎很有趣,而且ZouZou应该因他早期的评论而得到一些声誉。 - Pshemo
5
虽然新行为看起来更加“合乎逻辑”,但这显然是一种“向后不兼容性”的改变。这种改变的唯一理由是"some-string".split("")这个用法非常罕见。 - ivstas
4
.split("") 并不是唯一无需匹配任何内容的分割字符串的方式。我们在 jdk7 中使用了一个正向预查的正则表达式,它也会在开头匹配并产生一个空的头元素,但现在已经消失了。https://github.com/spray/spray/commit/5ab4fdf9ebd8986297e0137bc07088c6223276a0 - jrudolph

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