不使用System.arraycopy(),将List<char[]>转换为char[]数组

14

在Java中,将List<char[]>转换/压平为char[]的简单方法是什么?

我知道可以通过迭代List并使用System.arraycopy来实现,但我想知道是否有更简单的方法可以使用Java 8流来完成?

也许可以尝试类似于以下代码,但不需要将原始类型char装箱为Character

List<char[]> listOfCharArrays = ...

Character[] charArray =
    Stream.of(listOfCharArrays )
        .flatMap(List::stream)
        .toArray(Character[]::new);

不行!你不能这样做!你正试图将一个二维数据结构转换为一维的。 - Papai from BEKOAIL
13
@PapaifromBEKOAIL 我的意思是,这本身不是问题。这就是为什么flatMap存在的原因:将“二维”的源压扁成“一维”的流。 - Slaw
5
@PapaifromBEKOAIL 这没什么问题。 - Michael
5
由于Stream没有char原始类型的特化(如IntStream),我看不到任何方法可以比迭代解决方案更易读。即使有,我强烈怀疑迭代解决方案会更快,因为arrayCopy可以复制内存块,而流解决方案则需要逐个处理字符。 - Michael
如果你创建一个方法 char[] concat(char[] array1, char[] array2),那么就可以实现这个功能。然后使用 reduce 和身份元素 new char[0],你可以将所有这些 char[] 元素简单地连接成一个 char[]。这样做的缺点是会创建很多中间 char[]。更简单的选项需要两次迭代 - 一次获取总长度(可以使用流完成),一次复制。我个人会使用一个简单的 for 循环来完成后者,因为 System.arraycopy 的起始索引会不断变化,需要一些恶心的技巧才能在流内部使其正常工作。 - Rob Spoor
显示剩余3条评论
5个回答

18
这是我能想到的最易读版本。你可以通过 StringBuilder 将所有 char 数组附加到一个字符串,然后将其转换为 char[]
char[] chars = listOfCharArrays.stream()
    .collect(Collector.of(StringBuilder::new, StringBuilder::append, StringBuilder::append, StringBuilder::toString))
    .toCharArray();

由于arrayCopy可以复制内存块,因此可能比迭代版本要慢得多。

您可以考虑预先计算字符的总数以避免StringBuilder数组重新分配,但是这种优化和其他任何优化都会削弱使用流获得的可读性收益。

