一篇掘金文章引起了我对抽象的思考……

464 阅读13分钟

背景

昨天下班的十来分钟本想通过逛掘金来摸鱼的,怎料被一篇掘金文章狠狠吸引住。 文章名字是:《解密阿里大神写的天书般的Tree工具类,轻松搞定树结构!》

原文章地址

解密阿里大神写的天书般的Tree工具类,轻松搞定树结构! - 掘金 (juejin.cn)

说明

本篇文章只会在原文章基础上说一下自己的思考过程,然后对TreeUtil工具类里的makeTreemakeChildren两个方法的编程思想进行循序渐进的讲解。(更加不会讲解树的基本概念

不熟悉lambda的,建议直接拉到末尾先把这篇文章需要的lambda知识看完,再回头看。

由于不覆盖TreeUtil中的其它方法讲解,所以这里直接引入TreeUtil里的两个精髓方法

public class TreeUtil {

    /**
     * 将list合成树
     *
     * @param list 需要合成树的List
     * @param rootCheck 判断E中为根节点的条件,如:x->x.getPId()==-1L , x->x.getParentId()==null,x->x.getParentMenuId()==0
     * @param parentCheck 判断E中为父节点条件,如:(x,y)->x.getId().equals(y.getPId())
     * @param setSubChildren   E中设置下级数据方法,如: Menu::setSubMenus
     * @param <E>  泛型实体对象
     * @return   合成好的树
     */
    public static <E> List<E> makeTree(List<E> list, Predicate<E> rootCheck, BiFunction<E, E, Boolean> parentCheck, BiConsumer<E, List<E>> setSubChildren) {
        return list.stream().filter(rootCheck).peek(x -> setSubChildren.accept(x, makeChildren(x, list, parentCheck, setSubChildren))).collect(Collectors.toList());
    }
    
    private static <E> List<E> makeChildren(E parent, List<E> allData, BiFunction<E, E, Boolean> parentCheck, BiConsumer<E, List<E>> children) {
        return allData.stream().filter(x -> parentCheck.apply(parent, x)).peek(x -> children.accept(x, makeChildren(x, allData, parentCheck, children))).collect(Collectors.toList());
    }
}

以及作者使用的实体

public class MenuVo {
    private Long id;
    // -1 表示根
    private Long pId;
    private String name;
    private Integer rank=0;
    private List<MenuVo> subMenus=new ArrayList<>();


    public MenuVo(Long id, Long pId) {
        this.id = id;
        this.pId = pId;
    }
}

好了,接下来我们可以开始继续往后看了。

自己构造一棵树

忘记上面的TreeUtil工具类里的两个方法,如果现在让你基于MenuVo实体构造一棵树,你会怎么写?

我相信绝大多数同学是没有问题的,下面给出我实现的代码:

List<MenuVo> makeTree(List<MenuVo> menuVos) {
    // 找到所有根节点
    List<MenuVo> roots = menuVos.stream().filter(v -> v.getPId() == -1L).collect(Collectors.toList());
    // 遍历根节点
    for (MenuVo root : roots) {
        // 从根节点开始递归往下挂子节点
        makeTree(root, menuVos);
    }
    return roots;
}

// 往node上挂子节点
private void makeTree(MenuVo node, List<MenuVo> allData) {
    // 往node上挂子节点
    node.setSubMenus(allData.stream().filter(v -> v.getPId().equals(node.getId())).collect(Collectors.toList()));
    for (MenuVo subMenu : node.getSubMenus()) {
        // 递归的子过程
        makeTree(subMenu, allData);
    }
}

上面的代码是直接通过MenuVo实体构造的一棵树,这种方式是很友好的,因为容易看得懂。

如果上面代码没看懂的话,不建议往后观看

公共部分抽象 - 浅显的抽象

通过观察上面构建树的过程,我们发现,其实就用到了id,pId以及subMenus为了广泛使用,我们叫它subs)。

那么我们是不是可以往上提取一层,抽出一个基类,比如叫:BaseTree

public class BaseTree {
    protected Long id;
    // -1 表示根节点
    protected Long pId;
    protected List<BaseTree> subs =new ArrayList<>();

    public BaseTree(Long id, Long pId) {
        this.id = id;
        this.pId = pId;
    }
}

然后我们让各种类型的树继承这个BaseTree

比如给出例子中的菜单树MenuTree:

public class MenuTree extends BaseTree{

    private String name;
    private Integer rank=0;

    // and others fields

    public MenuTree(Long id, Long pId) {
        super(id, pId);
    }
}

这样我们就将一些公共部分给抽取出来了。接下来继续完成构造树的工具方法封装。

public class TreeUtil {

