Stream.flatMap() 的递归使用

Recursive use of Stream.flatMap()

考虑以下 class:

public class Order {

    private String id;

    private List<Order> orders = new ArrayList<>();

    @Override
    public String toString() {
        return this.id;
    }

    // getters & setters
}

注意:重要的是要注意我无法修改这个class,因为我正在使用它外部 API.

还要考虑以下订单层次结构:

Order o1 = new Order();
o1.setId("1");
Order o11 = new Order();
o11.setId("1.1");
Order o111 = new Order();
o111.setId("1.1.1");
List<Order> o11Children = new ArrayList<>(Arrays.asList(o111));
o11.setOrders(o11Children);

Order o12 = new Order();
o12.setId("1.2");
List<Order> o1Children = new ArrayList<>(Arrays.asList(o11, o12));
o1.setOrders(o1Children);

Order o2 = new Order();
o2.setId("2");
Order o21 = new Order();
o21.setId("2.1");
Order o22 = new Order();
o22.setId("2.2");
Order o23 = new Order();
o23.setId("2.3");
List<Order> o2Children = new ArrayList<>(Arrays.asList(o21, o22, o23));
o2.setOrders(o2Children);

List<Order> orders = new ArrayList<>(Arrays.asList(o1, o2));

可以这样形象地表示:

1
1.1
1.1.1
1.2
2
2.1
2.2
2.3

现在,我想将这个订单层次结构扁平化为 List,以便我得到以下内容:

[1, 1.1, 1.1.1, 1.2, 2, 2.1, 2.2, 2.3]

我已经设法通过递归使用 flatMap()(以及一个助手 class)来做到这一点,如下所示:

List<Order> flattened = orders.stream()
    .flatMap(Helper::flatten)
    .collect(Collectors.toList());

这是帮手class:

public final class Helper {

    private Helper() {
    }

    public static Stream<Order> flatten(Order order) {
        return Stream.concat(
            Stream.of(order), 
            order.getOrders().stream().flatMap(Helper::flatten)); // recursion here
    }
}

下面一行:

System.out.println(flattened);

产生以下输出:

[1, 1.1, 1.1.1, 1.2, 2, 2.1, 2.2, 2.3]

到目前为止一切顺利。结果完全正确。

但是,,我对在递归方法中使用 flatMap() 有一些担忧。特别是,我想知道流是如何扩展的(如果这是术语的话)。所以我修改了 Helper class 并使用 peek(System.out::println) 来检查:

public static final class Helper {

    private Helper() {
    }

    public static Stream<Order> flatten(Order order) {
        return Stream.concat(
            Stream.of(order), 
            order.getOrders().stream().flatMap(Helper::flatten))
        .peek(System.out::println);
    }
}

输出为:

1
1.1
1.1
1.1.1
1.1.1
1.1.1
1.2
1.2
2
2.1
2.1
2.2
2.2
2.3
2.3

我不确定这是否是应该打印的输出。

所以,我想知道让中间流包含重复元素是否可以。此外,这种方法的优缺点是什么?毕竟,以这种方式使用 flatMap() 是否正确?有没有更好的方法来实现同样的目标?

你这样使用flatMap确实没有问题。流中的每个中间步骤都是完全独立的(按设计),因此递归没有风险。您需要注意的主要事情是在流式传输时可能会改变基础列表的任何事情。在你的情况下,这似乎没有风险。

理想情况下,您可以将此递归作为 Order class 本身的一部分:

class Order {
    private final List<Order> subOrders = new ArrayList<>();

    public Stream<Order> streamOrders() {
        return Stream.concat(
            Stream.of(this), 
            subOrders.stream().flatMap(Order::streamOrders));
    }
}

然后你可以使用 orders.stream().flatMap(Order::streamOrders) 这对我来说似乎比使用助手 class.

更自然

出于兴趣,我倾向于使用这些类型的 stream 方法来允许使用集合字段而不是 getter 字段。如果该方法的用户不需要了解底层集合的任何信息或需要能够更改它,那么返回一个流是方便和安全的。

我会注意到您的数据结构中存在一个您应该注意的风险:一个订单可能是其他几个订单的一部分,甚至可能是其自身的一部分。这意味着导致无限递归和堆栈溢出是微不足道的:

Order o1 = new Order();
o1.setOrders(Arrays.asList(o1));
o1.streamOrders();

有很多好的模式可以避免这类问题,所以请询问您是否需要这方面的帮助。

您指出您无法更改 Order class。在那种情况下,我建议您扩展它以创建您自己的更安全的版本:

class SafeOrder extends Order {
    public SafeOrder(String id) {
        setId(id);
    }

    public void addOrder(SafeOrder subOrder) {
        getOrders().add(subOrder);
    }

    public Stream<SafeOrder> streamOrders() {
        return Stream.concat(Stream.of(this), subOrders().flatMap(SafeOrder::streamOrders));
    }

    private Stream<SafeOrder> subOrders() {
        return getOrders().stream().map(o -> (SafeOrder)o);
    }
}

这是一个相当安全的转换,因为您希望用户使用 addOrder。并非万无一失,因为他们仍然可以调用 getOrders 并添加 Order 而不是 SafeOrder。如果您有兴趣,也有一些模式可以防止这种情况发生。

好吧,我使用了相同的模式和通用的 Tree class 并且没有任何错误的感觉。唯一的区别是,Tree class 本身提供了 children()allDescendants() 方法,两者都返回 Stream 并且后者建立在前者的基础上。这与 “Should I return a Collection or a Stream?” and .

有关

Stream的角度来看,不同类型的flatMap到children(即遍历属性时)和flatMap 到 children 相同类型。如果返回的流再次包含相同的元素也没有问题,因为流的元素之间没有关系。原则上,您可以使用 flatMap 作为 filter 操作,使用模式 flatMap(x -> condition? Stream.of(x): Stream.empty())。也可以使用它来复制元素,例如 .