Guava使用指南2:为什么要使用Guava不可变集合类
不可变类型的优点
聊聊Guava中的ImmutableCollection,Guava中的大多数不变集合类都继承自这里。
不变类具有很多优点,但是这些优点不见得都是教条;观点在碰撞下才更能彰显其价值,下面详细讨论了几种常见的观点:
- 保证了集合浅层的不可变性,不可新增、删除、替换
反对意见:这样缺乏灵活性,如果需要再增加元素的话,岂不是又要重新copy,然后再添加元素
我的解释:是的,如果有这方面需求,可以创建可变类解决,也可以直接创建可变类,跳过创建不变类的中间步骤。
最佳实践:
经常变化的集合需要创建可变类;大多数方法的返回值应该为不变类,一个方法表示一个逻辑单元的结束,使用不可变类型作为返回值可以避免后续修改。 同时可以作为防御性编程的一种支持,防止他人修改返回结果。 - 可变类的性能不好
从性能角度来说,如果对于性能有特别的要求(大部分时间没有这方面的要求),可以使用可变类。
需要说明的是,不可变类的性能也不差,有时甚至更好,我们copyOf不可变类时,只是使用了浅拷贝,性能损耗没想象中那么大。
根据局部性原理,真实的代码使用不可变类性能有时更好。 - 集合元素不支持空对象,避免了无谓的空指针检查
反对意见:我在实际使用中也不会添加null到集合中啊
我的解释:是的,你不会,不代表别人不会。大部分时间我们都会遵循代码规范,但是bug出现也是家常便饭,没有一个程序员没有写过bug,这里做的事情就相当于加了一个安全声明,同时可以在更早期暴露出代码问题。
最佳实践: 严格遵循代码规范,如果需要表示空对象,可以使用Optional,或者使用空对象模式(NullObject)来实现。 - 线程安全
反对意见: 大部分时间代码都是在单一线程中实现的
我的解释:是的,线程安全是一个加分项,对于需要多线程的场景,可以直接使用不可变类。
最佳实践:如上 - 不可被继承,防止逻辑被修改,保证了其行为的一致性
反对意见: 这不是缺点吗,没有拓展性!
我的解释: *来自于不*。当你失去了继承的时候,你会获得很多。组合优于继承,可以使用组合实现相同的目的,代码还不易出现耦合性错误。
从继承的角度来说,大部分类从初始创建时就是难以继承的。
所以可以把类分为两种类型:面向继承设计的类和无法继承的类。面向继承的类需要优秀的设计,很多时候不是一蹴而就的。一个类如果是无法继承的,这不是他的原罪。
最佳实践: 如果需要拓展类的功能,可以使用组合或者继承。对于面向继承设计的类,可以使用继承。大部分时间我们需要的是组合。 - 可以建立多种视图 view: subList, elementSet, entrySet。asList 方法直接转换为列表形式,方便
很方便,我们使用他人编写的接口或者方法时,需要的参数常常是一股脑的List。
不过这里由于使用的是view,可能会推迟源对象的垃圾回收,极端情况下造成内存泄露。所以对于少数场景,可以再浅拷贝一份(使用copyOf方法)
最佳实践:使用不可变类型,快速获得不同的视图。 - 底层高效实现,不占用额外的空间
- ImmutableList底层不会使用额外的数组空间;
- ImmutableMap的内存占用显然比默认的HashMap实现小(HashMap使用- loadFactor平衡了内存占用和查询效率,默认loadFactor为0.75,至少有25%的数组空间为空)
- ImmutableBiMap具有懒计算的invert方法,同时再次调用invert直接返回自己;
- 浅拷贝方法对于相同对象只会拷贝一次,ImmutableList.copyOf(otherImmutableList) 返回的即是参数自己,无需再次拷贝,也无需担心修改的问题。
- 如何进行集合间运算
反对意见:可变类可以进行复杂高效的集合运算
我的解释:不可变类型也支持这种运算,可变类型的运算产生的副作用会令人难以理解,比如Set.retainAll()这个方法可以求集合的交集,但是原集合发生了改变,那么问题来了,这个对象应该如何命名呢,它在集合运算前是集合A,在运算后是集合A和B的交集。最简单的方法就是使用工具类计算交集,交集为另外一个对象。 - 不用空指针检查,因为默认不可变类型就是非空的。空集合不用null表示,所以可以直接调用isEmpty等方法。
反对意见: 没有人能严格遵守规范
我的解释:
是的。我们不应严于律人,宽于律己。编程的规范就是对于外部系统不完全信任,对于内部系统完成信任。对于外部系统返回的对象应该做严格的检查,对于内部系统参数的检查可以放松。对于外部系统,如果产生错误,应该提早暴露,所以做严格检查,避免产生问题而不及时解决。内部系统应该遵守代码规范,应该自成体系,如果内部系统也严格检查,反而说明不遵守代码规范,自己都不相信自己,进行反复检查、做重复的工作是得不偿失的。 - 很多不可变类的实现基于Guava实现
比如若想实现完全的不可变,使用 record + ImmutableCollection + 不可变元素 是一种很常见的选择; Immutables使用Guava作为不可变集合的实现;
Java标准库的实现
在工作中使用的是Java17版本,下面会比较Java8和Java17而不具体说明新特性在哪个版本引入的,因为Java17作为Java11后的长期支持大版本,其在未来可能被进一步重视,纠结于哪个版本发布的无异于研究茴香豆的茴字有多少种写法。
Java8中实现不可变类,使用Decorator模式,此外还有一些有着各种限制的集合实现,下面仅以List举例:
class ImmutableDemo {
public static void main(String[] args) {
// 创建列表的标准写法
List<Integer> list = new Arraylist<>(Arrays.asList(1, 2, 3));
// 包装器模式,为list增加新功能,这个新功能反而是限制对list的修改
List<Integer> immutableList = Collections.unmodifiableList(list);
// 非标准写法,类似于一个切片,不能修改list的大小
List<Integer> list2 = Arrays.asList(1, 2, 3);
// 空list, 不能修改大小
List<?> emptyList = Collections.emptyList();
// 单值list,不能修改大小
List<Integer> singletonList = Collections.singletonList(1);
// java 8, 推荐的写法(使用静态引用,改善可读性)Collectors.toList -> toList
// 底层实现是 ArrayList
List<Integer> list3 = Stream.of(1, 2, 3).collect(toList());
// Java 9
// Java 终于支持了直接静态方法创建list,这个list是不可变的,不支持空对象
// 我们来看一下其注释,其实就是借鉴Guava
List<Integer> list4 = List.of(1, 2, 3);
// Java17
// 底层实现是不可变 list
var list5 = Stream.of(1, 2, 3).toList();
}
}
List.of 或者 List.copyOf 返回的集合特点:
- 不可变,不能增加、删除、替换,不限制元素是否可变
- 不允许空对象
- 可序列化
- 有序,和入参数组一致
- 支持随机访问
- 值类型,不要用于同步
不可变类型我该用标准库还是Guava
- Guava的不可变类型相比标准库来说强大许多,实际上两者混用并不会造成太大差别,使用ImmutableCollection的心智负担会小一些,因为类型已经提示开发者不要修改集合对象。
- 对于对外提供的方法推荐使用Guava不可变类型,因为其更好地说明了返回值不可修改。
- 对于不对外使用的类型,推荐使用更能保证结果正确和可读性的实现。
业务核心逻辑推荐使用Guava。 - 需要序列化为Json的对象可以使用Jackson-Guava-Module;或者复制出一个DTO对象,集合类型使用标准库,自己写转换类或者使用map-struct。
- 对外提供的类库需要注意保证Guava版本的一致性,虽然Guava开发者团队努力保证兼容性,但不排除一些方法因版本升级无法实现兼容,建议升级版本时参考 release note。
妥协
Guava中的不可变集合并不能保证元素的不可变,我们常常能在代码中见到遍历集合对象对其进行修改,比如forEach, stream#peek, stream#forEach, 甚至在Stream#map中修改对象的值(强烈反对这种行为背后的编程思想,即不理解设计思想就进行实现)。
妥协不一定是坏事,这意味着开发者可以更*地进行编程实现。
三个时代: 谁是谁的子类
有人说英美之间是共轭父子,暂且不聊这个话题,但是这种说法来形容不可变集合再合适不过了。
ImmutableList作为List的子类不仅没有实现其写(write)的方法, 反而做了进一步限制,这显然违反了接口设计的要求。
不过这一切的始作俑者反而是父类自己,List像是一位胸怀大志的父亲,终于在老年的时候发现自己年轻时的雄心壮志无法一一实现。
其儿子ImmutableList继承其遗志,对外声称不确保完全实现父亲的诺言,只是努力保证实现部分,因为其言必行,行必果,反而走的更远,更容易受到他人的信赖。
多年之后,一位远房亲戚拜访儿子,他还是以过去的待人接物方式和儿子打交道,反而觉得这个儿子不如父亲那样有拼劲,便感慨年轻人变得越来越保守了。
在另外一个时代中(Kotlin),ImmutableList是父类,List是子类,子辈在父亲的基础上不断发展,最终事业越做越大。
有一次,有人误把儿子当成了父亲,曾经的父亲总是穿着一样的长衫,逢人便问茴字有几种写法,他发现这个孩子一天比一天善变,有时候衣服穿一天就换,经常跳起莫名其妙的舞蹈,他感叹礼乐崩坏,人心不古。
或许还有一个时代(Scala),这个时代家庭已不复存在,没有谁和谁有亲缘关系。有的人追求*,享受世间的繁花万种;有的人追求内心的平静,恪守规则,从一而终。
与前两个时代不同的是,人们有了选择的权利,一个人今天是格子间工作的打工人,明天就转而成为行万里路的旅人。
最佳实践–不要混用类型
为避免只看类型信息产生的望文生义,尽量不要混用可变集合和不可变集合。
如果对不可变类进行写操作(比如add、remove、replace等),会产生运行时异常。应该尽可能全面地进行测试,加入单元测试,将错误尽早暴露。
对于集合类型的使用,尽量不要使用写操作,比如可以使用Stream流进行处理,使用builder设计模式创建集合类。下一篇文章我们就来看看常见集合处理方式的不可变类实现。