可变集合操作的不可变实现(Guava使用指南3)

上篇文章主要讨论了不可变集合的优点,这次我们来看看如何具体使用不可变类重构现有代码,实现相同的结果。

基本思路

  1. 使用JDK原生支持的Stream流式处理,Stream 与不可变类可以做到无缝贴合,是实现不可变的利器。
  2. 使用Guava工具类。
  3. 可变类运算 -> 拷贝到不可变集合, 可以实现不对外暴露可变性,如果把方法视为一个黑箱的话,虽然内部使用了可变集合,但是从外界看同样实现了不可变。

我们先来看看标准库中的可变接口方法:

1. Iterator#remove

这个方法默认实现如下,开发人员调用的时候需要自己保证支持remove操作,违反了接口隔离原则:

维基百科中定义如下:A client should never be forced to implement an interface that it doesn't use, or clients shouldn't be forced to depend on methods they do not use.

理想的情况是子类实现接口方法或父类抽象方法,或者重写默认方法或父类方法,这里接口定义了一个可以实现也可以不实现的类,开发人员调用时需要自己确保已经实现其方法,侧面说明了良好的面向对象设计难度比较大。

Java 复制代码
default void remove() {
    throw new UnsupportedOperationException("remove");
}

改造方法: Stream.filter, 如果 remove 部分元素,可以结合dropWhile, takeWhile, limit等方法。

2. 删除

Collection#remove(Object o) -> boolean(是否成功删除)

Collection#removeAll(Collection<?> c) -> boolean(是否改变原集合) 改造方法:Stream流中的filter方法;如果使用的是这个方法的副作用,即判断是否包含,可以调用contains

3. 新增

Collection#add(E e) -> boolean (是否改变原集合)

Collection#addAll(Collection<? extends E> c) -> boolean(是否改变原集合)

改造方法:

  • 使用ImmutableCollection.builder 构造器模式创建新对象

  • 计算后进行不可变拷贝, 如ImmutableList.copyOf, List.copyOf

  • Streams.concat

如果使用的是副作用,可以直接调用 contains、containsAll, stream.findAny, stream.findFirst, stream.anyMatch, stream.allMatch等查询方法。

4. 条件删除

default Collection#removeIf(Predicate<? super E> filter) -> boolean(是否改变原集合/是否删除过元素)

Java8后新增的默认方法,对于原有代码如果使用的不是标准类库,虽然其实现了集合接口,但是执行结果无法保证。不过可以放心的是,Guava对于接口默认实现具有良好支持。

为什么子类使用默认实现结果未知呢?默认实现如下,通过分析代码可以看出,默认实现使用的是迭代器的删除方法,如第一条所述,这个方法是无法保证子类必然实现的。

Java 复制代码
default boolean removeIf(Predicate<? super E> filter) {
    Objects.requireNonNull(filter);
    boolean removed = false;
    final Iterator<E> each = iterator();
    while (each.hasNext()) {
        if (filter.test(each.next())) {
            each.remove();
            removed = true;
        }
    }
    return removed;
}

改造方法: 使用Stream.filter,这两个方法真的是天生一对,区别就在于有无副作用。

5. 清空

清空操作常常用作对象复用,在设计良好的可变对象中使用。

虽然我一直强调不可变对象的优势,但是设计良好的类对象也可以实现相同的目的,具有效率、可读性和可维护性等优点,只不过这种设计对开发人员的要求很高,需要维护的副作用很多,下篇文章就会谈谈如何使用Guava中的可变集合对象实现优秀的面向对象类设计。

改造方法:

使用新的集合类就行。

6. 集合操作

Collection#retainAll(Collection<?> c) -> boolean(是否改变原集合) 这个是典型的集合取交集操作,但是问题巨大,关键的问题是原有集合没有了,我们经常需要计算同时集合的交集、差集,但是这个方法改变了原集合,如果第二次计算不及时还是用的原有对象,计算就会出错,所以常常可以看到的解决方法是先复制,再计算,这种解决方法可读性极差。 还有一种更糟糕的解决方案,使用apache.collections4中的CollectionUtils.retainAll等工具类方法,如下:

static retainAll(Collection collection, Collection<?> retain) -> Collection

