上篇文章主要讨论了不可变集合的优点,这次我们来看看如何具体使用不可变类重构现有代码,实现相同的结果。
基本思路
- 使用JDK原生支持的Stream流式处理,Stream 与不可变类可以做到无缝贴合,是实现不可变的利器。
- 使用Guava工具类。
- 可变类运算 -> 拷贝到不可变集合, 可以实现不对外暴露可变性,如果把方法视为一个黑箱的话,虽然内部使用了可变集合,但是从外界看同样实现了不可变。
我们先来看看标准库中的可变接口方法:
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进行领域建模,应用面向对象思想,在对象中使用带有状态的集合类,封装可变性,对外暴露接口的同时保证对象的修改都经过对象本身,设计高内聚低耦合的类,保证可拓展性,方便进行单元测试。
敬请期待。