    public static <T extends BaseTree> List<T> makeTree(List<T> trees) {
        List<T> roots = trees.stream().filter(tree -> tree.getPId() == -1).collect(Collectors.toList());
        for (T root : roots) {
            makeTree(root, trees);
        }
        return roots;
    }

    /**
     * 往node节点下挂接子节点
     *
     * @param node
     * @param trees
     */
    private static <T extends BaseTree> void makeTree(T node, List<T> trees) {
        node.setSubs(trees.stream().filter(v -> v.getPId().equals(node.id)).collect(Collectors.toList()));
        for (BaseTree subMenu : node.getSubs()) {
            makeTree((T)subMenu, trees);
        }
    }
    
}

我们会发现,这个方法和上面针对MenuVo写的方法几乎是没有改变的,这是因为构造树的最主要三个属性已经被抽取出来了。

现在虽然工具类中已经有了,但是这个工具类有限制,这个限制就是所有树必须要继承BaseTree,如果我们这个base类是在系统开发之初完成的定义,那么这个系统中所有树结构是都没问题的,如果是后知后觉才想到要提取的base类呢?

所以,现在到我们的主线任务:从我们的工具方法中抽象出具体行为。

行为的抽象 - 高端的艺术

继续贴一下我们上面写的工具方法,在方法中我们来分析具体有哪些行为?

行为分析

public class TreeUtil {

    public static <T extends BaseTree> List<T> makeTree(List<T> trees) {
        // 行为1:从节点集合中过滤出根节点集合
        List<T> roots = trees.stream().filter(tree -> tree.getPId() == -1).collect(Collectors.toList());
        // 行为2:遍历根节点集合
        for (T root : roots) {
            // 行为3:递归往root根节点上挂子节点
            makeTree(root, trees);
        }
        return roots;
    }

    /**
     * 往node节点下挂接子节点
     *
     * @param node
     * @param trees
     */
    private static <T extends BaseTree> void makeTree(T node, List<T> trees) {
        // 行为4:过滤出是node的子节点集合
        // 行为5:往node节点里设置子节点集合
        node.setSubs(trees.stream().filter(v -> v.getPId().equals(node.id)).collect(Collectors.toList()));
        // 行为6:遍历node节点的所有子节点
        for (BaseTree subMenu : node.getSubs()) {
            // 行为7:递归往subMenu根节点上挂子节点
            makeTree((T)subMenu, trees);
        }
    }
    
}

好了,到此我们已经分析完构造一棵树的所有行为了,接下来针对每个点完成抽象的处理(落地到代码层面的抽象

从原文章中给出的TreeUtil中,我们可以看到是用lambda来做的,所以我们代码层面抽象尽量都往lambda表达式那边靠拢,使用流的方式来完成。

1、从节点集合中过滤出根节点集合

List<T> roots = trees.stream().filter(tree -> tree.getPId() == -1).collect(Collectors.toList());

代码中的那个行为可以被抽象?

很容易被想到的自然是filter过滤这部分代码可以被抽象,因为这里用到了具体的对象来实现过滤,我们的目的是为了让工具类不再出现我们的业务对象。

所以这里可以抽象出一个谓词Predicate(是filter方法的入参类型)

2、遍历根节点集合

for (T root : roots)

如何用lambda遍历,并且遍历之后还不会关闭流?

答案是:用peek

3、递归往root根节点上挂子节点

我们先定义一个行为,这个行为就叫dfs

4、过滤出是node的子节点集合

和第一个行为很像,也是个过滤操作,我们这里先再抽象一个谓词Predicate。(注:这样是使用不了的,后面会解释为什么用不了)

5、往node节点里设置子节点集合

这里其实是两个引用类型的操作,但是又不需要返回结果,如果你熟悉java8的流操作,那么肯定多少听过BiConsumer的名字:它的作用就是对两个入参做一些操作,但是不给返回结果。

类似伪代码:


public void add(Student s1, Student s2) {
    s1.name = s2.name;
}

看见没,虽然没有返回值,但是引用变量里的数据是有改变的。(很基础的一个知识点)

6、遍历node节点的所有子节点

同行为2,用peek

7、递归往subMenu根节点上挂子节点

同行为3,定义一个行为叫dfs

好嘞,最难的部分终于大功完成了。

用lambda给抽象的行为表述出来-1

/**
 * 
 * @param allData 
 * @param rootCheck 行为1的抽象
 * @param setSubMenus 行为5的抽象
 * @param sonCheck 行为4的抽象
 * @return
 * @param <E>
 */
<E> List<E> makeTree1(List<E> allData, Predicate<E> rootCheck, BiConsumer<E, List<E>> setSubMenus, Predicate<E> sonCheck) {
    return allData.stream().filter(rootCheck).peek(root -> dfs1(allData, root, setSubMenus, sonCheck)).collect(Collectors.toList());
}

/**
 * 方法本身是对行为3和7的抽象
 *
 * @param allData
 * @param parent
 * @param setSubMenus 行为5的抽象
 * @param sonCheck 行为4的抽象
 * @param <E>
 */
<E> void dfs1(List<E> allData, E parent, BiConsumer<E, List<E>> setSubMenus, Predicate<E> sonCheck) {
    setSubMenus.accept(parent, allData.stream().filter(sonCheck).peek(son -> dfs1(allData, son, setSubMenus, sonCheck)).collect(Collectors.toList()));
}

行为4真的可以用Predicate抽象吗?

好,这个时候我们再来调用一下上面的两个工具方法,测试一下。

MenuVo menu0 = new MenuVo(0L, -1L);
MenuVo menu1 = new MenuVo(1L, 0L);
MenuVo menu2 = new MenuVo(2L, 0L);
MenuVo menu3 = new MenuVo(3L, 1L);
MenuVo menu4 = new MenuVo(4L, 1L);
MenuVo menu5 = new MenuVo(5L, 2L);
MenuVo menu6 = new MenuVo(6L, 2L);
MenuVo menu7 = new MenuVo(7L, 3L);
MenuVo menu8 = new MenuVo(8L, 3L);
MenuVo menu9 = new MenuVo(9L, 4L);
//基本数据
List<MenuVo> menuList = Arrays.asList(menu0,menu1, menu2,menu3,menu4,menu5,menu6,menu7,menu8,menu9);

// 我们发现前面几个参数写的很顺利,到最后一个参数的时候卡住了,不知道怎么写。
System.out.println(JSON.toJSONString(makeTree1(menuList, x -> x.getPId() == -1L, (x, y) -> x.setSubMenus(y), ??? );
})));