int totalSize = listOfCharArrays.stream().mapToInt(arr -> arr.length).sum();
char[] chars = listOfCharArrays.stream()
    .collect(Collector.of(() -> new StringBuilder(totalSize), //... the same

有两个不必要的副本(StringBuilder -> String, String -> char[]),这些副本实际上是由于这些类并不完全适合此任务而产生的。使用CharBuffer更合适;请参见Maarten's answer


16

我只能想到一种方法,那就是使用CharBuffer。出于效率原因,我总是先计算正确的大小,然后执行复制操作。任何执行多次复制和/或执行字符串处理的解决方案都会效率低下。

这里是代码。第一行计算所需数组的总大小,然后分配足够的内存。第二行使用上述的put方法执行复制。最后一行返回支持CharBufferchar[]

CharBuffer fullBuffer = CharBuffer.allocate(
        listOfCharArrays.stream().mapToInt(array -> array.length).sum());
listOfCharArrays.forEach(fullBuffer::put);
char[] asCharArray = fullBuffer.array();

当然,我无法保证在CharBuffer#put方法的内部不会使用System.arrayCopy。但我很强烈地预计它将在内部使用System.arrayCopy或类似代码。这可能适用于此处提供的大多数解决方案。

如果您可以估计最大大小,则可以使用足够大的缓冲区来避免第一次大小计算,但这将需要对缓冲区中的数据进行额外的复制;CharBuffer#array只需返回正确大小的后备数组,这意味着数据仅被复制一次。


如果您想要使用面向对象的代码,也可以直接使用CharBuffer。请注意,在写入后,您需要确保使用flip进行翻转,并且CharBuffer是可变的(您可以使用duplicateasReadOnly方法传递副本 - 返回的实例引用相同的缓冲区,但具有独立的可变“位置”和“限制”字段)。

虽然Buffer和Java NIO类略微复杂,但一旦您理解它们,就可以从中获得巨大的好处,例如在使用CharEncoder或内存映射文件时。


3
如果您不想预先确定大小,您可以使用CharArrayWriter w = new CharArrayWriter(); listOfCharArrays.forEach(a -> w.write(a, 0, a.length)); char[] asCharArray = w.toCharArray(); - Holger

7

正如Holger所说,可以通过String或CharBuffer来完成。

char[] flatten(List<char[]> list) {
    return list.stream()
        .map(CharBuffer::wrap) // Better than String::new
        .collect(Collectors.joining())
        .toCharArray();
}

这需要"completed"数组,且开头和结尾都没有不完整的代理字符对。
因此,请将其与以下内容进行比较:
char[] flatten(List<char[]> list) {
    int totalLength = list.stream().mapToInt(a -> a.length).sum();
    char[] totalArray = new char[totalLength];
    int i = 0;
    for (char[] array : list) {
        System.arraycopy(array, 0, totalArray, i, array.length);
        i += array.length; 
    }
    return totalArray;
}

差别不是很大,而且代码更加稳定。

或将整个软件放在不可变的String上,而不是char[]


@MaartenBodewes 把它改成了 .mapToInt(a -> a.length()) - 看起来确实有问题。你的答案有两个有趣之处:*Buffer类值得更多使用。而且 put 很好地抽象了出来。它可能使用 SCOPED_MEMORY_ACCESS.copyMemory - Joop Eggen
@Holger 再次感谢。CharBuffer 的方向与 Maarten 的答案一致,后者已经获得了更多的投票。也许它可以改进他的答案。 - Joop Eggen

6
这里有一个使用Stream API的解决方案,它不需要进行额外的内存分配,如果您使用String和StringBuilder(因为即使在Java 8中也无法实例化没有复制数据的String,并且StringBuilder将为您提供访问其基础数组而不是副本的数组,而且自Java 9以来,String和StringBuilder都由byte[]数组支持而不是字符数组)。

首先,计算结果数组的大小是有意义的(正如@Maarten Bodewes和@Michael在他们的答案中已经提到的),这是一种非常快速的操作,因为我们不处理这些数组的数据,而只请求它们的长度。

然后为了构造结果数组,我们可以利用收集器,将流元素累积到底层char[]数组中,然后在所有流元素被处理完毕且没有中间转换和分配额外内存时将其提供出来。

所有 collector 的功能都需要是无状态的,更改应该只发生在其可变容器内部。因此,我们需要一个 可变容器 包装一个 char[] 数组,但它不应该像 StringBuilder 一样有一个 强封装,即允许访问其底层数组。我们可以通过使用 CharBuffer 来实现这一点。
所以基本上和 Maarten Bodewes 的答案 中介绍的相同思路完全使用流实现。 CharBuffer.allocate(length) 在幕后将实例化给定长度的 char[],而 CharBuffer.array() 将返回相同的数组,而不生成额外的副本。
public static void main(String[] args) {
    
    List<char[]> listOfCharArrays =
        List.of(new char[]{'a', 'b', 'c'},
                new char[]{'d', 'e', 'f'},
                new char[]{'g', 'h', 'i'});

    char[] charArray = listOfCharArrays.stream()
        .collect(Collectors.collectingAndThen(
            Collectors.summingInt(arr -> arr.length),  // calculating the total length of the arrays in the list
            length -> listOfCharArrays.stream().collect(
                Collector.of(
                    () -> CharBuffer.allocate(length), // mutable container of the collector
                    CharBuffer::put,                   // accumulating stream elements inside the container
                    CharBuffer::put,                   // merging the two containers with partial results (runs only when stream is being executed in parallel)
                    CharBuffer::array                  // finisher function performs the final transformation
                ))
        ));

    System.out.println(Arrays.toString(charArray));
}

输出:

[a, b, c, d, e, f, g, h, i]

@Holger 这篇文章的动机是介绍一种完全基于流的解决方案,它不会像其他所有解决方案一样造成额外的复制开销,除了 Maarten Bodewes 的解决方案。 - Alexander Ivanchenko
@Holger 修复了合并器。我最初的想法是禁止在并行中使用收集器,因为分配多个与目标数组长度相同的缓冲区(当它非常庞大时)的代价会显著增加内存消耗。 - Alexander Ivanchenko
2
是的,使用并行流会增加内存消耗并添加可避免的复制开销,但这不是唯一的情况。有很多情况下将Stream转换为并行会损害性能。但至少,结果应该保持正确。•确实,基于StringBuilder的方法具有额外的复制开销,有时甚至超出预期。自Java 9以来,内部数组每个字符使用一个字节,直到遇到第一个非拉丁字符。然后,整个内容必须被膨胀。 - Holger
@Holger 谢谢,我明白了。所以让我总结一下你的信息,以确保我理解正确:如果收集器根本不支持并行执行,那么就不应该创建它,对吗? - Alexander Ivanchenko
3
总的来说,如果您不得不添加类似“不适用于并行处理”或“假设实现细节xyz”的注释,那么您根本不应该使用Stream API。事实上,无法提供正确的合并函数既意味着假设一个实现细节(“只有并行流才使用合并函数”),也要求顺序执行。 - Holger

4

也许不是最好的解决方案,但您可以使用:

char[] chars = tableRowContainingOnlyRequestedColumns.stream()
        .map(String::valueOf)
        .collect(Collectors.joining())
        .toCharArray();

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