Returns a collection containing all the elements in collection that are also in retain. 这个方法直接改变了retainAll的语义,原有的集合变成不变了。如果你的代码中Java原生集合类库和apache集合工具类库混合使用的话,没有人不会乱的。这种改变方法原有语义的工具类方法还有retainAll, 正确的做法可以是重新取一个方法名比如intersect, 除此之外,这个类库对于泛型的支持也一般,有用的方法也就CollectionUtils.isEmpty, ListUtils.emptyIfNull等辅助判空的方法了,大部分集合操作在Guava中都可以实现。总的来说,不建议使用。

改造方法:

使用不可变类和工具类,Sets.union/Sets.intersection/Sets.difference,这三个方法返回类型为SetView,可以调用immutableCopy转为不可变类。

以下为一个简单示例(免责申明:忽略一些边界条件,仅作代码示例),计算两个用户的共同好友:

scss 复制代码
class CommonFriendsV1 {
    // 新增的方法
    ImmutableSet<Long> calcMaybeFriends(Long userId) {
        return friendMap.get(userId).stream()
                .flatMap(id1 -> friendMap.get(userId).stream()
                        .filter(id2 -> id2 > id1)
                        .flatMap(id2 -> calcCommonFriends(id1, id2).stream()))
                .collect(toImmutableSet());
    }
}

7. 动态数组/有序集合的修改

List#set(int index, E e) -> E(返回原有值)

List#remove(int index) -> E

List#subList => clear

List#sort

List#replaceAll

ListIterator#remove, set, add

SequencedCollection#reversed, addFirst, addLast, removeFirst, removeLast

改造方法:

  • 所有涉及下标的方法都可以使用Streams.mapWithIndex, zip转换为带下标的流实现,或者流从intStream开始进行计算。
  • 仅在方法内部使用可变类,返回不可变拷贝。
  • 排序方法 => Stream#sorted
  • add相关操作可以builder创建
  • 有时候使用函数式方法进行更精细的操作不见得比命令式方法直观,开发人员可以根据需要灵活选用更好的实现。
    以下为调用subList then clear 的不同实现, 可以看出使用不可变类型,可以产出可重用的代码(removeByRange),同时这个代码也是很容易测试的,相同的输入,输出永远相同。
scss 复制代码
class RemoveSubListDemo {
    public static void main(String[] args) {
        // 可变操作实现
        List<Integer> nums = IntStream.range(0, 10).boxed().collect(toList());
        // remove index [3, 6]
        nums.subList(3, 7).clear();
        System.out.println("nums = " + nums);
        
        // 不可变实现
        List<Integer> nums2 = IntStream.range(0, 10).boxed().toList();
        var removedNums = removeByRange(nums2, 3, 6);
        System.out.println("removedNums = " + removedNums);
    }

    public static ImmutableList<Integer> removeByRange(List<Integer> nums, int lower, int upper) {
        return IntStream.range(0, nums.size())
                .boxed()
                .filter(not(Range.closed(lower, upper)::contains))
                .collect(toImmutableList());
    }
}

Map操作/Multimap

java中的map没有实现Collection接口,使用stream流比较繁琐。有些Map生命周期比较短,常常用于以空间换时间,比如MyBatis中映射关联对象,对象组合成新对象等,这种对象可以使用不可变Map,可以利用不可变集合的优势;有的Map用作缓存处理,用于提高资源利用率,比如 Guava Cache的底层使用了Map, 必然不是不可变对象,Spring容器中的单例对象池,其集合经常发生变化,也不适合用不可变Map;涉及到并发操作时常常使用ConcurrentHashMap其也不可能被ImmutableMap替代。

Map#put, putIfAbsent, remove(Object), remove(obeject, object), replace(k, v), replace(k, v, v), merge, computeIfAbsent, computeIfPrecent等方法常常只涉及单一键值对的修改,使用不可变集合的效果并不好。

建议在类的设计时,不暴露可变性;可以一次性创建ImmutableMap时再进行创建。

对于一些一对多关系,可以使用ImmutableMultimap可以轻松实现inverse。

迭代遍历