上面代码中的最后一个参数怎么写?犯难了。

明明在行为1的时候过滤就是抽象出Predicate这个函数式接口的,怎么到行为4的时候同样抽象出Predicate就不好使了。行为1和行为4到底有什么不同?以及Predicate这个谓词是如何过滤的?

Predicate是如何过滤的?

image.png

这个就是Predicate的源码,是一个函数式接口,里面只有一个抽象的方法,这个方法交给外部自己实现的。 我们可以看到,这个test方法只有一个入参,返回值是boolean,为true表示通过了过滤,否则是false。

所以为什么在对行为4的抽象不可以直接用Predicate,因为行为4需要两个参数进行校验。其一是父节点、其二是其他节点与父节点做匹配

那么行为四的过滤应该怎么做呢?

既然过滤动作抽象不出来Predicate,那么我们就抓住Predicate中test方法的返回值。

其实过滤动作就是一个判断然后以布尔类型为结果进行过滤。

所以我们可以自己写一个过滤,然后返回一个true。

伪代码:


public boolean check(int a, int b) {
    return a > b;
}

熟悉lambda操作的同学应该能快速抽象出:BiFunction这个函数式接口的功能就是传入两个入参,经过一系列操作之后得到一个返回值,刚刚满足我们上面所需动作。

所以,我们对行为四的最终抽象方案是:BiFunction

用lambda给抽象的行为表述出来-2

和上面都差不多,就行为四的抽象有点变动,这里直接贴代码了。

<E> List<E> makeTree2(List<E> allData, Predicate<E> rootCheck, BiConsumer<E, List<E>> setSubMenus, BiFunction<E, E, Boolean> sonCheckFunction) {
    return allData.stream().filter(rootCheck).peek(root -> dfs2(allData, root, setSubMenus, sonCheckFunction)).collect(Collectors.toList());
}

// 递归将父节点和子节点相连
<E> void dfs2(List<E> allData, E parent, BiConsumer<E, List<E>> setSubMenus, BiFunction<E, E, Boolean> sonCheckFunction) {
    setSubMenus.accept(parent, allData.stream().filter(x -> sonCheckFunction.apply(parent, x)).peek(son -> dfs2(allData, son, setSubMenus, sonCheckFunction)).collect(Collectors.toList()));
}

测试代码同上,这里就不再贴了。经过我的测试是可以的。

异同点

在我实现完我自己分析的版本之后,回头去看了原文章贴出来的代码,发现还是有点不一样的。

原文章代码:

public static <E> List<E> makeTree(List<E> list, Predicate<E> rootCheck, BiFunction<E, E, Boolean> parentCheck, BiConsumer<E, List<E>> setSubChildren) {
    return list.stream().filter(rootCheck).peek(x -> setSubChildren.accept(x, makeChildren(x, list, parentCheck, setSubChildren))).collect(Collectors.toList());
}

