为什么要使用Guava不可变集合类(Guava使用指南2)

为什么要使用Guava不可变集合类

桦说编程原创,禁止转载

不可变类型的优点

聊聊Guava中的ImmutableCollection,Guava中的大多数不变集合类都继承自这里。

不变类具有很多优点,但是这些优点不见得都是教条;观点在碰撞下才更能彰显其价值,下面详细讨论了几种常见的观点:

  1. 保证了集合浅层的不可变性,不可新增、删除、替换
    反对意见:这样缺乏灵活性,如果需要再增加元素的话,岂不是又要重新copy,然后再添加元素
    我的解释:是的,如果有这方面需求,可以创建可变类解决,也可以直接创建可变类,跳过创建不变类的中间步骤。
    最佳实践:
    经常变化的集合需要创建可变类;大多数方法的返回值应该为不变类,一个方法表示一个逻辑单元的结束,使用不可变类型作为返回值可以避免后续修改。 同时可以作为防御性编程的一种支持,防止他人修改返回结果。
  2. 可变类的性能不好
    从性能角度来说,如果对于性能有特别的要求(大部分时间没有这方面的要求),可以使用可变类。
    需要说明的是,不可变类的性能也不差,有时甚至更好,我们copyOf不可变类时,只是使用了浅拷贝,性能损耗没想象中那么大。
    根据局部性原理,真实的代码使用不可变类性能有时更好。
  3. 集合元素不支持空对象,避免了无谓的空指针检查
    反对意见:我在实际使用中也不会添加null到集合中啊
    我的解释:是的,你不会,不代表别人不会。大部分时间我们都会遵循代码规范,但是bug出现也是家常便饭,没有一个程序员没有写过bug,这里做的事情就相当于加了一个安全声明,同时可以在更早期暴露出代码问题。 最佳实践: 严格遵循代码规范,如果需要表示空对象,可以使用Optional,或者使用空对象模式(NullObject)来实现。
  4. 线程安全👍
    反对意见: 大部分时间代码都是在单一线程中实现的
    我的解释:是的,线程安全是一个加分项,对于需要多线程的场景,可以直接使用不可变类。
    最佳实践:如上
  5. 不可被继承,防止逻辑被修改,保证了其行为的一致性
    反对意见: 这不是缺点吗,没有拓展性!
    我的解释: 自由来自于不自由。当你失去了继承的时候,你会获得很多。组合优于继承,可以使用组合实现相同的目的,代码还不易出现耦合性错误。
    从继承的角度来说,大部分类从初始创建时就是难以继承的。
    所以可以把类分为两种类型:面向继承设计的类和无法继承的类。面向继承的类需要优秀的设计,很多时候不是一蹴而就的。一个类如果是无法继承的,这不是他的原罪。
    最佳实践: 如果需要拓展类的功能,可以使用组合或者继承。对于面向继承设计的类,可以使用继承。大部分时间我们需要的是组合。
  6. 可以建立多种视图 view: subList, elementSet, entrySet。asList 方法直接转换为列表形式,方便 很方便,我们使用他人编写的接口或者方法时,需要的参数常常是一股脑的List。
    不过这里由于使用的是view,可能会推迟源对象的垃圾回收,极端情况下造成内存泄露。所以对于少数场景,可以再浅拷贝一份(使用copyOf方法)
    最佳实践:使用不可变类型,快速获得不同的视图。
  7. 底层高效实现,不占用额外的空间
    • ImmutableList底层不会使用额外的数组空间;
    • ImmutableMap的内存占用显然比默认的HashMap实现小(HashMap使用loadFactor平衡了内存占用和查询效率,默认loadFactor为0.75,至少有25%的数组空间为空)
    • ImmutableBiMap具有懒计算的invert方法,同时再次调用invert直接返回自己;
    • 浅拷贝方法对于相同对象只会拷贝一次,ImmutableList.copyOf(otherImmutableList) 返回的即是参数自己,无需再次拷贝,也无需担心修改的问题。
  8. 如何进行集合间运算
    反对意见:可变类可以进行复杂高效的集合运算
    我的解释:不可变类型也支持这种运算,可变类型的运算产生的副作用会令人难以理解,比如Set.retainAll()这个方法可以求集合的交集,但是原集合发生了改变,那么问题来了,这个对象应该如何命名呢,它在集合运算前是集合A,在运算后是集合A和B的交集。最简单的方法就是使用工具类计算交集,交集为另外一个对象。
  9. 不用空指针检查,因为默认不可变类型就是非空的。空集合不用null表示,所以可以直接调用isEmpty等方法。
    反对意见: 没有人能严格遵守规范
    我的解释: 是的。我们不应严于律人,宽于律己。编程的规范就是对于外部系统不完全信任,对于内部系统完成信任。对于外部系统返回的对象应该做严格的检查,对于内部系统参数的检查可以放松。对于外部系统,如果产生错误,应该提早暴露,所以做严格检查,避免产生问题而不及时解决。内部系统应该遵守代码规范,应该自成体系,如果内部系统也严格检查,反而说明不遵守代码规范,自己都不相信自己,进行反复检查、做重复的工作是得不偿失的。
  10. 很多不可变类的实现基于Guava实现
    比如若想实现完全的不可变,使用 record + ImmutableCollection + 不可变元素 是一种很常见的选择; Immutables使用Guava作为不可变集合的实现;

Java标准库的实现

本人在工作中使用的是Java17版本,下面会比较Java8和Java17而不具体说明新特性在哪个版本引入的,因为Java17作为Java11后的长期支持大版本,其在未来可能被进一步重视,纠结于哪个版本发布的无异于研究茴香豆的茴字有多少种写法。 Java8中实现不可变类,使用Decorator模式,此外还有一些有着各种限制的集合实现,下面仅以List举例:

java 复制代码
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设计模式创建集合类。下一篇文章我们就来看看常见集合处理方式的不可变类实现。

相关推荐
程序leo源25 分钟前
C语言:操作符详解1
android·java·c语言·c++·青少年编程·c#
轮到我狗叫了1 小时前
栈的应用,力扣394.字符串解码力扣946.验证栈序列力扣429.N叉树的层序遍历力扣103.二叉树的锯齿形层序遍历
java·算法·leetcode
冰之杍2 小时前
Vscode进行Java开发环境搭建
java·ide·vscode
跳动的梦想家h6 小时前
黑马点评 秒杀下单出现的问题:服务器异常---java.lang.NullPointerException: null(已解决)
java·开发语言·redis
苹果醋36 小时前
前端面试之九阴真经
java·运维·spring boot·mysql·nginx
哎呦没6 小时前
Spring Boot OA:企业办公自动化的高效路径
java·spring boot·后端
真心喜欢你吖6 小时前
Spring Boot与MyBatis-Plus的高效集成
java·spring boot·后端·spring·mybatis
2401_857636396 小时前
实验室管理技术革新:Spring Boot系统
数据库·spring boot·后端
2401_857600956 小时前
实验室管理流程优化:Spring Boot技术实践
spring boot·后端·mfc
2402_857589366 小时前
企业办公自动化:Spring Boot OA管理系统开发与实践
java·spring boot·后端