对于图或树的遍历,Guava提供了相关的类支持,对于其的遍历应该尽量保持数据值的不变性,否则其中大量的视图在不同阶段会返回不同的值。因为其利用了不可变对象可以使用懒计算的特性,具体实现原理我会单独写一篇文章,敬请期待。这里我们只看如何遍历对象,以遍历树为例,以下是两种实现:

实现1中树形结构需要打印每个节点的数据,需要设置子节点为空。

scss 复制代码
class TraverseDemoV1 {
    @Data
    @AllArgsConstructor
    static class Node {
        private List<Node> nodes;
        private int val;
    }
    public static List<Node> bfsFlatten(Node root) {
        Iterable<Node> nodes = Traverser.forTree(Node::getNodes).breadthFirst(root);
        return Streams.stream(nodes)
                .peek(n -> n.setNodes(null))
                .toList();
    }

    public static List<Node> preOrderFlatten(Node root) {
        Iterable<Node> nodes = Traverser.forTree(Node::getNodes).depthFirstPreOrder(root);
        return Streams.stream(nodes)
                .peek(n -> n.setNodes(null))
                .toList();
    }

    public static void main(String[] args) {
        // 创建一个完全三叉树
        var len = 20;
        var nodes = IntStream.range(0, len)
                .mapToObj(val -> new Node(new ArrayList<>(), val))
                .toList();
        nodes.stream()
                .skip(1L)
                .forEach(n -> nodes.get((n.getVal() - 1) / 3).getNodes().add(n));
        bfsFlatten(nodes.getFirst());
        // preOrderFlatten(nodes.getFirst));
    }

}
class TraverseDemoV2 {
    @Builder
    record Node(List<Node> subNodes, int val){}

    List<Integer> bfs(Node root) {
        Iterable<Node> nodes = Traverser.forTree(Node::subNodes).breadthFirst(root);
        return Streams.stream(nodes).map(Node::val).toList();
    }

    Stream<Integer> bfsV2(Node root) {
        // 懒计算实现
        Iterable<Node> nodes = Traverser.forTree(Node::subNodes).breadthFirst(root);
        return Streams.stream(nodes).map(Node::val);
    }
}

实际上实现1有一个bug,bfs遍历没有问题,但是dfs遍历会报空指针,这个问题违反直觉,因为我们已经走过了子节点。分析代码异常栈,遍历过程中改变了子节点的获取,树的遍历不允许 SuccessorsFunction返回空。不过,这里我们略去具体的代码分析,因为根本原因是在不可变的流处理中使用了副作用,如果直接将结果计算出来,再用forEach置空子节点,不会引起问题。

总的来说,不可变类可以简化代码理解,就像工具类一样进行计算,返回结果,而不改变入参状态。

不可变类方便测试,没有过多依赖,相同的输入总会得到相同的结果。

不可变类保护代码,返回结果后可以防止他人修改。

下篇预告

前面几篇文章都在说不可变类的好处,下次我们来看看可变类的好处,使用Guava进行领域建模,应用面向对象思想,在对象中使用带有状态的集合类,封装可变性,对外暴露接口的同时保证对象的修改都经过对象本身,设计高内聚低耦合的类,保证可拓展性,方便进行单元测试。

敬请期待。

相关推荐
砖业洋__15 分钟前
Spring高手之路24——事务类型及传播行为实战指南
java·spring·事务·nested·事务传播行为
黄俊懿15 分钟前
【深入理解SpringCloud微服务】了解微服务的熔断、限流、降级,手写实现一个微服务熔断限流器
java·分布式·后端·spring cloud·微服务·架构·手写源码
追风筝的Coder19 分钟前
泛微开发修炼之旅--44用友U9与ecology对接方案及源码
java
鱟鲥鳚29 分钟前
对象关系映射ORM
java
aristo_boyunv40 分钟前
【线程池】ThreadPoolExecutor应用
java·线程池·并发
Xua30551 小时前
浅谈Spring Cloud:OpenFeign
后端·spring·spring cloud
工程师老罗1 小时前
Java笔试面试题AI答之设计模式(4)
java·开发语言·设计模式
KuaiKKyo1 小时前
c++9月20日
java·c++·算法
xmh-sxh-13141 小时前
java缓存介绍
java
超级小的大杯柠檬水1 小时前
SpringBoot lombok(注解@Getter @Setter)
java·前端·spring