private static <E> List<E> makeChildren(E parent, List<E> allData, BiFunction<E, E, Boolean> parentCheck, BiConsumer<E, List<E>> children) {
    return allData.stream().filter(x -> parentCheck.apply(parent, x)).peek(x -> children.accept(x, makeChildren(x, allData, parentCheck, children))).collect(Collectors.toList());
}

不同点在于,我在dfs2(和原文章代码makeChildren一样的功能)的时候并没有返回值,但是原文章返回了。

在经过对比之后发现,我是在递归方法里设置的节点的子节点的,但是原方法是在递归方法的peek里设置的,所以这里其实是个人编程习惯,我觉得没差别。

lambda

整理了一下关于这篇文章需要用到的lambda知识点。

在Java 8中,BiFunctionBiConsumerPredicateConsumer是四个重要的函数式接口,它们各自具有特定的用途和定义。以下是这些接口的详细解释:

1. BiFunction

定义BiFunction是一个函数式接口,它代表了一个接受两个参数并返回一个结果的函数。它是Java 8中引入的函数式编程特性之一,用于简化代码、提高可读性和灵活性。

用途BiFunction接口通常用于对两个输入参数进行操作,并返回一个结果。这使得它非常适合于需要对两个参数进行组合或转换的场景。例如,可以用于数学运算(加、减、乘、除等)、数据转换(将两个对象组合成一个新的对象)等。

示例

	BiFunction<Integer, Integer, Integer> sum = (a, b) -> a + b;  

	int result = sum.apply(5, 3);  

	System.out.println(result); // 输出: 8

2. BiConsumer

定义BiConsumer是一个函数式接口,用于表示接受两个输入参数并执行某些操作的操作。它不接受返回值。

用途BiConsumer接口常用于需要对两个参数进行处理的场景,但不关心操作的结果。例如,在遍历集合时,对集合中的每个元素及其索引进行操作;或者在处理查询结果时,将结果映射到对象上。

示例

	List<String> names = Arrays.asList("Alice", "Bob", "Charlie");  

	names.forEach((index, name) -> System.out.println(index + ": " + name));  

	// 注意:标准Java Stream API的forEach不支持直接传入两个参数(元素和索引)  

	// 上述示例仅为说明BiConsumer可能的用途,实际使用中需要调整以适应具体的API

注意:标准的List.forEach方法并不直接支持BiConsumer,因为它通常只接受一个元素作为参数。上面的示例只是为了说明BiConsumer的概念,实际应用中可能需要使用其他方法或自定义逻辑来实现类似功能。

3. Predicate

定义Predicate是一个函数式接口,它代表一个输入参数并返回一个布尔值的函数。

用途Predicate接口通常用于对集合或流中的元素进行条件判断、筛选和过滤操作。它允许定义复杂的条件逻辑,以决定哪些元素应该被保留或进一步处理。

示例

	List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);  

	List<Integer> evenNumbers = numbers.stream()  

	                                   .filter(n -> n % 2 == 0)  

	                                   .collect(Collectors.toList());  

	System.out.println(evenNumbers); // 输出: [2, 4]

注意:虽然filter方法没有直接使用Predicate接口作为参数,但它接受一个符合Predicate函数签名的Lambda表达式或方法引用。

4. Consumer

定义Consumer是一个函数式接口,用于表示接受单个输入参数并在执行操作后不返回任何结果的操作。

用途Consumer接口通常用于对集合或流中的元素进行遍历、消费或修改操作。它允许对元素执行某些操作,但不关心操作的结果。

示例

	List<String> fruits = Arrays.asList("Apple", "Banana", "Orange");  

	fruits.forEach(fruit -> System.out.println(fruit));

lambda总结

这四个接口都是Java 8中引入的函数式编程特性的一部分,它们各自具有不同的用途和定义。通过使用这些接口,可以编写更加简洁、可读和灵活的代码。

不要觉得函数式接口一个接口类里只有一个抽象方法很鸡肋,其实用处很大,在java里是不支持以方法作为入参的,所以要实现回调函数只能通过函数式接口。更不要觉得回调鸡肋,在很多框架中的事件驱动、观察者模型都是基于回调函数实现的。所以回调很重要,函数式接口是实现这一切的基石。

总结

总的来说这篇文章的抽象是属于倒推的抽象,什么意思呢?就是先有了完整的代码,然后你反过来进行抽象,这样是比较简单的,如果是顺着抽象的话,我只会想到提取树的公共部分。这就是我只想到了第一层,大神已经处于大气层了。

虽然代码太过抽象,但确实很美。