封面来源:碧蓝航线 愚者的天平 活动 CG
本文涉及的代码:mofan-demo/new-feature/src/test/java/indi/mofan/lambda/backtracking
1. 背景介绍
Reduce Functions 一文从实际业务出发,围绕 Stream#reduce()
展开,介绍了整套的代码设计与实现。Java8 引入的 Stream
不仅可以完成集合的 filter
、map
等基本操作,将其融入到代码设计中还能够进一步抽象业务代码。
在 Java8 以前,方法(或函数)并不是一等公民,而在 Java8 引入 Lambda 表达式后,以另一种思路解决了这个问题。在实际开发过程中,使用 Lambda 表达式进行代码重构是我乐此不疲的事,它能够很好地实现千变万化的需求,将实际处理逻辑交给上游实现,而无需变更下游的接口定义。
本文将从一个简单的 DFS 出发,利用 Lambda 表达式对其进行重构,并完成回溯、剪枝等操作。
为保证阅读的流畅性,阅读前需要掌握的前置知识:
- 熟悉常见的函数式接口
- 对回溯有一定的理解,如果从未接触过回溯,可以参考 回溯算法理论基础 速通一波
2. 现状分析
在第一版需求中,需要基于 DFS 完成多个节点的遍历。假设节点有以下类型:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| public enum NodeType {
SINGLE_A,
SINGLE_B,
SINGLE_C,
CONTAINER,
VIRTUAL_CONTAINER }
|
SINGLE_A
、SINGLE_B
和 SINGLE_C
代表不同的 SINGLE
节点,这些节点内部不会嵌套其他节点。
如果想要将多个 SINGLE
节点进行组合,则需要使用容器节点。
VIRTUAL_CONTAINER
节点内部 只能 包含若干个 SINGLE
节点,CONTAINER
节点内部 只能 包含多个 VIRTUAL_CONTAINER
节点。
CONTAINER
节点内部不会直接嵌套 SINGLE
节点,但借由嵌套的 VIRTUAL_CONTAINER
节点,就好像嵌套了 SINGLE
节点一样。
进行节点遍历时,忽略 VIRTUAL_CONTAINER
类型的节点!
基于以上节点类型,可以新建出具有以下依赖关系的多个类:
3. 第一需求
给出一个节点列表,列表内存在各种的节点,要求实现遍历每个节点(不包括 VIRTUAL_CONTAINER
类型的节点,就像它的名称一样,它是虚拟的),但对每个节点的处理由调用方决定。
实现这个需求不难,无非两点:
- 挨个遍历节点,遇到容器节点时挨个遍历内部嵌套节点
- 要想对节点的处理由调用方决定,使用函数式接口
Consumer
即可
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| public class NodeFindSimpleSupport {
protected final List<? extends NodeInfo> nodes;
protected NodeFindSimpleSupport(List<? extends NodeInfo> nodes) { this.nodes = nodes; }
public static NodeFindSimpleSupport from(List<? extends NodeInfo> nodes) { return new NodeFindSimpleSupport(nodes); }
public void findNodes(Consumer<NodeInfo> consumer) { for (NodeInfo nodeInfo : CollectionUtils.emptyIfNull(this.nodes)) { findNode(nodeInfo, consumer); } }
private void findNode(NodeInfo nodeInfo, Consumer<NodeInfo> consumer) { if (nodeInfo == null) { return; } if (!NodeType.VIRTUAL_CONTAINER.equals(nodeInfo.getNodeType())) { consumer.accept(nodeInfo); } if (nodeInfo instanceof BaseContainerNode container) { from(container.getNodes()).findNodes(consumer); } } }
|
编写单元测试验证一下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| private List<NodeInfo> buildNodes() { SingleA a1 = new SingleA("A1"); SingleA a2 = new SingleA("A2"); SingleA a3 = new SingleA("A3"); SingleA a4 = new SingleA("A4");
SingleB b1 = new SingleB("B1"); SingleB b2 = new SingleB("B2");
SingleC c1 = new SingleC("C1"); SingleC c2 = new SingleC("C2");
VirtualContainerNode containerA = new VirtualContainerNode("ContainerA", List.of(a1, b1)); VirtualContainerNode containerB = new VirtualContainerNode("ContainerB", List.of(a2, b2, c1));
ContainerNode parentContainer = new ContainerNode("parentContainer", List.of(containerA, containerB));
return List.of(a3, a4, parentContainer, c2); }
@Test public void testNodeFindSimpleSupport() { NodeFindSimpleSupport support = NodeFindSimpleSupport.from(buildNodes()); AtomicInteger count = new AtomicInteger(0); support.findNodes(node -> count.incrementAndGet()); assertThat(count).hasValue(9); }
|
4. 第二需求
4.1 作用域内的节点
现在新增了一个需求:返回某一节点所在【作用域】内所有节点。
对于【作用域】,则有:
以上图为例:
- A3、A4、ContainerA、ContainerB 和 C2 属于同一作用域
- A1 和 B1 属于同一作用域
- A1 与 A2、A1 和 A3 并 不属于 同一作用域
4.2 需求剖析
新增需求的要点
新增的需求一共有两个要点:
- 先找到目标节点
- 然后返回目标节点所在作用域内的所有节点
找目标节点很简单,利用 DFS 遍历每个节点,如果当前节点 ID 等于目标节点 ID,那么就找到目标节点了。
为了能够更方便地获取目标作用域内的所有节点,在遍历节点时需要额外维护一个栈,栈内每个位置存放的元素类型是 List<? extends NodeInfo>
。
每当遍历到一个新的作用域时,先将包含该作用域内所有节点的集合入栈。如果目标节点存在于该作用域内,那么栈顶元素即为所求;如果遍历完该作用域内的所有节点都没有找到目标节点,那么弹出栈顶元素,继续遍历剩余节点,直到找到目标节点或遍历完所有节点。
当找到目标节点时,应当立即终止遍历, 后续的遍历没有意义。
新增需求带来的变化
与原始需求相比,新增需求后的代码实现也会存在变化:
如果了解回溯算法,不难发现就是要将原先的简单遍历改写成回溯,但是 不能影响原来的功能。
5. 实现重构
回溯相比于普通 DFS 遍历,就是增加了遍历终止条件。如果又要实现回溯,由不能影响原来的功能,那么可以将是否终止遍历交由上层调用方决定。
对于每个节点来说,原先使用函数式接口 Consumer
进行处理,重构成回溯后,为了能够判断是否需要终止遍历,需要选用函数式接口 Predicate
进行处理。如果需要遍历完所有节点,那么 Predicate
始终返回 false
即可。
容器节点共有两种:
CONTAINER
类型:依次遍历内部的虚拟容器节点即可,该类型节点嵌套的节点并不构成一个作用域
VIRTUAL_CONTAINER
类型:该类型节点嵌套的节点构成一个作用域,因此遍历时需要先将内部节点组成的集合入栈,然后再依次遍历内部的节点
重构后的代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110
| public class NodeFindSupport extends NodeFindSimpleSupport {
private NodeFindSupport(List<? extends NodeInfo> nodes) { super(nodes); }
public static NodeFindSupport from(List<? extends NodeInfo> nodes) { return new NodeFindSupport(nodes); }
@Override public void findNodes(Consumer<NodeInfo> consumer) { findNodes(objectNode -> { consumer.accept(objectNode); return false; }); }
private void findNodes(Predicate<NodeInfo> predicate) { findNodes(this.nodes, predicate, (supplier, nodes) -> supplier.getAsBoolean()); }
@SuppressWarnings("unchecked") public List<NodeInfo> findScopeNodes(String nodeId) { Deque<List<? extends NodeInfo>> stack = new ArrayDeque<>(); stack.push(this.nodes); findNodes(this.nodes, node -> Objects.equals(nodeId, node.getNodeId()), (supplier, nodes) -> { stack.push(nodes); if (supplier.getAsBoolean()) { return true; } stack.pop(); return false; }); return Optional.of(stack.pop()).map(i -> (List<NodeInfo>) i).orElse(Collections.emptyList()); }
private boolean findNodes(List<? extends NodeInfo> nodeList, Predicate<NodeInfo> predicate, BiPredicate<BooleanSupplier, List<NodeInfo>> biPredicate) { for (NodeInfo node : nodeList) { if (findNode(node, predicate, biPredicate)) { return true; } } return false; }
private boolean findNode(NodeInfo node, Predicate<NodeInfo> eachNodePredicate, BiPredicate<BooleanSupplier, List<NodeInfo>> virtualContainerNodePredicate) { if (node == null) { return false; } if (!NodeType.VIRTUAL_CONTAINER.equals(node.getNodeType()) && eachNodePredicate.test(node)) { return true; } switch (node) { case VirtualContainerNode virtual -> { List<NodeInfo> nodeList = virtual.getNodes(); if (virtualContainerNodePredicate.test(() -> findNodes(nodeList, eachNodePredicate, virtualContainerNodePredicate), nodeList)) { return true; } } case ContainerNode container -> { if (findNodes(container.getNodes(), eachNodePredicate, virtualContainerNodePredicate)) { return true; } } default -> { } } return false; } }
|
编写单元测试验证一下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| @Test public void testNodeFindSupport() { NodeFindSupport support = NodeFindSupport.from(buildNodes()); AtomicInteger count = new AtomicInteger(0); support.findNodes(node -> count.incrementAndGet()); assertThat(count).hasValue(9);
count.set(0); List<NodeInfo> nodes = support.findScopeNodes("B2"); assertThat(nodes).hasSize(3) .extracting(NodeInfo::getNodeId) .containsExactly("A2", "B2", "C1"); }
|
6. 总结思考
本文的篇幅并不长,核心内容也很简单,即:使用 Lambda 表达式将简单的 DFS 重构为回溯。
将 Consumer
修改成 Predicate
很好理解,但新增的 BiPredicate
或许有些难以理解,它主要用于获取某一作用域内的所有节点,并且能够在找到目标节点后立即停止遍历。
日常开发过程中可以参考本文思路利用 Lambda 表达式实现一些重构,但其中的度要把握好,不能世间万物皆用 Lambda 表达式完成,这种重构更适用于一些底层逻辑,上层需要一次或多次的封装,不能直接将接收函数式接口的方法暴露出来,以增加第三者维护这些代码的困难度。
当然,如果就是不想让其他人来维护这些代码,那当我没说,但希望后续自己维护时能够一眼看穿那些潜藏在 Lambda 表达式中的奥秘。 🤣